unlob Docs
Browse documentation

Mastra: unlob as an MCP client or typed tool

An MCP client, or a typed tool, in a TypeScript agent.

Through MCP

import { MCPClient } from "@mastra/mcp";
import { Agent } from "@mastra/core/agent";
import { anthropic } from "@ai-sdk/anthropic";

const mcp = new MCPClient({
  servers: {
    unlob: {
      url: new URL("https://api.unlob.com/mcp"),
      requestInit: { headers: { "x-api-key": process.env.UNLOB_API_KEY! } },
    },
  },
});

export const researcher = new Agent({
  name: "researcher",
  instructions:
    "Search with unlob. Use assemble_context for open questions and web_search for " +
    "specific ones. Call corroborate before stating anything as established fact.",
  model: anthropic("claude-opus-5"),
  tools: await mcp.getTools(),
});

getTools() fetches once, at construction. Use getToolsets() instead when the key varies per request — a multi-tenant app where each user brings their own.

A typed tool

import { createTool } from "@mastra/core/tools";
import { z } from "zod";

export const webSearch = createTool({
  id: "unlob-web-search",
  description:
    "Search the web for passages. Returns passage text, not links. Each hit carries " +
    "independentSources: how many distinct sites assert it.",
  inputSchema: z.object({
    query: z.string().describe('Supports AND, OR, -exclude, "exact phrase"'),
    vertical: z.string().optional(),
    limit: z.number().int().min(1).max(20).default(5),
  }),
  outputSchema: z.object({
    incomplete: z.boolean(),
    hits: z.array(
      z.object({
        title: z.string(),
        url: z.string(),
        text: z.string(),
        independentSources: z.number(),
      }),
    ),
  }),
  execute: async ({ context }) => {
    const url = new URL("https://api.unlob.com/search");
    url.searchParams.set("q", context.query);
    url.searchParams.set("limit", String(context.limit));
    // Set here rather than left to the model: drop single-source claims, and fold
    // near-duplicates.
    url.searchParams.set("min_independent_sources", "2");
    url.searchParams.set("collapse", "story");
    if (context.vertical) url.searchParams.set("vertical", context.vertical);

    const res = await fetch(url, {
      headers: { "x-api-key": process.env.UNLOB_API_KEY! },
    });
    if (!res.ok) throw new Error(`unlob ${res.status}: ${await res.text()}`);
    const body = await res.json();

    return {
      // A short result with no explanation gets reported as "nothing found".
      incomplete: body.partial === true,
      hits: body.results.map((h: any) => ({
        title: h.title,
        url: h.url,
        text: h.snippet,
        independentSources: h.independent_sources ?? 0,
      })),
    };
  },
});

In a workflow

assemble_context fits a workflow step better than a retrieval sub-graph: one call does search, dedupe, corroborate, rank and pack, and returns items each carrying the reason they were included.

const gather = createStep({
  id: "gather",
  execute: async ({ inputData }) => {
    const url = new URL("https://api.unlob.com/assemble_context");
    url.searchParams.set("q", inputData.question);
    url.searchParams.set("budget", "4000");
    const pack = await (
      await fetch(url, { headers: { "x-api-key": process.env.UNLOB_API_KEY! } })
    ).json();
    return { context: pack.items, tokens: pack.estimated_tokens };
  },
});

Handling the two 429s in a typed tool

The typed webSearch tool above throws on any non-ok response, which is right for most failures but loses the distinction between a rate limit and a key out of credits. Both arrive as 429. Check the header before you decide whether a retry is worth it:

if (!res.ok) {
  if (res.status === 429) {
    const retryAfter = res.headers.get("retry-after");
    throw new Error(
      retryAfter
        ? `unlob rate limited, retry after ${retryAfter}s`
        : "unlob monthly credits exhausted — do not retry"
    );
  }
  throw new Error(`unlob ${res.status}: ${await res.text()}`);
}

Mastra’s own retry configuration on a step or tool call will happily retry either error the same way unless the message tells it not to. Since the out-of-credits case will not clear until the billing period rolls over, that message is what stops a workflow from burning its own retry budget against a call that cannot succeed. See Rate limits and credits for 402 too — a suspended account, which is a third thing again and not something any retry policy fixes.

Choosing between the MCP client and the typed tool

getTools() is the least code: the profile’s tools appear with their schemas and descriptions already written, and a change on the server — a new argument, a clarified description — reaches the agent on the next fetch with nothing in your repository to update. That is the right default for an agent that should genuinely reach for the whole surface, including the graph calls, when a question calls for them.

The typed tool earns its extra code in two situations that come up constantly in practice. First, when the agent should only ever search — holding the full tool set in context for an agent that will only call one is schema the model has to read on every turn for no benefit. Second, when a default needs to be enforced rather than merely suggested: min_independent_sources baked into the tool’s execute function, as it is above, cannot be talked out of by a prompt, where the same value passed as advice in an instructions string can be. A workflow step, in particular, usually wants the typed form, since a workflow’s whole point is running the same deterministic thing every time rather than leaving a choice to whatever the model decides that run.

Nothing stops mixing both in one agent — MCP tools for open-ended research alongside one typed webSearch with pinned defaults for the common case — and Mastra’s tool array accepts either kind side by side without extra wiring.

The workflow step shown further up is really the typed-tool argument taken to its logical end: a step has no model deciding whether to call it, so there is nothing there for an MCP tool description to persuade in the first place, and the typed input and output schemas are the only interface that matters — the same reason assemble_context, shown as a step above, fits a workflow more naturally than it fits a loose agent tool call.

Next