Datacircle

LinkedIn profiles in the OpenAI Agents SDK: a LinkedIn tool through MCP or @function_tool

You're building an agent with OpenAI's Agents SDK in Python, 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. The SDK has no tool for that. Its hosted tools search the web or your files, run code, make images or call an MCP server you name. We searched its GitHub repository on October 11, 2026: no file in its docs, examples or code had the word LinkedIn.

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. You can connect your agent to our MCP server and write no tool code. Or you can write a @function_tool 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_tool function sends a URL to Fetchin only when Up2Data returns a 429, at its daily limit or its rate limit.

We ran both ways inside the SDK's agent loop, Runner.run, with a scripted stand-in for 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.

Before you start

  • Python 3.10 or later, which the SDK needs.
  • The SDK: pip install openai-agents. It installs requests, which the function uses, and the mcp package, which the MCP code uses.
  • 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 names no model, so the SDK uses its default model, gpt-5.6-luna. Pass model= to the Agent to choose another.

The MCP way: our MCP server in your agent's mcp_servers

The SDK connects to a remote MCP server with MCPServerStreamableHttp, as its MCP page shows, and the agent takes the server in mcp_servers. Our MCP server is at https://api.datacircle.dev/mcp. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. The SDK doesn't sign you in to a server by itself, and our server reads your key from Authorization, so the key goes in headers as a Bearer token. This file is the whole agent:

import asyncio
import os

from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp, create_static_tool_filter


async def main():
    async with MCPServerStreamableHttp(
        name="datacircle",
        params={
            "url": "https://api.datacircle.dev/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['DATACIRCLE_API_KEY']}"},
        },
        client_session_timeout_seconds=60,
        tool_filter=create_static_tool_filter(allowed_tool_names=["get_linkedin_profile"]),
    ) as server:
        agent = Agent(
            name="Profile researcher",
            instructions="Answer questions about the current role on a LinkedIn profile. Get the profile with get_linkedin_profile.",
            mcp_servers=[server],
        )
        result = await Runner.run(agent, "What is the current job title on https://www.linkedin.com/in/williamhgates?")
        print(result.final_output)


asyncio.run(main())

MCPServerStreamableHttp connects over Streamable HTTP, the transport our server uses. client_session_timeout_seconds is how long the SDK waits for a tool's answer: 5 seconds by default, and our API waits up to 45 seconds for the provider. With the default, our test server took 7 seconds to answer, and the model got "An error occurred while running the tool. Please try again." The server has other tools, such as get_balance, and the filter keeps get_linkedin_profile alone. The model sees it under that name: the SDK puts the server's name before it only if you set include_server_in_tool_names.

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 JSON, and the run goes on. 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 the async with block before the SDK calls the model, with MCPError: invalid API key or access token. With an mcp package older than 2, it's UserError: Failed to connect to MCP server 'datacircle': HTTP error 401.

HostedMCPTool: OpenAI's servers call ours

You can also use HostedMCPTool, if your agent runs on an OpenAI model through the Responses API. We haven't tested it: it needs a real OpenAI model, so this part comes from OpenAI's docs alone. OpenAI's servers list and call our tools, so your key goes to OpenAI with each request. OpenAI's API reference gives the tool two fields for this: headers, HTTP headers to send to the MCP server "for authentication or other purposes", and authorization, for "an OAuth access token". Neither the reference nor OpenAI's MCP guide says in what form OpenAI sends authorization to the server. Our server reads your key from an Authorization: Bearer <your key> header, which you can put in headers. The guide says the Responses API doesn't store authorization, and says nothing about headers.

In tool_config, set server_url to our URL, allowed_tools to get_linkedin_profile and require_approval to "never": by default, OpenAI asks for your approval before each call to an MCP server. If you store the response, OpenAI logs what it sends to our server for 30 days, unless your organization has Zero Data Retention.

The @function_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 agents import function_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


@function_tool(failure_error_function=None)
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, and the run stops

The SDK's @function_tool decorator turns the function into a tool: 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. OpenAI's newer examples import it as tool, from agents.decorators, where the SDK's code makes tool another name for function_tool. 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": …}

The function returns text, not a dict: the SDK passes a dict to the model through Python's str(), and in our test it came out with single quotes, which isn't JSON. 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.

failure_error_function=None is for a wrong key (401). By default, when a tool raises, the SDK tells the model "An error occurred while running the tool. Please try again." and, in our test, printed nothing else. With None, the error stops the run: UserError: Error running tool get_linkedin_profile: 401 Client Error: Unauthorized. 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, and you can run the tool without spending your balance. We tested the tool against one.

The agent

An Agent gets a name, instructions and its tools, and the Runner runs the loop: the model asks for a tool, reads the tool's answer, and replies. Runner.run_sync runs Runner.run for you, in a script with no event loop. With the @function_tool function:

from agents import Agent, Runner

from datacircle_tool import get_linkedin_profile

agent = Agent(
    name="Profile researcher",
    instructions="Answer questions about the current role on a LinkedIn profile. Get the profile with get_linkedin_profile.",
    tools=[get_linkedin_profile],
)
result = Runner.run_sync(agent, "What is the current job title on https://www.linkedin.com/in/williamhgates?")
print(result.final_output)

The answer is in result.final_output. The MCP example above builds the same agent, with mcp_servers where this one has tools, and await Runner.run inside main().

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

Our API's answers to the @function_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 that stops the run

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 openai-agents 0.23.1 and the mcp 2.3.0 and requests 2.34.2 it installs.
  • Each Python file above ran unchanged, through Runner.run or Runner.run_sync. Our test code pointed the MCP agent's URL at a stand-in server, without editing the file. The model was a scripted stand-in: a Model of our own, in place of OpenAI's, that asks for get_linkedin_profile once, then answers with what the tool returned.
  • 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 SDK listed get_linkedin_profile and called it, and our errors reached the model without stopping the run. With client_session_timeout_seconds left at 5, an answer that took 7 seconds reached the model as an error.
  • 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 UserError. The MCP agent stopped at MCPError: invalid API key or access token. These calls cost nothing.
  • We didn't run HostedMCPTool, or any test 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 agent looks up 1,000 profiles in a day with the @function_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 question, and we bill each call. OpenAI bills you for the model's tokens.

The same tool for a LangChain agent: LinkedIn profiles in LangChain. For a CrewAI crew: LinkedIn profiles in CrewAI. In TypeScript, with Vercel's AI SDK: LinkedIn profiles in the Vercel AI SDK. For a LlamaIndex agent: LinkedIn profiles in LlamaIndex. 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 the OpenAI Agents SDK have a LinkedIn tool?

No. Its hosted tools search the web or your files, run code, make images or call an MCP server you name. On October 11, 2026, no file in its GitHub repository had the word LinkedIn. Add our MCP server at https://api.datacircle.dev/mcp to your agent's mcp_servers, as an MCPServerStreamableHttp. Or write a @function_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 an OpenAI Agents SDK agent to an MCP server with an API key?

Open MCPServerStreamableHttp(name="datacircle", params={"url": "https://api.datacircle.dev/mcp", "headers": {"Authorization": "Bearer <your key>"}}) in an async with block, and pass it to the Agent's mcp_servers. The SDK waits 5 seconds for a tool's answer by default and our API can take up to 45, so set client_session_timeout_seconds=60. With HostedMCPTool, OpenAI's servers call the MCP server. OpenAI's API reference gives that tool a headers field, "for authentication or other purposes", and an authorization field, for "an OAuth access token". We haven't tested HostedMCPTool.

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