Datacircle

LinkedIn profiles in Spring AI: a LinkedIn tool through MCP or a Java method

You're building an app in Java with Spring AI, on Spring Boot, and the model should read a LinkedIn profile from its URL: the job title, company, location and headline. Spring AI's reference says it "aims to streamline the development of applications that incorporate artificial intelligence functionality without unnecessary complexity." Spring AI has no LinkedIn tool of its own.

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 app and write no tool code. Or you can write one Java method 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 apps in Spring AI, 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 method and from the MCP app: each got a 401. We haven't run either with a real key or a real model.

Spring AI has no LinkedIn tool

In Spring AI, a tool is a Java method marked @Tool, or a ToolCallback you build (see Spring AI's Tool Calling page). Its MCP client turns an MCP server's tools into tools the model can call. None of them is a LinkedIn tool.

On October 11, 2026, we searched GitHub's code for "linkedin" in the spring-ai repo and found 1 file: a test whose prompt names LinkedIn among other companies. In spring-ai-community, Spring AI's community GitHub organization (30 repos), we found no LinkedIn tool either.

Before you start

  • Spring Boot 4.1.1 needs Java 17 or later and Maven 3.6.3 or later. We tested on Java 20 with Maven 3.9.16. Spring AI's getting started page says "Spring AI 2.0.x supports Spring Boot 4.0.x and 4.1.x."
  • A Datacircle API key: Sign up at datacircle.dev with your work email: a $5 credit, that's 2,105 LinkedIn profiles at $2.375 per 1,000. 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.
  • An OpenAI API key in OPENAI_API_KEY. Spring AI reads it from there, so you don't put the key in any file. Spring AI's OpenAI page lists the other settings, and its reference lists the other model providers. We haven't tried other models.

Each way is a small Spring Boot project. This is the MCP way's pom.xml, with Spring AI's BOM, its OpenAI starter and its MCP client starter:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
  </parent>
  <groupId>com.example</groupId>
  <artifactId>agent</artifactId>
  <version>1.0</version>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-bom</artifactId>
        <version>2.0.1</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <!-- the MCP way only -->
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-starter-mcp-client</artifactId>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

The MCP way: our MCP server through Spring AI's MCP client

Spring AI's MCP client starter connects to our MCP server when your app starts, and turns its tools into tools the model can call. Our MCP server is at https://api.datacircle.dev/mcp. It gets a LinkedIn profile from its URL, through Up2Data, HarvestAPI or Fetchin. Put this in src/main/resources/application.properties:

spring.ai.openai.chat.model=gpt-5.4-mini
# Spring AI adds its default endpoint, /mcp
spring.ai.mcp.client.streamable-http.connections.datacircle.url=https://api.datacircle.dev
# above the 45 seconds our API waits for a provider
spring.ai.mcp.client.request-timeout=60s
# at most 4 calls per question, so at most 4 billed requests to our API
spring.ai.tools.limits.max-calls-per-tool.get_linkedin_profile=4

and this in src/main/java/com/example/agent/McpAgent.java, then run mvn spring-boot:run:

package com.example.agent;

import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.mcp.McpToolFilter;
import org.springframework.ai.mcp.SyncMcpToolCallbackProvider;
import org.springframework.ai.mcp.customizer.McpClientCustomizer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class McpAgent {

    record Profile(String jobTitle, String company, String location, String headline) {}

    public static void main(String[] args) {
        SpringApplication.run(McpAgent.class, args);
    }

    // Your Datacircle key, as a Bearer token on each request to our MCP server
    @Bean
    McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> datacircleKey(
            @Value("${DATACIRCLE_API_KEY}") String key) {
        return (name, transport) -> transport.httpRequestCustomizer(
                (request, method, uri, body, context) -> request.header("Authorization", "Bearer " + key));
    }

    // Of our server's tools, keep get_linkedin_profile only
    @Bean
    McpToolFilter linkedinOnly() {
        return (connection, tool) -> tool.name().equals("get_linkedin_profile");
    }

    @Bean
    CommandLineRunner run(ChatClient.Builder chat, SyncMcpToolCallbackProvider mcpTools) {
        return args -> {
            Profile profile = chat.build().prompt()
                    .system("Read the LinkedIn profile at the user's URL with get_linkedin_profile."
                            + " If up2data says its limit is reached, call it again with provider fetchin.")
                    .user("https://www.linkedin.com/in/example-profile")
                    .tools(mcpTools)
                    .call()
                    .entity(Profile.class);
            System.out.println(profile);
        };
    }
}

The starter has no setting for a header. The datacircleKey bean adds your key to each request as a Bearer token, the way Spring AI's upgrade notes say to customize the transport in 2.0. Spring AI doesn't give MCP tools to ChatClient on its own, so you pass them with .tools(mcpTools). .entity(Profile.class) asks the model for JSON with the record's four fields, and reads it into a Profile.

The linkedinOnly bean keeps only get_linkedin_profile of our server's tools. Check the tool name's spelling: when we misspelled the name, the model got no tool, and the app ran with no error or warning. The first request to the model was 2,614 bytes with one tool, and 4,788 with all five tools our MCP server docs list.

Spring AI gives the model our JSON text inside a JSON array

Our server sends each answer as text and as structuredContent. Spring AI gives the model the text, inside a JSON array: the provider's JSON, plus datacircle_meta, what the call cost and your balance after it:

[{"text":"{\"data\": {…}, \"meta\": {…}, \"datacircle_meta\": {…}}"}]

Up2Data's example answer was 1,167 characters, and 1,376 inside the array. Our stand-in server sent a Fetchin answer of 65,229 characters, and Spring AI passed it to the model whole, as 68,871. The model gets the tool's name, description and inputs. It doesn't get the tool's output schema, all 8,291 characters of it, or the instructions our server sends when your app connects.

At Up2Data's daily limit, the model reads our error and can call again through Fetchin

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. HarvestAPI costs more per 1,000 profiles than the other two, so the file's system message tells the model to use Fetchin. Spring AI turns our error into an exception, and gives the model its message:

Error calling tool: [TextContent[annotations=null, text={"error": "daily up2data limit reached for your account …"}, meta=null]]

The message holds no status code. Our scripted model read it, called again with fetchin and got the profile. Spring AI passed the other errors we tried to the model the same way, and the app kept running. Spring AI logs each one as an ERROR line.

With a wrong key, your app doesn't start

Spring AI connects to our server as the app starts. With a wrong key, the app stopped before any model call. We tried a wrong key on api.datacircle.dev:

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'run' defined in com.example.agent.McpAgent: …
…
Caused by: java.lang.RuntimeException: Client failed to initialize by explicit API call
…
Caused by: io.modelcontextprotocol.client.transport.McpHttpClientTransportAuthorizationException: Authorization error when sending message

The trace doesn't show our error message. Set the right key in DATACIRCLE_API_KEY. Without the datacircleKey bean, the app sends no key and stops the same way.

Set request-timeout above the 45 seconds our API waits for a provider

Spring AI waits 20 seconds for each MCP request by default. Our stand-in server answered after 50 seconds. With request-timeout=60s, the model got the profile. With the default, the model got this error after 20 seconds:

java.util.concurrent.TimeoutException: Did not observe any item or terminal signal within 20000ms in 'source(MonoDeferContextual)' (and no fallback has been configured)

The function way: one Java method 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 method sends Up2Data's and Fetchin's own requests, with X-Data-Provider naming the provider. Up2Data's request is in our API reference. Take the pom.xml above without spring-ai-starter-mcp-client, put this in application.properties:

spring.ai.openai.chat.model=gpt-5.4-mini
# at most 4 calls per question, so at most 4 billed requests to our API
spring.ai.tools.limits.max-calls-per-tool.get_linkedin_profile=4

and this in src/main/java/com/example/agent/ToolAgent.java, then run mvn spring-boot:run:

package com.example.agent;

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;

@SpringBootApplication
public class ToolAgent {

    record Profile(String jobTitle, String company, String location, String headline) {}

    public static void main(String[] args) {
        SpringApplication.run(ToolAgent.class, args);
    }

    @Bean
    CommandLineRunner run(ChatClient.Builder chat) {
        return args -> {
            Profile profile = chat.build().prompt()
                    .system("Read the LinkedIn profile at the user's URL with get_linkedin_profile.")
                    .user("https://www.linkedin.com/in/example-profile")
                    .tools(new LinkedInTool())
                    .call()
                    .entity(Profile.class);
            System.out.println(profile);
        };
    }
}

class LinkedInTool {

    static final String API = System.getenv().getOrDefault("DATACIRCLE_API_URL", "https://api.datacircle.dev");
    static final String KEY = System.getenv("DATACIRCLE_API_KEY");
    static final JsonMapper JSON = JsonMapper.shared();
    final HttpClient http = HttpClient.newHttpClient();

    @Tool(name = "get_linkedin_profile", description = """
            Get the current job title, company, location and headline on a LinkedIn profile, \
            from the profile's URL, like https://www.linkedin.com/in/example-profile. \
            Returns jobTitle, company, location and headline, or error when the profile can't be read. \
            Each call asks the provider live and may be billed to the Datacircle balance.""")
    Map<String, String> getLinkedInProfile(String url) throws IOException, InterruptedException {
        String body = JSON.writeValueAsString(Map.of("url", url));
        HttpResponse<String> answer = call("up2data", HttpRequest.newBuilder(URI.create(API + "/v1/profiles/enrich"))
                .header("Content-Type", "application/json")
                .POST(HttpRequest.BodyPublishers.ofString(body)));
        if (answer.statusCode() == 200) {
            JsonNode profile = JSON.readTree(answer.body()).path("data");
            return Map.of("jobTitle", text(profile, "/current_company/title"),
                    "company", text(profile, "/current_company/name"),
                    "location", text(profile, "/location/raw"),
                    "headline", text(profile, "/headline"));
        }
        if (answer.statusCode() == 429) { // Up2Data's daily limit: Fetchin answers instead
            String query = "?profileUrlOrUrn=" + URLEncoder.encode(url, StandardCharsets.UTF_8);
            answer = call("fetchin", HttpRequest.newBuilder(URI.create(API + "/api/v1/profile" + query)).GET());
            if (answer.statusCode() == 200) {
                JsonNode profile = JSON.readTree(answer.body());
                return Map.of("jobTitle", text(profile, "/jobTitle"),
                        "company", text(profile, "/companyName"),
                        "location", text(profile, "/location"),
                        "headline", text(profile, "/title"));
            }
        }
        int status = answer.statusCode();
        return switch (status) {
            case 404, 422 -> Map.of("error", "This LinkedIn profile is private or deleted.");
            case 400 -> Map.of("error", "This is not a LinkedIn profile URL."
                    + " Send one like https://www.linkedin.com/in/example-profile");
            case 402 -> Map.of("error", "The Datacircle balance is too low for this call."
                    + " Tell the user to add funds on their Datacircle dashboard.");
            case 429, 500, 502, 503, 504 -> Map.of("error", "The provider didn't answer (" + status + "),"
                    + " and the call wasn't charged. Try again in a minute.");
            // 401: DATACIRCLE_API_KEY is wrong
            default -> throw new IllegalStateException("Datacircle answered " + status + ": " + answer.body());
        };
    }

    // One call through one provider. Fetchin's 429 is its rate limit: wait 1 s and send it again, then 2 s
    HttpResponse<String> call(String provider, HttpRequest.Builder request) throws IOException, InterruptedException {
        HttpRequest sent = request.header("Authorization", "Token " + KEY)
                .header("X-Data-Provider", provider)
                .timeout(Duration.ofSeconds(60)) // above the 45 seconds our API waits for a provider
                .build();
        HttpResponse<String> answer = http.send(sent, HttpResponse.BodyHandlers.ofString());
        for (int wait = 1; wait <= 2 && provider.equals("fetchin") && answer.statusCode() == 429; wait++) {
            Thread.sleep(wait * 1000L);
            answer = http.send(sent, HttpResponse.BodyHandlers.ofString());
        }
        return answer;
    }

    static String text(JsonNode answer, String pointer) {
        return answer.at(pointer).asString("");
    }
}

@Tool makes the method a tool (see Spring AI's Tool Calling page). The model gets its name, its whole description, and url as a string, but not the method's return type, so the description names the answer's keys.

The method 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 method waits one second and sends it again, then waits two seconds and sends it once more.

The method returns a Map, and Spring AI gives it to the model as JSON:

{"jobTitle":…,"company":…,"location":…,"headline":…}

For a private or deleted profile, a URL that isn't a profile, a low balance or a provider error, the method returns one key, error, so the model can tell the user. Each field comes from the same JSON path as in our Python post:

Source of each field
FieldUp2Data's answerFetchin's answer
jobTitledata.current_company.titlejobTitle
companydata.current_company.namecompanyName
locationdata.location.rawlocation
headlinedata.headlinetitle

With a wrong key (401), the method throws. Spring AI logs nothing, gives the model the exception's message, and the app keeps running. We tried a wrong key on api.datacircle.dev, and the model read this:

Datacircle answered 401: {"error": "invalid api key"}

Set the right key in DATACIRCLE_API_KEY. To stop the app on a wrong key instead, set spring.ai.tools.throw-exception-on-error=true: the app stops on the exception, before a second model call.

With no timeout, the JDK's HttpClient waits forever. The method waits 60 seconds: our stand-in server answered after 50 seconds, and the model got the profile. A timeout is an IOException, and Spring AI passes those to your app, not to the model: with a 10 second timeout, the app stopped 10 seconds into the call. DATACIRCLE_API_URL is for tests: point it at a stand-in for our API, and you can run the method without spending your balance.

Cap the calls with max-calls-per-tool

Each tool call reaches our API, and we may bill it. 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 method sends up to four requests per call, and we bill at most one of them: Up2Data's 429 and Fetchin's 429 are free.

Spring AI counts tool calls for each question. By default, it allows 40 calls to one tool and 150 in all, "to guard against runaway loops", as its Tool Calling page says. A scripted model that asked for the tool at every turn made 40 calls to a stand-in for our API in under a second. With max-calls-per-tool.get_linkedin_profile=4, as in both files, it made 4.

At the limit, Spring AI stops and returns its own message as the answer:

Tool call limit (4) exceeded for tool 'get_linkedin_profile'. No further calls to this tool are allowed in this turn.

.entity(Profile.class) can't read that as JSON, so the app stopped with this exception:

tools.jackson.core.exc.StreamReadException: Unrecognized token 'Tool': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')

With on-limit-exceeded=RETURN_ERROR_RESPONSE, Spring AI gives that message to the model, and the model can ask for the tool again. Our scripted model kept asking, and Spring AI called it 4,739 times in the minute before we stopped the app.

At each turn, Spring AI sends the model the whole conversation so far. In our MCP test, each turn added the Fetchin answer of 65,229 characters again, so the requests grew from 2,614 bytes to 79,050, 155,486, 231,922 and 308,358.

Spring AI has no step that asks for your approval before a tool call. Its Tool Calling page lists an "Approval gate" as a step you can build into the tool loop yourself.

API answers: cost and what the tool returns

Our API's answers to the method in ToolAgent.java
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 after up to two retries, or error: "Try again in a minute"
402your balance can't cover the callfreeerror: "The Datacircle balance is too low for this call."
500, 502, 503 or 504the provider failed, or didn't answer within 45 secondsfreeerror: "Try again in a minute"
401your key is wrongfreean exception, whose message the model reads: Datacircle answered 401 and our error

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 tested on October 11, 2026, with Java 20, Maven 3.9.16, Spring Boot 4.1.1, Spring AI 2.0.1, the MCP Java SDK 2.0.0 and openai-java 4.49.0.
  • We built each file and each variant above with mvn package and ran it, and ran each file once with mvn spring-boot:run. OPENAI_BASE_URL pointed Spring AI at a scripted stand-in for OpenAI's API: Spring AI reads it from the environment, as it reads OPENAI_API_KEY.
  • We ran the method against a stand-in for our API that returned each answer in the table. We ran the MCP app against a stand-in for our MCP server with the tools our live server lists, and tried each error, a 50 second answer, both timeouts, the long answer and a wrong tool name.
  • We didn't try 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 app 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 less: $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. The model may call the tool more than once per question. Your model's provider bills you for its tokens.

The same tool in agent frameworks for Python and TypeScript: LinkedIn profiles in LangChain, LinkedIn profiles in DSPy, LinkedIn profiles in Microsoft Agent Framework and LinkedIn profiles in the Vercel AI SDK. The same call from a script: with Python or with Node.js. In Claude, ChatGPT or Cursor: our LinkedIn MCP server. Other vendors' prices per 1,000: LinkedIn profile API pricing compared.

Questions

Does Spring AI have a LinkedIn tool?

No. Connect our MCP server at https://api.datacircle.dev/mcp with Spring AI's MCP client starter. Or give ChatClient one @Tool method 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 in Spring AI?

Spring AI's MCP client starter has no property for a header. Add a bean of type McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> that calls httpRequestCustomizer on the builder, and set the header Authorization: Bearer <your key> on each request.

What is the MCP request timeout in Spring AI?

20 seconds by default, in Spring AI 2.0.1. Set spring.ai.mcp.client.request-timeout to 60s for our API. At the timeout, the model read a TimeoutException in our test, and the app kept running.

How many tool calls does Spring AI allow?

By default, 40 calls to one tool and 150 in all, for each question, in Spring AI 2.0.1. At the limit, Spring AI stops and returns its own message as the answer. Set spring.ai.tools.limits.max-calls-per-tool.<tool name> to lower the limit for that tool.

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