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 — worth fixing upstream or documenting around.
The returned handle carries three things:
tool_ids— the namespaced ids that were registered.server_instructions— echoes whatever you passed asinstructions=.close()— an awaitable no-op by default (yourasync withblocks already own teardown); passon_close=(an async callable) if the handle should tear something down itself.
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, register_mcp_server embeds every ingested tool as part of registration — budget a model load plus one embedding per tool on the first ingest. On a BM25-default catalog, call catalog.build_embeddings() once after the handle returns, before the first semantic or hybrid search.
A re-sync that re-registers an upstream tool invalidates its cached vector: eager catalogs re-embed at register, explicit ones need another build_embeddings(). What the engines cost is on Semantic & hybrid search.