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 shutdownregisterMcpServer 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 — everytools/listpage, 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
Register tools
Define executable tools, register a batch in one r.tools.register call, search and invoke through the guarded handle, and drop to the shared ToolCatalog or ToolRegistry underneath.
Register skills
r.skills: register Markdown playbooks that rank in their own corpus and surface next to tools — the core's SkillCatalog, exposed raw.