unlob Docs
Browse documentation

Mastra

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 };
  },
});

Next