Skip to content

fix(protocol): bind the ToolContext on fastmcp apps through their middleware; refuse only a declared reader without a seam - #162

Merged
earakely-scale merged 2 commits into
mainfrom
fix/protocol-fastmcp-middleware-binding
Oct 11, 2026
Merged

earakely-scale merged 2 commits into
mainfrom
fix/protocol-fastmcp-middleware-binding

Conversation

@earakely-scale

@earakely-scale earakely-scale commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Protocol 0.1.324 made every mount refuse an app without mcp FastMCP's _tool_manager. That is every environment built on the standalone fastmcp package (a plugin mounts through AgentEnvEnvironment.mount(fastmcp.FastMCP(..., middleware=[...]))), so those plugins pinned the protocol below 0.1.324. The check conflated "no mcp seam" with "cannot bind".

What changes. _check_tool_binding now decides per app, before any route or tool is added:

app binding
mcp's own FastMCP (mcp>=1.25,<2) the _tool_manager.call_tool wrap, unchanged
standalone fastmcp (3.x; a middleware list) a Middleware inserted first in the chain; first runs outermost, so a handler's own middleware and the tool both run under the binding. Its on_call_tool binds the ToolContext, runs on_tool_call, and maps a returned CallToolResult onto fastmcp's ToolResult with the error flag kept
neither refused only when a tool declares a ToolContext parameter or the handler overrides on_tool_call; otherwise mounts unbound with one warning naming the app type

The binding stays unconditional wherever a seam exists. That is the contract tools and dispatch guards rely on when they read ToolContext.current() without declaring anything; gating it on declared parameters would have silently removed the caller from every such reader.

Tests. fastmcp>=3.4,<4 joins the dev extras (root, which CI installs for the protocol tests, and the package's own), so the new module runs against the real package: _meta roles to a sync tool with an injected parameter and an async tool reading ToolContext.current(), the handler's own middleware seeing the bound role, the header role over streamable HTTP with its own session, hook refusal reaching the client with the tool never run, 24 concurrent callers, one handler per app, and the no-seam rule both ways. The one existing test that asserted the old refusal wording asserts the new one. 433 protocol tests pass locally with fastmcp 3.4.8 installed; the module skips when fastmcp is absent.

Not changed. The mcp FastMCP path is the same wrap as before, so environments on it (every synthetic server, the gateway) behave identically.

Chaos pass (nine agents, each a different way of instrumenting an environment, run against this branch; findings folded into the second commit).

  • mcp's own FastMCP: create_app, a caller-built app with its own lifespan, direct AgentEnvFastMCPApplication, 200 concurrent in-memory calls across eight sessions and four HTTP sessions with ten concurrent calls each, hook refusal, hook raising, hook returning garbage, tool raising, unknown tool, Starlette for a tool-less handler, extension routes: 15/15, and verdict-identical to the released 0.1.324 wheel run on the same file.
  • synthetic-pipelines BaseService servers on this protocol: the 18-file suite (697 tests) passes; 300 interleaved calls over three sessions with three roles, none crossed; the dispatch wrap and its marker unchanged.
  • agentenv-framework tst/unit on this protocol: green.
  • fastmcp 3.4.8: user middleware before and after mount, a short-circuiting middleware, cancellation mid-call, 400 concurrent calls, the header role over HTTP, malformed _meta: 15/15. Baselines reproduced: 0.1.321 mounts with no caller at all, 0.1.324 refuses the mount.
  • The portsim plugin's envs on this protocol: every observable (tool list and order, texts, episode counters and budgets) byte-identical to the plugin's own 0.1.321 venv, with the binding now live inside its tools. Its bundle-install tests fail in the scratch venv only because that venv's framework exact-pins a different protocol version.
  • fastmcp 4.1.0 on mcp 2.3.0: mount and binding work and header roles arrive, but _meta roles were dropped because mcp 2 hands _meta over as a dict. Fixed in the second commit and rechecked. create_fastmcp_app on mcp 2 is pre-existing (mcp 2 ships no bundled FastMCP).
  • Three review lenses (correctness, compatibility, isolation), folded: a middleware list is a seam only with add_middleware beside it; fastmcp is checked before the tool manager because fastmcp 2.x keeps a manager of another shape; fastmcp is imported in the check, before any route; the no-seam warning names the module-qualified app type, the environment and a remedy; the result conversion reads mcp 2's spellings first; the on_tool_call docstring states that on fastmcp a non-error answer passes through output validation. Noted and left: a middleware that replays a cached call_next from another call's task runs it under that task's caller (contextvar semantics), and the card schema advertises a fastmcp Context parameter (pre-existing).

Rollout. Carries bump-version; plugins drop their <0.1.324 pin once the release is out.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The reviewed changes appear safe to merge; no blocking issue was found.

Summary

This PR adds ToolContext binding for standalone fastmcp apps while keeping mcp's existing dispatch wrapper.

  • Standalone fastmcp apps now bind each tool call to its caller.
  • Apps without a binding seam mount only when nothing reads context.

No actionable new issues were found in the changes since the last reviewed commit. No previous review threads were supplied, and GitHub access was unavailable. Tests were inspected, not run.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["Mount environment"] --> B{"middleware list and add_middleware?"}
  B -->|Yes| C["Build ToolContext middleware"]
  B -->|No| D{"Tool manager supports dispatch?"}
  D -->|Yes| E["Choose dispatch wrapper"]
  D -->|No| F{"Declared ToolContext reader or hook?"}
  F -->|Yes| G["Refuse mount"]
  F -->|No| H["Warn and mount unbound"]
  C --> I["Add routes and tools"]
  E --> I
  I --> J["Install chosen binding"]
Loading

Reviews (2) · Last reviewed commit: "fix(protocol): read a dict _meta, take o..." · Reviewed by Greptile

…dleware; refuse only a declared reader without a seam

0.1.324 refused to mount any app without mcp FastMCP's _tool_manager, which
is every environment built on the standalone fastmcp package (plugins mount
through AgentEnvEnvironment.mount(fastmcp.FastMCP(...))); they had to pin the
protocol below 0.1.324. The check conflated "no mcp seam" with "cannot bind".

Now _check_tool_binding decides per app: mcp's FastMCP keeps the call_tool
wrap; a fastmcp app gets a Middleware inserted first in its chain (first runs
outermost, so a handler's own middleware and the tool run under the binding),
whose on_call_tool binds the ToolContext, runs on_tool_call and maps its
CallToolResult onto fastmcp's ToolResult; an app with neither seam is refused
only when a tool declares a ToolContext parameter or the handler overrides
on_tool_call, and otherwise mounts unbound with one warning. The binding
itself stays unconditional wherever a seam exists: tools and guards read
ToolContext.current() without declaring anything.

fastmcp>=3.4,<4 joins the dev extras so the new tests run against the real
package: _meta roles to sync and async tools, the header role over HTTP with
its own session, hook refusal, 24 concurrent callers, one handler per app,
and the no-seam rule. The one test that asserted the old refusal wording now
asserts the new one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@socket-security

socket-security Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpypi/​fastmcp@​3.4.810010090100100

View full report

… import fastmcp before any route

Folded from the chaos pass on the previous commit. mcp 2 hands a request's
_meta over as a plain dict rather than a model with model_extra, so on
fastmcp 4 the mount bound every call but read no role; _meta_entries now
takes either shape. A middleware list only counts as fastmcp's when
add_middleware is there too, so a foreign object carrying a list of its own
is treated as having no seam, and that branch is checked before the tool
manager because fastmcp 2.x keeps a manager whose dispatch has another
shape. The fastmcp import moves into the check, so a missing fastmcp fails
before any route or tool has been added. The no-seam warning names the
module-qualified app type, the environment and a remedy; the ToolResult
conversion reads mcp 2's spellings first so it never trips the deprecation
shims; on_tool_call's docstring says that on fastmcp a non-error answer
passes through output validation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@earakely-scale
earakely-scale merged commit 736590a into main Oct 11, 2026
13 of 14 checks passed
@earakely-scale
earakely-scale deleted the fix/protocol-fastmcp-middleware-binding branch October 11, 2026 19:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant