Skip to content

Add hook or middleware for transforming tools/list responses #1757

Open

Description

@tizmagik

Is your feature request related to a problem? Please describe.

The MCP SDK provides no public mechanism to transform tools/list responses before they're sent to the client. This forces servers that need to augment tool definitions — for example, promoting fields from _meta to the tool root level — to access private internals and re-implement the SDK's serialization logic.

Concrete use case: ChatGPT mixed-auth security schemes

ChatGPT's Actions/Connectors platform expects securitySchemes as a root-level field on each tool definition in tools/list responses (OpenAI docs: Build with Auth).

However, the SDK's registerTool only supports securitySchemes inside _meta, and the
built-in tools/list handler serializes it there — not at the root level.

To bridge this gap, servers must:

  1. Access the private _registeredTools field on McpServer (via as any cast)
  2. Override the ListToolsRequestSchema handler via server.setRequestHandler
  3. Re-implement the entire tool serialization — including normalizeObjectSchema,
    toJsonSchemaCompat, outputSchema handling, annotations, execution, etc.

This is ~80 lines of duplicated SDK internals that will silently break on any refactor of
the tool listing logic. Multiple production MCP servers have independently converged on this
identical workaround.

Describe the solution you'd like

Any of the following would solve this cleanly (in rough order of preference):

Option A: Response transform hook

mcpServer.onToolsList((tools) => {
  return tools.map(tool => ({
    ...tool,
    securitySchemes: tool._meta?.securitySchemes,
  }));
});

A callback that receives the fully-serialized tool list and returns a (potentially modified) version. This is the most flexible option and avoids exposing internal data structures.

Option B: Middleware / chaining for setRequestHandler

// Get the current handler before replacing it
const original = server.getRequestHandler(ListToolsRequestSchema);

server.setRequestHandler(ListToolsRequestSchema, async (req, extra) => {
  const result = await original(req, extra);
  // transform result.tools
  return result;
});

Adding a getRequestHandler method to Protocol would let consumers wrap existing handlers without re-implementing them. This is more general-purpose and benefits all request types.

Option C: Public read-only accessor for registered tools

const tools: ReadonlyMap<string, RegisteredTool> = mcpServer.registeredTools;

This would at least eliminate the as any cast, though consumers would still need to re-implement serialization. (See also #1036.)

Describe alternatives you've considered

  • Accessing _registeredTools directly — works today but requires as any, duplicates serialization logic, and is fragile across SDK versions. We pin to patch versions (~1.26.0) to mitigate breakage.
  • Intercepting at the transport level — even more fragile; requires parsing/modifying JSON-RPC messages.
  • Saving RegisteredTool references at registration time — the RegisteredTool interface provides .update() but no control over response serialization shape.

Additional context

Activity

  1. added
    enhancementRequest for a new feature that's not currently supported
    on Mar 25, 2026
  2. added a commit that references this issue on Mar 25, 2026
    64a0ddf
  3. tizmagik commented on Mar 25, 2026

    @tizmagik
    Author

    Opted for Option B as the most flexible/useful for other request types, if this is palatable:

  4. added a commit that references this issue on Mar 25, 2026
    b86068f
  5. tizmagik commented on Mar 30, 2026

    @tizmagik
    Author

    Thanks for the review on #1759 @felixweinberger. I understand the hesitation about exposing Protocol internals.

    I can see that mcpServer.server.setRequestHandler('tools/list', ...) works for overriding the handler — that part is straightforward. The blocker is what goes inside that handler: _registeredTools is private, so there's no way to access the tools that McpServer has registered in order to serialize them. The serialization logic (schema conversion, enabled filtering, outputSchema handling, etc.) is also inline in the handler rather than extracted as a reusable utility.

    So in practice, the "use Server" escape hatch requires either maintaining a parallel tool registry alongside McpServer's, or accessing _registeredTools via as any — which is the fragile workaround described in this issue.

    A couple of alternatives that wouldn't touch Protocol at all:

    1. Extract the tool serialization into a reusable utility — export the function that converts RegisteredTool → Tool (the map callback inside setToolRequestHandlers). Then users who override via mcpServer.server.setRequestHandler could call it without duplicating the logic. Minimal API surface — just one utility function.

    2. A public read-only accessor for registered tools (Option C from this issue / related to Simplify public access to McpServer registered tools #1036) — mcpServer.registeredTools as a ReadonlyMap. Combined with the exported serialization util above, this would let users build custom tools/list handlers cleanly.

    Neither of these touches Protocol. Would either direction be more acceptable?

  6. ghost removed
    needs confirmationNeeds confirmation that the PR is actually required or needed.
    on Apr 16, 2026
  7. ghost added
    needs decisionIssue is actionable, needs maintainer decision on whether to implement
    on Apr 16, 2026
  8. ghost added
    P3Nice to haves, rare edge cases
    on Apr 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P3Nice to haves, rare edge casesenhancementRequest for a new feature that's not currently supportedneeds decisionIssue is actionable, needs maintainer decision on whether to implement

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions