Datacircle

LinkedIn profiles in Pydantic AI: a LinkedIn tool through MCP or a function tool

You're building an agent with Pydantic AI, and it 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. Pydantic AI has no LinkedIn tool: its common tools search the web or fetch a page. We searched its GitHub repository on October 11, 2026: LinkedIn comes up once, in an example's prompt that sends the agent to a DuckDuckGo search.

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 your agent to our MCP server and write no tool code. Or you can write one Python function that calls our API, and decide what the model reads. Both call Up2Data first, unless your agent 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 function sends a URL to Fetchin only when Up2Data returns a 429, at Up2Data's daily limit or rate limit.

We ran both the MCP code and the function inside a Pydantic AI agent, with a scripted test model and a stand-in server that answers like our API. We called api.datacircle.dev with a wrong key, from the function 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

  • Python 3.11 or later.
  • Pydantic AI with OpenAI and MCP support, and requests for the function: pip install "pydantic-ai-slim[openai,mcp]" requests. The full package, pydantic-ai, works too.
  • 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.
  • An OpenAI API key in OPENAI_API_KEY. The code uses openai:gpt-5.6-luna. Any model Pydantic AI can use with tools works in its place.

The MCP way: our MCP server through MCPToolset

Pydantic AI reads a remote MCP server's tools with MCPToolset, as its MCP client page shows. Our MCP server is at https://api.datacircle.dev/mcp. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. A string in auth goes to our server as a Bearer token, which is how it reads your key. This file is the whole agent:

import os

from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset

datacircle = MCPToolset(
    "https://api.datacircle.dev/mcp",
    auth=os.environ["DATACIRCLE_API_KEY"],
    tool_error_behavior="failed",
).filtered(lambda ctx, tool: tool.name == "get_linkedin_profile")

agent = Agent(
    "openai:gpt-5.6-luna",
    toolsets=[datacircle],
    instructions="Answer questions about the current role on a LinkedIn profile. Get the profile with get_linkedin_profile.",
)

result = agent.run_sync("What is the current job title on https://www.linkedin.com/in/williamhgates?")
print(result.output)

The server has other tools, such as get_balance, and .filtered(...) keeps get_linkedin_profile alone. There's no timeout to set: MCPToolset waits up to 5 minutes for a tool's answer, and our API waits up to 45 seconds for the provider. To check the client's time limit, we made a stand-in server answer in 50 seconds: the model got the profile.

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 reads it once, as JSON:

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

To have the model read four short fields instead, use the function below. At Up2Data's limit, the server tells your agent to call again through Fetchin or HarvestAPI. The model picks one, and HarvestAPI costs more per 1,000 profiles than the other two. Our MCP server docs list every tool.

tool_error_behavior="failed" matters. Without it, Pydantic AI sends our error to the model with "Fix the errors and try again.". In our test, a model that sent the same URL again got the same error, and the run stopped with Tool 'get_linkedin_profile' exceeded max retries count of 1. We bill a repeated call again when the provider bills it, as Fetchin does for a profile it can't find. With "failed", the model gets our error JSON once, as a failed tool result, and the run goes on.

The first time, your client signs you in with your Datacircle email (OAuth). Or send your API key as a Bearer token. The code here does that. For the email sign in, Pydantic AI takes auth="oauth", which we haven't tried. With a wrong key, the file stops at run_sync, before Pydantic AI calls the model, with MCPError: invalid API key or access token.

The function way: one function 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 function sends Up2Data's own request, with X-Data-Provider naming the provider. Save the code below as datacircle_tool.py:

import os
import time

import requests

API = os.environ.get("DATACIRCLE_API_URL", "https://api.datacircle.dev")
KEY = os.environ["DATACIRCLE_API_KEY"]


def call(provider, method, path, **request):
    """One call to Datacircle's API through one provider. Fetchin's 429 is its rate limit: wait a second and send it again."""
    for wait in (0, 1, 2):
        time.sleep(wait)
        answer = requests.request(method, f"{API}{path}", headers={"Authorization": f"Token {KEY}", "X-Data-Provider": provider}, timeout=60, **request)
        if provider != "fetchin" or answer.status_code != 429:
            return answer
    return answer


def get_linkedin_profile(url: str) -> dict | str:
    """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."""
    answer = call("up2data", "POST", "/v1/profiles/enrich", json={"url": url})
    if answer.status_code == 200:
        profile = answer.json()["data"]
        company = profile.get("current_company") or {}
        return {"job_title": company.get("title"), "company": company.get("name"),
                "location": (profile.get("location") or {}).get("raw"), "headline": profile.get("headline")}
    if answer.status_code == 429:  # Up2Data's daily limit or rate limit: Fetchin answers instead
        answer = call("fetchin", "GET", "/api/v1/profile", params={"profileUrlOrUrn": url})
        if answer.status_code == 200:
            profile = answer.json()
            return {"job_title": profile.get("jobTitle"), "company": profile.get("companyName"),
                    "location": profile.get("location"), "headline": profile.get("title")}
    if answer.status_code in (404, 422):
        return "This LinkedIn profile is private or deleted."
    if answer.status_code == 400:
        return "This is not a LinkedIn profile URL. Send one like https://www.linkedin.com/in/williamhgates"
    if answer.status_code == 402:
        return "The Datacircle balance is too low for this call. Tell the user to add funds on their Datacircle dashboard."
    if answer.status_code in (429, 500, 502, 503, 504):
        return f"The provider didn't answer ({answer.status_code}), and the call wasn't charged. Try again in a minute."
    answer.raise_for_status()  # 401: DATACIRCLE_API_KEY is wrong

A Pydantic AI Agent takes a plain Python function in tools, as its tools page shows: the function's name is the tool's, its docstring is the description the model reads, and the type hint on url is its input. Pydantic AI runs a function that isn't async in a thread, so its waits don't hold up the agent. The function sends the URL to Up2Data, and to Fetchin when Up2Data returns a 429. Fetchin takes 5 requests a second across all our customers, and returns a 429 past that: the function waits one second and sends it again, then waits two seconds and sends it once more.

The function returns a dict, and Pydantic AI 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 a sentence, so the agent can tell the user and go on.

A wrong key (401) stops the run. Pydantic AI doesn't send this error to the model, as its docs on tool errors say. run_sync raises 401 Client Error: Unauthorized for url: …. Set the right key in DATACIRCLE_API_KEY. Each field comes from the same JSON path as in our Python post:

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

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

The agent

A Pydantic AI Agent gets a model, its tools and its instructions, and runs the loop: the model asks for a tool, reads the tool's answer, and replies. With get_linkedin_profile from datacircle_tool.py:

from pydantic_ai import Agent

from datacircle_tool import get_linkedin_profile

agent = Agent(
    "openai:gpt-5.6-luna",
    tools=[get_linkedin_profile],
    instructions="Answer questions about the current role on a LinkedIn profile. Get the profile with get_linkedin_profile.",
)

result = agent.run_sync("What is the current job title on https://www.linkedin.com/in/williamhgates?")
print(result.output)

run_sync runs the agent and waits for its reply, and result.output is the model's answer. In async code, use await agent.run(...). In the MCP example above, the agent is the same, with our MCP server in toolsets.

Each API answer: its cost and what the tool returns

Our API's answers to get_linkedin_profile in datacircle_tool.py
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 429Up2Data's 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 wrongfreenothing: the run stops with the error

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 Python 3.12, with pydantic-ai-slim 2.55.0 and the fastmcp-slim 4.1.0, mcp 2.3.0 and requests 2.34.2 it installs.
  • We ran each Python file above through Pydantic AI's agent loop, its text unchanged, with a scripted model in place of OpenAI's. Our test code made any model name, openai:gpt-5.6-luna included, load Pydantic AI's FunctionModel, which we scripted to ask for get_linkedin_profile once, then answer with what the tool returned. It also wrapped MCPToolset, so the MCP file's URL went to a stand-in server. Only the retry test below changed a file: we took out tool_error_behavior.
  • The agent and its function 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 model saw get_linkedin_profile alone, and got each of our errors as a failed tool result. We made one stand-in answer take 50 seconds, and the model got the profile. Without tool_error_behavior="failed", the run stopped on the second error when the model sent the same URL again.
  • We called api.datacircle.dev with a wrong key. Our API answered the function with a 401 and {"error": "invalid api key"}, and the run stopped with the error. The MCP agent stopped at MCPError: invalid API key or access token. These calls cost nothing.
  • We didn't run any test with a real Datacircle key or a real model, or try auth="oauth".

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 function, 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 agent may call the tool more than once per question, and we bill each call. OpenAI bills you for the model's tokens.

The same tool in other agent frameworks: LinkedIn profiles in LangChain, LinkedIn profiles in CrewAI, LinkedIn profiles in the OpenAI Agents SDK, LinkedIn profiles in LlamaIndex and LinkedIn profiles in Google ADK. The same call from a Python script: get LinkedIn profile data with Python. In Claude, ChatGPT or Cursor: our LinkedIn MCP server. Other vendors' prices per 1,000: LinkedIn profile API pricing compared.

Questions

Does Pydantic AI have a LinkedIn tool?

No. It has common tools that search the web or fetch a page, but none for LinkedIn. To use ours, add our MCP server at https://api.datacircle.dev/mcp with MCPToolset. Or give your agent a Python function 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 connect a Pydantic AI agent to an MCP server with an API key?

Create MCPToolset("https://api.datacircle.dev/mcp", auth="<your key>", tool_error_behavior="failed"), keep get_linkedin_profile with .filtered(lambda ctx, tool: tool.name == "get_linkedin_profile"), and pass it to Agent(toolsets=[...]). MCPToolset sends a string in auth as a Bearer token.

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