Ratel Docs
Python SDK

Register MCP servers

Ingest an upstream MCP server's tools with register_mcp_server: namespaced ids, the session-owning handle, error propagation, one ranked surface.

register_mcp_server lists an upstream server's tools and registers each one into the catalog, wiring its executor to the upstream tools/call over the same connection. It ships behind the mcp extra (Python 3.10+):

pip install 'ratel-ai[mcp]'

Ingest an MCP server's tools

You own the ClientSession lifecycle: set up the transport and session with async with, then pass the initialized session in. For a remote server, that transport is Streamable HTTP:

import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from ratel_ai import ToolCatalog, register_mcp_server


async def main() -> None:
    catalog = ToolCatalog()

    async with streamablehttp_client("https://example.com/mcp") as (read, write, _):
        async with ClientSession(read, write) as session:
            init = await session.initialize()
            handle = await register_mcp_server(
                catalog,
                name="issues",
                session=session,
                transport_label="streamable-http",  # recorded on upstream_register
                instructions=init.instructions,     # echoed as server_instructions
            )
            print(handle.tool_ids)  # ["issues__create_issue", ...]


asyncio.run(main())

A local server spawned as a subprocess uses the stdio transport instead. The session handling stays the same:

from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client

params = StdioServerParameters(
    command="npx", args=["-y", "@modelcontextprotocol/server-everything"]
)

async with stdio_client(params) as (read, write):
    async with ClientSession(read, write) as session:
        init = await session.initialize()
        handle = await register_mcp_server(
            catalog,
            name="fs",
            session=session,
            transport_label="stdio",
            instructions=init.instructions,
        )
        # handle.tool_ids → ["fs__echo", "fs__add", ...]

catalog.search and catalog.invoke now include the upstream tools. The third transport, sse_client (SSE), comes from the same mcp package. Because the caller owns the session, transport_label and instructions are caller-supplied: they default to "unknown" and None.

The caller-owns-the-session split is the one deliberate divergence from the TypeScript SDK, whose registerMcpServer reads the server instructions and transport label from the live handshake itself. Pass instructions= and transport_label= from your await session.initialize() result to make the emitted upstream_register event byte-identical across the two SDKs.

Namespaced ids and the handle

Each upstream tool registers under <name>__<toolName>: fs__read_file, github__create_issue. The prefix keeps ids collision-free across servers, and it is how search_capabilities groups results by server (everything before the first __). An upstream tool with no description registers with "", leaving only its name and schema text to rank on. That's worth fixing upstream or documenting around.

The returned handle carries three things:

  • tool_ids: the namespaced ids that were registered — every tools/list page, in upstream order.
  • server_instructions: echoes whatever you passed as instructions=.
  • close(): an awaitable no-op by default (your async with blocks already own teardown); pass on_close= (an async callable) if the handle should tear something down itself.

Paginated tools/list

register_mcp_server follows nextCursor until the server omits it. Per MCP, "" is a valid cursor — only an absent nextCursor ends pagination. The walk works across mcp client versions before and after 1.18, which changed how a cursor is passed.

A repeated cursor or a server that never stops (the cap is 64 pages) raises McpToolsListError, exported from ratel_ai, with a stable code: "RepeatedCursor" or "PaginationExceeded". A broken paginator is a typed failure, not a hang.

Error propagation

An upstream tools/call failure records an upstream_error trace event and rethrows, so it propagates as a raised exception from catalog.invoke, the same handling path as a local executor that throws. When the model reaches the tool through invoke_tool, that failure comes back as a structured { "error": "tool <id> threw: ...", "isError": true } instead, so the loop stays recoverable.

Many servers, one ranked surface

Call register_mcp_server once per server and keep registering local tools into the same catalog: the model sees a single ranked surface through the same capability tools. To make the aggregation visible, pass the upstream list to the search_capabilities factory with the keyword-only upstream_servers=: a sequence of UpstreamServerInfo(name, description=None, instructions=None, tool_count=None, needs_auth=False). The tool's description then names each aggregated server, and any result group whose server name matches gains that server's description and instructions.

Embeddings for upstream tools

Upstream tools enter the same corpus as local ones, so the embedding cache treats them identically. On a semantic- or hybrid-default catalog, await register_mcp_server(...) embeds every ingested tool as part of registration: budget a model load plus one embedding per tool on the first ingest. A BM25 catalog holds no vectors and cannot be retrofitted. Construct it with "semantic" or "hybrid" to rank ingested tools that way.

A re-sync that re-registers an upstream tool re-embeds it at register on a semantic/hybrid catalog. What the engines cost is on Semantic & hybrid search.

Next steps

On this page