- HttpSseTransport: HTTP POST + SSE streaming for modern MCP servers - ListMcpResourcesTool: browse MCP server resources by server/URI - ReadMcpResourceTool: read MCP resource content with auto-routing - McpManager: environment variable expansion (\) in config - McpManager: HTTP server support via 'url' field in mcp.json - Registered both resource tools in AppConfig (total: 26 tools) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>pull/1/head
parent
758d0d2980
commit
5a6798540a
@ -0,0 +1,337 @@ |
|||||||
|
package com.claudecode.mcp; |
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||||
|
import org.slf4j.Logger; |
||||||
|
import org.slf4j.LoggerFactory; |
||||||
|
|
||||||
|
import java.io.BufferedReader; |
||||||
|
import java.io.InputStreamReader; |
||||||
|
import java.net.URI; |
||||||
|
import java.net.http.HttpClient; |
||||||
|
import java.net.http.HttpRequest; |
||||||
|
import java.net.http.HttpResponse; |
||||||
|
import java.nio.charset.StandardCharsets; |
||||||
|
import java.time.Duration; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.concurrent.*; |
||||||
|
import java.util.concurrent.atomic.AtomicBoolean; |
||||||
|
|
||||||
|
/** |
||||||
|
* HTTP + SSE 传输层 —— 对应 claude-code 中的 HTTP 传输实现。 |
||||||
|
* <p> |
||||||
|
* 用于连接基于 HTTP 的 MCP 服务器,使用 SSE (Server-Sent Events) 接收通知, |
||||||
|
* 使用 HTTP POST 发送请求。 |
||||||
|
* <p> |
||||||
|
* MCP HTTP 传输协议流程: |
||||||
|
* <ol> |
||||||
|
* <li>建立 SSE 连接获取 endpoint URL</li> |
||||||
|
* <li>通过 POST 请求发送 JSON-RPC 消息到 endpoint</li> |
||||||
|
* <li>通过 SSE 流接收响应和通知</li> |
||||||
|
* </ol> |
||||||
|
* |
||||||
|
* @see McpTransport |
||||||
|
*/ |
||||||
|
public class HttpSseTransport implements McpTransport { |
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(HttpSseTransport.class); |
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper(); |
||||||
|
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); |
||||||
|
|
||||||
|
private final String baseUrl; |
||||||
|
private final HttpClient httpClient; |
||||||
|
private final Map<String, String> headers; |
||||||
|
private final Duration timeout; |
||||||
|
|
||||||
|
/** Endpoint URL received from SSE connection */ |
||||||
|
private volatile String messageEndpoint; |
||||||
|
|
||||||
|
/** Pending response futures: request id -> CompletableFuture */ |
||||||
|
private final ConcurrentHashMap<String, CompletableFuture<JsonNode>> pendingRequests = |
||||||
|
new ConcurrentHashMap<>(); |
||||||
|
|
||||||
|
/** SSE connection state */ |
||||||
|
private final AtomicBoolean connected = new AtomicBoolean(false); |
||||||
|
private volatile Future<?> sseListenerFuture; |
||||||
|
private final ExecutorService sseExecutor = Executors.newSingleThreadExecutor(r -> { |
||||||
|
Thread t = new Thread(r, "mcp-sse-listener"); |
||||||
|
t.setDaemon(true); |
||||||
|
return t; |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* 创建 HTTP+SSE 传输层。 |
||||||
|
* |
||||||
|
* @param baseUrl MCP 服务器的基础 URL (e.g., "http://localhost:3000") |
||||||
|
*/ |
||||||
|
public HttpSseTransport(String baseUrl) { |
||||||
|
this(baseUrl, Map.of(), DEFAULT_TIMEOUT); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 创建 HTTP+SSE 传输层(自定义头和超时)。 |
||||||
|
* |
||||||
|
* @param baseUrl MCP 服务器的基础 URL |
||||||
|
* @param headers 自定义 HTTP 头(如认证 token) |
||||||
|
* @param timeout 请求超时时间 |
||||||
|
*/ |
||||||
|
public HttpSseTransport(String baseUrl, Map<String, String> headers, Duration timeout) { |
||||||
|
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; |
||||||
|
this.headers = headers != null ? headers : Map.of(); |
||||||
|
this.timeout = timeout != null ? timeout : DEFAULT_TIMEOUT; |
||||||
|
this.httpClient = HttpClient.newBuilder() |
||||||
|
.connectTimeout(this.timeout) |
||||||
|
.build(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 连接到 SSE 端点并开始监听。 |
||||||
|
* 必须在发送请求前调用。 |
||||||
|
*/ |
||||||
|
public void connect() throws McpException { |
||||||
|
if (connected.get()) return; |
||||||
|
|
||||||
|
log.info("Connecting to MCP HTTP server at {}", baseUrl); |
||||||
|
|
||||||
|
// Start SSE listener
|
||||||
|
sseListenerFuture = sseExecutor.submit(() -> { |
||||||
|
try { |
||||||
|
listenSse(); |
||||||
|
} catch (Exception e) { |
||||||
|
if (connected.get()) { |
||||||
|
log.warn("SSE listener error: {}", e.getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Wait for endpoint URL
|
||||||
|
int waitMs = 0; |
||||||
|
while (messageEndpoint == null && waitMs < timeout.toMillis()) { |
||||||
|
try { |
||||||
|
Thread.sleep(100); |
||||||
|
waitMs += 100; |
||||||
|
} catch (InterruptedException e) { |
||||||
|
Thread.currentThread().interrupt(); |
||||||
|
throw new McpException("Interrupted while waiting for SSE endpoint"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (messageEndpoint == null) { |
||||||
|
throw new McpException("Timeout waiting for SSE endpoint from " + baseUrl); |
||||||
|
} |
||||||
|
|
||||||
|
connected.set(true); |
||||||
|
log.info("Connected to MCP HTTP server, endpoint: {}", messageEndpoint); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* SSE 监听循环 —— 连接到 /sse 端点并解析事件流。 |
||||||
|
*/ |
||||||
|
private void listenSse() throws Exception { |
||||||
|
String sseUrl = baseUrl + "/sse"; |
||||||
|
log.debug("Starting SSE listener at {}", sseUrl); |
||||||
|
|
||||||
|
var requestBuilder = HttpRequest.newBuilder() |
||||||
|
.uri(URI.create(sseUrl)) |
||||||
|
.timeout(Duration.ofMinutes(30)) // Long timeout for SSE
|
||||||
|
.GET(); |
||||||
|
|
||||||
|
// Add custom headers
|
||||||
|
for (var entry : headers.entrySet()) { |
||||||
|
requestBuilder.header(entry.getKey(), entry.getValue()); |
||||||
|
} |
||||||
|
|
||||||
|
HttpRequest request = requestBuilder.build(); |
||||||
|
HttpResponse<java.io.InputStream> response = httpClient.send( |
||||||
|
request, HttpResponse.BodyHandlers.ofInputStream()); |
||||||
|
|
||||||
|
if (response.statusCode() != 200) { |
||||||
|
throw new McpException("SSE connection failed with status " + response.statusCode()); |
||||||
|
} |
||||||
|
|
||||||
|
try (var reader = new BufferedReader( |
||||||
|
new InputStreamReader(response.body(), StandardCharsets.UTF_8))) { |
||||||
|
|
||||||
|
String eventType = null; |
||||||
|
StringBuilder dataBuffer = new StringBuilder(); |
||||||
|
|
||||||
|
String line; |
||||||
|
while ((line = reader.readLine()) != null && connected.get()) { |
||||||
|
if (line.startsWith("event:")) { |
||||||
|
eventType = line.substring(6).strip(); |
||||||
|
} else if (line.startsWith("data:")) { |
||||||
|
dataBuffer.append(line.substring(5).strip()); |
||||||
|
} else if (line.isEmpty() && dataBuffer.length() > 0) { |
||||||
|
// End of event
|
||||||
|
handleSseEvent(eventType, dataBuffer.toString()); |
||||||
|
eventType = null; |
||||||
|
dataBuffer.setLength(0); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 处理 SSE 事件。 |
||||||
|
*/ |
||||||
|
private void handleSseEvent(String eventType, String data) { |
||||||
|
if ("endpoint".equals(eventType)) { |
||||||
|
// Server sends the POST endpoint URL
|
||||||
|
if (data.startsWith("http://") || data.startsWith("https://")) { |
||||||
|
messageEndpoint = data; |
||||||
|
} else { |
||||||
|
messageEndpoint = baseUrl + (data.startsWith("/") ? data : "/" + data); |
||||||
|
} |
||||||
|
log.debug("Received SSE endpoint: {}", messageEndpoint); |
||||||
|
} else if ("message".equals(eventType) || eventType == null) { |
||||||
|
// JSON-RPC response or notification
|
||||||
|
try { |
||||||
|
JsonNode json = MAPPER.readTree(data); |
||||||
|
if (json.has("id")) { |
||||||
|
// It's a response to a pending request
|
||||||
|
String id = json.get("id").asText(); |
||||||
|
CompletableFuture<JsonNode> future = pendingRequests.remove(id); |
||||||
|
if (future != null) { |
||||||
|
future.complete(json); |
||||||
|
} else { |
||||||
|
log.debug("Received response for unknown request id: {}", id); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// It's a notification — log it
|
||||||
|
String method = json.has("method") ? json.get("method").asText() : "unknown"; |
||||||
|
log.debug("Received SSE notification: {}", method); |
||||||
|
} |
||||||
|
} catch (Exception e) { |
||||||
|
log.debug("Failed to parse SSE message data: {}", data, e); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public JsonNode sendRequest(String jsonRpcRequest) throws McpException { |
||||||
|
if (!connected.get() || messageEndpoint == null) { |
||||||
|
connect(); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
// Extract request ID for response matching
|
||||||
|
JsonNode requestNode = MAPPER.readTree(jsonRpcRequest); |
||||||
|
String requestId = requestNode.has("id") ? requestNode.get("id").asText() : null; |
||||||
|
|
||||||
|
// Register pending response
|
||||||
|
CompletableFuture<JsonNode> responseFuture = new CompletableFuture<>(); |
||||||
|
if (requestId != null) { |
||||||
|
pendingRequests.put(requestId, responseFuture); |
||||||
|
} |
||||||
|
|
||||||
|
// Send HTTP POST
|
||||||
|
var httpRequest = HttpRequest.newBuilder() |
||||||
|
.uri(URI.create(messageEndpoint)) |
||||||
|
.timeout(timeout) |
||||||
|
.header("Content-Type", "application/json") |
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonRpcRequest)) |
||||||
|
.build(); |
||||||
|
|
||||||
|
// Add custom headers
|
||||||
|
// Note: HttpRequest is immutable, headers must be set at build time
|
||||||
|
// For simplicity, rebuild if we have custom headers
|
||||||
|
if (!headers.isEmpty()) { |
||||||
|
var builder = HttpRequest.newBuilder() |
||||||
|
.uri(URI.create(messageEndpoint)) |
||||||
|
.timeout(timeout) |
||||||
|
.header("Content-Type", "application/json"); |
||||||
|
for (var entry : headers.entrySet()) { |
||||||
|
builder.header(entry.getKey(), entry.getValue()); |
||||||
|
} |
||||||
|
httpRequest = builder.POST(HttpRequest.BodyPublishers.ofString(jsonRpcRequest)).build(); |
||||||
|
} |
||||||
|
|
||||||
|
HttpResponse<String> httpResponse = httpClient.send( |
||||||
|
httpRequest, HttpResponse.BodyHandlers.ofString()); |
||||||
|
|
||||||
|
if (httpResponse.statusCode() >= 400) { |
||||||
|
pendingRequests.remove(requestId); |
||||||
|
throw new McpException("HTTP error " + httpResponse.statusCode() |
||||||
|
+ ": " + httpResponse.body()); |
||||||
|
} |
||||||
|
|
||||||
|
// If the HTTP response body contains JSON-RPC response, use it directly
|
||||||
|
String body = httpResponse.body(); |
||||||
|
if (body != null && !body.isBlank()) { |
||||||
|
try { |
||||||
|
JsonNode directResponse = MAPPER.readTree(body); |
||||||
|
if (directResponse.has("result") || directResponse.has("error")) { |
||||||
|
pendingRequests.remove(requestId); |
||||||
|
return directResponse; |
||||||
|
} |
||||||
|
} catch (Exception ignored) { |
||||||
|
// Not a JSON response, wait for SSE
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Wait for response via SSE
|
||||||
|
if (requestId != null) { |
||||||
|
try { |
||||||
|
return responseFuture.get(timeout.toMillis(), TimeUnit.MILLISECONDS); |
||||||
|
} catch (TimeoutException e) { |
||||||
|
pendingRequests.remove(requestId); |
||||||
|
throw new McpException("Timeout waiting for response to request " + requestId); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// No ID means notification — return empty
|
||||||
|
return MAPPER.createObjectNode(); |
||||||
|
|
||||||
|
} catch (McpException e) { |
||||||
|
throw e; |
||||||
|
} catch (Exception e) { |
||||||
|
throw new McpException("HTTP request failed: " + e.getMessage(), e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void sendNotification(String jsonRpcNotification) throws McpException { |
||||||
|
if (!connected.get() || messageEndpoint == null) { |
||||||
|
connect(); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
var httpRequest = HttpRequest.newBuilder() |
||||||
|
.uri(URI.create(messageEndpoint)) |
||||||
|
.timeout(timeout) |
||||||
|
.header("Content-Type", "application/json") |
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(jsonRpcNotification)) |
||||||
|
.build(); |
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send( |
||||||
|
httpRequest, HttpResponse.BodyHandlers.ofString()); |
||||||
|
|
||||||
|
if (response.statusCode() >= 400) { |
||||||
|
throw new McpException("HTTP notification failed with status " + response.statusCode()); |
||||||
|
} |
||||||
|
} catch (McpException e) { |
||||||
|
throw e; |
||||||
|
} catch (Exception e) { |
||||||
|
throw new McpException("Failed to send notification: " + e.getMessage(), e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public boolean isConnected() { |
||||||
|
return connected.get() && messageEndpoint != null; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public void close() throws Exception { |
||||||
|
connected.set(false); |
||||||
|
pendingRequests.values().forEach(f -> |
||||||
|
f.completeExceptionally(new McpException("Transport closed"))); |
||||||
|
pendingRequests.clear(); |
||||||
|
|
||||||
|
if (sseListenerFuture != null) { |
||||||
|
sseListenerFuture.cancel(true); |
||||||
|
} |
||||||
|
sseExecutor.shutdownNow(); |
||||||
|
log.info("HttpSseTransport closed"); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,109 @@ |
|||||||
|
package com.claudecode.tool.impl; |
||||||
|
|
||||||
|
import com.claudecode.mcp.McpClient; |
||||||
|
import com.claudecode.mcp.McpManager; |
||||||
|
import com.claudecode.tool.Tool; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
|
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
/** |
||||||
|
* ListMcpResources 工具 —— 列出 MCP 服务器提供的资源。 |
||||||
|
* <p> |
||||||
|
* 对应 claude-code 中浏览 MCP 资源的功能。 |
||||||
|
* 显示所有已连接 MCP 服务器的资源列表,包括 URI、名称、描述和 MIME 类型。 |
||||||
|
*/ |
||||||
|
public class ListMcpResourcesTool implements Tool { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "ListMcpResources"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return """ |
||||||
|
List resources available from connected MCP (Model Context Protocol) servers. |
||||||
|
Shows all resources with their URIs, names, descriptions, and MIME types. |
||||||
|
Use this to discover what data sources are available before reading them. |
||||||
|
Optionally filter by server name."""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String inputSchema() { |
||||||
|
return """ |
||||||
|
{ |
||||||
|
"type": "object", |
||||||
|
"properties": { |
||||||
|
"server": { |
||||||
|
"type": "string", |
||||||
|
"description": "Optional: filter resources by MCP server name" |
||||||
|
} |
||||||
|
} |
||||||
|
}"""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(Map<String, Object> input, ToolContext context) { |
||||||
|
McpManager mcpManager = context.getOrDefault("MCP_MANAGER", null); |
||||||
|
if (mcpManager == null) { |
||||||
|
return "No MCP servers configured."; |
||||||
|
} |
||||||
|
|
||||||
|
String serverFilter = (String) input.getOrDefault("server", null); |
||||||
|
var clients = mcpManager.getClients(); |
||||||
|
|
||||||
|
if (clients.isEmpty()) { |
||||||
|
return "No MCP servers connected."; |
||||||
|
} |
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder(); |
||||||
|
int totalResources = 0; |
||||||
|
|
||||||
|
for (var entry : clients.entrySet()) { |
||||||
|
String serverName = entry.getKey(); |
||||||
|
McpClient client = entry.getValue(); |
||||||
|
|
||||||
|
if (serverFilter != null && !serverFilter.isBlank() |
||||||
|
&& !serverName.equalsIgnoreCase(serverFilter)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (!client.isInitialized() || !client.isConnected()) { |
||||||
|
sb.append("⚠ Server '").append(serverName).append("': not connected\n"); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
var resources = client.getResources(); |
||||||
|
if (resources.isEmpty()) { |
||||||
|
sb.append("Server '").append(serverName).append("': no resources\n"); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
sb.append("## ").append(serverName).append(" (").append(resources.size()).append(" resources)\n\n"); |
||||||
|
|
||||||
|
for (var resource : resources) { |
||||||
|
sb.append("- **").append(resource.name()).append("**\n"); |
||||||
|
sb.append(" URI: `").append(resource.uri()).append("`\n"); |
||||||
|
if (!resource.description().isBlank()) { |
||||||
|
sb.append(" ").append(resource.description()).append("\n"); |
||||||
|
} |
||||||
|
sb.append(" Type: ").append(resource.mimeType()).append("\n\n"); |
||||||
|
totalResources++; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (totalResources == 0) { |
||||||
|
return serverFilter != null |
||||||
|
? "No resources found for server '" + serverFilter + "'." |
||||||
|
: "No MCP resources available from any connected server."; |
||||||
|
} |
||||||
|
|
||||||
|
return sb.toString().stripTrailing(); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String activityDescription(Map<String, Object> input) { |
||||||
|
return "📋 Listing MCP resources"; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,130 @@ |
|||||||
|
package com.claudecode.tool.impl; |
||||||
|
|
||||||
|
import com.claudecode.mcp.McpClient; |
||||||
|
import com.claudecode.mcp.McpManager; |
||||||
|
import com.claudecode.tool.Tool; |
||||||
|
import com.claudecode.tool.ToolContext; |
||||||
|
|
||||||
|
import java.util.Map; |
||||||
|
|
||||||
|
/** |
||||||
|
* ReadMcpResource 工具 —— 读取 MCP 服务器的指定资源。 |
||||||
|
* <p> |
||||||
|
* 对应 claude-code 中读取 MCP 资源的功能。 |
||||||
|
* 通过 URI 从 MCP 服务器读取资源内容。 |
||||||
|
*/ |
||||||
|
public class ReadMcpResourceTool implements Tool { |
||||||
|
|
||||||
|
@Override |
||||||
|
public String name() { |
||||||
|
return "ReadMcpResource"; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String description() { |
||||||
|
return """ |
||||||
|
Read a specific resource from a connected MCP (Model Context Protocol) server. |
||||||
|
Provide the resource URI (obtained from ListMcpResources) to fetch its content. |
||||||
|
The server name is optional — if omitted, all servers are searched for the URI."""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String inputSchema() { |
||||||
|
return """ |
||||||
|
{ |
||||||
|
"type": "object", |
||||||
|
"properties": { |
||||||
|
"uri": { |
||||||
|
"type": "string", |
||||||
|
"description": "The resource URI to read (e.g., 'file:///path' or 'custom://resource')" |
||||||
|
}, |
||||||
|
"server": { |
||||||
|
"type": "string", |
||||||
|
"description": "Optional: the MCP server name that provides this resource" |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": ["uri"] |
||||||
|
}"""; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String execute(Map<String, Object> input, ToolContext context) { |
||||||
|
String uri = (String) input.get("uri"); |
||||||
|
String serverFilter = (String) input.getOrDefault("server", null); |
||||||
|
|
||||||
|
if (uri == null || uri.isBlank()) { |
||||||
|
return "Error: 'uri' is required. Use ListMcpResources to discover available resources."; |
||||||
|
} |
||||||
|
|
||||||
|
McpManager mcpManager = context.getOrDefault("MCP_MANAGER", null); |
||||||
|
if (mcpManager == null) { |
||||||
|
return "Error: No MCP servers configured."; |
||||||
|
} |
||||||
|
|
||||||
|
var clients = mcpManager.getClients(); |
||||||
|
if (clients.isEmpty()) { |
||||||
|
return "Error: No MCP servers connected."; |
||||||
|
} |
||||||
|
|
||||||
|
// If server specified, try only that server
|
||||||
|
if (serverFilter != null && !serverFilter.isBlank()) { |
||||||
|
McpClient client = clients.get(serverFilter); |
||||||
|
if (client == null) { |
||||||
|
return "Error: MCP server '" + serverFilter + "' not found. " |
||||||
|
+ "Available servers: " + String.join(", ", clients.keySet()); |
||||||
|
} |
||||||
|
return readFromClient(client, serverFilter, uri); |
||||||
|
} |
||||||
|
|
||||||
|
// Try all connected servers
|
||||||
|
for (var entry : clients.entrySet()) { |
||||||
|
McpClient client = entry.getValue(); |
||||||
|
if (!client.isInitialized() || !client.isConnected()) continue; |
||||||
|
|
||||||
|
// Check if this server has the resource
|
||||||
|
boolean hasResource = client.getResources().stream() |
||||||
|
.anyMatch(r -> r.uri().equals(uri)); |
||||||
|
if (hasResource) { |
||||||
|
return readFromClient(client, entry.getKey(), uri); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// No server has this resource — try reading anyway (some servers allow arbitrary URIs)
|
||||||
|
for (var entry : clients.entrySet()) { |
||||||
|
McpClient client = entry.getValue(); |
||||||
|
if (!client.isInitialized() || !client.isConnected()) continue; |
||||||
|
try { |
||||||
|
String result = client.readResource(uri); |
||||||
|
if (result != null && !result.isBlank()) { |
||||||
|
return result; |
||||||
|
} |
||||||
|
} catch (Exception ignored) { |
||||||
|
// Try next server
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return "Error: Resource '" + uri + "' not found on any connected MCP server. " |
||||||
|
+ "Use ListMcpResources to see available resources."; |
||||||
|
} |
||||||
|
|
||||||
|
private String readFromClient(McpClient client, String serverName, String uri) { |
||||||
|
if (!client.isInitialized() || !client.isConnected()) { |
||||||
|
return "Error: MCP server '" + serverName + "' is not connected."; |
||||||
|
} |
||||||
|
try { |
||||||
|
String content = client.readResource(uri); |
||||||
|
if (content == null || content.isBlank()) { |
||||||
|
return "(Resource returned empty content)"; |
||||||
|
} |
||||||
|
return content; |
||||||
|
} catch (Exception e) { |
||||||
|
return "Error reading resource '" + uri + "' from server '" + serverName + "': " + e.getMessage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public String activityDescription(Map<String, Object> input) { |
||||||
|
String uri = (String) input.getOrDefault("uri", "?"); |
||||||
|
return "📖 Reading MCP resource: " + uri; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue