symfonic.capabilities.extensions.mcp¶
mcp ¶
The MCP adapter — an MCP server as an extension provider (T4.2.2).
The adapter does three things the pre-refactor MCPToolProvider did not, and
each one closes a hole rather than tidying a shape.
Names are namespaced by server, and routing is pinned at discovery. The
legacy provider kept one flat {tool_name: (connection, definition)} map
across every registered server and routed calls by looking a bare name up in it.
Two servers advertising search collided silently, last writer winning, and
the loser's calls went to the winner's server for the rest of the process — a
hostile or merely careless server could take over a trusted server's tool by
answering tools/list with its name. Here the exposed name is
"{server}.{tool}" and each contribution holds the connection it was
discovered on, so no later payload can re-point it (AS-INT-5).
Every field crosses a boundary that can refuse it. Names, descriptions,
schemas, and results are read through
:mod:~symfonic.capabilities.extensions.mcp_reader, which refuses malformed
advertisements and bounds every string (AS-INT-2, AS-ING-6).
Discovery is a value, not a mutation. discover() performs the I/O and
returns diagnostics; contribute() returns the frozen contribution. Nothing
is registered anywhere. A failing server costs its own tools and a diagnostic —
partial discovery is preserved from the legacy behaviour, because one
unreachable server should not take an agent down — but a malformed payload is
refused per tool rather than per server, so one bad entry no longer discards the
rest of the list.
McpExtensionAdapter ¶
McpExtensionAdapter(name: str = 'mcp', *, servers: Mapping[str, McpServerPort] | None = None, limits: TrustLimits | None = None)
One extension backed by one or more pinned MCP servers.
Source code in src/symfonic/capabilities/extensions/mcp.py
add_server ¶
Pin one server under a name the deployment chose (AS-INT-5).
The name is configuration, never payload: it prefixes every tool the server advertises, so a server cannot name itself into another server's namespace by answering cleverly.
It may not contain the . that joins it to a tool name. Otherwise the
mapping (server, tool) -> "{server}.{tool}" stops being injective —
servers a and a.b would both be able to produce a.b.c, and
the payload-supplied half of the name would decide which server won
the exposed name, which is the legacy takeover this adapter closed.
Source code in src/symfonic/capabilities/extensions/mcp.py
close
async
¶
Close every pinned server, surviving individual failures.
Source code in src/symfonic/capabilities/extensions/mcp.py
contribute ¶
Return the frozen contribution built by the last discover().
Calling it before discovery answers a contribution with no tools rather than raising. An adapter whose servers were never reached contributes nothing, which is the same outcome as an adapter with no servers, and both are ordinary deployments rather than errors.
Source code in src/symfonic/capabilities/extensions/mcp.py
discover
async
¶
Read every server's tool list and build the contribution.
Re-discovery replaces the previous reading rather than merging into it. The legacy provider accumulated, so a tool a server had withdrawn stayed callable for the life of the process.