Datacircle

LinkedIn profiles in Genkit: a LinkedIn tool through MCP or ai.defineTool

You're building an app in TypeScript with Genkit on Node.js, and the model should read a LinkedIn profile from its URL: the job title, company, location and headline. genkit.dev calls Genkit "Google's open-source framework for building AI-powered apps and agents in TypeScript, Go, Python, and Dart." Genkit has no LinkedIn tool of its own.

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. Add $50 to your account: you get $50 of API PLUS the flat file. Right now we have 3 live LinkedIn profile APIs that we trust: Up2Data, HarvestAPI and Fetchin.

You can connect our MCP server to your app 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. 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 charges for a profile it can't find, and all our customers share its rate limit. Both switch to Fetchin only when Up2Data hits its daily limit and returns a 429.

We ran both files in Genkit, with a scripted stand-in for OpenAI's API as the model, 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. We haven't run either with a real key or a real model.

Genkit has no LinkedIn tool

A Genkit tool is a function you define with ai.defineTool: its name, its description, its input schema and the code Genkit runs, as Genkit's tool calling page shows. Genkit's MCP plugin, @genkit-ai/mcp, turns an MCP server's tools into Genkit tools. Neither comes with a LinkedIn tool.

On October 12, 2026, we searched GitHub's code for "linkedin". Genkit's repository has none. Across the genkit-ai organization, we found 11 files, each with a link to a LinkedIn page, such as Genkit's company page in its docs site's footer. Genkit's JavaScript docs, all in one file, never name LinkedIn. On npm, none of the 70 packages tagged genkit-plugin does either, and Genkit 1.42.0 with its MCP and OpenAI plugins, as npm installs them, holds no "linkedin" at all.

Before you start

  • Node.js 20 or later, as Genkit's Express tutorial says. We tested on 24.2.0, 22.18.0 and 20.10.0.
  • A package.json with { "type": "module" }, as in that tutorial, and the packages:
    npm install genkit @genkit-ai/compat-oai @genkit-ai/mcp
    npm install -D tsx
    npm installs the MCP TypeScript SDK that the MCP plugin needs. tsx runs a TypeScript file: npx tsx mcp-app.ts.
  • A Datacircle API key: Sign up at datacircle.dev with your work email: a $5 credit, that's 2,105 LinkedIn profiles at $2.375 per 1,000. 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.
  • An OpenAI API key in OPENAI_API_KEY, for Genkit's OpenAI plugin, openAI(). With another model provider, you change the plugin and the model line. We haven't tried another provider's plugin.

The MCP way: our MCP server through @genkit-ai/mcp

Genkit's MCP plugin connects to one MCP server with createMcpClient. 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 we put the key in requestInit's headers as a Bearer token. Save the code below as mcp-app.ts and run npx tsx mcp-app.ts:

import { openAI } from "@genkit-ai/compat-oai/openai";
import { createMcpClient } from "@genkit-ai/mcp";
import { genkit } from "genkit";

const ai = genkit({ plugins: [openAI()] });

const datacircle = createMcpClient({
  name: "datacircle",
  mcpServer: {
    url: "https://api.datacircle.dev/mcp",
    requestInit: { headers: { Authorization: `Bearer ${process.env.DATACIRCLE_API_KEY}` } },
  },
});

// Keep get_linkedin_profile alone. With a wrong key, getActiveTools() returns no tools and throws nothing: stop here
const tools = await datacircle.getActiveTools(ai);
const getLinkedinProfile = tools.find((tool) => tool.__action.name === "datacircle/get_linkedin_profile");
if (!getLinkedinProfile) throw new Error("Datacircle's MCP server gave no get_linkedin_profile tool: check DATACIRCLE_API_KEY");

try {
  const { text } = await ai.generate({
    model: openAI.model("gpt-5.4-mini"),
    // The model doesn't read the tool's description: say here what to do at Up2Data's daily limit
    system:
      "Read LinkedIn profiles with get_linkedin_profile. " +
      "If it answers that the daily up2data limit is reached, call it again with provider fetchin.",
    prompt: "What is the current job title on https://www.linkedin.com/in/example-profile?",
    tools: [getLinkedinProfile],
    maxTurns: 2, // Up2Data, then Fetchin at its daily limit. A third round of tool calls throws
  });
  console.log(text);
} finally {
  await datacircle.disable();
}

Genkit names the tool datacircle/get_linkedin_profile, after our server's name, and the model sees get_linkedin_profile. We give the model that tool alone. The first request to the model was 1,432 bytes with it, and 2,880 with all our server's tools.

With a wrong key, getActiveTools() returns no tools

With a wrong key, our server answers 401, and getActiveTools() doesn't throw: Genkit logged the error and returned no tools. The example on Genkit's MCP page passes whatever getActiveTools() returns to the model, here an empty list: the model ran with no tools, and the script ended without an error. The file throws instead, before any model call. We tried it on api.datacircle.dev:

[MCP Client] Error connecting server via http transport: Error: Streamable HTTP error: Error POSTing to endpoint: {"jsonrpc": "2.0", "id": 0, "error": {"code": -32001, "message": "invalid API key or access token"}}
…
Error: Datacircle's MCP server gave no get_linkedin_profile tool: check DATACIRCLE_API_KEY

Set the right key in DATACIRCLE_API_KEY.

The model gets our whole JSON answer

Our server sends each answer as text and as structuredContent. Genkit gives the model the structuredContent, written as JSON: the provider's JSON, plus datacircle_meta, what the call cost and your balance after it:

{"data":{…},"meta":{…},"datacircle_meta":{…}}

Up2Data's example answer was 937 characters. Genkit gave the model a saved Fetchin answer whole, in 63,166 characters: it writes the JSON without spaces, where our text has 65,229.

On a failed call, the model gets our error JSON as a string inside an error field, and generate() keeps going. At Up2Data's daily limit, the model read this:

{"error":"{\"error\": \"daily up2data limit reached for your account …\"}"}

At Up2Data's daily limit, tell the model to switch to Fetchin

The tool takes url, and provider: up2data (the default), harvestapi or fetchin. At Up2Data's limit, the server tells your agent to call again through Fetchin or HarvestAPI. Through Genkit's OpenAI plugin, the model reads this fallback only in the provider field's description: the plugin sends a tool's name and input schema, but not the tool's description, and Genkit leaves out our server's instructions. We put the fallback in the system message of mcp-app.ts. HarvestAPI costs more per 1,000 profiles than the other two, so the message names Fetchin. Our scripted model called again with fetchin and got the profile. It does what its script says, so this shows the second call goes through Genkit and our server, not that a real model follows the message. Our MCP server docs list every tool.

Genkit waits 60 seconds for our server, which waits 45 for a provider

Genkit's MCP client waits 60 seconds for a tool call, the MCP SDK's default, and our API waits 45 seconds for a provider. Our stand-in server answered in 50 seconds, and the model got the profile. We made it answer in 65 seconds: the client stopped at 60 seconds with McpError: MCP error -32001: Request timed out, and generate() threw it.

The function way: one tool made with ai.defineTool

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 and Fetchin's own requests, with X-Data-Provider naming the provider. Up2Data's request is in our API reference. Save this file as tool-app.ts and run npx tsx tool-app.ts:

import { openAI } from "@genkit-ai/compat-oai/openai";
import { genkit, z } from "genkit";

const ai = genkit({ plugins: [openAI()] });

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) : null,
      signal: AbortSignal.timeout(60_000), // above the 45 seconds our API waits for a provider
    });
  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;
}

const getLinkedinProfile = ai.defineTool(
  {
    name: "get_linkedin_profile",
    description: "Get the current job title, company, location and headline on a LinkedIn profile, from its URL.",
    inputSchema: z.object({
      url: z.string().describe("The profile's LinkedIn URL, like https://www.linkedin.com/in/example-profile"),
    }),
  },
  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 { error: "This LinkedIn profile is private or deleted." };
    if (answer.status === 400) return { error: "This is not a LinkedIn profile URL. Send one like https://www.linkedin.com/in/example-profile" };
    if (answer.status === 402) return { error: "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 { error: `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
  },
);

const { text } = await ai.generate({
  model: openAI.model("gpt-5.4-mini"),
  // The model doesn't read the tool's description: say here what the tool returns
  system:
    "Read LinkedIn profiles with get_linkedin_profile. " +
    "It returns the current job title, company, location and headline.",
  prompt: "What is the current job title on https://www.linkedin.com/in/example-profile?",
  tools: [getLinkedinProfile],
  maxTurns: 2, // at most 2 rounds of tool calls. A third throws
});
console.log(text);

The tool sends the URL to Up2Data. If Up2Data answers 429 for its daily limit, the tool sends the same URL to Fetchin. Fetchin takes 5 requests a second across all our customers, 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 tool returns an object, and Genkit gives it to the model 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 one key, error, so the model 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

The OpenAI plugin drops a tool's description

Genkit's tool calling page says a tool's name, description and input are "vital for the LLM to make effective use of the available tools". In each request our stand-in got, the tool had its name and its input schema, with the url field's description, and no description of its own. The OpenAI plugin's toOpenAITool sends the name and the parameters alone, in version 1.42.0 and on Genkit's main branch today. Put the tool's description in a field's describe(), or in a system message, as tool-app.ts does.

With a wrong key, generate() throws

On a 401, the tool throws, and generate() throws the same error: the model never reads it, and the script stops. The error comes after one model call: the model asked for the tool, and the tool threw. We tried a wrong key on api.datacircle.dev:

Error: Datacircle answered 401: {"error": "invalid api key"}

Set the right key in DATACIRCLE_API_KEY. The signal stops a call after 60 seconds, above the 45 seconds our API waits for a provider. Our stand-in server answered in 50 seconds, and the model got the profile, with the signal and without it. DATACIRCLE_API_URL is for tests: point it at a stand-in for our API, and you can run the tool without spending your balance.

Cap the tool calls with maxTurns

Each tool call reaches our API, and we may bill it. We bill each call that gets a profile, and each call for a profile Fetchin or HarvestAPI can't find, at the price on our pricing page. The function tool sends up to four requests per call, and we bill at most one of them: Up2Data's 429 and Fetchin's 429 are free.

generate() runs the loop: the model asks for tools, Genkit runs them and sends back what they return, until the model answers. maxTurns caps the rounds of tool calls, 5 by default, as Genkit's tool calling page says. A scripted model that asked for the tool at every turn made 5 calls to a stand-in for our API, then generate() threw:

GenerationResponseError [GenkitError]: ABORTED: Exceeded maximum tool call iterations (5)

You get no answer from the model. With maxTurns: 2, as in both files, it made 2 calls before the same error. A round holds every call the model asks for at once: when our scripted model asked for two calls in one answer, Genkit ran both.

Genkit can stop before a tool call. Its toolApproval middleware, from @genkit-ai/middleware, interrupts a call to a tool missing from its approved list. We gave it an empty list: generate() returned with finishReason "interrupted", and our stand-in got no call, from either file. With restartTool and a second generate(), as the middleware page shows, the function file's call went through. Genkit's interrupts page has more ways to pause a tool. We haven't tried them.

API answers: cost and what the tool returns

Our API's answers to the tool in tool-app.ts
AnswerMeaningCostThe tool returns
Up2Data 200the profile$2.375 per 1,000the four fields
Up2Data 422the profile is private or deletedfreeerror: "This LinkedIn profile is private or deleted."
Up2Data 400not a LinkedIn profile URLfreeerror: "This is not a LinkedIn profile URL."
Up2Data 429its daily 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 lookuperror: "This LinkedIn profile is private or deleted."
Fetchin 429its rate limit, which all our customers sharefreeFetchin's answer after up to two retries, or error: "Try again in a minute"
402your balance can't cover the callfreeerror: "The Datacircle balance is too low for this call."
500, 502, 503 or 504the provider failed, or didn't answer within 45 secondsfreeerror: "Try again in a minute"
401your key is wrongfreean error that generate() throws, before the model reads it

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 tested on October 12, 2026, on Node.js 24.2.0, 22.18.0 and 20.10.0, with genkit, @genkit-ai/mcp and @genkit-ai/compat-oai 1.42.0, the MCP TypeScript SDK 1.32.1, openai 4.104.0, tsx 4.23.15 and @genkit-ai/middleware 0.10.0. Both files passed TypeScript 7.0.2's strict check, with the tsconfig.json that tsc --init writes.
  • We ran each file and each variant above in Genkit, with OPENAI_BASE_URL on a scripted stand-in for OpenAI's API. The OpenAI plugin's client reads it, so the files ran unchanged. We sent the MCP file's requests to a stand-in server through a fetch wrapper we loaded first.
  • We ran the function tool against a stand-in for our API that returned each answer in the table. We ran the MCP file against a stand-in for our MCP server with the tools our live server lists, and tried each error, a 50 and a 65 second answer, the long answer, all the server's tools and a wrong key.
  • We didn't try an OAuth sign in, a real Datacircle key, a real model or Genkit's Developer UI.

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 app looks up 1,000 profiles in a day, 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. Through Fetchin alone, the same 1,000 would cost less: $1.485. Both files call Up2Data first because it bills nothing for a profile it can't find, and all our customers share Fetchin's rate limit. Up2Data costs less once 38% or more of your URLs are profiles it can't find. Your model may call the tool more than once per question. 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 other TypeScript frameworks: LinkedIn profiles in the Vercel AI SDK and LinkedIn profiles in Mastra. In Python, with Google's ADK: LinkedIn profiles in Google ADK. In Claude, ChatGPT or Cursor: our LinkedIn MCP server. Other vendors' prices per 1,000: LinkedIn profile API pricing compared.

Questions

Does Genkit have a LinkedIn tool?

No. Connect our MCP server at https://api.datacircle.dev/mcp with createMcpClient from @genkit-ai/mcp, or define one tool with ai.defineTool that 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 send an API key to a remote MCP server in Genkit?

Give createMcpClient an mcpServer with the server's url and requestInit: { headers: { Authorization: "Bearer <your key>" } }. Genkit passes requestInit to the MCP SDK's HTTP transport, which sends the header with each request.

Why does getActiveTools() return no tools in Genkit?

If the MCP server refuses the connection, Genkit's MCP client logs the error and getActiveTools() returns an empty array. It doesn't throw. With a wrong key, our server answers 401. Look for the tool you need in the array, and throw if it isn't there.

What does maxTurns default to in Genkit?

5 rounds of tool calls in genkit 1.42.0. At the limit, generate() throws GenerationResponseError "Exceeded maximum tool call iterations (5)", and you get no answer from the model.

Does the model read a Genkit tool's description?

Not through Genkit's OpenAI plugin, @genkit-ai/compat-oai 1.42.0. It sends the model each tool's name and input schema, and leaves out the description. Put the tool's description in a field's describe() or in the system message.

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