Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Give a Mastra Agent B2B Data (REST Tools and MCP)

A TypeScript agent that sizes an audience for free, searches 10 people, and enriches 3. 13 records a run, with 402 and 429 handled in code.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

You give a Mastra agent B2B data by wrapping three LeadOcean endpoints in createTool. The agent can then size an audience, find people and enrich the ones it picks. This guide builds that in TypeScript, with error handling for 402 and 429 and a hard cap on records per run.

TL;DR

  • Takes about 30 minutes. You need a LeadOcean key and a model key.
  • One run spends 13 records at most: 0 for the count, 10 for one search page, 3 for enrichments.
  • REST gives you typed tools and a budget in code. The hosted MCP server at https://api.leadocean.io/mcp is the shorter route, with OAuth sign-in instead of a key.

Prerequisites

  • A LeadOcean account on the free plan (1,000 records, one-off, no card). Export the key as $LEADOCEAN_API_KEY.
  • Node 20 or later and a Mastra project. Mastra's manual install guide (October 2026) installs @mastra/core and zod.
  • A model provider key for the agent. Mastra accepts OpenAI, Anthropic and others.

Full code for this guide is in the post.

Step 1: Set the environment

Add both keys to .env and install the packages.

bash
npm install @mastra/core@latest zod@^4
npm install -D typescript @types/node mastra@latest

Then put the keys in .env:

bash
LEADOCEAN_API_KEY=paste-your-leadocean-key
OPENAI_API_KEY=paste-your-model-key

Replace the second key with whichever provider you use. Never commit .env.

LeadOcean meters in records, not requests. One person returned is one record. A search page of 10 costs 10, an enrichment costs 1, and a count costs nothing (LeadOcean docs, October 2026).

Step 2: Write one fetch helper

Put all HTTP and error logic in one function. Tools then return a plain object the model can read, and never throw.

ts
// src/mastra/lib/leadocean.ts
const BASE = 'https://api.leadocean.io'
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

export type LoOk = { ok: true; data: any; meta: any; records: number }
export type LoErr = { ok: false; code: string; message: string; retryAfter?: number }

export async function lo(
  path: string,
  opts: { method?: 'GET' | 'POST'; query?: Record<string, string | number | boolean>; body?: object } = {},
): Promise<LoOk | LoErr> {
  const url = new URL(path, BASE)
  for (const [k, v] of Object.entries(opts.query ?? {})) url.searchParams.set(k, String(v))

  for (let attempt = 0; attempt < 3; attempt++) {
    const res = await fetch(url, {
      method: opts.method ?? 'POST',
      headers: { 'x-api-key': process.env.LEADOCEAN_API_KEY!, 'content-type': 'application/json' },
      body: opts.body ? JSON.stringify(opts.body) : undefined,
    })
    const json: any = await res.json().catch(() => null)
    if (res.ok) return { ok: true, data: json.data, meta: json.meta ?? {}, records: json.meta?.credits ?? 0 }

    const message = json?.error?.message ?? res.statusText
    if (res.status === 401) return { ok: false, code: 'bad_key', message }
    if (res.status === 402) return { ok: false, code: 'out_of_records', message }
    if (res.status === 404) return { ok: false, code: 'not_found', message }
    if (res.status === 400) return { ok: false, code: 'bad_request', message: JSON.stringify(json?.error?.details ?? message) }
    if (res.status === 429) {
      const retryAfter = Number(res.headers.get('retry-after') ?? 1)
      if (retryAfter <= 2 && attempt < 2) { await sleep(retryAfter * 1000); continue }
      return { ok: false, code: 'throttled', message, retryAfter }
    }
    if (res.status === 503) { await sleep(2 ** attempt * 1000); continue }
    return { ok: false, code: `http_${res.status}`, message }
  }
  return { ok: false, code: 'unavailable', message: 'LeadOcean did not answer after 3 tries' }
}

The retry rules follow the docs. A 429 with Retry-After: 1 is your plan's rate limit (5 requests a second on Free, 50 on Solo, 200 on Agency), so a short wait fixes it. A 429 with Retry-After: 60 means a paid plan reached its fair-use pace and is held to one request a minute until the window resets. Do not retry into that. A 503 is transient and never billed. A 402 means the free 1,000 are spent and will not come back.

Step 3: Define the tools

Mastra's createTool takes an id, a description, an inputSchema and an execute function. The first argument to execute is the input, already validated against the schema (Mastra reference, October 2026). That validation is your first guard: a model that invents a field name gets a schema error before any request leaves your server.

Three tools: count, search, enrich. Each has a Zod schema and a short description, which is what the model reads to choose.

ts
// src/mastra/tools/leadocean.ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
import { lo } from '../lib/leadocean'

const filters = z.object({
  title: z.array(z.string()).optional().describe('Job title keywords, e.g. ["Head of Sales"]'),
  jobLevel: z.array(z.enum(['C-Team', 'VP', 'Director', 'Manager', 'Staff', 'Other'])).optional(),
  country: z.array(z.string().length(2)).optional().describe('ISO 3166-1 alpha-2, e.g. ["CA"]'),
  domain: z.array(z.string()).optional().describe('Company domains, e.g. ["acme.com"]'),
  emailStatus: z.array(z.string()).default(['verified']),
  emailType: z.array(z.string()).default(['work']),
})

export function makeLeadOceanTools(maxRecords = 25) {
  let spent = 0
  const overBudget = (need: number) =>
    spent + need > maxRecords
      ? { ok: false, code: 'run_budget', message: `Run cap of ${maxRecords} records reached (${spent} spent).` }
      : null

  const countPeople = createTool({
    id: 'count-people',
    description: 'Free. Count people matching filters before searching. Total is capped at 100,000.',
    inputSchema: filters,
    execute: async (f) => {
      const r = await lo('/v1/people/search', { query: { count: true }, body: { ...f, limit: 1 } })
      return r.ok ? { total: r.meta.total, totalIsExact: r.meta.totalIsExact } : r
    },
  })

  const searchPeople = createTool({
    id: 'search-people',
    description: 'Find people. Costs 1 record per person returned. Returns no email addresses.',
    inputSchema: filters.extend({ limit: z.number().int().min(1).max(10).default(5) }),
    execute: async (f) => {
      const blocked = overBudget(f.limit)
      if (blocked) return blocked
      const r = await lo('/v1/people/search', { body: f })
      if (!r.ok) return r
      spent += r.records
      return {
        spent,
        people: r.data.map((p: any) => ({
          person_id: p.meta?.source_ids?.person_id,
          name: p.profile_data?.profile_full_name,
          title: p.contact_data?.contact_current_experiences?.[0]?.job_title,
          company: p.contact_data?.contact_current_experiences?.[0]?.company_name,
          has_email: p.contact_data?.has_email,
          email_status: p.contact_data?.email_status,
        })),
      }
    },
  })

  const enrichPerson = createTool({
    id: 'enrich-person',
    description: 'Get the email for one person from search. Costs 1 record. Pass the person_id.',
    inputSchema: z.object({ person_id: z.string().regex(/^[1-9][0-9]{0,19}$/) }),
    execute: async ({ person_id }) => {
      const blocked = overBudget(1)
      if (blocked) return blocked
      const r = await lo('/v1/people/enrich', { body: { person_id, reveal_email: true } })
      if (!r.ok) return r
      spent += r.records
      return { spent, emails: r.data.contact_data?.contact_emails ?? [] }
    },
  })

  return { countPeople, searchPeople, enrichPerson }
}

Three design choices matter here. The search tool caps limit at 10, so a confused model cannot buy 100 rows. The makeLeadOceanTools closure keeps a per-run counter of records spent. And enrichment takes person_id, because search returns no addresses and every search row carries a person_id.

Step 4: Build the agent

Register the tools on an Agent and tell it the order to use them.

ts
// src/mastra/agents/prospect-agent.ts
import { Agent } from '@mastra/core/agent'
import { makeLeadOceanTools } from '../tools/leadocean'

export function makeProspectAgent() {
  return new Agent({
    id: 'prospect-agent',
    name: 'Prospect Agent',
    instructions: `
      You find B2B contacts with LeadOcean tools.
      Always call count-people first and report the total.
      Then search-people with a limit of 10 or less.
      Enrich at most 3 people, only if the user asks for emails.
      If a tool returns ok:false, stop and tell the user the code and message. Do not retry.
    `,
    model: 'openai/gpt-5.6-sol',
    tools: makeLeadOceanTools(25),
  })
}

Mastra's docs show the same tools record and note that naming tools in the instructions helps the model pick them (Mastra tools, October 2026). The model string is Mastra's own example. Swap in your provider.

Creating the agent per request gives each run a fresh counter of 25 records. The cap is a ceiling. The instructions aim for 13.

Step 5: Run it

Keep the prompt narrow. The agent follows the order in its instructions, so a vague prompt wastes steps and a specific one stays near the budget.

Call generate with a step limit so the agent cannot loop on tool calls.

ts
// src/run.ts
import { makeProspectAgent } from './mastra/agents/prospect-agent'

const agent = makeProspectAgent()
const result = await agent.generate(
  'VPs and C-Team in Canada. How many are mailable? Show 5 and get emails for 2.',
  { maxSteps: 8 },
)
console.log(result.text)

maxSteps is a documented option on generate (Mastra reference, October 2026). Expect this order: one count, one search of 5, two enrichments. That is 7 records.

Cost per run, worst case under the instructions above:

  • Count: 0 records.
  • Search page of 10: 10 records.
  • Three enrichments: 3 records.
  • Total: 13. The closure refuses anything past 25.

Paid plans (Solo $97 a month, Agency $497 a month) have no per-record price. Past the fair-use pace a key slows down until the window resets, and the 429 handler above reports it. See pricing.

Step 6: Use the MCP server instead

The MCP route skips the tool code. Point Mastra's MCPClient at the hosted server and pass its tools to the agent.

ts
// src/mastra/mcp/leadocean.ts
import { MCPClient } from '@mastra/mcp'

export const leadoceanMcp = new MCPClient({
  id: 'leadocean',
  servers: {
    leadocean: { url: new URL('https://api.leadocean.io/mcp') },
  },
})

// in the agent: tools: await leadoceanMcp.listTools()

LeadOcean's server uses OAuth 2.1, with no key to paste, and exposes 13 tools. Mastra's MCP guide covers OAuth through an authProvider (Mastra, October 2026). Its provider supports dynamic client registration, and LeadOcean's OAuth server offers that at https://api.leadocean.io/oauth/register (scope mcp).

We have not tested Mastra's sign-in flow against it. Mastra's interactive flow also uses a local loopback callback, so it suits a dev machine and not a hosted multi-user app.

Pick REST when the agent runs on a server for many users. You get a record cap you control and one shared key. Pick MCP for local agents where one person signs in.

What you get

Two outputs matter: what the count tells you before you spend, and the shape of a search row.

The count tool is free. This is a real result from the LeadOcean MCP count tool on 2026-10-21. Filters: jobLevel VP or C-Team, jobFunction Sales & Business Development, country CA. The count uses the default mailable filter (verified, catch_all_valid or catch_all).

json
{ "total": 15322, "totalIsExact": true, "totalAll": 75629, "credits": 0 }

A search row looks like this. Response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": [
    {
      "profile_data": { "profile_full_name": "Jane Doe", "profile_url": "https://www.linkedin.com/in/janedoe-example" },
      "contact_data": {
        "has_email": true,
        "email_status": "verified",
        "email_type": "work",
        "contact_current_experiences": [{ "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }]
      },
      "meta": { "source_ids": { "person_id": "12345678" } }
    }
  ],
  "meta": { "count": 1, "limit": 5, "credits": 1, "nextCursor": "..." }
}

Your tool trims each row to six fields before the model sees it. That keeps the context small and the run cheap in tokens too.

Search rows carry flags, not addresses. has_email, email_status and email_type tell the agent whether an address exists and whether it is safe to send. The address itself comes from enrich-person, which is why the instructions say to enrich only when the user asks. LeadOcean does not refund bounced emails, so have the agent read email_status before anyone sends to the list.

Troubleshooting

ErrorCauseFix
401Missing, wrong or revoked keyCheck $LEADOCEAN_API_KEY in .env. Create a new key in the app.
402Free plan: the 1,000 records are spent. They do not resetMove to Solo ($97 a month) or Agency ($497 a month). Do not retry.
429 with Retry-After: 1Above your plan's rate on one key (5 a second on Free, 200 on Agency)Wait the header value. The helper retries twice. Ask for full pages instead of more calls.
429 with Retry-After: 60A paid plan reached its fair-use pace, held to one request a minuteStop and report. Read X-Quota-Reset or GET /v1/account for when the window resets.
400 or empty dataA filter value is not an exact enum stringLook the value up at GET /v1/enums/{name}. Pass it exactly.
400 export_limit_rowsAn export asked for more than 50,000 rows in one requestSplit the pull. The daily ceiling is 500,000 rows.

FAQ

Should I use REST tools or the MCP server with Mastra?

Use REST for server-side agents. You control the schemas, the record cap and the key. Use MCP when a person runs the agent locally and can sign in through OAuth.

How many records does one agent run cost?

Under the instructions here, 13 at most: a free count, a search page of 10 and 3 enrichments. The closure cap of 25 is the hard limit. Each person returned counts one record.

Why enrich by person_id and not by name?

There is no name plus domain lookup. Search by domain or title first, then pass the person_id from the row to POST /v1/people/enrich. It also works for people with no LinkedIn URL.

What happens when the free records run out?

The API answers 402 and the helper returns out_of_records to the agent, which then tells the user instead of retrying. Rejected requests are never metered. The 1,000 free records are a one-off, so a 402 on the free plan means upgrade, not wait.

Can the agent pull thousands of rows?

Not through search. A search pages 100 rows at most, and one walk stops at 10,000. For big pulls use POST /v1/exports: up to 50,000 rows a request and 500,000 a day.

Give your Mastra agent real B2B data

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →

Related guides: LangGraph, LlamaIndex, Pydantic AI. More agent guides live on the LeadOcean blog.