Skip to content

feat: track mcp/sdk servers in any order, before or after build() - #12

Open
naji247 wants to merge 2 commits into
mainfrom
feat/call-order
Open

naji247 wants to merge 2 commits into
mainfrom
feat/call-order

Conversation

@naji247

@naji247 naji247 commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Summary

With mcp/sdk, the place of AgentCat::track() no longer changes what AgentCat records, as with the TypeScript SDK. Version 1.0.0 had four order rules, and this pull request removes them:

Rule in 1.0.0 In this pull request
track() must run before build(); after build() it logs "the mcp/sdk Builder was already built" and tracks nothing; a built Mcp\Server is not accepted track() takes the Builder before or after build() or buildStateless(), and the built Mcp\Server
A tools/call or tools/list handler added with addRequestHandler() before track() answers first, and AgentCat records nothing AgentCat's handlers go to the front of the Builder's list, wherever track() runs, and delegate to the customer's
setReferenceHandler() after track() loses the exception of a failing tool AgentCat wraps the reference handler at the first call, not at track()
Symfony: a handler that the customer's own compiler pass adds stays ahead of AgentCat's Fixed by the same front-of-list change

How it works

  • Before build() (McpSdkAdapter::install()): AgentCat puts its two handlers before every handler added so far (BuilderAccess::prependRequestHandlers()), and no longer calls setReferenceHandler() or setContainer(). At the first tools/call, Delegates::runner() copies the library's CallToolHandler around a CapturingReferenceHandler of the reference handler that build() gave it.
  • After build() (BuiltServer): the request handlers are fixed in readonly members, so AgentCat changes the two objects they share, which stay mutable:
    • The tool registry. Each tool is replaced by a tool that advertises AgentCat's parameters and runs through RegistryCall. That handler takes mcp/sdk's injected RequestContext, runs the same interception as before build() with a copy of the library's handler over the tools as registered, and returns the result or rethrows the tool's exception, so the library answers and logs as it would without AgentCat.
    • The library handler's schema validator (InjectedSchemaValidator), which checks the arguments without AgentCat's injected ones against the tool's own schema. The errors are the same as before build().

Limits after build()

The library answers these before any tool runs, so they give no event; the docs say so:

  • A call to a tool name that the registry does not know.
  • A call whose arguments fail the tool's input schema (the client gets the library's -32602, as before).
  • A call that a tools/call handler of the customer's answers.

A server with a reference handler of the customer's is not tracked after build(): AgentCat's tool handler would run inside it, which could run each tool twice. AgentCat logs "the mcp/sdk server has a reference handler of its own (…): track the Builder before build()".

The docs change is agentcathq/agentcat-ui#256.

Two listing differences after build(): get_more_tools is the registry's last tool, so a full last page puts it on a page of its own, and ResponseEvent listeners see the injected tools.

Behavior changes for 1.0.0 users

  • A tools/call or tools/list handler added before track() (or in a Symfony compiler pass) is now tracked: its listing gets session_id, context and get_more_tools, and each call gives an event. Under PHP-FPM, such a tools/list handler also runs once for each page that AgentCat reads during a tools/call.
  • With setReferenceHandler() after track(), error events now carry the tool's exception.
  • track() after build() now tracks the server.

Tests

  • The mcp/sdk scenario suites (CallTool, ListTools, CallErrors, Hooks, RequestContext, ResponseEvent, ListingGolden) run three times, through Entry: the Builder before build(), the Builder after build(), and the built server. The tests of the limits above skip after build(), with the reason.
  • The HTTP (php -S) and stdio end-to-end tests also run on the built server.
  • New tests: handlers added before track(), a reference handler set after track(), track() on the Builder or the server after build(), tracking both, a Builder after buildStateless(), a custom reference handler after build(), a registry that cannot be read, InjectedSchemaValidator, and Delegates::runner() with drift.
  • Contract tests and member snapshots for the new private members (Builder::$requestHandlers, Registry::$tools, CallToolHandler::$registry, $referenceHandler, $logger, $schemaValidator) and the extended SchemaValidator.

Test plan

  • composer check: php-cs-fixer, Rector, PHPStan (both configurations), the dependency analyser and every suite
  • mcp/sdk 0.9.0 leg: Contract, Integration and E2E, 1,161 tests
  • Mutation testing on the changed lines: 192 mutants, 99% covered MSI (one equivalent mutant left)
  • php tests/bin/docs-samples.php on the docs pull request: 58 PHP blocks pass php -l and PHPStan
  • CI on this pull request, including every released-version leg

AgentCat::track() takes the Builder before or after build() or
buildStateless(), and the Mcp\Server built from it. The place of the
call among the customer's calls no longer changes what AgentCat records:

- AgentCat's request handlers go to the front of the Builder's list, so a
  tools/call or tools/list handler added before track(), or by a Symfony
  compiler pass, is delegated to and recorded.
- AgentCat wraps the reference handler at the first call instead of at
  track(), so setReferenceHandler() after track() keeps the exception.
- After build(), AgentCat instruments the server's tool registry and the
  schema validator of the library's tools/call handler, which stay
  mutable. Calls to unknown tools, calls with invalid arguments and calls
  answered by a customer's tools/call handler give no event there, and a
  server with a reference handler of its own is tracked only before
  build().

The mcp/sdk scenario suites and the HTTP and stdio end-to-end tests run
for the Builder before build(), the Builder after build() and the built
server.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant