Ratel Docs
TypeScript SDK

Register MCP servers

Ingest an MCP server's tools into the ratel() core with registerMcpServer: namespaced ids, the returned handle, error propagation, and many servers on one ranked surface.

Hand registerMcpServer the core's catalog and a connected MCP transport: Ratel calls tools/list, registers each upstream tool under a server-namespaced id (<name>__<toolName>), and wires its executor to tools/call over the same connection. The upstream tools rank and run alongside your local ones.

Ingest an MCP server's tools

The SDK owns the MCP client: hand it a transport and it connects, reads the server's instructions, and lists the tools itself. For a remote server, that transport is Streamable HTTP:

import { ratel, registerMcpServer } from "@ratel-ai/sdk";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

const r = ratel();
const handle = await registerMcpServer(r.tools.catalog, {
  name: "issues",
  transport: new StreamableHTTPClientTransport(
    new URL("https://example.com/mcp"),
  ),
});

// handle.toolIds            → ["issues__create_issue", "issues__search_issues", ...]
// handle.serverInstructions → the upstream's instructions, if any
// r.tools.search / r.tools.invoke now rank and run the upstream tools alongside local ones.

await handle.close(); // disconnect on shutdown

registerMcpServer is driver-level: it takes the raw ToolCatalog, which the core exposes at r.tools.catalog.

A local server spawned as a subprocess uses the stdio transport instead. Everything else stays the same:

import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const handle = await registerMcpServer(r.tools.catalog, {
  name: "fs",
  transport: new StdioClientTransport({
    command: "npx",
    args: ["-y", "@modelcontextprotocol/server-everything"],
  }),
});

Any transport from @modelcontextprotocol/sdk works: Streamable HTTP, stdio, or SSE. A failure during connect, tools/list, or registration rejects the returned promise — and closes the SDK-owned client first, so no connection leaks.

The options object is RegisterMcpServerOptions = { name, transport }; the promise resolves to an McpServerHandle = { toolIds, serverInstructions, close }. Registration wraps connect, tools/list, and ingest in one ratel.upstream.register span and records an upstream_register trace event.

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:

  • toolIds: the namespaced ids that were registered — every tools/list page, in upstream order.
  • serverInstructions: the upstream's instructions, read from the live handshake.
  • close(): closes the SDK-owned MCP client; call it when your process winds down.

Paginated tools/list

registerMcpServer follows nextCursor until the server omits it. Per MCP, "" is a valid cursor — only an absent nextCursor ends pagination.

A repeated cursor or a server that never stops (the cap is 64 pages) throws McpToolsListError, with a stable code (McpToolsListErrorCode): "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 rejected promise from r.tools.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 registerMcpServer once per server and keep registering local tools on the same core: the model sees a single ranked surface through the same capability tools from r.modelTools(). To advertise the aggregation in the search tool's description, assemble it yourself with the piecemeal searchCapabilitiesTool factory, which accepts the upstream list:

const search = searchCapabilitiesTool(r.tools.catalog, r.skills, {
  upstreamServers: [{ name: "gmail", description: "Send and search email", toolCount: 12 }],
});
// UpstreamServerInfo = { name, description?, instructions?, toolCount?, needsAuth? }

The second argument is an optional SkillCatalog — here the core's own r.skills. Each entry renders one line in the tool's description, via the exported formatUpstreamLine helper, as - <name> — <description> (<n> tools) (auth required) with every part after the name conditional. The matching entry also fills a result group's server.description and server.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 core, await registerMcpServer(...) embeds every ingested tool as part of registration: budget a model load plus one embedding per tool on the first ingest. A BM25 core holds no vectors and cannot be retrofitted. Construct it as ratel({ method: "hybrid", embedding: … }) (or "semantic") to rank ingested tools that way.

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

Next steps

On this page