Datacircle

LinkedIn profiles in the Vercel AI SDK: a LinkedIn tool through MCP or tool()

You're building an agent or a chat with Vercel's AI SDK in TypeScript, and the model should read a LinkedIn profile from its URL: the job title, company, location and headline. Say it answers a question about someone's current role, or keeps the job title in a record up to date. The SDK has no tool for that. Its providers' tools search the web, run code or edit files, and its tools registry has no package for LinkedIn. We searched its GitHub repository on October 11, 2026: no doc, example or provider in it mentions LinkedIn.

Datacircle is a data co-op. Step 1: Query your favorite B2B data APIs through us. Same request, same price, no markup. Step 2: You're DONE. Every morning, you get the flat file of your data plus everyone else's. Right now we have 3 live LinkedIn profile APIs that we trust: Up2Data, HarvestAPI and Fetchin.

You can connect to our MCP server with the SDK's MCP client and write no tool code. Or you can write one tool() that calls our API, and decide what the model reads. Both call Up2Data first, unless the model names another provider through MCP. Up2Data costs $2.375 per 1,000 profiles it finds, and nothing for a profile it can't find. Fetchin is cheaper at $1.485 per 1,000, but it bills a profile it can't find, and all our customers share its rate limit. The tool() sends a URL to Fetchin only when Up2Data returns a 429, at its daily limit or its rate limit.

We ran both ways inside the SDK's generateText loop, with the SDK's mock model in place of a real one and a stand-in server that answers like our API. We called api.datacircle.dev with a wrong key, from the tool and from the MCP code: each got a 401, at no charge. We haven't run either with a real key or a real model.

Before you start

  • Node.js 22 or later.
  • The packages: npm install ai @ai-sdk/mcp @ai-sdk/openai zod, and npm install -D tsx to run a TypeScript file with npx tsx. @ai-sdk/mcp is the MCP client, zod describes the tool's input, and @ai-sdk/openai is the OpenAI provider.
  • A Datacircle API key: Log in at datacircle.dev/login with your work email. Your API key is on the page once you're in. Put it in DATACIRCLE_API_KEY. You get a $5 credit, enough for 2,105 profiles through Up2Data.
  • A model that calls tools. The code uses openai("gpt-6-astra"), from the SDK's OpenAI provider, which reads your key from OPENAI_API_KEY. Put your own model on that line.

The MCP way: our MCP server through createMCPClient

The SDK's MCP client is createMCPClient, from @ai-sdk/mcp, as its MCP tools page shows. Older examples write experimental_createMCPClient from ai: it moved to @ai-sdk/mcp, which still exports that name. Our MCP server is at https://api.datacircle.dev/mcp. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. It reads your key from Authorization, so the key goes in headers as a Bearer token. Save this file as mcp-agent.ts and run npx tsx mcp-agent.ts:

import { createMCPClient } from "@ai-sdk/mcp";
import { openai } from "@ai-sdk/openai";
import { generateText, isStepCount } from "ai";

async function main() {
  const mcpClient = await createMCPClient({
    transport: {
      type: "http",
      url: "https://api.datacircle.dev/mcp",
      headers: { Authorization: `Bearer ${process.env.DATACIRCLE_API_KEY}` },
    },
  });
  try {
    const { get_linkedin_profile } = await mcpClient.tools();
    const { text } = await generateText({
      model: openai("gpt-6-astra"),
      tools: { get_linkedin_profile },
      stopWhen: isStepCount(5),
      instructions: "Answer questions about the current role on a LinkedIn profile. Get the profile with get_linkedin_profile.",
      prompt: "What is the current job title on https://www.linkedin.com/in/williamhgates?",
    });
    console.log(text);
  } finally {
    await mcpClient.close();
  }
}

main().catch(console.error);

type: "http" is Streamable HTTP, the transport our server uses. mcpClient.tools() turns each of the server's tools into an SDK tool, under the same name. The server has other tools, such as get_balance, and the code gives the model get_linkedin_profile alone. By default, generateText stops after its first step, when the model has asked for the tool, before it has written an answer. isStepCount(5) lets the model call the tool, read what it returns and write its answer, with room for a second call. The finally closes the client after generateText returns, as Vercel's docs say to.

The tool takes url, and provider: up2data (the default), harvestapi or fetchin. It returns the provider's whole JSON, every job and school included, plus datacircle_meta: what the call cost and your balance after it. On a failed call, the model gets our API's error JSON, and generateText keeps running. At Up2Data's limit, the server tells your agent to call again through Fetchin or HarvestAPI. In our test, our scripted model called again with fetchin, as the error said, and got the profile. The model picks the provider, and HarvestAPI costs more per 1,000 profiles than the other two. Our MCP server docs list every tool.

The client sets no time limit on a tool call, so it waits as long as our API, which gives up on the provider after 45 seconds. In our test, the client waited 50 seconds for our test server, and the model got the profile.

A wrong key stops createMCPClient before generateText calls the model, with MCPClientError: invalid API key or access token. Vercel's reference for createMCPClient checks error.statusCode for a 401. With our server, it's undefined: our 401 carries a JSON-RPC error, which the client reads first. Check error.code, -32001, instead.

OAuth in place of the key

The SDK's MCP client signs in with OAuth only through an authProvider you write: an object that stores the tokens, opens the sign-in page and takes the code back on your callback URL. The SDK finds our sign-in server, registers your app and trades the code for a token. Vercel's example has an authProvider, with a small server for the callback. For a script or a server, the Bearer key is simpler, and this post uses it.

With an OpenAI model on the Responses API, the OpenAI provider has openai.tools.mcp: OpenAI's servers list and call our tools, and your key goes to OpenAI with each request, in its headers. We haven't tested it.

The tool() way: one tool that calls our API

You send the provider's own request to api.datacircle.dev, with your Datacircle key. That's the only change. The tool sends Up2Data's own request, with X-Data-Provider naming the provider. Save the code below as datacircle-tool.ts:

import { tool } from "ai";
import { z } from "zod";

const API = process.env.DATACIRCLE_API_URL ?? "https://api.datacircle.dev";
const KEY = process.env.DATACIRCLE_API_KEY;

// One call to Datacircle's API through one provider. Fetchin's 429 is its rate limit: wait a second and send it again
async function call(provider: string, path: string, body?: object) {
  const send = () =>
    fetch(`${API}${path}`, {
      method: body ? "POST" : "GET",
      headers: { Authorization: `Token ${KEY}`, "X-Data-Provider": provider, ...(body && { "Content-Type": "application/json" }) },
      body: body && JSON.stringify(body),
      signal: AbortSignal.timeout(60_000),
    });
  let answer = await send();
  for (const wait of [1, 2]) {
    if (provider !== "fetchin" || answer.status !== 429) break;
    await new Promise((resolve) => setTimeout(resolve, wait * 1000));
    answer = await send();
  }
  return answer;
}

export const getLinkedinProfile = tool({
  description:
    "Get the current job title, company, location and headline on a LinkedIn profile, from the profile's URL, " +
    "like https://www.linkedin.com/in/williamhgates. Each call asks the provider live and is billed to the Datacircle balance.",
  inputSchema: z.object({ url: z.string().describe("The profile's LinkedIn URL") }),
  execute: async ({ url }) => {
    let answer = await call("up2data", "/v1/profiles/enrich", { url });
    if (answer.status === 200) {
      const { data } = await answer.json();
      return { job_title: data.current_company?.title, company: data.current_company?.name, location: data.location?.raw, headline: data.headline };
    }
    if (answer.status === 429) {
      // Up2Data's daily limit: Fetchin answers instead
      answer = await call("fetchin", `/api/v1/profile?profileUrlOrUrn=${encodeURIComponent(url)}`);
      if (answer.status === 200) {
        const profile = await answer.json();
        return { job_title: profile.jobTitle, company: profile.companyName, location: profile.location, headline: profile.title };
      }
    }
    if (answer.status === 404 || answer.status === 422) return "This LinkedIn profile is private or deleted.";
    if (answer.status === 400) return "This is not a LinkedIn profile URL. Send one like https://www.linkedin.com/in/williamhgates";
    if (answer.status === 402) return "The Datacircle balance is too low for this call. Tell the user to add funds on their Datacircle dashboard.";
    if ([429, 500, 502, 503, 504].includes(answer.status)) {
      return `The provider didn't answer (${answer.status}), and the call wasn't charged. Try again in a minute.`;
    }
    throw new Error(`Datacircle answered ${answer.status}: ${await answer.text()}`); // 401: DATACIRCLE_API_KEY is wrong
  },
});

The SDK's tool(), from ai, holds the three parts of a tool: the description the model reads, the inputSchema the model's input must match, and the execute function the SDK runs with it. The tool sends the URL to Up2Data. At Up2Data's daily limit (its 429), it sends the same URL to Fetchin. Fetchin takes 5 requests a second from all our customers together, and returns a 429 past that: the tool waits one second and sends it again, then waits two seconds and sends it once more.

The model gets four fields back, as JSON:

{"job_title": …, "company": …, "location": …, "headline": …}

For a private or deleted profile, a URL that isn't a profile, a low balance or a provider error, the tool returns a sentence instead, so the agent can tell the user and go on. Each field comes from the same JSON path as in our Node.js post:

Source of each field
FieldUp2Data's answerFetchin's answer
job_titledata.current_company.titlejobTitle
companydata.current_company.namecompanyName
locationdata.location.rawlocation
headlinedata.headlinetitle

A wrong key (401) makes execute throw. The SDK's tool calling page says an error thrown in execute becomes a tool-error part, so the model can read it and go on. We tested it: generateText didn't throw, and the model got Error: Datacircle answered 401: {"error": "invalid api key"} as the tool's result. Your code sees the error only in the run's steps, so the agent below prints it. Set the right key in DATACIRCLE_API_KEY.

DATACIRCLE_API_URL is for tests: point it at a mock of our API, and you can run the tool without spending your balance. We tested the tool against one.

The agent: generateText with the tool

generateText runs the loop: the model asks for a tool, the SDK runs execute, and the model reads the answer and replies, up to the stopWhen limit. The key in tools, get_linkedin_profile, is the name the model sees. Save this file as agent.ts, next to datacircle-tool.ts, and run npx tsx agent.ts:

import { openai } from "@ai-sdk/openai";
import { generateText, isStepCount } from "ai";

import { getLinkedinProfile } from "./datacircle-tool";

async function main() {
  const { text, steps } = await generateText({
    model: openai("gpt-6-astra"),
    tools: { get_linkedin_profile: getLinkedinProfile },
    stopWhen: isStepCount(5),
    instructions: "Answer questions about the current role on a LinkedIn profile. Get the profile with get_linkedin_profile.",
    prompt: "What is the current job title on https://www.linkedin.com/in/williamhgates?",
  });
  // An error thrown in execute (a wrong key) goes to the model as the tool's result: print it for yourself too
  for (const part of steps.flatMap((step) => step.content)) {
    if (part.type === "tool-error") console.error("get_linkedin_profile failed:", part.error);
  }
  console.log(text);
}

main().catch(console.error);

The answer is in text. The MCP file above calls generateText the same way, with the MCP client's tool in tools. AI SDK 7 renamed system to instructions and stepCountIs to isStepCount, as its migration guide says. Older examples use the old names, and the SDK's code still takes stepCountIs, marked deprecated. For a streaming chat, streamText takes the same tools and stopWhen.

Each API answer: its cost and what the tool returns

Our API's answers to the tool
AnswerWhat it meansCostThe tool returns
Up2Data 200the profile$2.375 per 1,000the four fields
Up2Data 422the profile is private or deletedfree"This LinkedIn profile is private or deleted."
Up2Data 400not a LinkedIn profile URLfree"This is not a LinkedIn profile URL."
Up2Data 429its daily limit or rate limitfreeFetchin's answer
Fetchin 200the profile$1.485 per 1,000the four fields
Fetchin 404 with PROFILE_NOT_FOUNDthe profile is private or deleted$1.485 per 1,000: Fetchin bills the lookup"This LinkedIn profile is private or deleted."
Fetchin 429its rate limit, which all our customers sharefreeFetchin's answer to a second or third try, or "Try again in a minute"
402your balance can't cover the callfree"The Datacircle balance is too low for this call."
502, 503 or 504the provider failed, or didn't answer within 45 secondsfree"Try again in a minute"
401your key is wrongfreean error, which the model reads and your code finds in the steps

Up2Data takes $1 a day per account (421 profiles), with a shared daily limit for all customers, then answers 429 until 00:00 UTC. HarvestAPI has no daily limit. Fetchin has no daily limit either.

The tests we ran

  • We ran these tests on October 11, 2026, on Node.js 24.2.0 and 22.18.0, with ai 7.0.137, @ai-sdk/mcp 2.0.73, @ai-sdk/openai 4.0.91, zod 4.6.5 and tsx 4.23.15. The three files passed TypeScript's strict check.
  • Each file above ran unchanged except one line: we put the SDK's mock model, MockLanguageModelV4 from ai/test, where the code has openai("gpt-6-astra"). The mock model asks for get_linkedin_profile, again through Fetchin when our MCP server says Up2Data is at its daily limit, then answers with what the tool returned. We sent the MCP file's requests to a stand-in server through a fetch wrapper we loaded first, without editing the file.
  • The agent and its tool ran against a stand-in server on our machine that answers like our API: the example 200 answers for Up2Data and Fetchin from our API reference, then each error in the table. Each time, the model got what the table lists for that answer.
  • The MCP agent ran against a stand-in that answers like our MCP server: the client listed get_linkedin_profile and called it. The model got our errors, and the run kept going. At Up2Data's daily limit, our scripted model called again through Fetchin and got the profile. To check the client's time limit, we made the stand-in answer in 50 seconds: the model still got the profile.
  • We called api.datacircle.dev with a wrong key. Our API answered the tool with a 401 and {"error": "invalid api key"}, which the model got as the tool's result. The MCP agent stopped at MCPClientError: invalid API key or access token, before generateText called the model. These calls cost nothing.
  • We didn't run the SDK's OAuth sign-in or openai.tools.mcp, or any test with a real Datacircle key or a real model.

Cost per 1,000 profiles

We charge your balance the prices on our pricing page, with no markup:

Our price per 1,000 profiles and daily limit, by provider
Up2DataHarvestAPIFetchin
Per 1,000 found$2.375$3.70$1.485
Per 1,000 not foundfree$2.30$1.485
Daily limit421 profiles per accountnonenone

Say your agent looks up 1,000 profiles in a day with the tool(), one call each, and the providers find every one. You pay us at most $1.86: $1.00 for 421 through Up2Data and $0.86 for the other 579 through Fetchin. A model may call the tool more than once for a question, and we bill each call. Your model's provider bills you for its tokens.

The same call from a Node.js script: get LinkedIn profile data with Node.js. The same tool in Python, with OpenAI's Agents SDK: LinkedIn profiles in the OpenAI Agents SDK. For a LangChain agent: LinkedIn profiles in LangChain. For a CrewAI crew: LinkedIn profiles in CrewAI. In an n8n workflow: LinkedIn profiles in n8n. In Claude, ChatGPT or Cursor: our LinkedIn MCP server. Other vendors' prices per 1,000: LinkedIn profile API pricing compared.

Questions

Does the Vercel AI SDK have a LinkedIn tool?

No. Its providers' tools search the web, run code or edit files, and its tools registry has no package for LinkedIn. On October 11, 2026, no doc, example or provider in its GitHub repository mentioned LinkedIn. Connect our MCP server at https://api.datacircle.dev/mcp with createMCPClient from @ai-sdk/mcp. Or write one tool() whose execute sends POST {"url": "<the profile's LinkedIn URL>"} to https://api.datacircle.dev/v1/profiles/enrich, with the headers Authorization: Token <your key> and X-Data-Provider: up2data.

How do I connect the AI SDK's MCP client to an MCP server with an API key?

Call createMCPClient({ transport: { type: "http", url: "https://api.datacircle.dev/mcp", headers: { Authorization: "Bearer <your key>" } } }) from @ai-sdk/mcp. Pass the tools from mcpClient.tools() to generateText, and close the client after generateText returns. The client can sign in with OAuth instead, through an authProvider you write.

Does the model see an error thrown in a tool's execute?

Yes. The AI SDK gives the error to the model as the tool's result, and generateText keeps running instead of throwing. Your code finds the error in result.steps, as a tool-error part. In our test, we sent a wrong key, and the model got Error: Datacircle answered 401: {"error": "invalid api key"}.

How much does a LinkedIn profile cost?

$2.375 per 1,000 through Up2Data (a profile it can't find is free), $3.70 per 1,000 through HarvestAPI. $1.485 per 1,000 through Fetchin, a profile it can't find billed the same. HarvestAPI bills a profile it can't find at $2.30 per 1,000.

Is there a daily limit?

Up2Data takes $1 a day per account (421 profiles), with a shared daily limit for all customers, then answers 429 until 00:00 UTC. HarvestAPI has no daily limit. Fetchin has no daily limit either.

Is each request live, or cached?

Live. Each request goes to the provider and gets the profile as it is today.

Do I need a LinkedIn account?

No. You send the profile's URL with your Datacircle key: no LinkedIn login, no cookies, no browser.

What happens when my balance runs out?

A call your balance can't cover answers 402. Add funds, from $5, on your dashboard.

Get started

Sign up at datacircle.dev with your work email: a $5 credit, that's 2,105 LinkedIn profiles at $2.375 per 1,000.

Sign up
Ask AI about Datacircle

Each opens with our question