DEV Community

Solon Framework
Solon Framework

Posted on

Wire Remote MCP Tools into Solon ReActAgent with McpClientProvider

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 → ...
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Maven dependency:

<dependency>
    <groupId>org.noear</groupId>
    <artifactId>solon-ai-mcp</artifactId>
</dependency>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

You can also smoke-test tools without an agent:

String raw = weatherMcp
        .callTool("getWeather", Map.of("location", "Hangzhou"))
        .getContent();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

What happens at runtime:

  1. The model sees both local and remote tool descriptions.
  2. It may call get_order_city (local), then getWeather (MCP).
  3. Observations come back into the ReAct loop as normal tool messages.
  4. 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();
Enter fullscreen mode Exit fullscreen mode

Per-call attachment is also valid:

chatModel.prompt("How is the weather in Hangzhou today?")
        .options(o -> o.toolAdd(weatherMcp))
        .call();
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode
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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Practical rules:

  1. Prefer one client instance per remote server.
  2. Close clients on app shutdown hooks.
  3. Keep cacheSeconds modest so tool list updates are visible without hammering the server.
  4. 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)

Framework version referenced: Solon v4.0.3.

Top comments (0)