LinkedIn profiles in Strands Agents: a LinkedIn tool through MCP or a Python function
You're building an agent in Python with Strands Agents, and the model should read a LinkedIn profile from its URL: the job title, company, location and headline. The Strands Agents README calls it "an open-source SDK for building and running AI agents in Python and TypeScript". It has no LinkedIn tool of its own. We searched the strands-agents organization on GitHub for "linkedin" on October 11, 2026: we found it in two tools for other vendors' APIs, and in one sample.
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 agent 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. 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 agents inside Strands, 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 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.
LinkedIn in Strands' tools
A Strands agent calls your Python functions, any MCP server's tools, and the tools that ship with the SDK, such as http_ and web_. None of them is a LinkedIn tool. We found "linkedin" in two tools of strands-agents-tools, which Strands' docs call "the former community tools package". Both call other vendors' APIs:
bright_datahasweb_with the sourcedata_feed linkedin_. It calls Bright Data's API for the profile, with yourperson_ profile BRIGHT_, and Bright Data bills you. Our comparison with Bright Data: Bright Data LinkedIn scraper alternative.DATA_ API_KEY exasearches the web with your Exa key, and you can set its category tolinkedin profile.
We haven't tried the SDK's http_ with our API. Strands also has a TypeScript SDK. This page is Python only.
Before you start
- Python 3.10 or later. We tested on 3.12.
- Strands Agents with its OpenAI extra, and httpx for the function tool below:In our test, pip installed 54 packages, with mcp 2.1.1 for the MCP client.
pip install "strands-agents[openai]" httpx - 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 - An OpenAI API key in
OPENAI_, for Strands'API_KEY OpenAIModel. Strands' default model is on Amazon Bedrock, and its docs say the same agent code runs on each provider it supports: the tools below stay the same, and only themodelline changes.
The MCP way: our MCP server through MCPClient
Strands connects to an MCP server with MCPClient, as its MCP page shows. Given url and headers, it opens the connection itself and sends your key to our server as a Bearer token, with each request. Our MCP server is at https://. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. Save this file as mcp_ and run python mcp_agent.py:
import os
from strands import Agent
from strands.models.openai import OpenAIModel
from strands.tools.mcp import MCPClient
datacircle = MCPClient(
url="https://api.datacircle.dev/mcp",
headers={"Authorization": f"Bearer {os.environ['DATACIRCLE_API_KEY']}"},
tool_filters={"allowed": ["get_linkedin_profile"]},
)
with datacircle:
agent = Agent(
model=OpenAIModel(model_id="gpt-5.4-mini"),
tools=datacircle.list_tools_sync(),
system_prompt="Get a profile with get_linkedin_profile. If up2data says its limit is reached, call it again with provider fetchin.",
callback_handler=None,
)
result = agent(
"What is the current job title on https://www.linkedin.com/in/example-profile?",
limits={"turns": 4}, # at most 4 rounds with the model: each tool call is a call to our API
)
print(result)Strands' transports page builds the same connection with streamablehttp_ from the mcp package. pip installed mcp 2.1.1, which dropped it, and the import failed:
ImportError: cannot import name 'streamablehttp_client' from 'mcp.client.streamable_http' (…). Did you mean: 'streamable_http_client'?with datacircle: connects before the agent runs, and closes the connection when the block ends. Strands' docs show Agent(tools=[datacircle]) with no with: it connected when we created the Agent, and worked the same.
The server has other tools, such as get_, and tool_filters keeps get_ alone. Check the name's spelling: with a name our server doesn't have, the agent got no tool and no error, and the model answered without the profile.
The tool returns our JSON: the provider's whole answer, every job and school included, plus datacircle_meta, what the call cost and your balance after it. Strands gives it to the model as JSON text. Through Up2Data, it looks like this:
{"data": {…}, "meta": {…}, "datacircle_meta": {…}}Fetchin's answer has the profile's own fields at the top, such as jobTitle, next to datacircle_meta. We sent a saved Fetchin answer of 65,229 characters, and our model got all of it.
Strands sends the model each tool's name, description and inputs, and leaves out its output schema. Our server describes the answer in a long schema, and none of it reached the model: the first request, with the one tool, was 1,874 bytes. Without tool_filters, it was 2,908 bytes, with our five tools.
At Up2Data's limit, the model reads our error text with no 429
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. That instruction is in the tool's description, which Strands sends to the model. HarvestAPI costs more per 1,000 profiles than the other two, so the file's prompt names Fetchin. Our server marks Up2Data's 429 as an error, but with OpenAIModel, Strands passes the model our JSON and drops that mark. At Up2Data's daily limit, the model reads this:
{"error": "daily up2data limit reached for your account ($1 a day); it resets at 00:00 UTC"}The file's prompt tells the model to call fetchin when up2data says it has reached its limit. Our scripted model called again with fetchin and got the profile. Our MCP server docs list every tool.
A wrong key stops the script before the model runs
With a wrong key, MCPClient raised as it entered the with block. Our server answered the first requests with a 401, and the script stopped before any model call. We tried it on api.datacircle.dev:
client failed to initialize
…
mcp.shared.exceptions.MCPError: invalid API key or access token
…
strands.types.exceptions.MCPClientInitializationError: the client initialization failed: unhandled errors in a TaskGroup (1 sub-exception)The last line doesn't say why: our message, invalid API key or access token, is higher up in the traceback. With Agent(tools=[datacircle]), Agent raised a ValueError ending in unhandled errors in a TaskGroup (1 sub-exception). Set the right key in DATACIRCLE_. This code sends the key as a Bearer token, which needs no sign in. We haven't tried an OAuth sign in through MCPClient.
Keep any timeout above the 45 seconds our API waits for a provider
MCPClient sets no time limit on a tool call, and the HTTP client it makes waits 300 seconds for an answer. Our stand-in server waited 50 seconds before it answered, longer than our API waits, and the model got the profile.
MCPClient has no argument for a call's time limit. Each tool that list_tools_sync() returns has a timeout, empty by default. We set it to timedelta(seconds=10), and after 10 seconds the model got this error and no profile:
Tool execution failed: Request 'tools/call' timed outThe function way: one Python 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. Up2Data's request is in our API reference. Save this file as tool_ and run python tool_agent.py:
import os
import time
import httpx
from strands import Agent, tool
from strands.models.openai import OpenAIModel
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 the keys job_title, company, location and headline, or 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: 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 = Agent(model=OpenAIModel(model_id="gpt-5.4-mini"), tools=[get_linkedin_profile], callback_handler=None)
result = agent(
"What is the current job title on https://www.linkedin.com/in/example-profile?",
limits={"turns": 4}, # at most 4 rounds with the model: each tool call is a call to our API
)
print(result)Strands' @tool turns the function into a tool. The model gets the function's name, the docstring above Args as the description, and the Args line as the description of url. The model never sees -> dict, so we name the keys of the answer in the docstring.
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 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 Strands gives it to the model as JSON text:
{"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 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:
| Field | Up2Data's answer | Fetchin's answer |
|---|---|---|
job_ | data. | jobTitle |
company | data. | companyName |
location | data. | location |
headline | data. | title |
With a wrong key (401), the function raises. Strands gives the model the error as the tool's result, prints nothing, and the run goes on. We tried a wrong key on api.datacircle.dev, and the model read this:
Error: HTTPStatusError - Client error '401 Unauthorized' for url 'https://api.datacircle.dev/v1/profiles/enrich'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401A 401 costs nothing. Set the right key in DATACIRCLE_. Strands sets no time limit on a function, and httpx waits 60 seconds here: our stand-in server waited 50 seconds before it answered, and the model got the answer.
callback_ keeps Strands from printing the answer as it streams, so print(result) prints it once. DATACIRCLE_ is for tests: point it at a stand-in for our API, and you can run the function without spending your balance.
Cap the agent's calls with limits
Each tool call is a call to our API. 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.
Strands sets no limit by default. Without one, a scripted model that asked for the tool at every turn made 315 calls to a stand-in for our API in about 20 seconds. Then the run stopped on Python's recursion limit, with an error that reads like a network failure:
strands.types.exceptions.EventLoopException: Connection error.Strands' limits caps the model's turns in one call to the agent. With limits={"turns": 4}, as in both files, the same model got 4 profiles and the run stopped there, with result. set to limit_turns and no answer: print(result) printed an empty line. A model that asked for two profiles at a time got 8. Check result. before you use the answer.
By default, Strands calls the tool without asking you. With interventions=[HumanInTheLoop()] on the Agent, from Strands' human in the loop page, the run stopped before calling our server, with result. set to interrupt. Say in your task how many profiles the agent may look up.
API answers: cost and what the tool returns
| Answer | Meaning | 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 | error: "This LinkedIn profile is private or deleted." |
| Up2Data 400 | not a LinkedIn profile URL | free | error: "This is not a LinkedIn profile URL." |
| Up2Data 429 | its daily 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 | error: "This LinkedIn profile is private or deleted." |
| Fetchin 429 | its rate limit, which all our customers share | free | Fetchin's answer after up to two retries, or error: "Try again in a minute" |
| 402 | your balance can't cover the call | free | error: "The Datacircle balance is too low for this call." |
| 500, 502, 503 or 504 | the provider failed, or didn't answer within 45 seconds | free | error: "Try again in a minute" |
| 401 | your key is wrong | free | an exception, which the model reads as Error: HTTPStatusError and the error message |
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
strands-agents1.59.0,mcp2.1.1, openai 2.54.0 and httpx 0.28.1. We read strands-agents-tools 0.8.9 and didn't use it. - We ran each file above in a real Strands agent, then each section's variants, with
OPENAI_on a stand-in for OpenAI's API, scripted to ask forBASE_URL get_, again throughlinkedin_ profile fetchinat Up2Data's limit, then to answer. For the limit tests, the stand-in asked at every turn. - We ran the function against a stand-in for our API: the example answers from our API reference, then each error in the table, and the model got what the table lists.
- We ran the MCP agent against a stand-in for our MCP server: each error, Up2Data's limit, a 50 second answer, a timeout, the long answer, a wrong tool name and the approval step.
- We called api.datacircle.dev with a wrong key: a 401 for each file, at no charge.
- We didn't try Bedrock, an OAuth sign in, 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, 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 $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. Your agent may call the tool more than once per question. 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, LinkedIn profiles in the Claude Agent SDK, LinkedIn profiles in smolagents, LinkedIn profiles in Agno, LinkedIn profiles in Microsoft Agent Framework, LinkedIn profiles in Haystack and LinkedIn profiles in DSPy. 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 Strands Agents have a LinkedIn tool?
Not one of its own. Its former community package, strands-agents-tools, has a Bright Data tool that reads a LinkedIn profile with your Bright Data API key. To read profiles through us, connect our MCP server at https://
How do I send an API key to a remote MCP server with Strands' MCPClient?
Pass the URL and the header to MCPClient: MCPClient(url="https://
Why can't I import streamablehttp_client in Strands?
In our test, pip install strands-agents installed mcp 2.1.1, which has no streamablehttp_
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