unlob Docs
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 }]

Next