LinkedIn profiles in Mastra: a LinkedIn tool through MCP or createTool
You're building an agent with Mastra 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. Mastra has no tool for that. Its built-in tools search the web, fetch a page, ask the user a question or keep a task list. We searched its GitHub repository on October 11, 2026: the one LinkedIn toolkit in it is from another company, Arcade, and it posts on your own LinkedIn account and can't read profiles.
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 Mastra's MCPClient and write no tool code. Or you can write one createTool() 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 createTool() sends a URL to Fetchin only when Up2Data returns a 429, at its daily limit or its rate limit.
We ran both ways inside Mastra's agent., with a scripted 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.18 or later, which runs a TypeScript file itself:
node run.ts. - A
package.jsonwith{ "type": "module" }, as in Mastra's quickstart, and the packages:npm install @mastra/core @mastra/mcp zod.@mastra/mcpis the MCP client, and you describe the tool's input with zod. - 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_. You get a $5 credit, enough for 2,105 profiles through Up2Data.API_KEY - A model that calls tools. The code uses
"openai/, in thegpt -5.6 -sol" provider/modelform of Mastra's models, which reads your key fromOPENAI_. Put your own model on that line.API_KEY
The MCP way: our MCP server through MCPClient
Mastra's MCP client is MCPClient, from @mastra/mcp. Our MCP server is at https://. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. It reads your key from Authorization, so you put the key in the server's requestInit headers as a Bearer token. Save this file as src/:
import { Agent } from "@mastra/core/agent";
import { MCPClient } from "@mastra/mcp";
export const mcp = new MCPClient({
servers: {
datacircle: {
url: new URL("https://api.datacircle.dev/mcp"),
requestInit: { headers: { Authorization: `Bearer ${process.env.DATACIRCLE_API_KEY}` } },
},
},
});
// A wrong key doesn't make listTools() throw: it would leave the agent with no tool. listToolsWithErrors() says why
const { tools, errors } = await mcp.listToolsWithErrors();
if (errors.datacircle) throw new Error(`Datacircle's MCP server: ${errors.datacircle}`);
export const linkedinMcpAgent = new Agent({
id: "linkedin-mcp-agent",
name: "LinkedIn MCP Agent",
instructions: "Answer questions about the current role on a LinkedIn profile. Get the profile with datacircle_get_linkedin_profile.",
model: "openai/gpt-5.6-sol",
tools: { datacircle_get_linkedin_profile: tools.datacircle_get_linkedin_profile },
});The client names each tool after its server, so our get_ comes back as datacircle_, the name the model sees. The server has other tools, such as get_, and the code gives the model datacircle_ alone.
Save this file as run, next to src, and run node run-mcp.ts:
import { linkedinMcpAgent, mcp } from "./src/mastra/agents/linkedin-mcp-agent.ts";
try {
const { text } = await linkedinMcpAgent.generate("What is the current job title on https://www.linkedin.com/in/williamhgates?");
console.log(text);
} finally {
await mcp.disconnect();
}The finally closes the client's connection once the agent has answered.
With a wrong key, our server answers 401, and listTools(), the call in Mastra's examples, doesn't throw. In our test, it logged the error and returned no tools. The agent ran without one, and the script ended without an error. listToolsWithErrors() returns the same tools with an errors entry for each server that failed, and the code throws on ours, before the agent calls the model:
Error: Datacircle's MCP server: Failed to connect to MCP server datacircle: SdkHttpError: Version negotiation failed: the server requires authorization (HTTP 401)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. The model gets it as JSON text. On a failed call, the model gets our API's error JSON, and generate() keeps running. Mastra prints each failed call to your console as "Error calling tool", with a stack trace. 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.
MCPClient waits 60 seconds for a tool call by default, and our API gives up on the provider after 45 seconds, so there's no timeout to set. To test the client's wait, we made our test server take 50 seconds to answer, longer than our API waits: the model got the profile.
Why the key, not OAuth
Mastra's client signs in with OAuth through MCPOAuthClientProvider, which doesn't register itself with a sign-in server: you give it a client you registered in advance, or the URL of a client metadata document. Our sign-in server registers each client on the client's first request, and reads no such document. We haven't tested Mastra's OAuth with our server. We use the Bearer key, which needs neither.
The createTool() 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 src/:
import { createTool } from "@mastra/core/tools";
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 linkedinProfileTool = createTool({
id: "get-linkedin-profile",
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
},
});Mastra's createTool(), from @mastra/, takes an id, the description the model reads, the inputSchema the model's input must match, and the execute function Mastra 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. The tool has no outputSchema, since it returns JSON or a sentence. Each field comes from the same JSON path as in our Node.js post:
| Field | Up2Data's answer | Fetchin's answer |
|---|---|---|
job_ | data. | jobTitle |
company | data. | companyName |
location | data. | location |
headline | data. | title |
DATACIRCLE_ is for tests: point it at a mock of our API, and you can run the tool without spending your balance.
The agent: the tool in a Mastra agent
Save the agent as src/:
import { Agent } from "@mastra/core/agent";
import { linkedinProfileTool } from "../tools/linkedin-profile-tool.ts";
export const linkedinAgent = new Agent({
id: "linkedin-agent",
name: "LinkedIn Agent",
instructions: "Answer questions about the current role on a LinkedIn profile. Get the profile with linkedinProfileTool.",
model: "openai/gpt-5.6-sol",
tools: { linkedinProfileTool },
hooks: {
// An error thrown in the tool (a wrong key) goes to the model as the tool's result: print it for yourself too
afterToolCall: ({ toolName, error }) => {
if (error instanceof Error) console.error(`${toolName} failed: ${error.message}`);
},
},
});The model sees the tool under its key in tools, linkedinProfileTool, and not under its id. A wrong key (401) makes execute throw. Mastra gives the error's message to the model as the tool's result, and generate() returns without an error. You see no error unless you check for it, so the afterToolCall hook prints it. We tried a wrong key: the model got Datacircle answered 401: {"error": "invalid api key"}, and the hook printed the same line after linkedinProfileTool failed:. Set the right key in DATACIRCLE_.
Register the agent with Mastra in src/:
import { Mastra } from "@mastra/core";
import { linkedinAgent } from "./agents/linkedin-agent.ts";
export const mastra = new Mastra({
agents: { linkedinAgent },
});Then save this file as run.ts, next to src, and run node run.ts:
import { mastra } from "./src/mastra/index.ts";
const agent = mastra.getAgentById("linkedin-agent");
const { text } = await agent.generate("What is the current job title on https://www.linkedin.com/in/williamhgates?");
console.log(text);generate() runs the loop: the model asks for a tool, Mastra runs execute, and the model reads the answer and replies. It stops after 5 steps by default, enough to call the tool, read what it returns, call it a second time and answer. The answer is in text. Mastra warns that no storage is set: this agent keeps no memory, so it needs none.
Each API answer: its cost and what the tool returns
| Answer | What it means | Cost | The tool returns |
|---|---|---|---|
| Up2Data 200 | the profile | $2.375 per 1,000 | the four fields |
| Up2Data 422 | the profile is private or deleted | free | "This LinkedIn profile is private or deleted." |
| Up2Data 400 | not a LinkedIn profile URL | free | "This is not a LinkedIn profile URL." |
| Up2Data 429 | its daily limit or rate limit | free | Fetchin's answer |
| Fetchin 200 | the profile | $1.485 per 1,000 | the four fields |
Fetchin 404 with PROFILE_ | the profile is private or deleted | $1.485 per 1,000: Fetchin bills the lookup | "This LinkedIn profile is private or deleted." |
| Fetchin 429 | its rate limit, which all our customers share | free | Fetchin's answer to a second or third try, or "Try again in a minute" |
| 402 | your balance can't cover the call | free | "The Datacircle balance is too low for this call." |
| 502, 503 or 504 | the provider failed, or didn't answer within 45 seconds | free | "Try again in a minute" |
| 401 | your key is wrong | free | an error, which the model reads and afterToolCall prints |
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
@mastra/core1.75.0,@mastra/mcp2.2.0 and zod 4.6.5. The six files passed TypeScript's strict check. - Each file above ran unchanged except one line: we put a scripted model, built on the AI SDK's
MockLanguageModelV4, where the code has"openai/. The scripted model asks for the LinkedIn tool, 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 files' requests to a stand-in server through agpt -5.6 -sol" fetchwrapper we loaded first, without editing the files. - 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_and called it. The model got our errors, and the agent kept running. At Up2Data's daily limit, our scripted model called again through Fetchin and got the profile. Mastra's client checks each answer against the tool's schema, and Fetchin's answers passed it. To check the client's time limit, we made the stand-in answer in 50 seconds: the model got the profile.linkedin_ 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 and the hook printed. The MCP agent stopped at the error above, before it called the model. These calls cost nothing. - We didn't run Mastra's OAuth sign-in, its Studio (
mastra dev), 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:
| Up2Data | HarvestAPI | Fetchin | |
|---|---|---|---|
| Per 1,000 found | $2.375 | $3.70 | $1.485 |
| Per 1,000 not found | free | $2.30 | $1.485 |
| Daily limit | 421 profiles per account | none | none |
Say your agent looks up 1,000 profiles in a day with the createTool(), 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. Your model may call the tool more than once per 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. In Vercel's AI SDK: LinkedIn profiles in the Vercel AI SDK. In Python, with OpenAI's Agents SDK: LinkedIn profiles in the OpenAI Agents SDK. For a LangChain agent: LinkedIn profiles in LangChain. 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 Mastra have a LinkedIn tool?
No. Mastra's built-in tools search the web, fetch a page, ask the user a question or keep a task list. On October 11, 2026, its GitHub repository listed one LinkedIn toolkit, from another company, Arcade, which posts on your own LinkedIn account and can't read profiles. To read profiles, connect our MCP server at https://
How do I connect Mastra's MCPClient to an MCP server with an API key?
Give the server a url and requestInit: { headers: { Authorization: "Bearer <your key>" } } in new MCPClient({ servers: { ... } }). listTools() and listToolsWithErrors() name each tool after its server: our get_
Why does my Mastra agent have no MCP tools?
MCPClient's listTools() doesn't throw when a server refuses to connect. It logs the error and returns the tools of the servers that answered, so the agent runs without that server's tools. With a wrong key, our server answers 401. Call listToolsWithErrors() instead, and stop when errors has an entry for your server.
Does the model see an error your Mastra tool's execute throws?
Yes. Mastra gives the error's message to the model as the tool's result, and generate() returns instead of throwing. The agent's afterToolCall hook gets the error, so your code can print it. In our test, we sent a wrong key, and the model got this tool result: 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