Datacircle

LinkedIn profiles in the Claude Agent SDK: a LinkedIn tool through MCP or @tool

You're building an agent in Python with Anthropic's Claude Agent SDK, 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 LinkedIn tool. Its built-in tools read and edit files, run commands, search the web and fetch a page. We searched its GitHub repository on October 11, 2026: LinkedIn isn't in it. Anthropic names it once in its demo repository, in a demo's prompt, among the sites its agent searches with WebSearch.

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 @tool function 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 function sends a URL to Fetchin only when Up2Data returns a 429, at its daily limit or its rate limit.

We ran both agents inside the SDK, with a script in place of Anthropic's API, 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.10 or later.
  • The SDK, and httpx for the function: pip install claude-agent-sdk httpx. The SDK includes Claude Code and runs it for you.
  • 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 Anthropic API key in ANTHROPIC_API_KEY. The SDK doesn't read a .env file, so export both keys in your shell.

The MCP way: our MCP server in mcp_servers

The SDK connects to a remote MCP server you list in mcp_servers, as Anthropic's MCP 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. It reads your key from the Authorization header as a Bearer token. Save this file as mcp_agent.py and run python mcp_agent.py:

import asyncio
import os

from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient, ResultMessage

options = ClaudeAgentOptions(
    mcp_servers={
        "datacircle": {
            "type": "http",
            "url": "https://api.datacircle.dev/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['DATACIRCLE_API_KEY']}"},
        }
    },
    allowed_tools=["mcp__datacircle__get_linkedin_profile"],
    permission_mode="dontAsk",  # refuse every tool call allowed_tools doesn't approve
    tools=[],  # no built-in tools: no shell, no files, no web
    env={"MCP_CONNECTION_NONBLOCKING": "0"},  # connect to the MCP server before the client is ready
    system_prompt="Answer questions about the current role on a LinkedIn profile. Get the profile with mcp__datacircle__get_linkedin_profile.",
)


async def main():
    async with ClaudeSDKClient(options=options) as client:
        server = next(s for s in (await client.get_mcp_status())["mcpServers"] if s["name"] == "datacircle")
        if server["status"] in ("failed", "needs-auth"):
            raise SystemExit(f"Datacircle's MCP server didn't connect ({server['status']}): {server.get('error', '')}")
        await client.query("What is the current job title on https://www.linkedin.com/in/williamhgates?")
        async for message in client.receive_response():
            if isinstance(message, ResultMessage):
                print(message.result)


asyncio.run(main())

The SDK names each tool after the name you gave its server in mcp_servers, so our get_linkedin_profile becomes mcp__datacircle__get_linkedin_profile. allowed_tools lets it run. The model also sees the server's other tools, such as get_balance, and dontAsk refuses them. We had our scripted model call get_balance. Our server got no call, and the model got this answer:

Permission to use mcp__datacircle__get_balance has been denied because Claude Code is running in don't ask mode.

Without permission_mode, our test session started in auto mode, which Anthropic's permissions page says can be the default. The call to get_balance then went first to a second Claude model, sent with your Anthropic API key: the classifier that, as that page says, decides whether a tool may run. tools=[] leaves out the built-in tools, so the model can't run a command or read a file.

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 one JSON object:

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

The SDK gives our server's errors to the model as a tool result marked is_error, and the agent keeps running. At Up2Data's limit, the server tells your agent to call again through Fetchin or HarvestAPI. We scripted our stand-in for Anthropic's API to call again with fetchin, and it 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.

{"type": "tool_result", "content": "{\"error\": …}", "is_error": true}

HarvestAPI's and Fetchin's answers can pass 50,000 characters for a profile with a long history. Above that, Claude Code saves the answer to a file and gives the model its path and the first 2 KB, as Anthropic's page on output limits says. With tools=[], the model can't open that file. We sent a Fetchin answer longer than that, saved from an earlier test of our API, and the model got the path and the first 2 KB. The function below returns four fields, so its answer stays under 50,000 characters.

There's no MCP timeout to set. Claude Code waits 60 seconds for each request to a remote MCP server by default, and our API gives up on the provider after 45 seconds. We made our stand-in server answer in 50 seconds, and the model got the profile. At 65 seconds, the model got The operation timed out. instead.

With a wrong key, the SDK runs the agent without our tools, and the model answers with no profile. The code uses ClaudeSDKClient rather than query() to check the server before it sends the question: MCP_CONNECTION_NONBLOCKING makes the client connect first, then get_mcp_status() gives the status. We tried a wrong key, and the script stopped before any model call, with:

Datacircle's MCP server didn't connect (failed): Server rejected the configured Authorization header (HTTP 401). Check that the token is valid for this MCP endpoint …

With query(), the init message lists the server as failed after the question has gone to the model, and when we broke out of its loop in our test, query() printed another error. Set the right key in DATACIRCLE_API_KEY.

Use an API key: the SDK doesn't run an OAuth sign-in

Anthropic's MCP page says the SDK doesn't open a browser or run an OAuth sign-in. We tried our stand-in server without the Authorization header: the SDK read the server's OAuth metadata, registered no OAuth client, and reported needs-auth. Claude Code caches that needs-auth result for 15 minutes, in mcp-needs-auth-cache.json in its config folder. In our test, a run with the key our stand-in server accepts still read needs-auth within those 15 minutes and never called the server. Delete that file or wait 15 minutes. We use the Bearer key, which needs no sign-in.

The @tool 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 this file as tool_agent.py and run python tool_agent.py:

import asyncio
import json
import os
from typing import Annotated

import httpx
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, ToolResultBlock, UserMessage, create_sdk_mcp_server, query, tool

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


async def call(client, 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):
        await asyncio.sleep(wait)
        answer = await client.request(method, f"{API}{path}", headers={"Authorization": f"Token {KEY}", "X-Data-Provider": provider}, **request)
        if provider != "fetchin" or answer.status_code != 429:
            return answer
    return answer


def reply(text, error=False):
    return {"content": [{"type": "text", "text": text}], "is_error": error}


@tool(
    "get_linkedin_profile",
    "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.",
    {"url": Annotated[str, "The profile's LinkedIn URL"]},
)
async def get_linkedin_profile(args):
    async with httpx.AsyncClient(timeout=60) as client:
        answer = await call(client, "up2data", "POST", "/v1/profiles/enrich", json={"url": args["url"]})
        if answer.status_code == 200:
            profile = answer.json()["data"]
            company = profile.get("current_company") or {}
            return reply(json.dumps({"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 = await call(client, "fetchin", "GET", "/api/v1/profile", params={"profileUrlOrUrn": args["url"]})
            if answer.status_code == 200:
                profile = answer.json()
                return reply(json.dumps({"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 reply("This LinkedIn profile is private or deleted.", error=True)
    if answer.status_code == 400:
        return reply("This is not a LinkedIn profile URL. Send one like https://www.linkedin.com/in/williamhgates", error=True)
    if answer.status_code == 402:
        return reply("The Datacircle balance is too low for this call. Tell the user to add funds on their Datacircle dashboard.", error=True)
    if answer.status_code in (429, 500, 502, 503, 504):
        return reply(f"The provider didn't answer ({answer.status_code}), and the call wasn't charged. Try again in a minute.", error=True)
    raise RuntimeError(f"Datacircle answered {answer.status_code}: {answer.text}")  # 401: DATACIRCLE_API_KEY is wrong


datacircle = create_sdk_mcp_server(name="datacircle", tools=[get_linkedin_profile])

options = ClaudeAgentOptions(
    mcp_servers={"datacircle": datacircle},
    allowed_tools=["mcp__datacircle__get_linkedin_profile"],
    permission_mode="dontAsk",  # refuse every tool call allowed_tools doesn't approve
    tools=[],  # no built-in tools: no shell, no files, no web
    system_prompt="Answer questions about the current role on a LinkedIn profile. Get the profile with mcp__datacircle__get_linkedin_profile.",
)


async def main():
    question = "What is the current job title on https://www.linkedin.com/in/williamhgates?"
    async for message in query(prompt=question, options=options):
        if isinstance(message, UserMessage) and isinstance(message.content, list):
            for block in message.content:
                if isinstance(block, ToolResultBlock) and block.is_error:
                    print(f"The tool failed: {block.content}")  # the model reads it too
        if isinstance(message, ResultMessage):
            print(message.result)


asyncio.run(main())

The SDK's @tool takes the tool's name, the description the model reads, and its input: here one url. create_sdk_mcp_server puts the function in an MCP server that runs inside your Python process, and the model sees it as mcp__datacircle__get_linkedin_profile, as in the MCP way. The function 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 function waits one second and sends it again, then waits two seconds and sends it once more. It calls our API with httpx's async client.

The model gets four fields back, as JSON text:

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

The SDK passes on only content and is_error from what the function returns. For a private or deleted profile, a URL that isn't a profile, a low balance or a provider error, the function returns one sentence with is_error set, so the agent can tell the user and go on. 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

With a wrong key (401), the function raises. The SDK gives the exception's text to the model as the tool's result, marked is_error, and the run goes on. The loop prints each failed call. We tried a wrong key: the script printed The tool failed: Datacircle answered 401: {"error": "invalid api key"}, and the model got the same text. 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 function without spending your balance.

API answers: cost and what the tool returns

Our API's answers to the function in tool_agent.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 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 the loop 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 Python 3.12, with claude-agent-sdk 0.2.165, the Claude Code 2.1.294 it bundles, the mcp 2.3.0 it installs, and httpx 0.28.1.
  • We ran each file above unchanged, inside the SDK, with our test's own Claude Code config folder. In place of a model, we pointed ANTHROPIC_BASE_URL at a stand-in for Anthropic's API. We scripted it to ask for the LinkedIn tool, to ask for it again through Fetchin when our MCP server says Up2Data is at its daily limit, then to answer with what the tool returned. Our test code sent the MCP file's requests to a stand-in server, without editing the file.
  • The 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 read the profile once, got each of our errors as a tool result and went on, and got Fetchin's profile at Up2Data's daily limit. We delayed answers by 50 and 65 seconds, and sent the saved Fetchin answer longer than 50,000 characters.
  • We called api.datacircle.dev with a wrong key. Our API answered the function with a 401 and {"error": "invalid api key"}, which the model got and the script printed. The MCP agent stopped at the error above, before it called a model. These calls cost nothing.
  • We didn't run any test with a real Datacircle key, a real Anthropic 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 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. Anthropic bills you for the model's tokens.

The same tool in other agent frameworks: LinkedIn profiles in LangChain, LinkedIn profiles in the OpenAI Agents SDK, LinkedIn profiles in Pydantic AI, LinkedIn profiles in Google ADK and LinkedIn profiles in smolagents. 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 the Claude Agent SDK have a LinkedIn tool?

No. Its built-in tools read and edit files, run commands, search the web and fetch a page, and Anthropic made none of them for LinkedIn. To read profiles, add our MCP server at https://api.datacircle.dev/mcp in mcp_servers. Or write one @tool 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 the Claude Agent SDK to a remote MCP server with an API key?

In ClaudeAgentOptions, set mcp_servers={"datacircle": {"type": "http", "url": "https://api.datacircle.dev/mcp", "headers": {"Authorization": "Bearer <your key>"}}} and allowed_tools=["mcp__datacircle__get_linkedin_profile"]. The SDK names each tool mcp__<server>__<tool>, after the server's name in mcp_servers.

Why does my Claude Agent SDK agent have no MCP tools?

The server didn't connect, and the SDK runs the agent without its tools instead of stopping. You'll find the server's status in the init message, or from get_mcp_status() on a ClaudeSDKClient: failed when it refused your key, needs-auth when it asks for an OAuth sign-in, which the SDK doesn't run. Stop on either before you send your prompt.

What does the model see when a Claude Agent SDK tool raises an error?

The SDK returns the exception's text as the tool's result and marks it is_error. The agent keeps running. In our test, with a wrong key, the model got: 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