Outbound MCP client#
Your copilot calling other people's MCP servers — the mirror image of the hosted MCP endpoint your app exposes. Guide: external MCP clients.
import { connectMcp, createMcpPool } from '@janux/agent';connectMcp(options)#
const remote = connectMcp({
url: 'https://mcp.example.com/mcp',
token: process.env.EXAMPLE_TOKEN,
namespace: 'example',
});
const tools = await remote.tools(); // RemoteTool[]
await remote.call('example.search', { q: 'x' });McpClientOptions |
Notes |
|---|---|
url |
The server's JSON-RPC endpoint |
token |
Forwarded as a bearer token — the user's, not a service key, when the remote authorizes per user. Omit for public servers |
namespace |
Prefix for remote tool names, so search from two servers doesn't collide with your own tools |
fetchImpl |
Inject a fetch — for tests, retries, or an outbound proxy |
McpConnection is { tools(), call(name, input) } and is dual-era: requests go out speaking MCP 2026-07-28 (per-request _meta, no handshake, mirrored Mcp-* headers — including Mcp-Param-* for params the server annotates with x-mcp-header). A server that rejects that with a legacy 400 gets the initialize handshake (protocol 2025-06-18) instead, sent lazily and once, and the connection remembers the era — so constructing a connection costs nothing until you actually use it, and no request re-probes. Accept covers both application/json and text/event-stream, so servers that answer over SSE work unchanged.
RemoteTool extends the agent's own AgentTool shape and adds call(input) — which is why remote tools can go straight into the model's tool list next to your local ones.
createMcpPool()#
const pool = createMcpPool();
const remote = pool.get(userId, { url, token: userToken, namespace: 'example' });Caches one connection per key. Key by whatever scopes authorization — usually the user or org id, since the token differs per user; keying by URL alone would share one user's credentials across everyone.
Failures evict. If tools() or a call rejects, the pooled entry is dropped so the next request builds a fresh connection instead of retrying a poisoned one forever. That makes a remote server's restart self-healing.
Namespacing matters#
Remote tools land in the same list as your app's intents and api() tools. Without a namespace, a remote search and your own catalog.search are one bad prompt away from the model picking the wrong one. Prefix every server you connect.
Trust boundary#
A remote tool's description is untrusted input — it comes from someone else's server and the model reads it. Treat a connected MCP server like a dependency you audit: namespace it, scope the token to what it needs, and keep your dangerous local intents behind confirm so a hostile description can't turn into an unattended action (guardrails, intents and guards).
Related: Driving a Janux app from an external MCP client · Agent guardrails