Datacircle

LinkedIn profiles in CrewAI: a LinkedIn tool for your crew, through MCP or @tool

Your crew runs on CrewAI in Python, and one of its agents needs a LinkedIn profile from its URL: the job title, company, location and headline. It may answer a question about someone's current role, or keep the job title in a record up to date. CrewAI's tools pages list no LinkedIn profile tool. Its annotations page uses a LinkedInProfileTool() in an example, but neither crewai nor crewai-tools has a class by that name. On CrewAI's forum, a thread from January 2025 asks whether anyone has built a LinkedIn tool, and nobody had answered it on October 11, 2026. Composio's LinkedIn toolkit signs in to your own LinkedIn account to post, comment and read your own profile. Its one tool for someone else's profile takes a person ID, not a URL, and returns the name and picture.

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 give your agent a LinkedIn profile tool in two ways. Through our MCP server, you add one entry to the agent's mcps and write no tool code. Through a @tool function that calls our API, you 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. So the function sends a URL to Fetchin only when Up2Data answers 429, at its daily limit or its rate limit.

We ran the @tool function against a stand-in server that answers like our API, and ran both crews with a scripted stand-in for the model. We called api.datacircle.dev with a wrong key, from the function and from the MCP crew: 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 to 3.13, the versions CrewAI supports.
  • CrewAI and requests: pip install crewai requests. CrewAI installs the mcp package the MCP way needs, and the @tool way uses requests.
  • 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 model that calls tools. The code below uses openai/gpt-5.6-terra, from CrewAI's LLMs page, which reads your OpenAI key from OPENAI_API_KEY. Put your own model there.

The MCP way: add our MCP server to the agent's mcps field

A CrewAI agent takes MCP servers in its mcps field, the way CrewAI's MCP page recommends. Our MCP server is at https://api.datacircle.dev/mcp. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. If you put a plain URL in mcps, CrewAI sends no header, and our server reads your key from Authorization. So add it as an MCPServerHTTP with your key in headers. This file is the whole crew:

import os

from crewai import Agent, Crew, Task
from crewai.mcp import MCPServerHTTP
from crewai.mcp.filters import create_static_tool_filter

researcher = Agent(
    role="Profile researcher",
    goal="Answer questions about the current role on a LinkedIn profile",
    backstory="You read LinkedIn profiles with your tool and answer only from what it returns.",
    mcps=[
        MCPServerHTTP(
            url="https://api.datacircle.dev/mcp",
            headers={"Authorization": f"Bearer {os.environ['DATACIRCLE_API_KEY']}"},
            tool_filter=create_static_tool_filter(allowed_tool_names=["get_linkedin_profile"]),
        ),
    ],
    llm="openai/gpt-5.6-terra",  # your choice of model
)
task = Task(
    description="What is the current job title on {url}?",
    expected_output="The job title and the company, in one sentence.",
    agent=researcher,
)
crew = Crew(agents=[researcher], tasks=[task])
result = crew.kickoff(inputs={"url": "https://www.linkedin.com/in/williamhgates"})
print(result.raw)

MCPServerHTTP connects over Streamable HTTP by default, the transport our server uses. The server has other tools, such as get_balance, and the filter keeps get_linkedin_profile alone. CrewAI lists the tools when the crew starts, then opens a new connection for each call. As its MCP DSL page says, it puts the server's name before the tool's, so the model sees api_datacircle_dev_mcp_get_linkedin_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. On a failed call, the model gets our API's error, and CrewAI records it in result.tool_failures without stopping the run. At Up2Data's limit, the server tells your agent to call again through Fetchin or HarvestAPI. The model picks which one, and HarvestAPI costs more per 1,000 profiles than the other two. Our MCP server docs list every tool.

A wrong key stops crew.kickoff with MCPAuthenticationError, before CrewAI calls the model. CrewAI's docs give MCPServerAdapter too, from crewai-tools[mcp], for when you open and close the connection yourself.

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 the code below as datacircle_tool.py:

import json
import os
import time

import requests
from crewai.tools import 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 = 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


@tool("get_linkedin_profile")
def get_linkedin_profile(url: str) -> 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 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: Fetchin answers instead
        answer = call("fetchin", "GET", "/api/v1/profile", params={"profileUrlOrUrn": url})
        if answer.status_code == 200:
            profile = answer.json()
            return 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 "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


# With Crew(cache=True), CrewAI reuses a tool's answer for the same URL: keep only profiles, so a retry asks again
get_linkedin_profile.cache_function = lambda args, result: result.startswith("{")

CrewAI's @tool decorator, from crewai.tools, turns the function into a tool. The name in quotes is the tool's name, the docstring is the description the model reads, and the type hint on url is its input. 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.

The model gets four fields back, 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 tool returns a sentence instead, so the agent can tell the user and go on. With a wrong key (401), the tool raises an error. CrewAI catches it, gives the model Error executing tool: 401 Client Error, and records it in result.tool_failures: 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

The last line is for CrewAI's tool cache, which is off unless you pass cache=True to the crew or the agent. That's what crewai 1.15.27's code does, though its crews page says the cache is on by default. With it on, if the agent asks for a URL twice in one run, the tool fetches it once and we bill it once. The cache_function keeps only profiles. Without it, a retry after "Try again in a minute" got the same sentence back from the cache in our test.

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

The crew

A crew is a team of agents and the tasks they do. The Agent gets a role, a goal and a backstory, the Task says what to do and what to hand back, and crew.kickoff runs it, filling {url} from inputs. With the @tool function:

from crewai import Agent, Crew, Task

from datacircle_tool import get_linkedin_profile

researcher = Agent(
    role="Profile researcher",
    goal="Answer questions about the current role on a LinkedIn profile",
    backstory="You read LinkedIn profiles with your tool and answer only from what it returns.",
    tools=[get_linkedin_profile],
    llm="openai/gpt-5.6-terra",  # your choice of model
)
task = Task(
    description="What is the current job title on {url}?",
    expected_output="The job title and the company, in one sentence.",
    agent=researcher,
)
crew = Crew(agents=[researcher], tasks=[task])
result = crew.kickoff(inputs={"url": "https://www.linkedin.com/in/williamhgates"})
print(result.raw)

You pick the model: the llm string is "provider/model-id", or pass an LLM object. The agent calls the tool and writes the task's answer from what it returns, in result.raw. The MCP example above builds the same crew, with mcps where this one has tools.

Each API answer: its cost and what the @tool function returns

Our API's answers to the @tool function
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 CrewAI passes to the model

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.

How we tested the code

  • We ran these tests on October 11, 2026, on Python 3.12, with crewai 1.15.27 and the mcp 1.28.1 it installs.
  • The @tool function, as it is above, 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. We called it with tool.run, with no model, and each call returned what the table says.
  • We ran both crews with a scripted stand-in for the model: a CrewAI custom LLM that asks for the tool once, then answers. The stand-in model read the tool's answer each time. The MCP crew ran against a stand-in that answers like our MCP server: CrewAI listed get_linkedin_profile and called it.
  • With cache=True and the same URL asked twice, the tool called the stand-in once for a found profile. After a 503, the tool called the stand-in again and got the profile.
  • We called api.datacircle.dev with a wrong key, and our API answered the @tool function with a 401 and {"error": "invalid api key"}. The MCP crew stopped at MCPAuthenticationError: "MCP server refused the connection with HTTP 401 Unauthorized". These calls cost nothing.
  • We didn't run any of these tests with 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:

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 crew looks up 1,000 profiles in a day with the @tool 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. An agent may call the tool more than once for a task, and we bill each call. Your model's provider bills you for its tokens.

The same tool for a LangChain agent: LinkedIn profiles in LangChain. The same call from a Python script: get LinkedIn profile data with Python. In an n8n workflow: LinkedIn profiles in n8n. In Claude, ChatGPT or Cursor: our LinkedIn MCP server. Other vendors' prices per 1,000: LinkedIn profile API pricing compared.

Questions

Does CrewAI have a LinkedIn tool?

CrewAI's tools pages list no tool that gets a LinkedIn profile from its URL. In the crewai-tools code, BrightDataDatasetTool has a LinkedIn profile dataset that you read with your own Bright Data key, but CrewAI left that dataset off the tool's docs page. Give your agent one of two tools. Add our MCP server at https://api.datacircle.dev/mcp to the agent's mcps, as an MCPServerHTTP. Or write a @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 a CrewAI agent to an MCP server with an API key?

Add MCPServerHTTP(url="https://api.datacircle.dev/mcp", headers={"Authorization": "Bearer <your key>"}) to the agent's mcps list. If you pass a plain URL string in mcps, CrewAI sends no header. CrewAI lists the server's tools when the crew starts and puts the server's name before each tool's name: api_datacircle_dev_mcp_get_linkedin_profile.

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