Skip to content

Inside the adapters

Section 5 · From docs/MCP_INTERNALS.md on GitHub

5.1 Detection

detect_server_kind(server) checks, in order: NativeMCPServer instance; any class in the MRO named FastMCP from a module whose top package is fastmcp; a type whose module starts with mcp.server.mcpserver or mcp.server.fastmcp; an instance of mcp.server.lowlevel.Server; duck typing (add_request_handler or request_handlers means low-level; _lowlevel_server or _mcp_server means high-level). register_into sets the prefix first, then calls the matching install.

5.2 lowlevel.py: LowLevelBinding

The binding wraps request handlers in place. _METHODS lists what it handles:

Method 1.x request class 2.x params class include kind Implementation
tools/list ListToolsRequest PaginatedRequestParams tools _list_tools
tools/call CallToolRequest CallToolRequestParams tools _call_tool
resources/list ListResourcesRequest PaginatedRequestParams resources _list_resources
resources/templates/list ListResourceTemplatesRequest PaginatedRequestParams templates _list_templates
resources/read ReadResourceRequest ReadResourceRequestParams resources _read_resource
prompts/list ListPromptsRequest PaginatedRequestParams prompts _list_prompts
prompts/get GetPromptRequest GetPromptRequestParams prompts _get_prompt
completion/complete CompleteRequest CompleteRequestParams completions _complete
resources/subscribe SubscribeRequest SubscribeRequestParams subscriptions _subscribe
resources/unsubscribe UnsubscribeRequest UnsubscribeRequestParams subscriptions _unsubscribe
logging/setLevel SetLevelRequest SetLevelRequestParams logging _set_level

sdk_major is 2 when the server has add_request_handler, else 1.

On mcp 2.x, _install reads the existing entry with server.get_request_handler(method), keeps its handler as orig and its params type, and registers handler(ctx, params), which calls impl(ctx, params_as_wire_dict, orig_call) where orig_call is lambda: orig(ctx, params) or None. On mcp 1.x it replaces server.request_handlers[RequestClass] with handler(req), which fetches server.request_context (a LookupError means no context), calls the implementation and wraps the result in ServerResult; orig_call unwraps the original's .root. Every implementation therefore receives the context, the params as a camelCase dict, and a way to call the host's handler.

Routing by ownership:

  • tools/call and prompts/get: owns_tool / owns_prompt require the prefix (when set) and a registry entry for the stripped name. Not ours: call the host, or raise -32602 Unknown tool when there is no host handler.
  • resources/read: owns_resource is an exact URI match or any template match. Not ours: host, or -32602 / -32002 by era.
  • completion/complete: ours when the prompt or the exact template string is ours; else the host, or an empty completion.
  • resources/subscribe, unsubscribe, logging/setLevel: recorded for cloudg, then always passed to the host when it has a handler.

List methods call the host first (when there is a host handler) and append cloudg's entries in _merge. When the host's result has a nextCursor, cloudg returns it untouched, so cloudg's entries end up after the host's last page. Entries whose key (name, URI or template) the host already has are dropped with a warning.

Errors raised to the SDK are MCPError(code, message, data) on 2.x and McpError(ErrorData(...)) on 1.x. NotFoundError from read_resource uses -32602 on 2026-07-28 requests and -32002 otherwise; InvalidArgumentsError maps to -32602; other layer errors keep their own code.

Era and session bookkeeping: _is_modern(ctx) compares ctx.protocol_version with mcp_types.version.MODERN_PROTOCOL_VERSIONS. _session_key(ctx) is the per-connection object used for subscriptions, log levels and list-changed fan-out: on 2.x the private ctx.session._connection for handshake-era requests and None for 2026 requests (which have no connection to notify); on 1.x the ServerSession itself.

Principal: request_info(ctx) reads ctx.request (the Starlette request on HTTP) for headers and the ASGI scope (where TokenAuthASGIMiddleware put the principal), asks mcp.server.auth.middleware.auth_context.get_access_token() for an OAuth token, and reads ctx.session.client_params.client_info. The transport is http when there is a request and stdio otherwise.

Tool context: _tool_context(ctx, principal) builds a ToolContext whose progress callback calls session.report_progress(...) on 2.x (the SDK sends nothing unless the client sent a progress token) or session.send_progress_notification(token, ...) on 1.x (only with a progressToken in ctx.meta, falling back to the old signature without message), and whose log callback filters by the level set with logging/setLevel (default info) on handshake-era sessions and calls session.send_log_message(...). On 2026 requests the SDK itself drops log notifications unless the request asked for them.

Three further patches:

  • _find_bus() (2.x): use MCPServer._subscriptions when the owner has one, else the bus of an existing subscriptions/listen handler (handler._bus), else install ListenHandler(InMemorySubscriptionBus()) when subscriptions is included. Change events are published on it as ToolsListChanged, PromptsListChanged, ResourcesListChanged and ResourceUpdated(uri).
  • _patch_initialization_options(): wraps server.create_initialization_options so that, when called without NotificationOptions, it advertises listChanged for tools, resources and prompts. Applied once per server (_cloudg_init_patched).
  • _patch_input_schema() (2.x): wraps server.get_tool_input_schema so the SDK's Mcp-Param-* header validation sees cloudg's input schemas for cloudg tools.

The binding appends itself to server.__dict__["_cloudg_bindings"], which keeps it (and its ChangeFanout) alive as long as the server.

5.3 mcp_sdk.py

is_sdk_highlevel_server(obj) checks the module path; inner_lowlevel_server(server) returns server._lowlevel_server (mcp 2.x MCPServer) or server._mcp_server (mcp 1.x FastMCP) and raises TypeError with a pointer to build_server when neither exists; install applies LowLevelBinding to the inner server with owner=server, which is where _find_bus looks for _subscriptions. The host's decorated tools live in the same low-level handlers, which is why wrapping works without touching the high-level API. create_lowlevel_server(layer, name=None) builds Server(name, version=..., instructions=...), retrying without instructions for very old 1.x; create_highlevel_server builds MCPServer on 2.x and FastMCP on 1.x.

5.4 fastmcp.py: FastMCPBinding

fastmcp keeps tools, resources, templates and prompts as component objects, and its own list handlers render them. The binding defines four subclasses at install time, closing over the binding:

Class Base Override
CloudGTool fastmcp.tools.Tool run(arguments) calls binding._run_tool(self.name, arguments)
CloudGResource fastmcp.resources.Resource read() calls binding._read(uri)
CloudGTemplate fastmcp.resources.ResourceTemplate matches(uri) uses cloudg's regex; create_resource returns a CloudGResource; read(arguments) expands the template and reads
CloudGPrompt fastmcp.prompts.Prompt render(arguments) calls binding._render(name, arguments)

_register instantiates one per spec with parameters set to the layer's inputSchema, output_schema, annotations (as mcp.types.ToolAnnotations), title and meta when the installed fastmcp's model has those fields, and tags {"cloudg", category, *tags}. Template parameters are a synthetic object schema with one required string per variable. Tools and prompts are registered under their exposed names, so set the prefix before installing (which register_into does).

Tool results: fastmcp normally rebuilds the CallToolResult from its own ToolResult. To keep cloudg's exact result (including isError with structured content and _meta), the binding uses ToolResult.from_mcp_result on fastmcp 4 and, on older versions, a ToolResult subclass whose to_mcp_result() returns the stored raw CallToolResult. The subclass is a pydantic model with a private attribute where the base class is a pydantic model (fastmcp 3.4.8) and a plain subclass otherwise (2.14.7).

Resource reads return fastmcp 3/4's ResourceResult([ResourceContent(...)]) when available, else the first content's text or bytes. Prompt renders return fastmcp's PromptResult and Message on newer versions and a list of SDK PromptMessage on fastmcp 2.

Policy filtering: fastmcp lists every registered component to everyone, so _install_middleware adds a fastmcp Middleware subclass (on_list_tools, on_list_resources, on_list_resource_templates, on_list_prompts) that drops cloudg components the caller may not see and swaps in the per-principal outputSchema. Calls are still enforced by the layer when the middleware is missing (fastmcp older than 2.9).

Protocol features: completions and resource subscriptions cannot be expressed as fastmcp components, so _install_lowlevel applies LowLevelBinding with include={"completions", "subscriptions"} and list_changed=False to server._mcp_server, and the binding then shares that binding's ChangeFanout. Verified state per version:

fastmcp mcp inner binding sdk_major 2026 listen bus result class
4.0.11 2.3.0 2 yes none (from_mcp_result)
3.4.8 1.30.0 1 no RawToolResult (pydantic)
2.14.7 1.30.0 1 no PlainRawToolResult

Request facts come from fastmcp.server.dependencies.get_context(), get_http_request() and get_access_token(). Errors: NotFoundError becomes fastmcp's NotFoundError, other layer errors ToolError, ResourceError or PromptError (ToolError when the installed version has no PromptError).

5.5 export.py

export_definitions(layer, principal) snapshots the four wire lists for the principal and returns closures over layer. The handlers take (params, *, principal=None) so a gateway can serve many principals from one export; tool_functions are bound to the export's principal. The converters read layer.tools_wire(principal) and reshape it; they do not call the layer.

5.6 ChangeFanout

ChangeFanout(layer, notifier, publish=None)

Registers a weakly referenced listener on the layer. track(session) records a session (weak set) and binds the fan-out to the running loop. on_change(kind, uri) schedules _dispatch on that loop, directly when called on it and with run_coroutine_threadsafe from other threads. _dispatch expands resources with a URI into a list change plus an update, publishes each event on the 2026 bus when publish is set, and calls notifier(session, kind, uri) for every tracked session, sending resource events only to sessions subscribed to that URI. A session whose notification raises is dropped. Sessions, subscriptions and log levels are all held in weak containers, so closed connections disappear on their own.