If you have been following this series, you have seen how Solon AI streams chat as semantic events and how it chunks documents by meaning. This time we move from conversation plumbing to capability plumbing: how Solon AI turns ordinary Java code into MCP (Model Context Protocol) services, and how it consumes remote MCP servers without hand-writing protocol code.
All code in this post was verified against the Solon AI 4.1.x source tree (solon-ai-mcp and mcp-core modules).
Why MCP, and why in-process?
MCP standardizes how an LLM application discovers and invokes tools, reads resources, and loads prompts from an external provider. Instead of hard-coding function calls into your prompt pipeline, you point your app at an MCP endpoint — local or remote — and the capability list arrives over the wire.
Solon AI ships MCP support in two layers:
-
mcp-core— a self-contained protocol implementation covering MCP spec revisions from2024-11-05through2025-11-25. -
solon-ai-mcp— the application-facing layer: an annotation-driven server and aToolProvider-compatible client that plugs straight intoChatModel.
The design goal is visible in the dependency direction: the protocol layer knows nothing about Solon AI, and the integration layer adds almost nothing you have to learn.
The server: one annotation per capability
Declare an endpoint class with @McpServerEndpoint, then annotate plain methods:
@McpServerEndpoint(
mcpEndpoint = "/mcp/sse",
heartbeatInterval = "30s")
@Component
public class McpServerTool {
// Tip: enable the -parameters compiler flag,
// or give every @Param an explicit name.
@ToolMapping(description = "查询天气预报")
public String getWeather(@Param(description = "城市位置") String location) {
return "晴,14度";
}
}
That is the whole server. At startup, McpPlugin scans @McpServerEndpoint classes and builds an McpServerEndpointProvider from them. Method-level providers — MethodToolProvider, MethodResourceProvider, MethodPromptProvider — extract @ToolMapping, @ResourceMapping, and @PromptMapping methods and register them with the endpoint's lifecycle. No JSON schemas to maintain by hand, no dispatch switch, no transport wiring.
Details worth knowing:
-
heartbeatIntervaldefaults to"30s"on the server side. -
sseEndpoint()andmessageEndpoint()are deprecated; the unifiedmcpEndpoint()is the way to go. - There are two hosting models: a stateful host (
STREAMABLEchannel) that keeps session state per client, and a stateless one (STREAMABLE_STATELESS) where every request carries everything it needs — the better fit behind load balancers. -
enableOutputSchema()can turn on output schema validation for tools that need it.
The client: McpClientProvider
On the consuming side, one class implements ToolProvider, ResourceProvider, and PromptProvider:
McpClientProvider mcpClient = McpClientProvider.builder()
.url("http://localhost:8081/sse")
.build();
ChatModel chatModel = ChatModel.of(chatConfig)
.defaultToolAdd(mcpClient)
.build();
ChatResponse resp = chatModel
.prompt("杭州天气和北京降雨量如何?")
.call();
The provider is lazy: the underlying McpAsyncClient is created on first use, guarded by a lock. From then on, ChatModel treats MCP tools exactly like local function tools — the model sees them in its tool list, picks one, and Solon AI routes the invocation through callTool(name, args).
Four channels, one builder
McpChannel defines STDIO, SSE, STREAMABLE, and STREAMABLE_STATELESS. The builder picks the transport for you:
| Channel | Transport | Typical use |
|---|---|---|
STDIO |
StdioClientTransport |
Launching a local MCP binary as a subprocess |
SSE |
WebRxSseClientTransport |
Classic HTTP + server-sent events |
STREAMABLE |
WebRxStreamableHttpTransport |
Modern streamable HTTP, stateful session |
STREAMABLE_STATELESS |
WebRxStreamableHttpTransport |
Stateless streamable HTTP, LB-friendly |
For per-call granularity instead of defaults, push the tool list into the prompt options:
chatModel.prompt("今天杭州的天气情况?")
.options(options -> options.toolAdd(mcpClient.getTools()))
.stream()
.filter(e -> e.is(ChatEventType.TEXT_DELTA) && e.hasText())
.map(ChatEvent::getText);
Note how this composes with the ChatEvent streaming API from the first post in this series — MCP tools and semantic events are orthogonal layers.
Self-healing connections
Network transport code fails in boring, repetitive ways. McpClientProvider centralizes the retry:
public <T> T executeWithRetry(Function<McpAsyncClient, Mono<T>> action) {
try {
return action.apply(getClient()).block();
} catch (Throwable ex) {
if (isTransportError(ex)) {
this.reset(); // drop the broken client
return action.apply(getClient()).block(); // reconnect, retry once
}
throw ex;
}
}
isTransportError matches McpTransportException, timeouts, connection refusals, and friends. Protocol-level errors (a tool that returned an error, for example) propagate untouched — retrying those would be wrong.
If you enable heartbeats, a failed beat doubles the backoff interval on each retry, capped at 10 minutes. Intervals under 5 seconds are rejected outright. And note the asymmetry: the server sends heartbeats every 30s by default, while the client opts in explicitly — a deliberate choice to keep the client quiet unless you ask.
Caching and change notification
Listing tools, resources, and prompts over MCP is a round trip you do not want on every prompt. The client caches these lists locally for 30 seconds by default (cacheSeconds). When the server emits a change notification, the matching cache entry is invalidated — the next listing goes back over the wire. The notification clears the cache; it does not push the new list. Subtle, but it is the difference between "eventually fresh" and "push-updated," and it keeps the client simple.
Tool allow-lists and deny-lists
allowedTools and disallowedTools filter what the client exposes. Filtering applies the allow-list first, then the deny-list — so a tool must pass both gates to reach your model. Handy for exposing a curated subset of a large third-party MCP server.
Configuration-driven wiring
Instead of building clients in code, bind them from configuration under the solon.ai.mcp.client.<name> prefix:
@Bean
public McpClientProvider clientWrapper(
@Inject("${solon.ai.mcp.client.demo}") McpClientProvider client) {
return client;
}
There is also McpClientProviders.fromMcpServers(uri) for loading a whole mcpServers-style map at once — useful when your tool landscape lives in config rather than Java.
Gotchas I hit while reading the source
-
The Javadoc lies a little. The class-level example in
McpClientProvidershows.apiUrl(...)and.defaultToolsAdd(...); the actual builder method is.url(...), and the demo code usesdefaultToolAdd. Trust the code, not the comment — I have reported the drift. -
-parametersmatters. Without the compiler flag, parameter names vanish from bytecode, and@Paramneeds an explicitname. - Heartbeat defaults are asymmetric. Server: 30s on. Client: off. Do not assume both ends keep-alive the same way.
-
Stateless is a mode, not a transport.
STREAMABLE_STATELESSuses the same HTTP transport; the difference is in session handling on the server host.
When does this matter?
The annotation server shines when you have an existing Solon service full of business methods that AI agents suddenly need to call. The client shines when you want to compose capabilities across process boundaries — a weather server here, a database MCP there, all flowing into one ChatModel with retries and caching you did not write.
Together with the streaming events and semantic splitting covered earlier, that completes a picture worth remembering: Solon AI treats MCP not as a bolt-on integration but as another expression of the same builder-and-provider abstractions the rest of the framework runs on.
References
- Solon AI repository and docs: https://solon.noear.org/article/learn-solon-ai
- Model Context Protocol specification: https://modelcontextprotocol.io
- Earlier in this series: Semantic Chat Events, SemanticSplitter
Verified against the Solon AI 4.1.x source tree; class and method names reflect that version.
Top comments (0)