MCP
Connect to Model Context Protocol servers and expose their tools to the agentic loop as a canonical ToolSet.
The MCP module connects to a Model Context Protocol server, lists its tools, and maps them into a canonical ToolSet you can hand straight to generateText or streamChat. Tool execution proxies transparently to the server — when the model calls an MCP tool, the SDK invokes it over the live connection and feeds the result back into the loop.
Beyond tools, the client also exposes the server's resources and prompts, and can answer server-initiated elicitation requests (all per the 2025-11-25 MCP revision).
@modelcontextprotocol/sdk is a lazy optional peer (^1.29.0): it is imported with a dynamic import() only when you actually create a client, so the edge bundle never pulls it in unless you use MCP. Install it yourself:
npm i @modelcontextprotocol/sdkIf it is missing at runtime, createMcpClient / createStdioMcpClient reject with an InvalidRequestError telling you to install it.
Transports
Two transports live behind two different subpath exports, split by where they can run:
| Subpath | Function | Transport | Runtime |
|---|---|---|---|
@deuz-sdk/core/mcp | createMcpClient | http (Streamable HTTP) or sse (legacy) | Edge-safe (fetch-only, no Node builtins) |
@deuz-sdk/core/mcp/stdio | createStdioMcpClient | stdio (spawns a child process) | Node-only |
Use http for remote MCP servers reachable over the network — it is the current Streamable HTTP transport. sse is the legacy fallback some servers (e.g. Firecrawl's /v2/sse) still expose. The stdio transport spawns a local command (e.g. npx -y firecrawl-mcp) and talks to it over stdin/stdout, so it only works in a Node process and is kept on its own subpath to keep the edge core free of Node builtins.
createMcpClient
import { createMcpClient } from '@deuz-sdk/core/mcp';
const client = await createMcpClient(options);createMcpClient is async and returns a Promise<McpClient>. It loads the SDK, builds the transport, and connects before resolving.
Options
interface McpClientOptions {
transport: McpHttpTransport;
name?: string;
version?: string;
onElicitationRequest?: McpElicitationHandler;
}
interface McpHttpTransport {
type: 'http' | 'sse';
url: string;
headers?: Record<string, string>;
}| Option | Type | Default | Notes |
|---|---|---|---|
transport.type | 'http' | 'sse' | — | http = Streamable HTTP; sse = legacy SSE fallback. |
transport.url | string | — | The server endpoint. |
transport.headers | Record<string, string> | — | Extra request headers (e.g. Authorization). Sent on every request. |
name | string | 'deuz' | Client name advertised to the server. |
version | string | '0.0.0' | Client version advertised to the server. |
onElicitationRequest | McpElicitationHandler | — | Answer server-initiated user-input requests — see Elicitation. Providing it declares the elicitation capability (form + url). |
createStdioMcpClient
import { createStdioMcpClient } from '@deuz-sdk/core/mcp/stdio';
const client = await createStdioMcpClient(options);Options
interface McpStdioOptions {
command: string;
args?: string[];
env?: Record<string, string>;
name?: string;
version?: string;
onElicitationRequest?: McpElicitationHandler; // same semantics as the HTTP client
}| Option | Type | Default | Notes |
|---|---|---|---|
command | string | — | Executable to spawn, e.g. 'npx'. |
args | string[] | [] | Arguments, e.g. ['-y', 'firecrawl-mcp']. |
env | Record<string, string> | — | Environment for the child process (e.g. API keys the server needs). |
name | string | 'deuz' | Client name advertised to the server. |
version | string | '0.0.0' | Client version advertised to the server. |
The McpClient
Both factories return the same transport-agnostic McpClient:
interface McpClient {
/** MCP tools mapped to a ToolSet ready for generateText({ tools }). */
listTools(namespace?: string): Promise<ToolSet>;
/** Call a tool directly (rarely needed; the loop uses execute). */
callTool(name: string, args: unknown): Promise<unknown>;
/** All resources / prompts, auto-paginated (cursor handled for you). */
listResources(): Promise<McpResource[]>;
readResource(uri: string): Promise<McpResourceContent[]>;
listPrompts(): Promise<McpPrompt[]>;
getPrompt(name: string, args?: Record<string, string>): Promise<McpGetPromptResult>;
close(): Promise<void>;
}| Method | Returns | Notes |
|---|---|---|
listTools(namespace?) | Promise<ToolSet> | Lists the server's tools and maps each into a canonical Tool. The optional namespace prefixes every tool name with `${namespace}_` so you can merge multiple servers without collisions. |
callTool(name, args) | Promise<unknown> | Invokes a tool directly. The loop never needs this — each mapped tool's execute calls it for you. |
listResources() | Promise<McpResource[]> | All resources across pages — cursor pagination is handled internally (capped at 100 pages against endless cursors). |
readResource(uri) | Promise<McpResourceContent[]> | One resource's contents: entries carry text or base64 blob plus mimeType. |
listPrompts() | Promise<McpPrompt[]> | All prompt templates across pages (same pagination handling). |
getPrompt(name, args?) | Promise<McpGetPromptResult> | Renders a prompt with string arguments. Messages come back in MCP's own shape ({ role, content } blocks), not the canonical Message — map them yourself if you feed them to a model. |
close() | Promise<void> | Closes the connection / terminates the child process. Always call it when done. |
The resource/prompt methods need SDK ^1.29.0 — on an older installed SDK they reject with an actionable upgrade error.
How tools map
listTools() returns a ToolSet — a Record<string, Tool> — built from the server's tool definitions:
- The MCP
inputSchemais a JSON Schema, so it goes straight ontoTool.parameters(no conversion). The server'soutputSchema, when present, is carried ontoTool.outputSchemaas metadata. - Each tool's
executeproxies to the server viacallTool. - Structured results win: when the server returns
structuredContent,executereturns that object verbatim (per spec, the text blocks are just a redundant serialization). Otherwise the text content blocks are joined into a string. If the server marks the result as an error (isError),executethrows — which the tool loop catches and feeds back as anis_errortool result, so the model can self-heal rather than the call crashing.
Because the mapped tools have an execute, the agentic loop runs them automatically; you do not write the round-trip yourself.
Behavior note: before 1.3.0,
executealways returned joined text. Servers that declare anoutputSchemanow yield the structured object instead — if your code string-matched those results, read the object.
Elicitation
Servers can pause mid-operation and ask the user for input (elicitation/create). Pass onElicitationRequest to handle it; the request is a two-mode union:
const mcp = await createMcpClient({
transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
onElicitationRequest: async (req) => {
if (req.mode === 'form') {
// req.requestedSchema: flat object, primitive props — render a form.
const content = await showForm(req.message, req.requestedSchema);
return content ? { action: 'accept', content } : { action: 'decline' };
}
// req.mode === 'url': show req.url to the user and ask for consent.
// NEVER auto-open or prefetch it (spec requirement). accept = consent
// only — the interaction completes out-of-band on the server's side.
const consented = await confirmOpenUrl(req.message, req.url);
return { action: consented ? 'accept' : 'decline' };
},
});Return { action: 'accept', content? }, { action: 'decline' }, or { action: 'cancel' }. Form-mode content must match requestedSchema. URL mode exists for sensitive flows (OAuth, payment, API keys) that must not pass through the client — treat the URL as untrusted, display the full host, and let the user decide.
HTTP server in a tool loop
Connect to a remote MCP server, list its tools, and let the model use them. Remember to close().
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { createMcpClient } from '@deuz-sdk/core/mcp';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const mcp = await createMcpClient({
transport: {
type: 'http',
url: 'https://mcp.example.com/mcp',
headers: { Authorization: `Bearer ${process.env.MCP_TOKEN!}` },
},
});
try {
const tools = await mcp.listTools();
const { text, steps } = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'Scrape example.com and summarize it.' }],
maxSteps: 5,
tools,
});
console.log(text);
console.log(steps?.length);
} finally {
await mcp.close();
}stdio local server (Node)
Spawn a local MCP server as a child process. This only works in a Node runtime, so import from @deuz-sdk/core/mcp/stdio.
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { createStdioMcpClient } from '@deuz-sdk/core/mcp/stdio';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const mcp = await createStdioMcpClient({
command: 'npx',
args: ['-y', 'firecrawl-mcp'],
env: { FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY! },
});
try {
const tools = await mcp.listTools();
const { text } = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'Find the pricing page on example.com.' }],
maxSteps: 5,
tools,
});
console.log(text);
} finally {
await mcp.close();
}Combining MCP tools with local tools
A ToolSet is just a record, so spread MCP tools alongside your own. Use a namespace per server to avoid name collisions when you connect to more than one.
import { generateText } from '@deuz-sdk/core';
import { createAnthropic } from '@deuz-sdk/core/anthropic';
import { createMcpClient } from '@deuz-sdk/core/mcp';
import { z } from 'zod';
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
const search = await createMcpClient({
transport: { type: 'http', url: 'https://search.example.com/mcp' },
});
const docs = await createMcpClient({
transport: { type: 'sse', url: 'https://docs.example.com/v2/sse' },
});
try {
const tools = {
// Namespaced MCP tools: e.g. `search_query`, `docs_fetch`.
...(await search.listTools('search')),
...(await docs.listTools('docs')),
// A local tool, defined inline with a Zod schema.
now: {
description: 'Return the current ISO timestamp.',
parameters: z.object({}),
execute: async () => ({ now: new Date().toISOString() }),
},
};
const { text } = await generateText({
model: anthropic('claude-opus-4-8'),
messages: [{ role: 'user', content: 'What time is it, and what does example.com sell?' }],
maxSteps: 6,
tools,
});
console.log(text);
} finally {
await Promise.all([search.close(), docs.close()]);
}Calling a tool directly
You rarely need this — the loop runs execute for you — but callTool is there for ad-hoc invocation outside a model call:
const result = await mcp.callTool('scrape', { url: 'https://example.com' });
console.log(result); // joined text content, or the raw blocks if there is no textIf the server returns an error result, callTool throws, mirroring the execute behavior.
Notes
- Always
close()the client when you are finished — for stdio this terminates the spawned child process. - Namespacing only renames keys. It prefixes tool names so multiple servers can coexist; it does not change the underlying tool the model calls.
- Edge vs Node:
createMcpClient(/mcp) is fetch-only and runs anywhere;createStdioMcpClient(/mcp/stdio) requires Node. Importing/mcp/stdioin an edge runtime will fail.
See also
- generateText — the buffered agentic loop these tools plug into.
- streamChat — the streaming counterpart; pass the same
tools. - Tool loop — how tool calls, parallel execution, and self-healing work.