Most teams start with local tools wired into a ReAct agent. That works until the weather API, CRM connector, or warehouse service lives in another process — or another team’s service.
Solon AI treats MCP clients as first-class tool providers. A remote MCP server can be plugged into ReActAgent the same way a local AbsToolProvider is: one defaultToolAdd(...), no custom RPC glue.
This walkthrough uses only official Solon v4.0.3 APIs.
The idea in one sentence
McpClientProvider implements ToolProvider.
ReActAgent accepts any ToolProvider.
Therefore: remote MCP tools join the Reason → Act loop without a special agent type.
MCP Server (tools over streamable/sse/stdio)
│
▼
McpClientProvider ──implements──► ToolProvider
│
▼
ReActAgent.defaultToolAdd(mcpClient)
│
▼
Thought → Action(tool call) → Observation → ...
1. Publish a small MCP tool server
Same pattern as the official weather sample: annotate a class with @McpServerEndpoint, expose methods with @ToolMapping.
import org.noear.solon.Solon;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.mcp.McpChannel;
import org.noear.solon.ai.mcp.server.annotation.McpServerEndpoint;
import org.noear.solon.annotation.Param;
@McpServerEndpoint(channel = McpChannel.STREAMABLE, mcpEndpoint = "/mcp")
public class WeatherMcpServer {
// Prefer compile with -parameters so arg names are preserved
@ToolMapping(description = "Query weather forecast for a city")
public String getWeather(@Param(description = "City location") String location) {
return "Sunny, 14°C in " + location;
}
}
public class WeatherMcpApp {
public static void main(String[] args) {
Solon.start(WeatherMcpApp.class, args);
}
}
Maven dependency:
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-ai-mcp</artifactId>
</dependency>
STREAMABLE is the preferred HTTP channel today (single endpoint, long-lived session). SSE still works but is marked deprecated in the docs. Use STREAMABLE_STATELESS when you need short-lived, cluster-friendly HTTP without sticky sessions.
2. Build the MCP client (singleton)
Official guidance: McpClientProvider holds long-lived connections — use a singleton, or call close() when you are done.
import org.noear.solon.ai.mcp.McpChannel;
import org.noear.solon.ai.mcp.client.McpClientProvider;
McpClientProvider weatherMcp = McpClientProvider.builder()
.channel(McpChannel.STREAMABLE)
.url("http://localhost:8080/mcp")
.cacheSeconds(30) // tool metadata cache
// .heartbeatInterval(null) // default is 15s; null disables heartbeat
.build();
You can also smoke-test tools without an agent:
String raw = weatherMcp
.callTool("getWeather", Map.of("location", "Hangzhou"))
.getContent();
McpClientProvider exposes three content types through standard interfaces:
| Interface | What you get |
|---|---|
ToolProvider |
Remote tools for models / agents |
ResourceProvider |
Remote resources |
PromptProvider |
Remote prompts |
Closeable |
Release connection; stops auto-reconnect |
3. Plug MCP into ReActAgent
Local domain tools stay AbsToolProvider + @ToolMapping. Remote tools are just another provider.
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.agent.session.InMemoryAgentSession;
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.tool.AbsToolProvider;
import org.noear.solon.ai.mcp.McpChannel;
import org.noear.solon.ai.mcp.client.McpClientProvider;
import org.noear.solon.annotation.Param;
public class LocalOrderTools extends AbsToolProvider {
@ToolMapping(description = "Look up order city by order id")
public String get_order_city(@Param(description = "Order id") String orderId) {
if ("ORD_20251229".equals(orderId)) {
return "Hangzhou";
}
return "unknown";
}
}
// remote weather service
McpClientProvider weatherMcp = McpClientProvider.builder()
.channel(McpChannel.STREAMABLE)
.url("http://localhost:8080/mcp")
.build();
ChatModel chatModel = /* your model */;
ReActAgent agent = ReActAgent.of(chatModel)
.name("ops_assistant")
.role("Operations assistant that checks orders and live weather")
.defaultToolAdd(new LocalOrderTools()) // local tools
.defaultToolAdd(weatherMcp) // remote MCP tools
.maxTurns(8)
.autoRethink(true)
.modelOptions(o -> o.temperature(0.1))
.build();
var session = InMemoryAgentSession.of("demo_mcp_react_001");
String answer = agent.prompt(
"Order ORD_20251229 is delayed. Check the destination city weather " +
"and suggest whether outdoor delivery is still safe today.")
.session(session)
.call()
.getContent();
System.out.println(answer);
What happens at runtime:
- The model sees both local and remote tool descriptions.
- It may call
get_order_city(local), thengetWeather(MCP). - Observations come back into the ReAct loop as normal tool messages.
- The agent finishes with a natural-language recommendation.
No adapter layer. No “MCP-only agent”. Same defaultToolAdd surface as the official e-commerce after-sales sample.
4. Same provider also works on ChatModel
If you do not need multi-step ReAct yet, bind MCP tools directly to the chat model:
ChatModel chatModel = ChatModel.of(chatConfig)
.defaultToolAdd(weatherMcp) // McpClientProvider is a ToolProvider
.build();
String reply = chatModel.prompt("How is the weather in Hangzhou today?")
.call()
.getMessage()
.getContent();
Per-call attachment is also valid:
chatModel.prompt("How is the weather in Hangzhou today?")
.options(o -> o.toolAdd(weatherMcp))
.call();
5. Config-driven clients (production style)
Hard-coding URLs is fine for demos. In apps, inject clients from config:
solon.ai:
chat:
demo:
apiUrl: "http://127.0.0.1:11434/api/chat"
model: "qwen2.5:1.5b"
mcp:
client:
weather:
channel: "streamable"
url: "http://localhost:8080/mcp"
crm:
channel: "streamable"
url: "http://crm-internal.example/mcp"
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
import org.noear.solon.ai.agent.react.ReActAgent;
import org.noear.solon.ai.chat.ChatConfig;
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.mcp.client.McpClientProvider;
@Configuration
public class AgentConfig {
@Bean
public McpClientProvider weatherMcp(
@Inject("${solon.ai.mcp.client.weather}") McpClientProvider client) {
return client; // singleton bean
}
@Bean
public ChatModel chatModel(@Inject("${solon.ai.chat.demo}") ChatConfig cfg) {
return ChatModel.of(cfg).build();
}
@Bean
public ReActAgent opsAgent(ChatModel chatModel,
McpClientProvider weatherMcp,
McpClientProvider crmMcp) {
return ReActAgent.of(chatModel)
.name("ops_assistant")
.role("Operations assistant")
.defaultToolAdd(weatherMcp)
.defaultToolAdd(crmMcp)
.maxTurns(10)
.autoRethink(true)
.build();
}
}
Adding a third-party MCP service becomes a config change, not a rewrite of agent code.
6. Heartbeat, reconnect, and lifecycle
MCP sessions are long-lived (the server may call back for notifications or sampling). Solon MCP clients therefore:
- Send heartbeat pings (default interval 15s)
- On network error or failed heartbeat, retry reconnect on the next request
- Stop reconnecting after
close()
// disable heartbeat when an external client cannot tolerate ping frames
McpClientProvider client = McpClientProvider.builder()
.channel(McpChannel.STREAMABLE)
.url("http://localhost:8080/mcp")
.heartbeatInterval(null)
.build();
// later, when the process shuts down
client.close();
// or reopen after intentional close
// client.reopen();
Practical rules:
- Prefer one client instance per remote server.
- Close clients on app shutdown hooks.
- Keep
cacheSecondsmodest so tool list updates are visible without hammering the server. - Treat remote tools as untrusted I/O: still gate high-risk actions with HITL if needed (separate interceptor topic).
7. Channel cheat sheet
| Channel | Session | Link style | Notes |
|---|---|---|---|
stdio |
yes | process pipes | One stdio endpoint per process; no console log noise on server |
sse |
yes | long-lived HTTP (2 endpoints) | Works; docs mark it deprecated |
streamable |
yes | long-lived HTTP (1 endpoint) | Default choice for remote services |
streamable_stateless |
yes (client) / short HTTP | short-lived | Cluster-friendly, no sticky routing required |
Developer experience is the same; only the channel (and endpoint path) changes.
8. Why this composition matters
| Approach | Cost |
|---|---|
Wrap every third-party API as local @ToolMapping
|
Own every connector forever |
| Hand-roll HTTP clients inside tools | Duplicate auth, retry, schema work |
MCP server + McpClientProvider |
Publish once, consume from ChatModel / ReActAgent / multiple apps |
ReAct still owns the decision loop. MCP only supplies arms and legs that can live outside the JVM.
Minimal checklist
- [ ] Server:
@McpServerEndpoint+@ToolMapping - [ ] Client:
McpClientProvider.builder().channel(...).url(...).build() - [ ] Agent:
ReActAgent.of(model).defaultToolAdd(mcpClient)...build() - [ ] Lifecycle: singleton client +
close()on shutdown - [ ] Optional: YAML
solon.ai.mcp.client.*injection for multi-server fleets
References (official)
- solon-ai-mcp overview
- Publish / consume Tool services
- Client build & model integration
- Client heartbeat & reconnect
- sse / streamable / stdio differences
- ReActAgent config & build
- ReActAgent call options
- E-commerce after-sales ReAct sample (local tools pattern)
Framework version referenced: Solon v4.0.3.
Top comments (0)