Datacircle

LinkedIn profiles in Microsoft Agent Framework: a LinkedIn tool through MCP or a Python function

You're building an agent in Python with Microsoft Agent Framework, and the model should read a LinkedIn profile from its URL: the job title, company, location and headline. Microsoft's overview calls Agent Framework "the direct successor" of Semantic Kernel and AutoGen, "created by the same teams". It has no LinkedIn tool of its own. We searched its GitHub repository for "linkedin" on October 11, 2026: the one match is in a .NET test, inside the name SymlinkedIntermediateSegment.

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 returns a 429, at its daily limit.

We ran both agents inside Agent Framework, 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.

Agent Framework tools that can reach our API

Agent Framework's tools for Python run your functions, connect to MCP servers, and use tools the model's service runs, such as web search, code interpreter and file search. None of them is a LinkedIn tool. Three can reach our API:

  • MCPStreamableHTTPTool connects your script to an MCP server: the MCP way below.
  • A function tool lets the model call your Python function: the function way below.
  • A hosted MCP tool has the model's service call the server, through get_mcp_tool on OpenAI's Responses client. We haven't tried it.

Microsoft's docs show the same MCP and function tools for .NET. This page is Python only.

Before you start

  • Python 3.10 or later. We tested on 3.12.
  • Agent Framework, and httpx for the function:
    pip install agent-framework httpx
    In our test, pip installed 208 packages, with mcp 1.30.0 for the MCP tool. A smaller install works too, with mcp below 2:
    pip install agent-framework-core agent-framework-openai "mcp<2" httpx
    With mcp 2.3.0, the latest, the MCP tool failed to connect: MCP server failed to initialize: 'InitializeResult' object has no attribute 'protocolVersion'. Microsoft's MCP page says to install mcp with --pre, which installs 2.3.0 today.
  • 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, for OpenAIChatClient, which calls OpenAI's Responses API. You can swap in any Agent Framework chat client whose model calls tools.

The MCP way: our MCP server through MCPStreamableHTTPTool

Agent Framework connects to a remote MCP server with MCPStreamableHTTPTool, as its MCP page shows. static_headers sends your key to our server as a Bearer token, with each request: Microsoft recommends it for fixed credentials. 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 asyncio
import os

from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.openai import OpenAIChatClient


async def main():
    async with MCPStreamableHTTPTool(
        name="datacircle",
        url="https://api.datacircle.dev/mcp",
        static_headers={"Authorization": f"Bearer {os.environ['DATACIRCLE_API_KEY']}"},
        allowed_tools=["get_linkedin_profile"],
    ) as datacircle:
        client = OpenAIChatClient(
            model="gpt-5.4-mini",
            function_invocation_configuration={
                "max_function_calls": 3,  # each call to the tool is a call to our API
                "include_detailed_errors": True,  # the model reads our error, a 429 included
            },
        )
        agent = Agent(
            client=client,
            instructions="Get a profile with get_linkedin_profile. If it answers 429, call it again with provider fetchin.",
            tools=datacircle,
        )
        result = await agent.run("What is the current job title on https://www.linkedin.com/in/example-profile?")
        print(result.text)


asyncio.run(main())

async with connects before the agent runs, and closes the connection when the block ends. The server has other tools, such as get_balance, and allowed_tools keeps get_linkedin_profile 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. Agent Framework gives it to the model as JSON text, in full. We sent a saved Fetchin answer of 65,229 characters, and our model got all of it:

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

Agent Framework 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,888 bytes. Without allowed_tools, it was 2,902 bytes, with our five tools.

Set include_detailed_errors, so the model reads our 429

The tool takes url, and provider: up2data (the default), harvestapi or fetchin. When our scripted model sent url alone, our stand-in server used Up2Data. At Up2Data's limit, the server tells your agent to call again through Fetchin or HarvestAPI. HarvestAPI costs more per 1,000 profiles than the other two, so the file's instructions name Fetchin. Our server marks Up2Data's 429 as an error. By default, Agent Framework hides an error's text from the model, which reads only this:

Error: Function failed.

With that line, the model can't tell a 429 from any other error. In our test without the setting, our scripted model never called Fetchin. With include_detailed_errors set to True, as in the file above, the model read our error, called again with fetchin and got the profile:

Error: Function failed. Exception: {"error": …}

Microsoft's function tools page says to turn it on only for a trusted channel, since an error's text can hold sensitive data. Ours holds our API's error. Our MCP server docs list every tool.

A wrong key stops the script before the model runs

With a wrong key, MCPStreamableHTTPTool raised as it entered the async with block. Our server answered the first request with a 401, and the script stopped before any model call. We tried it on api.datacircle.dev:

agent_framework.exceptions.ToolException: MCP server failed to initialize: Client error '401 Unauthorized' for url 'https://api.datacircle.dev/mcp'

It raised the same way when we gave the tool to Agent inside async with Agent(...), as Microsoft's complete example does. Set the right key in DATACIRCLE_API_KEY. This code sends the key as a Bearer token, which needs no sign in. We haven't tried an OAuth sign in through MCPStreamableHTTPTool.

Keep any timeout above the 45 seconds our API waits for a provider

Our API waits up to 45 seconds for a provider. Agent Framework sets no time limit on an MCP call, and its HTTP client waits 300 seconds for an answer. Our stand-in server waited 50 seconds before it answered, longer than our API ever waits, and the model still got the profile. If you set request_timeout, keep it above 45. With request_timeout=10, the model got this error after 10 seconds, and never got the profile:

Error: Function failed. Exception: ('Timed out while waiting for response to ClientRequest. Waited 10.0 seconds.', McpError(…))

The 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_agent.py and run python tool_agent.py:

import asyncio
import os
import time
from typing import Annotated

import httpx
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient
from pydantic import Field

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


def get_linkedin_profile(
    url: Annotated[str, Field(description="The profile's LinkedIn URL, like https://www.linkedin.com/in/example-profile")],
) -> 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."""
    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


async def main():
    client = OpenAIChatClient(model="gpt-5.4-mini", function_invocation_configuration={"max_function_calls": 3})
    agent = Agent(client=client, tools=[get_linkedin_profile])
    result = await agent.run("What is the current job title on https://www.linkedin.com/in/example-profile?")
    print(result.text)


asyncio.run(main())

Agent Framework lets the model call each Python function in tools. The model gets the function's name, its docstring as the description, and the Field description as the description of url. The model never sees -> dict, so the docstring names the keys of the answer.

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 Agent Framework 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:

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. Agent Framework logs the error, gives the model Error: Function failed. as the tool's result, and continues the run. We tried a wrong key on api.datacircle.dev, and the terminal showed:

Function failed. Error: Client error '401 Unauthorized' for url 'https://api.datacircle.dev/v1/profiles/enrich'
…
Function 'get_linkedin_profile' raised an exception; returning an error result to the model. Set include_detailed_errors=True for the full detail.

A 401 costs nothing. Set the right key in DATACIRCLE_API_KEY. Every other answer comes back as a dict the model reads, so this file leaves include_detailed_errors out. Agent Framework sets no time limit on a function, and httpx waits 60 seconds here: our stand-in server waited 50 seconds before it answered, longer than our API ever waits, and the model still got the answer.

DATACIRCLE_API_URL 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 max_function_calls

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. With the function tool, the function sends up to four requests per call, and we bill at most one of them: Up2Data's 429 and Fetchin's 429 are free.

max_function_calls is off by default. With it at 3, a model that asked for the tool at every turn got 3 profiles, then Agent Framework asked it once more with tool_choice set to none, and the model answered. Without it, Agent Framework allows 40 rounds with the model: our scripted model got 40 profiles, 40 calls we would bill. Agent Framework checks the limit after each round: a model that asked for two profiles at a time got 4.

By default, Agent Framework calls the tool without asking you. With approval_mode="always_require" on MCPStreamableHTTPTool, Agent Framework stopped the run before calling our server, with the call in result.user_input_requests, as Microsoft's tool approval page shows. 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
AnswerMeaningCostThe 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 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 as Error: Function failed.

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 agent-framework 1.21.0 (its core package 1.21.0, agent-framework-openai 1.16.0), mcp 1.30.0, openai 3.28.0 and httpx 0.28.1.
  • We ran each file above in a real Agent Framework agent, then each section's variants, with OPENAI_BASE_URL on a stand-in for OpenAI's Responses API. We scripted the stand-in to ask for get_linkedin_profile, to ask again through fetchin after a 429, then to answer. For the limit tests, it asked at every turn.
  • The function ran against a stand-in for our API: the example answers from our API reference, then each error in the table. The model got what the table lists each time.
  • The MCP agent ran against a stand-in for our MCP server: each error with and without include_detailed_errors, Fetchin after Up2Data's 429, a 50 second answer, the long answer and a wrong tool name.
  • We called api.datacircle.dev with a wrong key: a 401 for each file, at no charge.
  • We didn't try the hosted MCP tool, 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:

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. 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 and LinkedIn profiles in Agno. 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 Microsoft Agent Framework have a LinkedIn tool?

Not one of its own. Its tools call your functions, search the web, run code and connect to MCP servers. To read profiles through us, connect our MCP server at https://api.datacircle.dev/mcp with MCPStreamableHTTPTool. Or write one Python 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 MCPStreamableHTTPTool?

Pass it in static_headers: MCPStreamableHTTPTool(name="datacircle", url="https://api.datacircle.dev/mcp", static_headers={"Authorization": "Bearer <your key>"}). It sends the header with each request to that server, the first one included.

Why does my Agent Framework agent read "Error: Function failed."?

A tool raised an error, or an MCP server returned one, and Agent Framework hides its text from the model by default. Set "include_detailed_errors": True in the chat client's function_invocation_configuration, and the model reads the error. That setting lets the model see Up2Data's 429 from our MCP server and call again through Fetchin.

Why does MCPStreamableHTTPTool fail with "'InitializeResult' object has no attribute 'protocolVersion'"?

You have mcp 2 installed, and Agent Framework 1.21.0's MCP tool needs mcp 1. pip install agent-framework installs mcp 1.30.0. If you install agent-framework-core alone, add "mcp<2".

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