LangChain4j Agents & MCP

This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

This page was written against LangChain4j 1.20.2 (langchain4j, langchain4j-core) with the beta-versioned modules langchain4j-agentic, langchain4j-mcp and langchain4j-agentic-mcp at 1.20.2-beta30, on Java 21 with Maven 3.9.16, as verified on 2026-09-29. The running example is DocsAssistant, which answers questions over this site’s documentation with Ollama’s llama3.1:8b chat model over the pgvector store built in Integrating with LangChain (LangChain4j). Every Java snippet on this page was compiled with Maven against those artifacts and then run. Ollama was not available while the snippets were checked, so all model calls came from ScriptedModel, a deterministic ChatModel shown below that replays prepared replies (including tool calls); no output comes from a live model. The MCP server used by the client snippets is real: a FastMCP server (mcp 1.30.0 on Python 3.12) started as a subprocess over stdio. The in-memory DocsSearchTool stands in for the pgvector-backed retriever, whose LangChain4j wiring is covered on the vector-rag page rather than repeated here.

This is the second of the two LangChain4j pages. The core API (ChatModel, AiServices, @Tool, memory, structured outputs, streaming) is on LangChain4j; this page adds the agentic module on top of it and the MCP client next to it. The Python equivalents are Multi-Agent & Deep Agents, LangGraph Workflows and MCP Adapters.

Experimental and beta-versioned

LangChain4j’s own tutorial states that the whole langchain4j-agentic module "has to be considered experimental and is subject to change in future releases". The version numbers say the same thing: the core library is released as 1.20.2, while langchain4j-agentic, langchain4j-agentic-mcp and langchain4j-mcp carry a -beta30 suffix, which the langchain4j-bom manages through a separate beta version property. Treat that as a contract, not a formality:

  • Pin the BOM and let it choose the beta versions, so the three modules always move together.

  • Expect renames between betas. The tutorial page and the 1.20.2-beta30 jar already disagree on one detail: the tutorial lists beforeToolExecution and afterToolExecution on AgentListener, while the jar has beforeAgentToolExecution and afterAgentToolExecution. This page therefore uses only the invocation callbacks that both agree on, and takes every name from the jar it compiled against.

  • Keep the agentic types at the edge. Application code should depend on your own interfaces (DocsAssistant below), so a rename in a later beta is a change in one factory class.

  • Prefer plain AiServices (see LangChain4j) when one model with tools is enough. Reach for the agentic module when you need several cooperating agents, explicit workflows, or a supervisor.

Setup

The BOM supplies the versions of all four artifacts, so the dependencies carry none:

Maven dependencies (versions come from the BOM)
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>dev.langchain4j</groupId>
      <artifactId>langchain4j-bom</artifactId>
      <version>1.20.2</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
  </dependency>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-ollama</artifactId>
  </dependency>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-agentic</artifactId>
  </dependency>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-mcp</artifactId>
  </dependency>
  <dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-agentic-mcp</artifactId>
  </dependency>
</dependencies>

<properties>
  <maven.compiler.release>21</maven.compiler.release>
  <maven.compiler.parameters>true</maven.compiler.parameters>
</properties>

langchain4j-agentic-mcp is only needed for the McpAgent shown at the end. The last property matters more than it looks: LangChain4j derives tool-argument names and @V variable names from the compiled parameter names when they are not given explicitly. In the first run of these snippets the classes were compiled without -parameters: the model’s call carried {"query": …​}, but the tool received a null query, because the parameter had not been registered under that name. Setting maven.compiler.parameters fixed it, so keep it on (or name every parameter with @P(name = …​) and @V).

Agents

An agent in this module is an AI service with two additions: the @Agent annotation, which gives it a name and a description other agents and the supervisor can read, and an output key, the name of the shared variable its result is written to. The first DocsAssistant agents classify a question, research it with the documentation search tool, write a cited answer, and score that answer against its sources:

DocsAssistant’s agents as annotated interfaces
import dev.langchain4j.agentic.Agent;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.V;

public final class DocsAgents {

    public enum Category { HOWTO, REFERENCE, OTHER }

    public interface QuestionRouter {
        @UserMessage("""
                Classify this question about our documentation as HOWTO, REFERENCE or OTHER.
                Reply with the category only.
                The question is: {{question}}
                """)
        @Agent(value = "Classifies a documentation question", outputKey = "category")
        Category classify(@V("question") String question);
    }

    public interface DocsResearcher {
        @SystemMessage("You research this site's documentation. Use the search tool and never invent pages.")
        @UserMessage("Collect the documentation sections that answer: {{question}}")
        @Agent(value = "Searches the documentation", outputKey = "notes")
        String research(@V("question") String question);
    }

    public interface AnswerWriter {
        @UserMessage("""
                Answer the question using only these notes, and cite the page paths.
                Question: {{question}}
                Notes: {{notes}}
                """)
        @Agent(value = "Writes a cited answer", outputKey = "answer")
        String write(@V("question") String question, @V("notes") String notes);
    }

    public interface AnswerReviewer {
        @UserMessage("""
                Give a score between 0.0 and 1.0 for how well the answer is supported by the notes.
                Return only the score.
                Answer: {{answer}}
                Notes: {{notes}}
                """)
        @Agent(value = "Scores an answer against its sources", outputKey = "score")
        double score(@V("answer") String answer, @V("notes") String notes);
    }

    // AnswerReviser, Summarizer, RelatedPages, OutOfScope and IssueDrafter follow the same shape
}

Return types drive parsing exactly as in plain AI services: Category is parsed from the model’s text, double from 0.9, List<String> from lines. The description in @Agent is what a supervisor reads when it decides who to call, and the agent’s name defaults to the annotated method’s name (classify, research, …​) unless @Agent(name = …​) or the builder’s name method says otherwise. Agents are built with AgenticServices.agentBuilder; the researcher gets the same @Tool class as a plain AI service would:

Building single agents
import dev.langchain4j.agentic.AgenticServices;
import dev.langchain4j.model.chat.ChatModel;

static QuestionRouter router(ChatModel model) {
    return AgenticServices.agentBuilder(QuestionRouter.class)
            .chatModel(model)
            .build();
}

static DocsResearcher researcher(ChatModel model) {
    return AgenticServices.agentBuilder(DocsResearcher.class)
            .chatModel(model)
            .tools(new DocsSearchTool())
            .build();
}

DocsSearchTool is an ordinary class with one method annotated @Tool("Search the documentation and return matching page paths with a one-line snippet") and a @P("search query") parameter, exactly as covered in LangChain4j; tool design rules (narrow, typed, read-only where possible) are in Tool Design. Because an agent is a plain AI service, everything else on the builder works too: chatMemoryProvider, contentRetriever, toolProvider, guardrails, systemMessage, userMessage.

The snippets on this page keep their factory methods in two small helper classes, Workflows (the router, researcher and workflow builders) and Advanced (the MCP client and the issue workflow), which is also how the compiled project was organised; docsClient() and mcpResearcher(…​) in the MCP section belong to Advanced.

A model to test with

Nothing above needs a network as long as the ChatModel is a fake. ScriptedModel overrides doChat, matches a marker in the prompt, and either answers with text or, for the tool-using agent, first asks for a tool call and then answers once the tool result is in the conversation:

A deterministic ChatModel for agent tests
public class ScriptedModel implements ChatModel {

    private record Rule(String marker, Function<ChatRequest, AiMessage> reply) { }

    private final List<Rule> rules = new CopyOnWriteArrayList<>();
    public final List<String> prompts = new CopyOnWriteArrayList<>();

    public ScriptedModel onText(String marker, String text) {
        rules.add(new Rule(marker, r -> AiMessage.from(text)));
        return this;
    }

    public ScriptedModel onTextSequence(String marker, String... texts) {
        var i = new AtomicInteger();
        rules.add(new Rule(marker, r -> AiMessage.from(texts[Math.min(i.getAndIncrement(), texts.length - 1)])));
        return this;
    }

    /** First call calls the tool, once its result is in the conversation the model answers. */
    public ScriptedModel onToolThenText(String marker, String tool, String arguments, String text) {
        rules.add(new Rule(marker, r -> {
            boolean hasResult = r.messages().stream().anyMatch(m -> m instanceof ToolExecutionResultMessage);
            return hasResult
                    ? AiMessage.from(text)
                    : AiMessage.from(ToolExecutionRequest.builder()
                            .id("call-1").name(tool).arguments(arguments).build());
        }));
        return this;
    }

    @Override
    public ChatResponse doChat(ChatRequest request) {
        var joined = request.messages().stream().map(Object::toString).collect(Collectors.joining("\n"));
        prompts.add(joined);
        for (Rule rule : rules) {
            if (joined.contains(rule.marker())) {
                return ChatResponse.builder().aiMessage(rule.reply().apply(request)).build();
            }
        }
        throw new IllegalStateException("No scripted reply for: " + joined);
    }
}

Testing agent systems beyond fakes (recorded fixtures, Testcontainers with Ollama) is discussed on Testing; the same idea applies here, and the supervisor section below shows why a scripted planner is sometimes the only practical way to test one.

The AgenticScope

Agents in a system do not call each other. They read and write a shared AgenticScope: a map of named variables plus a record of every agent invocation. An agent’s input arguments come from scope variables with the names given by @V, and its return value is written to its output key. That is why the same question reaches the router, the researcher and the writer without anyone passing it along.

A workflow gets its input as a map, and invokeWithAgenticScope returns the result together with the scope, which is the easiest way to inspect what happened. The listener and the monitor come from the same builder; the error handler recovers from a missing argument by writing it into the scope and retrying:

Reading the scope, listening and recovering from errors
import dev.langchain4j.agentic.agent.ErrorRecoveryResult;
import dev.langchain4j.agentic.agent.MissingArgumentException;
import dev.langchain4j.agentic.observability.AgentListener;
import dev.langchain4j.agentic.observability.AgentMonitor;
import dev.langchain4j.agentic.observability.AgentResponse;

static UntypedAgent observedPipeline(ChatModel model, AgentMonitor monitor) {
    var writer = AgenticServices.agentBuilder(AnswerWriter.class).chatModel(model).build();
    return AgenticServices.sequenceBuilder()
            .subAgents(Workflows.researcher(model), writer, Workflows.reviewLoop(model))
            .listener(monitor)
            .listener(new AgentListener() {
                @Override
                public boolean inheritedBySubagents() {
                    return true;
                }

                @Override
                public void afterAgentInvocation(AgentResponse response) {
                    if (response.agentName().equals("score")) {
                        System.out.println("score = " + response.output());
                    }
                }
            })
            .errorHandler(ctx -> {
                if (ctx.exception() instanceof MissingArgumentException missing
                        && missing.argumentName().equals("question")) {
                    ctx.agenticScope().writeState("question", "What does this documentation cover?");
                    return ErrorRecoveryResult.retry();
                }
                return ErrorRecoveryResult.throwException();
            })
            .outputKey("answer")
            .build();
}

var monitor = new AgentMonitor();
var pipeline = observedPipeline(model, monitor);

var result = pipeline.invokeWithAgenticScope(Map.of("question", "How do I connect an MCP client?"));
System.out.println("result = " + result.result());
System.out.println("state keys = " + result.agenticScope().state().keySet());
result.agenticScope().agentInvocations().forEach(i -> System.out.println("invoked " + i.agentName()));
System.out.println(monitor.successfulExecutions().get(0));

// a missing argument is repaired by the error handler instead of failing the run
System.out.println(pipeline.invoke(Map.of()));

Run with the scripted model (whose scorer answers 0.4 and then 0.9), it printed:

Output (fake model, real agentic module)
score = 0.4
score = 0.9
result = Use McpToolProvider, as in building-clients.adoc.
state keys = [notes, question, score, answer]
invoked research
invoked write
invoked score
invoked revise
invoked score
invoked Loop
AgentInvocation{agent=Sequential, ... duration=25 ms, tokens=0, inputs={question=How do I connec...}, output=Use McpToolProv...}
|=> AgentInvocation{agent=research, ...}
    |=> ToolExecution{request=ToolExecutionRequest { id = "call-1", name = "searchDocs", ... }, result=ToolExecutionResult {isError = false, ...}}
|=> AgentInvocation{agent=write, ...}
|=> AgentInvocation{agent=Loop, ...}
    |=> AgentInvocation{agent=score, iteration=0, ... output=0.4}
    |=> AgentInvocation{agent=revise, iteration=0, ...}
    |=> AgentInvocation{agent=score, iteration=1, ... output=0.9}
Use McpToolProvider (see building-clients.adoc).

Points to notice. A listener registered on the root sees only the root unless inheritedBySubagents returns true; here it is true, so the score callback fired for the agents inside the loop. AgentMonitor is a ready-made listener that is inherited by sub-agents and records the whole invocation tree, including tool executions, with timings and token counts (zero here, because the fake model reports none). Both callbacks run on the thread that runs the agent, so they should not block. The recovery in the last line worked: the first attempt failed with a MissingArgumentException, the handler wrote question into the scope, and retry() re-ran the agent. The other two outcomes are ErrorRecoveryResult.throwException() (the default) and ErrorRecoveryResult.result(…​), which substitutes a value for the failed agent’s output.

An agent’s own state stays in memory. When an agent uses a @MemoryId, its scope is kept in an in-memory registry per memory id and lives until you evict it, which needs the root agent to extend AgenticScopeAccess. DocsAssistant’s per-user conversation is the natural example:

Per-user memory and scope eviction
import dev.langchain4j.agentic.scope.AgenticScopeAccess;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.service.MemoryId;

public interface RememberingWriter {
    @UserMessage("Answer {{question}} using only these notes: {{notes}}")
    @Agent(value = "Writes a cited answer, remembering the conversation", outputKey = "answer")
    String write(@MemoryId String userId, @V("question") String question, @V("notes") String notes);
}

public interface ChatDocsAssistant extends AgenticScopeAccess {
    @Agent("Answers documentation questions in a conversation")
    String ask(@MemoryId String userId, @V("question") String question);
}

static ChatDocsAssistant chatAssistant(ChatModel model) {
    var writer = AgenticServices.agentBuilder(RememberingWriter.class)
            .chatModel(model)
            .chatMemoryProvider(userId -> MessageWindowChatMemory.withMaxMessages(10))
            .build();
    return AgenticServices.sequenceBuilder(ChatDocsAssistant.class)
            .subAgents(Workflows.researcher(model), writer)
            .outputKey("answer")
            .build();
}

var assistant = chatAssistant(model);
assistant.ask("alice", "How do I connect an MCP client?");
assistant.ask("alice", "And retries?");
System.out.println(assistant.getAgenticScope("alice").state().keySet());
System.out.println(assistant.evictAgenticScope("alice"));
System.out.println(assistant.getAgenticScope("alice"));

The second call’s prompt to the writer contained the first exchange (checked in the recorded prompts), and the run printed:

Output
[@MemoryId, notes, question, answer]
true
null

The registry is purely in memory; an AgenticScopeStore plug-in point exists for durable storage, and a scope you never evict stays in memory forever, so evict it when the user’s session ends. The per-thread checkpointing of the Python side is described in Short-Term Memory & Checkpointers.

Workflows

The deterministic patterns are builders that take sub-agents and compose them into a new agent, so they nest. The routing and answering part of DocsAssistant uses all four:

flowchart LR Q([question]) --> R[router] R --> C{category} C -->|HOWTO or REFERENCE| A C -->|OTHER| O[outOfScope] subgraph A [answerQuestion, sequence] direction LR S[research] --> W[write] W --> L subgraph L [loop, at most 3 rounds] direction LR V[score] --> X{score at least 0.8} X -->|no| Y[revise] Y --> V end end L --> P subgraph P [enrich, parallel] direction LR M[summarize] N[related pages] end P --> D([DocsAnswer]) O --> D

Sequence and loop

A sequence runs its sub-agents in order. A loop repeats its sub-agents until an exit condition on the scope holds or maxIterations is reached. By default the condition is checked after every agent, so the loop stops as soon as the score is high enough; testExitAtLoopEnd(true) checks it only at the end of an iteration. The answer pipeline is a sequence whose last step is a review loop:

Sequence and loop
public interface DocsQA {
    @Agent(name = "answerQuestion", value = "Answers a documentation question with citations")
    String answer(@V("question") String question);
}

static UntypedAgent reviewLoop(ChatModel model) {
    var reviewer = AgenticServices.agentBuilder(AnswerReviewer.class).chatModel(model).build();
    var reviser = AgenticServices.agentBuilder(AnswerReviser.class).chatModel(model).build();
    return AgenticServices.loopBuilder()
            .subAgents(reviewer, reviser)
            .maxIterations(3)
            .exitCondition(scope -> scope.readState("score", 0.0) >= 0.8)
            .build();
}

static DocsQA answerPipeline(ChatModel model, DocsResearcher researcher) {
    var writer = AgenticServices.agentBuilder(AnswerWriter.class).chatModel(model).build();
    return AgenticServices.sequenceBuilder(DocsQA.class)
            .subAgents(researcher, writer, reviewLoop(model))
            .outputKey("answer")
            .build();
}

sequenceBuilder() without a class returns an UntypedAgent invoked with a Map; passing an interface (here DocsQA) gives a typed method instead. reviser writes its output to answer, the same key as the writer, so each revision replaces the answer that the next score reads. Run with a scorer that answers 0.4 and then 0.9, the pipeline revised the answer once and stopped:

Output
Use McpToolProvider, as in building-clients.adoc.

The loop is bounded twice, by maxIterations and by the exit condition, which is the point of expressing it as a workflow rather than letting a model decide when it is done. This is the same reviewer-loop pattern that Workflow Patterns describes in the abstract and that LangGraph Workflows builds with conditional edges.

Parallel

A parallel workflow runs independent sub-agents concurrently and lets you assemble their outputs with an output function of the scope. DocsAssistant summarizes the answer and suggests related pages at the same time:

Parallel workflow with an assembled result
public interface DocsExtras {
    @Agent("Adds a summary and related pages to an answer")
    DocsAnswer enrich(@V("question") String question, @V("answer") String answer);
}

static DocsExtras extras(ChatModel model, Executor executor) {
    var summarizer = AgenticServices.agentBuilder(Summarizer.class).chatModel(model).build();
    var related = AgenticServices.agentBuilder(RelatedPages.class).chatModel(model).build();
    return AgenticServices.parallelBuilder(DocsExtras.class)
            .subAgents(summarizer, related)
            .executor(executor)
            .output(scope -> new DocsAnswer(
                    scope.readState("answer", ""),
                    scope.readState("summary", ""),
                    scope.readState("related", List.<String>of())))
            .build();
}

Called with Executors.newFixedThreadPool(2), it returned:

Output
DocsAnswer[answer=Use McpToolProvider., summary=Use McpToolProvider., related=[a.adoc, b.adoc]]

Without executor(…​) the module uses an internal cached thread pool. If you pass your own, you own its lifecycle: the first run of this snippet hung until the pool was shut down, because a fixed pool’s threads keep the JVM alive. parallelMapperBuilder is the variant that runs the same sub-agent once per item of a collection, and async(true) on an agent builder lets an agent inside a sequence run in the background until something needs its result; see the tutorial for both.

Conditional

A conditional workflow runs a sub-agent only when its predicate on the scope holds. DocsAssistant routes on the category the router wrote, and declines off-topic questions without spending a search:

Routing with a conditional workflow
public interface DocsAssistant {
    @Agent("Routes a question and answers it")
    String ask(@V("question") String question);
}

static DocsAssistant assistant(ChatModel model) {
    var outOfScope = AgenticServices.agentBuilder(OutOfScope.class).chatModel(model).build();
    var inScope = AgenticServices.sequenceBuilder()
            .subAgents(answerPipeline(model, researcher(model)))
            .build();
    var routed = AgenticServices.conditionalBuilder()
            .subAgents(scope -> scope.readState("category", Category.OTHER) != Category.OTHER, inScope)
            .subAgents(scope -> scope.readState("category", Category.OTHER) == Category.OTHER, outOfScope)
            .build();
    return AgenticServices.sequenceBuilder(DocsAssistant.class)
            .subAgents(router(model), routed)
            .outputKey("answer")
            .build();
}

With the router scripted to answer HOWTO the assistant ran the whole pipeline and returned Use McpToolProvider (see building-clients.adoc).; with the router answering OTHER it returned I only answer documentation questions. without calling the researcher. The predicates read category with a default so that a missing value routes to the safe branch. A branch nested inside another workflow behaves like any other agent, which is why inScope can simply wrap the whole answerQuestion sequence.

Non-AI agents and human approval

Not every step needs a model. Any Java class with one method annotated @Agent can take part, and AgenticServices.agentAction wraps a small function of the scope. The write path of DocsAssistant, opening a GitHub issue about a gap in the documentation, shows three of them together: an action that unpacks the drafted issue into title and body, a HumanInTheLoop agent that asks a maintainer, and a conditional so nothing is opened unless the answer is yes. The final step is the MCP tool itself, wrapped as an agent (see below):

Draft, approve, then act
public interface IssueFiling {
    @Agent(name = "fileIssue",
           value = "Drafts a documentation issue about a question and opens it once a maintainer approves")
    String file(@V("question") String question, @V("answer") String answer);
}

static IssueFiling issueFiling(ChatModel model, McpClient client, Function<AgenticScope, String> approver) {
    var drafter = AgenticServices.agentBuilder(IssueDrafter.class).chatModel(model).build();
    var split = AgenticServices.agentAction(scope -> {
        var draft = (IssueDraft) scope.readState("draft");
        scope.writeState("title", draft.title());
        scope.writeState("body", draft.body());
    });
    var approval = AgenticServices.humanInTheLoopBuilder()
            .description("Asks a maintainer to approve opening the drafted issue")
            .outputKey("approved")
            .responseProvider(approver)
            .build();
    var open = McpAgent.builder(client)
            .toolName("open_github_issue")
            .inputKeys("title", "body")
            .outputKey("issue")
            .build();
    var guarded = AgenticServices.conditionalBuilder()
            .subAgents(scope -> "yes".equals(scope.readState("approved", "no")), open)
            .build();
    return AgenticServices.sequenceBuilder(IssueFiling.class)
            .subAgents(drafter, split, approval, guarded)
            .outputKey("issue")
            .build();
}

responseProvider is any function of the scope: a console prompt in a demo, a blocking call to a review UI, or a check of an entitlement. With the approver returning yes and the scripted drafter, the workflow called the MCP tool; with no it skipped it and returned null:

Output (approved, then rejected)
{"result":"created issue #101: Document MCP retries"}
null

The {"result": …​} wrapper is how FastMCP returns a plain string, and the client hands the text on unchanged, so parse it if you need the issue number. Two properties make this a real approval gate rather than a prompt hint. The tool is reachable only through this workflow, never from a model’s tool list (the researcher’s provider below excludes it), and the decision comes from responseProvider, which is ordinary code, not from the model’s text. The tutorial also describes an asynchronous form of HumanInTheLoop (async(true)), which it recommends for approvals that take minutes, and a suspended form in which the response provider does not block: the run stops with an AgenticSystemSuspendedException and, when an AgenticScopeStore is configured, can be resumed later. Neither is used here. The Python counterpart, a durable interrupt(), is in Human-in-the-Loop & Time Travel; the design rules are in Human-in-the-Loop for Agents.

The supervisor

The workflows above are fixed at build time. A supervisor is the module’s "pure agentic" pattern: an LLM planner is given the sub-agents (name, description, argument list) and decides which to call next until it returns done with a summary. DocsAssistant’s supervisor can answer questions and file issues, and lets the planner choose the order:

A supervisor over the answer pipeline and the issue workflow
static SupervisorAgent supervisor(ChatModel planner, ChatModel worker, McpClient client) {
    var qa = Workflows.answerPipeline(worker, Advanced.mcpResearcher(worker, client));
    var filing = Advanced.issueFiling(worker, client, scope -> "yes");
    return AgenticServices.supervisorBuilder()
            .chatModel(planner)
            .subAgents(qa, filing)
            .responseStrategy(SupervisorResponseStrategy.SUMMARY)
            .build();
}

var reply = supervisor(planner, worker, client).invoke("How do I connect an MCP client?");

The planner is a separate ChatModel so you can give it a stronger model than the workers. It receives a system prompt that lists the sub-agents as {'answerQuestion$0', '<description>', [question: String]} and a user message asking for JSON of the form {"agentName": …​, "arguments": {…​}}. That is also what a scripted planner has to produce: three replies, calling answerQuestion, then fileIssue with the question and answer arguments, then done with a response. Run that way, the supervisor executed the pipeline (search over MCP, write, review loop), the issue workflow (draft, approve, open over MCP), and returned:

Output (scripted planner)
Answered the question and opened issue #101.

SupervisorResponseStrategy decides what the caller gets back: LAST (the default) returns the last sub-agent’s result, SUMMARY returns the planner’s recap (right for an action-oriented request such as this one), and SCORED lets a scorer agent pick between the two. contextGenerationStrategy controls what the planner remembers (CHAT_MEMORY, SUMMARIZATION or both), supervisorContext injects policies such as "prefer internal tools", and maxAgentsInvocations caps the number of steps. Because the plan is the model’s, the same caution applies as for any autonomous agent: keep write actions behind approval steps inside the sub-agents (as IssueFiling does), give the planner only the agents it needs, and cap the steps. Custom patterns are built by implementing the module’s Planner interface, which the built-in workflows also use; that is beyond this page, see the tutorial. When to choose a supervisor over a workflow, and how it compares with other frameworks, is in Multi-Agent Systems and The Agent Loop & Architectures.

The declarative API

Every builder call above has an annotation form. An interface can declare its own workflow, and AgenticServices.createAgenticSystem instantiates it, using the given ChatModel for the sub-agents that do not define their own. Static methods in the interface supply the parts that are not annotations, here the tools:

The answer pipeline declared with annotations
public interface DeclarativeResearcher {
    @SystemMessage("You research this site's documentation. Use the search tool and never invent pages.")
    @UserMessage("Collect the documentation sections that answer: {{question}}")
    @Agent(value = "Searches the documentation", outputKey = "notes")
    String research(@V("question") String question);

    @ToolsSupplier
    static Object[] tools() {
        return new Object[] {new DocsSearchTool()};
    }
}

public interface DeclarativeQA {
    @SequenceAgent(outputKey = "answer", subAgents = {DeclarativeResearcher.class, AnswerWriter.class})
    String answer(@V("question") String question);
}

var qa = AgenticServices.createAgenticSystem(DeclarativeQA.class, model);
System.out.println(qa.answer("How do I connect an MCP client?"));

It printed Use McpToolProvider (see building-clients.adoc)., the same as the programmatic pipeline. The tutorial’s table lists the other supplier and condition annotations (@ChatModelSupplier, @ExitCondition, @ActivationCondition, @Output, @ParallelExecutor, @AgentListenerSupplier, …​). The declarative form is compact for static systems; the builder form is easier when tools, models or clients are objects you construct at runtime, as the MCP client is below.

The MCP client

MCP servers, transports and primitives are explained in the Model Context Protocol sub-section, and the other Java clients (including Spring AI’s) are compared in Building MCP Clients. This section is the LangChain4j side: langchain4j-mcp, which is beta-versioned like the agentic module. It supports the Streamable HTTP and stdio transports of the specification, a Docker stdio transport (langchain4j-mcp-docker), and a non-standard WebSocket transport that is only known to work with the Quarkus MCP server extension.

The server for the following snippets is a FastMCP stdio server with two tools, search_docs (read-only) and open_github_issue (a write, stubbed so that it records nothing), and one resource:

The MCP server the Java client talks to (Python, FastMCP)
from mcp.server.fastmcp import FastMCP
from mcp.types import ToolAnnotations

mcp = FastMCP("docs")

PAGES = {
    "modules/ROOT/pages/ai/mcp/building-clients.adoc":
        "Building MCP clients: transports, tool discovery and calling tools.",
    "modules/ROOT/pages/database/vector-rag/integrating-with-langchain.adoc":
        "Integrating vector stores with LangChain and LangChain4j.",
}


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))
def search_docs(query: str) -> str:
    """Search the documentation and return matching page paths with a one-line snippet."""
    words = query.lower().split()
    hits = [f"{path}: {text}" for path, text in PAGES.items()
            if any(w in text.lower() or w in path for w in words)]
    return "\n".join(hits) or "no matches"


@mcp.tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False))
def open_github_issue(title: str, body: str) -> str:
    """Open a GitHub issue in the documentation repository (stub: records nothing)."""
    return f"created issue #101: {title}"


@mcp.resource("docs://index", mime_type="text/plain")
def docs_index() -> str:
    """The list of documented pages."""
    return "\n".join(PAGES)


if __name__ == "__main__":
    mcp.run("stdio")

Writing such servers is covered in Python MCP Servers. The client needs three objects: a transport, a client, and a tool provider. StdioMcpTransport starts the server as a subprocess; DefaultMcpClient negotiates the protocol and, by default, auto-detects whether the server speaks the legacy (2025-11-25) or the modern stateless (2026-07-28) protocol, preferring the newer one; McpToolProvider turns the client’s tools into something an AI service or agent can use:

Client, tool provider and a read-only researcher
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.mcp.client.DefaultMcpClient;
import dev.langchain4j.mcp.client.McpClient;
import dev.langchain4j.mcp.client.transport.McpTransport;
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;

static McpClient docsClient() {
    McpTransport transport = StdioMcpTransport.builder()
            .command(List.of("python", "docs_mcp_server.py"))
            .build();
    return DefaultMcpClient.builder()
            .key("docs")
            .transport(transport)
            .build();
}

static DocsResearcher mcpResearcher(ChatModel model, McpClient client) {
    McpToolProvider readOnly = McpToolProvider.builder()
            .mcpClients(client)
            .filterToolNames("search_docs")
            .build();
    return AgenticServices.agentBuilder(DocsResearcher.class)
            .chatModel(model)
            .toolProvider(readOnly)
            .build();
}

filterToolNames is the least-privilege switch. The server exposes open_github_issue, but this provider offers the model only search_docs, so a prompt-injected model cannot reach the write tool; only the approval workflow above holds a client reference to it. Running the researcher with the scripted model, the conversation the model saw was:

The tool exchange (recorded prompt, abbreviated)
AiMessage { ... toolExecutionRequests = [ToolExecutionRequest { id = "call-1", name = "search_docs", arguments = "{"query":"mcp client"}" }] }
ToolExecutionResultMessage{id='call-1', toolName='search_docs', contents=[TextContent { text = "{"result":"modules/ROOT/pages/ai/mcp/building-clients.adoc: Building MCP clients: transports, tool discovery and calling tools."}" }], isError=false, ...}

The provider fetches the tool list from the server, so the tool’s name, description and schema came from the Python docstring and type hints, not from Java. That also means tool descriptions are supplied by whoever runs the server and enter the model’s context, which is why untrusted servers are a prompt-injection channel; see MCP Security and Agent Guardrails & Safety.

Providers, mappers and plain AI services

McpToolProvider also works with a plain AiServices builder, and one provider can wrap several clients. When two servers expose the same tool name, toolNameMapper renames what the model sees while the executor still sends the original name to the server. The same docs_search_docs name appears in the model’s tool call below:

A tool provider on a plain AI service, with a name mapper
public interface DocsBot {
    String answer(String question);
}

static DocsBot bot(ChatModel model, McpClient client) {
    McpToolProvider provider = McpToolProvider.builder()
            .mcpClients(client)
            .filterToolNames("search_docs")
            .toolNameMapper((c, spec) -> c.key() + "_" + spec.name())
            .build();
    return AiServices.builder(DocsBot.class)
            .chatModel(model)
            .toolProvider(provider)
            .build();
}

The scripted model called docs_search_docs, the call reached the server’s search_docs, and the bot answered See building-clients.adoc. The builder also has filter(BiPredicate) for per-client rules (several filters are ANDed), toolSpecificationMapper to rewrite descriptions, and failIfOneServerFails to choose whether one unreachable server aborts tool discovery for all (the default is to ignore it and continue).

Resources, remote servers and versions

Resources are read directly on the client. Against the server above:

Listing and reading a resource
client.listResources().forEach(r -> System.out.println(r.uri() + " " + r.name()));
McpReadResourceResult result = client.readResource("docs://index");
System.out.println(result.contents());

which printed docs://index docs_index and a single McpTextResourceContents holding the two page paths. A McpResourcesAsToolsPresenter on the provider can instead expose resources to the model through two synthetic tools, which is convenient and widens what the model can read, so decide deliberately.

For a remote server use Streamable HTTP. The bearer token is read from an environment variable at request time by a header supplier, never written in code or passed on a command line, and the protocol version can be pinned to skip the auto-detection round trip:

Remote server over Streamable HTTP (compiled, not connected)
static McpClient remoteClient() {
    McpTransport transport = StreamableHttpMcpTransport.builder()
            .url("https://docs-mcp.example.com/mcp")
            .customHeaders(() -> Map.of("Authorization", "Bearer " + System.getenv("DOCS_MCP_TOKEN")))
            .timeout(Duration.ofSeconds(20))
            .build();
    return DefaultMcpClient.builder()
            .key("docs-remote")
            .transport(transport)
            .protocolVersion("2026-07-28")
            .build();
}

That snippet compiled but was not run, since no such server exists. A static token is the simplest case; a token issued to one user must not be forwarded to a different downstream service, as MCP Authorization explains. The client also offers listeners (McpClientListener), a metadata supplier for trace context, and log handlers; see the tutorial. Deploying servers to speak to is covered by Deploying Remote MCP Servers.

MCP tools as agents

langchain4j-agentic-mcp adds a second way to use MCP: McpAgent wraps one MCP tool as a non-AI agent that calls the tool directly, with no model in between. The open step in the issue workflow above is that. Its input keys are the tool’s parameter names, so they read title and body from the scope, and its result goes to issue:

An MCP tool as a workflow step
var open = McpAgent.builder(client)
        .toolName("open_github_issue")
        .inputKeys("title", "body")
        .outputKey("issue")
        .build();

The result is an UntypedAgent; McpAgent.builder(client, SomeInterface.class) gives a typed one, and the @McpClientAgent and @McpClientSupplier annotations declare it in the declarative style. Choosing between the two routes is a design decision. Give the model an MCP tool through McpToolProvider when the model should decide whether and how to call it (a read tool such as search). Wrap it in McpAgent when the workflow decides, and a gate must come first (a write tool such as opening an issue).

The Java DocsAssistant

Putting the pieces together, the production wiring differs from the tests in only two places: a real ChatModel and a real approver. The Ollama models are the ones used across this section, and the approver is application code:

Wiring DocsAssistant for production (compiles against the same artifacts)
ChatModel model = OllamaChatModel.builder()
        .baseUrl("http://localhost:11434")
        .modelName("llama3.1:8b")
        .temperature(0.0)
        .build();

try (McpClient docs = Advanced.docsClient()) {
    var answerPipeline = Workflows.answerPipeline(model, Advanced.mcpResearcher(model, docs));
    var filing = Advanced.issueFiling(model, docs, scope -> reviewQueue.askMaintainer(scope));
    var assistant = AgenticServices.supervisorBuilder()
            .chatModel(model)
            .subAgents(answerPipeline, filing)
            .responseStrategy(SupervisorResponseStrategy.SUMMARY)
            .build();
    System.out.println(assistant.invoke("How do I connect an MCP client?"));
}

reviewQueue is your own approval mechanism. Behind the small interface hides everything that is not agentic: a Spring Boot or Quarkus service, authentication of the caller, and a retrieval step over pgvector rather than the in-memory search tool. The OllamaChatModel builder was compiled but not run here, for the reason given in the lead paragraph, so whether llama3.1:8b reliably produces the planner’s JSON is unchecked; weak local models are the usual weak link of a supervisor, and a workflow with a router is the safer default for them (see Local Models and Running LLMs Locally).

How this maps to Python

The constructs correspond closely to the ones on the Python pages, which helps when a team uses both:

Table 1. LangChain4j agentic module and the Python stack
LangChain4j Python (LangChain 1.x / LangGraph 1.x) Where

AiServices with @Tool

create_agent with tools

Agents with create_agent

AgenticScope and output keys

Graph state with reducers

LangGraph Fundamentals

Sequence, loop, parallel, conditional builders

Edges, conditional edges, Send

LangGraph Workflows

Supervisor agent

Supervisor with subagents as tools

Multi-Agent & Deep Agents

HumanInTheLoop agent

interrupt() and Command(resume=…​)

Human-in-the-Loop & Time Travel

AgentListener, AgentMonitor

LangSmith tracing

Observability with LangSmith

McpToolProvider, McpAgent

MultiServerMCPClient

MCP Adapters

The largest difference is durability. LangGraph checkpoints every step of a run so it can resume, be replayed and be inspected. The agentic module keeps its scope in memory unless you supply an AgenticScopeStore; with one, the tutorial’s recoverability section describes checkpointing after each agent invocation and resuming a suspended run after a restart. That support is part of the experimental module, so long-running approvals deserve extra testing. Guardrails, streaming agents (a TokenStream return type plus streamingChatModel) and dynamic chat-model selection also exist in the module and are described in the tutorial.