Browse documentation
Vercel AI SDK
MCP tools in a streaming route handler, or one tool you define.
Through MCP
The AI SDK can connect to an MCP server and use its tools directly.
import { experimental_createMCPClient as createMCPClient } from "ai";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { anthropic } from "@ai-sdk/anthropic";
import { streamText } from "ai";
const mcp = await createMCPClient({
transport: new StreamableHTTPClientTransport(
new URL("https://api.unlob.com/mcp"),
{ requestInit: { headers: { "x-api-key": process.env.UNLOB_API_KEY! } } },
),
});
const result = streamText({
model: anthropic("claude-opus-5"),
tools: await mcp.tools(),
maxSteps: 8,
messages,
onFinish: () => mcp.close(),
});
mcp.close() in onFinish matters. A route handler that opens a client per request and
never closes it leaks a connection per request, and the symptom shows up much later as a
process that will not shut down.
The MCP client API in the AI SDK is still marked experimental and its import name has moved between versions; check yours if the import above does not resolve.
One tool, defined yourself
Often better for a product surface. You control the schema, the defaults and the shape of what reaches the model.
import { tool, streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const webSearch = tool({
description:
"Search the web for passages. Returns passage text, not links. Results are " +
"deduplicated and trust-ranked; each carries independent_sources, the number " +
"of distinct sites asserting it.",
parameters: z.object({
query: z.string().describe('Supports AND, OR, -exclude, "exact phrase"'),
vertical: z.string().optional().describe("e.g. code, science. Omit to auto-route."),
limit: z.number().int().min(1).max(20).default(5),
}),
execute: async ({ query, vertical, limit }) => {
const url = new URL("https://api.unlob.com/search");
url.searchParams.set("q", query);
url.searchParams.set("limit", String(limit));
// Defaults applied here rather than left to the model: drop single-source
// claims, and fold near-duplicates so the context window is not five copies
// of one article.
url.searchParams.set("min_independent_sources", "2");
url.searchParams.set("collapse", "story");
if (vertical) url.searchParams.set("vertical", vertical);
const res = await fetch(url, {
headers: { "x-api-key": process.env.UNLOB_API_KEY! },
});
if (!res.ok) return `Search failed: ${res.status} ${await res.text()}`;
const body = await res.json();
return {
// Say it out loud. A model handed a short list with no explanation reports
// it as "nothing found".
incomplete: body.partial === true,
results: body.results.map((h: any) => ({
title: h.title,
url: h.url,
text: h.snippet,
independentSources: h.independent_sources ?? 0,
})),
};
},
});
const result = streamText({
model: anthropic("claude-opus-5"),
tools: { webSearch },
maxSteps: 5,
messages,
});
Edge runtime
Everything above is fetch and JSON, so it runs on the edge unchanged — no Node APIs, no
native dependencies. The MCP client route needs Node; the hand-defined tool does not.
RSC and generative UI
assemble_context is a good fit for streaming a research panel: one call returns a packed,
trust-ranked reading set where each item carries the reason it was included, which is
exactly the metadata a citation component wants.
const url = new URL("https://api.unlob.com/assemble_context");
url.searchParams.set("q", question);
url.searchParams.set("budget", "4000");
const pack = await (await fetch(url, { headers })).json();
// pack.items: [{ hit, reason }]