Skip to content

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
def __init__(
    self,
    name: str = "mcp",
    *,
    servers: Mapping[str, McpServerPort] | None = None,
    limits: TrustLimits | None = None,
) -> None:
    self._limits = limits or TrustLimits()
    self._limits.validate()
    self._name = validate_identifier(
        name, field="extension name", origin="mcp", limits=self._limits
    )
    self._servers: dict[str, McpServerPort] = {}
    for server_name, port in (servers or {}).items():
        self.add_server(server_name, port)
    self._tools: list[ToolContribution] = []
    self._diagnostics: list[ExtensionDiagnostic] = []

add_server

add_server(server_name: str, port: McpServerPort) -> None

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
def add_server(self, server_name: str, port: McpServerPort) -> None:
    """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.
    """
    validate_identifier(
        server_name,
        field="server name",
        origin=self._name,
        limits=self._limits,
        allow_dot=False,
    )
    if server_name in self._servers:
        raise UntrustedPayloadError(
            f"{self._name}: server {server_name!r} is already registered; "
            "two servers under one name would make routing ambiguous."
        )
    self._servers[server_name] = port

close async

close() -> None

Close every pinned server, surviving individual failures.

Source code in src/symfonic/capabilities/extensions/mcp.py
async def close(self) -> None:
    """Close every pinned server, surviving individual failures."""
    for server_name, port in self._servers.items():
        try:
            await port.close()
        except Exception:  # noqa: BLE001 - one bad close must not strand the rest
            logger.debug(
                "MCP server %r raised on close", server_name, exc_info=True,
            )

contribute

contribute() -> ExtensionContribution

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
def contribute(self) -> ExtensionContribution:
    """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.
    """
    return ExtensionContribution.build(
        self._name,
        tools=tuple(self._tools),
        lifecycle=(
            LifecycleContribution(
                hook_id=f"{self._name}.close",
                extension=self._name,
                phase=LifecyclePhase.TEARDOWN,
                run=self.close,
            ),
        ),
    )

discover async

discover() -> tuple[ExtensionDiagnostic, ...]

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.

Source code in src/symfonic/capabilities/extensions/mcp.py
async def discover(self) -> tuple[ExtensionDiagnostic, ...]:
    """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.
    """
    self._tools = []
    self._diagnostics = []
    for server_name in sorted(self._servers):
        await self._discover_server(server_name, self._servers[server_name])
    return tuple(self._diagnostics)