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
beforeToolExecutionandafterToolExecutiononAgentListener, while the jar hasbeforeAgentToolExecutionandafterAgentToolExecution. 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 (
DocsAssistantbelow), 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:
<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:
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:
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:
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:
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:
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:
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:
[@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:
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:
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:
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:
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:
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:
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):
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:
{"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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
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:
| LangChain4j | Python (LangChain 1.x / LangGraph 1.x) | Where |
|---|---|---|
|
|
|
|
Graph state with reducers |
|
Sequence, loop, parallel, conditional builders |
Edges, conditional edges, |
|
Supervisor agent |
Supervisor with subagents as tools |
|
|
|
|
|
LangSmith tracing |
|
|
|
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.