Skip to content

Tools

The toolkit supports multiple tool input formats through ToolLoader.

Supported tool formats

  1. @tool-decorated Python functions
  2. .py file paths containing @tool functions
  3. Directory paths containing tool files
  4. FastMCP server objects, bare or as {"type": "fastmcp", "server": ..., "allowed_tools": [...]}
  5. MCP script configs ({"type": "mcp_script", ...})
  6. MCP command configs ({"type": "mcp_command", ...})
  7. Provider-native remote MCP configs ({"type": "mcp", "server_url": ...})
  8. Pre-built session dicts ({"session": ..., "name": ...})
  9. Client-side remote MCP configs ({"type": "mcp_remote_client", "url": ...})

Formats 7 and 9 both address a remote MCP server; they differ in who holds the connection. See Client-side remote MCP. Every MCP format takes allowed_tools to expose only some of a server's tools; see Choosing which tools a server exposes.

Input examples by format

1) @tool function

from doc_processing_toolkit import tool

@tool
def add(a: int, b: int) -> int:
  return a + b

2) Python file path

tools=["tools/my_tools.py"]

3) Directory path

tools=["tools/"]

4) FastMCP server object

from fastmcp import FastMCP

server = FastMCP("calculator")
tools=[server]

# The dict form takes options (0.5.1):
tools=[{"type": "fastmcp", "server": server, "allowed_tools": ["add"]}]

Tools of the SDK-bundled FastMCP and of fastmcp<3 are called through their own run since 0.5.3 (the SDK's validates and coerces the arguments as its server does over stdio), and a synchronous tool runs on a worker thread — see MCP and the two switches DPT_FASTMCP_VALIDATED_CALL and DPT_FASTMCP_SYNC_TOOLS_OFFLOOP.

5) MCP script config

tools=[{"type": "mcp_script", "path": "tests/fixtures/sample_mcp_server.py", "name": "calculator"}]

6) MCP command config

tools=[{"type": "mcp_command", "command": "python", "args": ["-m", "tests.fixtures.sample_mcp_server"], "name": "command-server"}]

Formats 5 and 6 start a subprocess, and a subprocess does not inherit this process's environment: the MCP SDK starts it with a short allow-list (PATH, the home directory and a few others; the list differs by platform), so an API key or a DPTTOOLS_* setting the server needs has to be named in the config's env (merged over that allow-list; since 0.5.3). Values are strings, numbers or booleans (true / false); None is refused at Agent(...). cwd sets the directory it starts in: relative args of an mcp_command resolve against it, while an mcp_script path is resolved against this process's directory first, so a relative script path keeps working. Neither value is logged, and agent.to_config() emits the entry without its env values (the names go under unserialisable.tool_env).

tools=[{
    "type": "mcp_command",
    "command": sys.executable,
    "args": ["-m", "doc_processing_std_tools.skills"],
    "name": "std-tools-skills",
    "env": {"DPTTOOLS_SKILLS_DIR": "/srv/skills"},
    "cwd": "/srv",
}]

7) Provider-native remote MCP config

tools=[{"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"}]

8) Pre-built session

tools=[{"session": existing_mcp_session, "name": "calculator"}]

9) Client-side remote MCP config

tools=[{"type": "mcp_remote_client", "url": "https://mcp.deepwiki.com/mcp", "name": "deepwiki"}]

Client-side remote MCP

Format 9 ({"type": "mcp_remote_client", ...}) makes this process open the session to the remote server over Streamable HTTP (or SSE) and execute its tools like local ones. Because the tool call never leaves the toolkit's own loop, it:

  • works on every provider, including Google, which has no server-side remote MCP;
  • takes the function-calling path, so it is compatible with output_schema;
  • is visible to hooks and appears in result.tools_used like any local tool;
  • is owned by ToolLoader, so it follows the MCP session lifecycle below and works with run_sync().
from doc_processing_toolkit import Agent

agent = Agent(
    model="gemini-3.5-flash-lite",
    instructions="Use the remote documentation tools.",
    tools=[
        {
            "type": "mcp_remote_client",
            "url": "https://mcp.deepwiki.com/mcp",
            "name": "deepwiki",
        }
    ],
)

result = agent.run_sync("What transports does the MCP spec support?")
print(result.output, result.tools_used)

Configuration keys:

Key Default Meaning
url required Server endpoint (server_url is accepted as an alias)
name "remote-mcp" Server label used in logs and LoadedTool.server_name
transport "streamable_http" "streamable_http" or "sse"
headers none Extra HTTP headers sent on every request
authorization_token none Bearer token; becomes an Authorization header unless headers already carries one
auth none An httpx.Auth instance (for example mcp.client.auth.OAuthClientProvider)
timeout 30 HTTP timeout in seconds, also the session read timeout
sse_read_timeout 300 Read timeout for the event stream
allowed_tools all Tool names to expose; the model is shown only these (see Choosing which tools a server exposes)

Credentials given as headers, authorization_token, or auth are attached to the HTTP client, so the very first initialize request carries them, and they never reach LoadedTool, the logs, or error messages. A rejected connection raises RuntimeError: Failed to connect to remote MCP server '<name>' at <url>: HTTP 401 Unauthorized. Other failures are unwrapped to the leaf exception rather than surfacing as a nested ExceptionGroup, and list_tools pagination (nextCursor) is followed to the end.

url must be an absolute http/https URL and may not embed credentials (https://user:secret@host raises a ValueError) — pass them as headers, authorization_token, or auth instead. Messages and debug logs quote a redacted scheme://host[:port]/path, never the userinfo, query string, or fragment, so a token carried in a query parameter cannot leak through an error.

Format 9 is an explicit opt-in, not a replacement for format 7

{"type": "mcp", "server_url": ...} still routes to the provider's own remote-MCP connector (Anthropic, OpenAI, Fireworks) and is unchanged. Switching to format 9 changes latency, token accounting (tool results now flow through your prompt), hook coverage, and which host makes the outbound request — choose it deliberately.

Remote tool results are unbounded

A client-side tool result is inserted into the next request, so a chatty server dominates cost: one measured run pulled 131,740 input tokens ($0.26) to answer a question a sibling run answered for $0.012. Use allowed_tools, or cap result size server-side.

Choosing which tools a server exposes

Every MCP format takes an allowed_tools list, the names of the tools to expose. No key exposes every tool, as before.

Format Who filters How
4, FastMCP in-process the toolkit the dict form {"type": "fastmcp", "server": server, "allowed_tools": [...]} (a bare server object has nowhere to put the key)
5, mcp_script; 6, mcp_command; 8, pre-built session the toolkit the key in the config dict
9, mcp_remote_client the toolkit the key in the config dict
7, provider-run, on OpenAI OpenAI forwarded as its own allowed_tools: a list of names ([] exposes none), or OpenAI's filter object {"tool_names": [...], "read_only": bool}, sent as given (parameter_mapping.openai_allowed_tools)
7, provider-run, on Anthropic Anthropic translated into the toolset's allow-list: default_config: {"enabled": false} plus configs: {name: {"enabled": true}} for each listed tool. Any default_config / configs you give are kept, and allowed_tools decides enabled
7, provider-run, on Fireworks nobody refused: Agent(...) raises a ValueError pointing to format 9 (parameter_mapping.refuse_provider_ignored_allowed_tools)
Google — no provider-run MCP; use format 9

For the formats the toolkit runs, the filter sits on the session the provider is given: every provider lists a session's tools itself, so the model is shown only the allowed ones, and a call to any other name (from a replayed history, say) gets an error result without reaching the server. The filtered listing follows list_tools pagination to the end, so an allowed tool on a later page is shown too. A listed name the server does not offer logs one WARNING naming the server and what it does offer. allowed_tools must be a list of names and is checked when the Agent is built: a bare string raises TypeError (on mcp_remote_client it used to be read as a set of characters, which hid every tool). The same pre-built session object can take only one filter: listed twice with different allowed_tools, only the first entry applies, with a WARNING.

A provider-run server's value is checked when the Agent is built too (parameter_mapping.check_provider_run_allowed_tools), whether the server is in tools= or in the mcp_remote_servers= keyword (the form the agent factory's create_agent builds). Anthropic takes a list of names, and OpenAI a list or its filter object. Anything else raises TypeError, and Fireworks refuses the key with a ValueError.

A direct provider call (call_<provider>_llm_* with mcp_remote_servers) is covered when its request is built:

  • Anthropic translates the key into the allow-list; it used to ignore it.
  • OpenAI checks it.
  • Fireworks refuses it; it used to drop it.

In both old cases the model saw every tool. A bad value fails the call: it raises, except on Anthropic's tool loop, which reports it as the run's error.

On OpenAI an empty list is sent, so the model sees no tool from that server. Before 0.5.1 the request dropped it, and the model saw every tool: 280 input tokens on DeepWiki against 18 with the list sent (measured 2026-09-29). OpenAI's filter object is sent as given, and OpenAI decides what it means. Measured the same day:

  • {}, {"read_only": false} and {"tool_names": []} expose every tool.
  • {"read_only": true} exposes only the tools the server marks read-only (none on DeepWiki).
  • A misspelled key such as tool_name draws a 400 naming tool_names.

To hide every tool, send the empty list, not an empty tool_names.

from doc_processing_toolkit import Agent
from doc_processing_toolkit.mcp_servers.multimedia_server import multimedia_server

# The app speaks through its own tool; the agent gets only the server's image tools.
agent = Agent(
    model="claude-sonnet-5",
    tools=[
        speak,  # the app's own @tool
        {"type": "fastmcp", "server": multimedia_server, "allowed_tools": ["generate_image", "edit_image"]},
    ],
)

Fireworks ignores allowed_tools on its provider-run MCP

Measured 2026-09-27 on kimi-k3 against the DeepWiki server: with and without allowed_tools: ["read_wiki_structure"] the model listed all three tools, at the same input size, and a made-up field was accepted just as silently. Passing the key on would expose tools you meant to hide, so the toolkit refuses it. mcp_remote_client filters on every provider, Fireworks included.

mcp_remote_client before 0.5.1

Its allowed_tools used to trim only the toolkit's own tool list, while the provider listed the session's tools itself, so the model still saw every tool. Measured on gemini-3.5-flash-lite with DeepWiki: the provider loaded 3 tools with the filter before the fix, 1 after it.

MCP session lifecycle

ToolLoader opens MCP sessions for formats 4, 5, 6, 8 and 9 when the Agent connects, and it does so once per Agent per event loop:

  • The first run() connects; every later run() and every AgentSession turn reuses those sessions. One stdio server process serves the whole loop.
  • await agent.close() closes the sessions and drops the tools they registered, but keeps the server configs — the next run() reconnects. An Agent is reusable after close().
  • run_sync() (on Agent and AgentSession) creates its own asyncio.run loop, so it connects, runs, and releases inside that call: one server process per call. To reuse a single process across runs, keep a long-lived loop and await agent.run(...).
  • Sessions are only closed from the loop that opened them; sessions found on a dead loop are discarded and reconnected instead of being awaited.
  • A failed connect rolls back its partial registrations, so a retry never ships duplicate tool names.

Pre-built sessions (format 8) stay async-only

A session you built yourself belongs to the task and loop you opened it in, so it must stay open for the whole run, and run_sync() cannot use it. Reach for format 9 when you want the toolkit to own the connection — it supports run_sync and reconnects on its own.

FastMCP objects on fastmcp>=3

Standalone fastmcp 3.x exposes no private tool registry, so such servers are registered at load() and their tools are listed when the Agent connects, through the public async list_tools() / call_tool(). SDK-bundled mcp.server.fastmcp and fastmcp<3 are unchanged. A server offering neither surface logs a warning naming it.

Tool schema model

  • @tool parses Python signatures + type hints + docstrings.
  • Generated schema is normalized for provider compatibility.
  • Provider conversion is handled by ToolLoader.convert_to_provider_format(...).
  • Under OpenAI strict mode every property is required (the toolkit lists them all in required), so a model cannot omit an optional argument it does not need: it fills it with a placeholder — 0, "", tab_id=0 — or with null where the schema allows one. A tool author should treat such placeholders as absent; std-tools 0.6.0 does.

Strict tool schemas on Anthropic

Agent(strict_tools=True) (the default) sends Anthropic tools with strict: true, so the model's tool inputs always match their schemas. Anthropic limits strict mode per request, and refuses a request that goes over any limit with a 400 naming it. The limits were measured on 2026-09-28:

Limit What counts
20 strict tools each tool sent with strict: true (deferred tools included; the response schema is not a tool)
24 optional properties every property not in its object's required list, nested objects included, in every strict tool and in a structured run's response schema
16 union-typed parameters every anyOf / oneOf with two or more branches and every type list, such as Optional[str], counted the same way

A subschema written twice counts twice, and a $defs entry counts once. Since 0.5.1 the toolkit keeps every request within all three limits. It counts a schema as it is sent, and it counts a single-branch anyOf / oneOf or a one-element type list as a union too, on the safe side:

  • The response schema's share is reserved first.
  • The tools with the fewest optional properties are made strict first, then those with the fewest union-typed parameters, then by name. The choice does not depend on the order the tools arrive in, and it is made once per run.
  • A different response schema reserves a different share, so a turn with its own output_schema can make a different set of tools strict than the turn before. The tool definitions then differ, and Anthropic's prompt cache is written again for that request.
  • A tool that does not fit is still offered, without strict mode, and one WARNING names every such tool.
  • A tool with more than 24 optional properties on its own was already sent without strict mode, and still is.

A large MCP server routinely exceeds the budget on its own: attached whole, std-tools' browser server (0.6.0) leaves several of its tools without strict mode and its computer server one or more — how many depends on the profile and on whether the server runs in-process or over mcp_command, whose schemas differ. That is the expected shape, not a fault — the WARNING names the tools and the run proceeds; narrow the server with allowed_tools when every tool must be strict.

Before 0.5.1 only each tool's own optional properties were checked. The agent-factory server's nine small tools hold 40 between them, so every Claude agent that attached the server drew the 400, as did any agent with more than 20 tools. DPT_ANTHROPIC_STRICT_BUDGET=off restores the per-tool check alone (see Configuration). strict_tools=False turns strict mode off entirely, and non-strict tools count against none of the limits.

Execution model

  • Local tools, local MCP sessions, and client-side remote MCP sessions are connected before run execution and reused for the life of the event loop.
  • Provider-native remote MCP configs (format 7) are stored and passed to provider-native fields.
  • An in-process FastMCP tool (format 4) runs in this process: called by the toolkit on the SDK-bundled FastMCP and fastmcp<3, through the server's public call_tool on fastmcp>=3. On the first kind a return value that is, or holds, an Image / Audio helper or an MCP text, image or audio content object is converted the way the server's own call_tool converts it (0.5.1), so an image reaches the model as an image; every other value keeps its formatting.
  • max_tool_iterations bounds tool-call loops.
  • max_tool_result_chars bounds the size of each tool result fed back to the model — see below.
  • defer_tools hides chosen client tools behind the provider's native tool search (Anthropic and OpenAI) so their definitions load only when the model searches for them. Enable the tool_search built-in tool alongside it. Elsewhere, or without tool_search, every tool loads eagerly with one WARNING. result.deferred_tools lists what was actually deferred. See Tool search and deferred tools.

Tool-result size cap

A tool can return far more text than the model needs — a remote MCP server once handed back a 400,000-token wiki page in one result. Every result the toolkit executes is capped at the point it is fed back to the model, identically on all four providers: @tool functions, every local MCP format and client-side remote MCP (mcp_remote_client). It applies whoever wrote the server: the toolkit's own servers (multimedia, document processing) are capped like any third-party one.

Agent(max_tool_result_chars=...) Cap
None (default) Derived from the registry: context_window_tokens × 4 × 0.25 characters — a quarter of the model's window at four characters per token, numerically one character per token of window. 200,000 characters on a 200k-token model, 1,000,000 on a 1M-token model (utils.TOOL_RESULT_CAP_CHARS_PER_TOKEN, utils.TOOL_RESULT_CAP_CONTEXT_FRACTION; a row with no window floors at 128,000).
a positive integer That many characters.
0 Off — every result is fed back whole, the behaviour before this release. A value larger than any result has the same effect.

The text items of a result are counted together. A longer result keeps its text up to the cap, the item that crosses it ends with a visible marker the model can read — … [truncated: N of M characters shown; raise max_tool_result_chars to keep more] — later text items are dropped, and images or other non-text items pass through untouched. One WARNING is logged per truncated result. In YAML the same setting is the top-level key max_tool_result_chars: (a non-negative integer, or omitted for the default), and Agent.from_config(..., max_tool_result_chars=N) overrides it. The cap applies to what the model sees (and so to what a session stores). The tool_call_result stream and hook events carry their own copy, cut separately by tool_result_preview_chars: the first 500 characters by default, the whole result at 0 (see Tool results in events). The raw results the loop keeps as tools_used evidence are never cut.

Provider-executed tools are not capped

A {"type": "mcp", ...} server (the provider's own connector) runs inside the provider's request: the provider feeds its result to the model without the toolkit ever seeing it, so no client-side cap can apply. Use mcp_remote_client for a server whose results you need to bound. The same holds for provider built-in tools (web search, code execution and the like): the provider runs them and feeds their results to the model itself.

Letting the model read past the cut

The marker tells the model that it holds only part of a result, but by default it cannot see the rest. Agent(truncated_result_read_chars=N) (0.5.1, off by default) lets it read on, up to N more characters per result:

agent = Agent(
    model="claude-sonnet-5",
    tools=[fetch_filing],
    max_tool_result_chars=50_000,  # what the model is shown at first
    truncated_result_read_chars=200_000,  # how much more it may read back, per result
)

With the setting on:

  • The built-in tool read_truncated_result(tool_call_id, offset) is on the tool list from the first request of every run that has tools the toolkit runs, so the list never changes mid-run and prompt caching is unaffected. Its description tells the model to use it only on a result marked truncated.
  • The marker on a cut result names it: … [truncated: N of M characters shown. Call read_truncated_result(tool_call_id="…", offset=N) to read up to K more.], with the real id, offset and allowance filled in (K is never more than what is left).
  • Each call returns the next slice, at most max_tool_result_chars long, ending with where it stopped: … [characters A to B of M. Call read_truncated_result(tool_call_id="…", offset=B) to read up to R more.], or : the end of the result.. Every character a call returns counts against the allowance, a part read twice included; past it the tool answers with a short refusal the model can read. An id that names no truncated result answers "This result was not truncated", at the cost of one small tool round, and a bad offset gets an error naming the valid range.
  • Only truncated results are kept, in full and in memory: for the run, or for an AgentSession's lifetime, so a later turn can read on from an earlier turn's result. Several text items are kept one per line, as the model was shown them. They are never part of an exported session: in a restored session a read of an old result answers that it is no longer available (its id is never reused for a newer one). session.clear() and session.close() release them, and two sessions running turns on one Agent at the same time keep their own.
  • It covers what the cap covers: @tool functions and every MCP server the toolkit runs. Images and other non-text items are never cut, so there is nothing to read back, and a provider-run MCP server never passes through the toolkit.
  • read_truncated_result calls appear in tools_used, on the tool_call_result events and in hooks like any tool (a hook can decline one), and their slices are billed as ordinary input on the next request.

The ids in the marker are the provider's tool-call ids, made unique with a -2, -3… suffix where a provider reuses one across turns, or result-1, result-2… where it gives none (Gemini models before 3.5). The setting has no effect with max_tool_result_chars=0 (nothing is cut) or with a tool_choice that forces a call ("any", "required", a named tool: the run ends after one tool round, so the model could not read on), and the tool is not offered when one of your tools already has that name; each logs one WARNING. In YAML the key is truncated_result_read_chars: (a non-negative integer or null), and Agent.from_config(..., truncated_result_read_chars=N) overrides it. The kept text lives in a utils.TruncatedResultStore, and the tool is served by loader.TruncatedResultSession under the server name truncated-results.

Steering tool use with tool_choice

tool_choice is normalised by the toolkit and translated per provider, so the same value works everywhere:

Value Meaning
"auto" or unset Model decides whether to call a tool
"any" / "required" Model must call at least one tool
"none" Tools are disabled for the turn
{"type": "tool", "name": "<tool>"} Force one named tool
{"type": "tool", "name": ["<a>", "<b>"]} Google only — force a call, restricted to this allow-list

Two provider caveats apply to the forced forms:

  • Some models ignore or reject a forced tool. A registry row may declare supports_forced_tool_choice: false; absent, the key means true. On Fireworks a forced {"type": "tool", "name": ...} is still forwarded as-is and the toolkit logs a warning naming the tool you asked for, because the request is valid and often still works — treat it as a hint and verify what actually ran in result. On Anthropic the flag is a harder gate: a forced any/tool choice is downgraded to auto with a warning, and tool-based structured output (output_schema plus tools on a model without supports_function_calling_and_structured_outputs) refuses with a compatibility error before any request is sent, rather than returning a provider 400 mid-session.

    Three rows carry the flag today, claude-fable-5-1, claude-opus-5-5 and claude-sonnet-5-5 — all Anthropic, and all reject a forced choice on Messages, Batches and token counting. The Fireworks carrier that used to hold it, muse-glimmer-30b, left with the 0.3.1 catalogue audit, so the Fireworks warning path currently sees no carrier at all. Set it on a further row only when a live run shows that model ignoring or rejecting a named tool_choice. - The list form is Google-specific. It becomes mode=ANY plus an allowed_function_names restriction on the Gemini ToolConfig. Anthropic, OpenAI, and Fireworks expect a single string and will not accept a list there.

The Google allow-list is actually sent as of 0.3.0

An inverted type check nulled every list-shaped allow-list before the request was built, so mode=ANY went out with no restriction at all and Gemini could call any tool it liked. The single-string form was unaffected. This looked from the outside like a Gemini API bug — models returning parallel calls outside the allow-list — and was tracked as one until a log review traced it to the toolkit. The config actually sent is logged on every call (Built Google ToolConfig: mode=..., allowed_function_names=...), so a future discrepancy is diagnosable from the logs alone. A tool_choice naming nothing usable now logs a warning instead of silently degrading.

Gemini and tool_choice under tool combination

Through 0.2.x, combining built-in and custom tools on Gemini overrode an AUTO or unset tool_choice to VALIDATED, because the API rejected AUTO in that configuration. The API changed and 0.3.0 passes your tool_choice through unchanged.

Google's tool-combination guide now carries a Limitations line saying the mode defaults to VALIDATED and that AUTO "is not supported" when server-side tool invocations are enabled — but a bounded live check on 2026-09-20 (five runs across gemini-3.5-flash-lite and gemini-3.7-flash, a custom tool plus web search, with AUTO, with VALIDATED and with the mode omitted) executed the custom tool and invoked and billed the built-in every time. The toolkit therefore keeps sending your tool_choice, and the default stays "auto" on every provider. See the built-in tools recipe.

Built-in tools and local tools together

  • Built-in tools are configured via built_in_tools (for example web search or code execution).
  • Local tools are configured via tools and go through MCP/session tool loading.
  • Concurrent behavior is provider and model dependent, gated on the registry flag supports_concurrent_built_in_and_custom_tools. Anthropic and OpenAI support it broadly, Google supports it on Gemini 3 text models (Preview), and Fireworks does not support it at all. Supplying both kinds to a model flagged false raises a ValueError.
  • See the provider comparison and the built-in tools recipe.