Datacircle

LinkedIn profiles in smolagents: a LinkedIn tool through MCP or @tool

You're building an agent in Python with Hugging Face's smolagents, 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. There's no LinkedIn tool in smolagents. Its built-in tools search the web, visit a webpage and read it as Markdown, search Wikipedia, run Python, ask the user and transcribe speech. We searched its GitHub repository on October 11, 2026: LinkedIn isn't in it.

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 load our MCP server into your agent 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. 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 returns a 429, at its daily limit or its rate limit.

We ran both agents inside smolagents, 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 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. We tested on 3.12.
  • smolagents with its MCP extra, the MCP SDK before version 2, and httpx for the function:
    pip install "smolagents[mcp]" "mcp<2" httpx
    Without "mcp<2", pip installed mcp 2.3.0 in our test, and MCPClient failed before it connected, with ImportError: cannot import name 'streamablehttp_client' from 'mcp.client.streamable_http'. smolagents reaches MCP servers through the mcpadapt library, which calls a function version 2 removed. mcpadapt has an open issue for it.
  • 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 Hugging Face token in HF_TOKEN. InferenceClientModel() runs smolagents' default model through Hugging Face's Inference Providers, as its guided tour shows. You can swap in any model smolagents supports.

The MCP way: our MCP server through MCPClient

smolagents loads a remote MCP server's tools with MCPClient, as its tools page shows. MCPClient hands the dict you give it to the MCP SDK's HTTP client, so the headers entry sends your key to our server as a Bearer token. Our MCP server is at https://api.datacircle.dev/mcp. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. Save this file as mcp_agent.py and run python mcp_agent.py:

import os

from smolagents import InferenceClientModel, MCPClient, ToolCallingAgent

server = {
    "url": "https://api.datacircle.dev/mcp",
    "transport": "streamable-http",
    "headers": {"Authorization": f"Bearer {os.environ['DATACIRCLE_API_KEY']}"},
}

with MCPClient(server, structured_output=True) as tools:  # the connection closes when the block ends
    agent = ToolCallingAgent(
        tools=[tool for tool in tools if tool.name == "get_linkedin_profile"],
        model=InferenceClientModel(),
        instructions="Get a profile with get_linkedin_profile, provider up2data. If it answers 429, call it again with provider fetchin.",
        max_steps=4,
    )
    print(agent.run("What is the current job title on https://www.linkedin.com/in/example-profile?"))

The server has other tools, such as get_balance, and the list keeps get_linkedin_profile alone. With structured_output=True, the tool returns our JSON as a Python dict: the provider's whole answer, every job and school included, plus datacircle_meta, what the call cost and your balance after it. A ToolCallingAgent gives the model that dict as text, in full. We sent a Fetchin answer of 65,089 characters, which we saved from an earlier test of our API, and our model got all of it:

Observation:
{'data': {…}, 'meta': {…}, 'datacircle_meta': {…}}

The tool takes url, and provider: up2data (the default), harvestapi or fetchin. smolagents doesn't read which inputs our server marks optional, so it tells the model both are required. When our scripted model sent url alone, it got Argument provider is required and no profile. The model has to name a provider on each call, and instructions tells it which one. At Up2Data's limit, the server tells your agent to call again through Fetchin or HarvestAPI. We scripted our model to call again with fetchin at Up2Data's 429, and it got the profile. HarvestAPI costs more per 1,000 profiles than the other two. Our MCP server docs list every tool.

Our server marks a failed call as an error, and smolagents drops that mark. The model reads our error JSON the way it reads a profile, and the run goes on:

Observation:
{'error': …}

There's no timeout to set. Our API gives up on a provider after 45 seconds, and MCPClient waits longer than that: our stand-in server answered in 50 seconds, and the model got the profile.

With a wrong key, MCPClient raises before the agent starts, but only after 30 seconds. Our server answered the first request with a 401 at once, and a background thread printed it. MCPClient went on waiting for the connection, then raised. The agent made no model call:

httpx.HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.datacircle.dev/mcp'
…
TimeoutError: Couldn't connect to the MCP server after 30 seconds

Set the right key in DATACIRCLE_API_KEY. The connection lives in a background thread while the with block runs, and closes when the block ends. In our test, a tool call after the block raised RuntimeError: Event loop is closed, so run the agent inside it.

The code here sends the key as a Bearer token, which needs no sign in. We haven't tried an OAuth sign in through MCPClient.

Use a ToolCallingAgent with MCP

We ran this file with CodeAgent in place of ToolCallingAgent too. With structured_output=True, a CodeAgent writes the tool's whole output schema into its system prompt, so the model knows the dict's keys. Our schema lists the top fields of the three providers' answers: the system prompt grows from 9,618 to 21,770 characters, and the agent sends it again at each step. A CodeAgent also cuts what its code prints at 50,000 characters: from our long Fetchin answer, it kept the start and the end and dropped the middle. For a CodeAgent, use the function below.

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 and Fetchin's own requests, with X-Data-Provider naming the provider. Save this file as tool_agent.py and run python tool_agent.py:

import os
import time

import httpx
from smolagents import CodeAgent, InferenceClientModel, tool

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 = httpx.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


@tool
def get_linkedin_profile(url: str) -> dict:
    """Get the current job title, company, location and headline on a LinkedIn profile, from the profile's URL.
    Returns a dict with the keys job_title, company, location and headline, or a dict with one key, error, when the
    profile can't be read. Each call asks the provider live and is billed to the Datacircle balance.

    Args:
        url: The profile's LinkedIn URL, like https://www.linkedin.com/in/example-profile
    """
    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 {"error": "This LinkedIn profile is private or deleted."}
    if answer.status_code == 400:
        return {"error": "This is not a LinkedIn profile URL. Send one like https://www.linkedin.com/in/example-profile"}
    if answer.status_code == 402:
        return {"error": "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 {"error": 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


agent = CodeAgent(tools=[get_linkedin_profile], model=InferenceClientModel(), max_steps=4, executor_kwargs={"timeout_seconds": 120})
print(agent.run("What is the current job title on https://www.linkedin.com/in/example-profile?"))

smolagents' @tool builds a tool from a function's name, type hints and docstring, which must describe each argument under Args. The function returns a dict, so the tool's output type is object. A CodeAgent shows the model each tool as a Python function: def get_linkedin_profile(url: string) -> object:, then the docstring. It leaves out a Returns section, so the docstring names the dict's keys in its first lines.

The function sends the URL to Up2Data. At Up2Data's daily limit or rate limit (its 429), it sends the same URL to Fetchin. 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.

A CodeAgent writes Python that calls the function, and the dict stays in a Python variable. The model reads only what its code prints. Our scripted model printed the dict and read:

Execution logs:
{'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 function returns a dict with one key, error, 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. smolagents gives the error to the model, adds a line asking it to retry, and the run goes on. We tried a wrong key on api.datacircle.dev, and the model read:

Code execution failed at line 'profile = get_linkedin_profile(url="https://www.linkedin.com/in/example-profile")' due to: HTTPStatusError: Client error '401 Unauthorized' for url 'https://api.datacircle.dev/v1/profiles/enrich'
…
Now let's retry: take care not to repeat previous errors! If you have retried several times, try a completely different approach.

A 401 costs nothing, and max_steps caps the retries. Set the right key in DATACIRCLE_API_KEY.

Give a CodeAgent more than 30 seconds

smolagents stops waiting for a CodeAgent's code after 30 seconds by default, and our API waits up to 45 seconds for a provider. We made our stand-in server answer in 40 seconds and left the default: the call went through, so we'd bill it, but the model read this instead of the profile:

Code execution exceeded the maximum execution time of 30 seconds

With executor_kwargs={"timeout_seconds": 120}, as in the file above, the model got the profile.

The function works in a ToolCallingAgent too: put ToolCallingAgent in place of CodeAgent in the import and in the agent line, and drop executor_kwargs. We ran it that way: the model read the dict as text, and a tool error after Error executing tool 'get_linkedin_profile'. A ToolCallingAgent has no 30 second limit, and our 40 second answer reached the model.

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

Cap the agent's calls with max_steps

Each call to the tool is a call to our API. We bill each one that gets a profile, and a profile Fetchin or HarvestAPI can't find, at the price on our pricing page. max_steps=4 stops the agent after 4 steps. In our test, a model that called the tool at every step made 4 calls, then smolagents asked it for an answer with no tools. But max_steps counts steps, not calls. A CodeAgent can call the tool in a loop in one step: we scripted a loop over the same URL, and our stand-in server got 5 calls from one step. Say in your task how many profiles the agent may look up.

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 deletedfreeerror: "This LinkedIn profile is private or deleted."
Up2Data 400not a LinkedIn profile URLfreeerror: "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 lookuperror: "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 error: "Try again in a minute"
402your balance can't cover the callfreeerror: "The Datacircle balance is too low for this call."
502, 503 or 504the provider failed, or didn't answer within 45 secondsfreeerror: "Try again in a minute"
401your key is wrongfreean exception, which the model reads

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 smolagents 1.26.0 and the mcpadapt 0.1.20 it installs, mcp 1.30.0 and httpx 0.28.1.
  • We ran each file above unchanged, through smolagents' agent loop. In place of InferenceClientModel, our test code loaded a smolagents model class we scripted: it asked for get_linkedin_profile once, asked again through fetchin when the MCP tool returned 429, then answered with what it read. Our test code sent the MCP file's requests to a stand-in server.
  • 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, in a CodeAgent and in a ToolCallingAgent. 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 got the profile, each of our errors as a tool result with no error mark, and Fetchin's profile after Up2Data's 429. We delayed answers by 50 seconds, sent the long Fetchin answer, and ran the file with a CodeAgent.
  • We called api.datacircle.dev with a wrong key. Our API answered the function with a 401, which the model read. 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 or a real model, or try an OAuth sign in.

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, 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 that gets a profile. Your model's provider bills you for its tokens.

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

No. Its built-in tools search the web, visit a webpage, search Wikipedia and run Python. To read profiles, load our MCP server at https://api.datacircle.dev/mcp with MCPClient. 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 send an API key to a remote MCP server with smolagents' MCPClient?

Put it in the dict's headers: MCPClient({"url": "https://api.datacircle.dev/mcp", "transport": "streamable-http", "headers": {"Authorization": "Bearer <your key>"}}, structured_output=True). MCPClient hands the dict to the MCP SDK's HTTP client, which sends the header with each request.

Why does MCPClient fail with "cannot import name 'streamablehttp_client'"?

pip installed version 2 of the MCP SDK, which removed that function, and mcpadapt, the library MCPClient runs on, still calls it. Install "mcp<2" with "smolagents[mcp]". We tested mcp 1.30.0.

Why does a CodeAgent say "Code execution exceeded the maximum execution time of 30 seconds"?

smolagents stops waiting for a CodeAgent's code after 30 seconds by default, and an API call can take longer. In our test the call finished, but the model never got the profile. Pass executor_kwargs={"timeout_seconds": 120} to CodeAgent.

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