OpenAPI Toolset

Turn any OpenAPI 3 or Swagger 2 spec into callable agent tools with one import

Point an agent at a REST API’s spec and every operation becomes a tool it can call — no per-endpoint wrapper functions, no external MCP process.

Diagram

OpenAPI Toolset

📋 OpenAPI Spec

🔌 OpenAPIToolset

🧰 List of Operations

🤖 Agent

🌐 HTTP call

✅ Response

Quick Start

  1. Load a spec and start the agent

    Every operation in the spec becomes a tool the agent can call directly.

    python
    import os
    from praisonaiagents import Agent
    from praisonaiagents.tools.openapi_toolset import OpenAPIToolset
    
    toolset = OpenAPIToolset(
        spec_url="https://api.example.com/openapi.json",
        auth={"type": "bearer", "token": os.environ["API_TOKEN"]},
    )
    
    agent = Agent(name="ops", tools=toolset.get_tools())
    agent.start("List the last 5 incidents and open a ticket for the top one")
  2. Install httpx

    httpx is needed at call time.

    bash
    pip install httpx

    YAML specs also need PyYAML (already a dependency).


How It Works

get_tools() returns one callable OpenAPIOperation per spec operation. When the agent calls one, it builds the request, attaches auth headers, sends it with httpx, and returns the parsed JSON.

Diagram
APIhttpxOperationAgentUserAPIhttpxOperationAgentUserTransport failures and safety refusals (off-origin,scheme downgrade, hostless URL) all come back as“{name} failed: {reason}” so the model can reactinstead of ending the turnRequestcall(**kwargs)build_request() + attach auth headerssend requestHTTP callresponseJSON or textresultResponse

Loading a Spec

Provide the spec exactly one of three ways — otherwise a ValueError is raised.

Diagram

Already parsed
dict/YAML loaded

Raw JSON/YAML text

Hosted at a URL

Keep some operations

Avoid name clashes

How is the spec
available?

spec_dict=…

spec_str=…

spec_url=…

Combining specs
or limiting tools?

tool_filter=…

tool_name_prefix=…


Configuration Options

All constructor arguments are keyword-only.

OptionTypeDefaultDescription
spec_dictOptional[Dict[str, Any]]NoneParsed spec (JSON/YAML already loaded)
spec_strOptional[str]NoneRaw spec text (JSON or YAML)
spec_urlOptional[str]NoneURL of a JSON or YAML spec
base_urlOptional[str]inferredOverrides servers[0].url / host+basePath. Required only when the spec has no server info
authOptional[Dict[str, Any]]NoneSee the auth table below
tool_filterOptional[Callable[[str, str, str], bool]]NoneCalled with (name, method, path); return True to keep the operation
tool_name_prefixOptional[str]NonePrepended to every generated tool name
header_providerOptional[Callable[[], Dict[str, str]]]NoneCalled per request; adds/overrides headers (use for rotating tokens)
timeoutint30Per-request timeout, in seconds

Exactly one of spec_dict, spec_str, spec_url is required.

Methods

MethodReturnsDescription
get_tools()List[OpenAPIOperation]One callable per operation, ready for Agent(tools=...)

OpenAPIOperation

Each tool returned by get_tools() is an OpenAPIOperation.

MemberTypeDescription
operation(**kwargs)`dict \str`Parsed JSON, else response text; on failure returns "{name} failed: {error}"
build_request(**kwargs)Dict[str, Any]Pure argument-binding (no HTTP) — preview or test the assembled request
to_openai_tool()Dict[str, Any]Function-calling dict, via the same helper MCP tools use

Attributes: name, description, method, path, base_url, parameters, body_schema, body_param, input_schema, auth, header_provider, timeout.

Auth Shapes

The auth dict is keyed by type.

auth["type"]Required keysHeader emitted
"bearer"tokenAuthorization: Bearer <token>
"api_key"key, optional name (default X-API-Key)<name>: <key>
"basic"value (already base64-encoded user:pass)Authorization: Basic <value>

Safety Behaviour

The toolset refuses unsafe requests and never fetches arbitrary URLs while parsing.

Diagram

No

Yes

No

Yes

No

Yes

No

Yes

📝 Tool call

🧠 build_request

Same host
as base_url?

⛔ Refuse: off-origin

Same scheme
https vs http?

⛔ Refuse: scheme downgrade

Has a host
at all?

⛔ Refuse: pass base_url=

Public
address?

⛔ Refuse: unsafe URL

🌐 Send to API

BehaviourWhat happens
http:// + authRaises ValueError — credentials are never sent in the clear. Pass an https:// base_url or fix the spec
Swagger 2 schemesWhen both http and https are listed, https wins (not schemes[0])
Remote $refIgnored — parsing a spec never fetches external URLs. Self-referential schemas are cycle-broken to {"type": "object"}
Path parametersEvery value is quote(value, safe="") before substitution, so ../../admin or https://other/ cannot alter host, path, query or fragment
Origin pin (host)If an absolute path in the spec, or any argument path, would send the request to a host other than the configured base_url, the tool returns "{name} failed: {name}: refusing to send credentialed request to {other_origin}; configured origin is {configured_origin}". Credentials never leave the configured origin
Origin pin (scheme)A same-host http:// value in the spec is refused the same way when the configured base_url is https:// — no silent HTTPS→HTTP downgrade
SSRF guardEvery outgoing request URL and the initial spec_url fetch run through is_safe_http_url. Loopback (127.0.0.1, ::1), private ranges (RFC1918), link-local (169.254.0.0/16, including cloud metadata 169.254.169.254), multicast and unspecified addresses are refused. A refused call returns "{name} failed: refusing unsafe URL '{url}'"; a refused spec_url raises ValueError at construction. Both httpx clients pass follow_redirects=False, so a redirect to an internal host cannot slip past. Set ALLOW_LOCAL_CRAWL=true to opt out for local development
Strict body (declared properties)Only properties declared in the requestBody schema are sent. Model hallucinations and framework-injected kwargs (idempotency_key, run_id, …) are dropped, so additionalProperties: false schemas stay clean
Composed body (allOf / oneOf / anyOf)Properties from every branch are advertised and accepted. oneOf / anyOf take the union — the superset of what the tool may accept. Only allOf contributes to the request’s required-field list; a field required by one branch of a oneOf is not required of the request as a whole. Nested composition and self-referential $refs are handled without hangs
Free-form body (bare {"type": "object"} or additionalProperties: true)Arguments the model supplies are forwarded verbatim. A closed empty schema (additionalProperties: false with no properties, directly or in an allOf branch) is honoured instead — nothing is sent
Swagger 2 in: bodyThe whole named argument becomes the JSON body verbatim
Relative servers[0].urlA remotely loaded spec with servers: [{ url: "/v1" }] resolves against the spec_url. With spec_dict= or spec_str= there is no URL to resolve against — the tool returns "{name} failed: {name}: no host to send to -- the spec's server URL is relative ('/v1') and could not be resolved. Pass an absolute base_url= to OpenAPIToolset." instead of an opaque transport error
Tool namesoperationId is sanitised; when absent, name is method + "_" + sanitised path — stable across runs
Errors return, not raiseA network/HTTP failure comes back as "{name} failed: {exc}" so the model can react instead of ending the turn. The ValueErrors raised inside build_request() (the two origin/hostless refusals above) are caught by __call__() and returned the same way
header_provider faultsIf the provider raises, the request continues without its headers (a warning is logged)

Framework-injected kwargs are stripped on all paths — free-form, declared, and composed — so durable-execution metadata never leaks into a strict API.

Body Shapes

Every requestBody schema falls into one of three modes, decided in this order.

Diagram

Yes

No

Yes

No

📋 requestBody schema

additionalProperties: false
and no declared properties?

⛔ Closed empty
No body sent

Declares properties?
top-level or via
allOf / oneOf / anyOf

✅ Strict
Only declared keys pass

✅ Free-form
All model keys pass

Framework-injected kwargs are dropped in every mode.


Common Patterns

Restrict the tool surface to read-only operations:

python
from praisonaiagents.tools.openapi_toolset import OpenAPIToolset

toolset = OpenAPIToolset(
    spec_url="https://api.example.com/openapi.json",
    tool_filter=lambda name, method, path: method.lower() == "get",
)

Namespace a spec when combining several on one agent:

python
stripe = OpenAPIToolset(
    spec_url="https://api.stripe.com/openapi.json",
    tool_name_prefix="stripe_",
)

Rotate auth per request:

python
def fetch_token() -> str:
    return "..."  # your token-refresh logic

toolset = OpenAPIToolset(
    spec_url="https://api.example.com/openapi.json",
    header_provider=lambda: {"Authorization": f"Bearer {fetch_token()}"},
)

Preview a request without calling the API:

python
tools = OpenAPIToolset(spec_url="https://api.example.com/openapi.json").get_tools()
request = tools[0].build_request(id="123")
print(request)   # {"method": ..., "url": ..., "headers": ...}

Call an operation whose body uses allOf — no special configuration:

python
from praisonaiagents import Agent
from praisonaiagents.tools.openapi_toolset import OpenAPIToolset

spec = {
    "openapi": "3.0.0",
    "servers": [{"url": "https://api.example.com"}],
    "paths": {"/pets": {"post": {
        "operationId": "createPet",
        "requestBody": {"content": {"application/json": {"schema": {
            "allOf": [
                {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
                {"type": "object", "properties": {"age": {"type": "integer"}}},
            ],
        }}}},
    }}},
}

agent = Agent(name="pets", tools=OpenAPIToolset(spec_dict=spec).get_tools())
agent.start("Create a pet called Rex, age 3")

Both name and age are advertised to the model and forwarded to the API — a composed spec works with no extra wiring.

Force a base URL when the spec has a relative servers: entry:

python
from praisonaiagents.tools.openapi_toolset import OpenAPIToolset

toolset = OpenAPIToolset(
    spec_dict=my_spec,                   # servers: [{ url: "/v1" }]
    base_url="https://api.example.com",  # required — no spec_url to resolve against
    auth={"type": "bearer", "token": "..."},
)

OpenAPI Toolset vs MCP

Both expose external tools to an agent; reach for the toolset when the API already has a spec.

OpenAPI ToolsetMCP OpenAPI server
Extra processNonenpx subprocess
Language runtimePython onlyNode.js required
tool_filterBuilt inNot available
tool_name_prefixBuilt inNot available
Agent viewRoutes through build_openai_tool_dict — identical to MCP toolsIdentical

A workaround existed (MCP("npx -y @ivotoby/openapi-mcp-server ...")); the toolset removes the extra process and the npx dependency, and adds first-class tool_filter and tool_name_prefix.


Best Practices

Always use https when sending auth

The toolset refuses to attach credentials to an http:// endpoint. If the spec declares a cleartext server, pass an https:// base_url explicitly. A same-host scheme downgrade — a spec supplying http:// for an origin you configured as https:// — is refused too, so a token never travels in the clear.

Filter to the operations the agent actually needs

A large spec can generate dozens of tools. Use tool_filter to keep only read-only or task-relevant operations so the model isn’t overwhelmed.

Prefix tools when combining specs

Two specs may both define get_users. Set a distinct tool_name_prefix per toolset so names stay unique on one agent.

Trust the origin pin when pointing at a spec you don't control

When you point the toolset at a spec URL you do not control, the spec — not just the model — can try to relocate a call. The toolset pins every credentialed request to your configured origin’s host and scheme, so an absolute path in the spec cannot silently send your token somewhere else. Nothing to do — this is on by default; the tool returns a refusal message the model can see.

Set ALLOW_LOCAL_CRAWL=true only in local dev

OpenAPI tools pointed at loopback or private hosts are refused by default because an operation URL that resolves to 169.254.169.254 can read cloud metadata. Set ALLOW_LOCAL_CRAWL=true in your shell only when you explicitly want the agent to call a local API.

Preview requests in tests with build_request

build_request(**args) binds arguments to URL, headers and body without any HTTP call — assert the assembled request in a unit test.


Related