mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +08:00
fix(mcp): resilient inbound processing for non-JSON stdout from MCP servers
The upstream StdioClientTransport breaks out of its inbound read loop on any JSON parse error, permanently killing the reader thread. Some MCP servers write non-JSON debug output to stdout (e.g. "=== Document parser messages ==="), which triggers this and causes all subsequent valid JSON-RPC responses to be lost → 30s timeout → agent stuck. Override connect() in CwdAwareStdioClientTransport to skip non-JSON lines (log at DEBUG) instead of breaking, keeping the inbound thread alive. Closes #226
This commit is contained in:
parent
9488634372
commit
7f4d3e3ab9
@ -3,12 +3,22 @@ package vip.mate.tool.mcp.runtime;
|
|||||||
import io.modelcontextprotocol.client.transport.ServerParameters;
|
import io.modelcontextprotocol.client.transport.ServerParameters;
|
||||||
import io.modelcontextprotocol.client.transport.StdioClientTransport;
|
import io.modelcontextprotocol.client.transport.StdioClientTransport;
|
||||||
import io.modelcontextprotocol.json.McpJsonMapper;
|
import io.modelcontextprotocol.json.McpJsonMapper;
|
||||||
|
import io.modelcontextprotocol.spec.McpSchema;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.publisher.Sinks;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.lang.reflect.Field;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enhanced stdio MCP transport with:
|
* Enhanced stdio MCP transport with:
|
||||||
@ -16,6 +26,9 @@ import java.util.Map;
|
|||||||
* <li>Working directory (cwd) support</li>
|
* <li>Working directory (cwd) support</li>
|
||||||
* <li>Automatic PATH enrichment for Desktop app environments where
|
* <li>Automatic PATH enrichment for Desktop app environments where
|
||||||
* Node.js/npx may not be in the JRE process's PATH</li>
|
* Node.js/npx may not be in the JRE process's PATH</li>
|
||||||
|
* <li>Resilient inbound processing: non-JSON lines from the server
|
||||||
|
* (e.g. debug output) are skipped instead of killing the
|
||||||
|
* inbound reader thread</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@ -52,6 +65,186 @@ public class CwdAwareStdioClientTransport extends StdioClientTransport {
|
|||||||
this.cwd = cwd;
|
this.cwd = cwd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override parent {@code connect()} to add resilient inbound processing.
|
||||||
|
*
|
||||||
|
* <p>The upstream {@link StdioClientTransport} breaks out of its inbound
|
||||||
|
* read loop on any JSON parse error, killing the reader thread permanently.
|
||||||
|
* Some MCP servers write non-JSON debug output to stdout (e.g.
|
||||||
|
* {@code "=== Document parser messages ==="}), which triggers this and
|
||||||
|
* causes subsequent valid JSON-RPC responses to be lost → 30 s timeout.
|
||||||
|
*
|
||||||
|
* <p>This override replaces the inbound processing with a version that
|
||||||
|
* {@link Log#debug logs} and {@code continue}s past non-JSON lines
|
||||||
|
* instead of breaking. Outbound and error processing remain unchanged.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Mono<Void> connect(
|
||||||
|
Function<Mono<McpSchema.JSONRPCMessage>, Mono<McpSchema.JSONRPCMessage>> handler) {
|
||||||
|
return Mono.<Void>fromRunnable(() -> {
|
||||||
|
log.info("MCP server starting (resilient mode).");
|
||||||
|
|
||||||
|
// Wire up the parent's inbound + error sinks so downstream
|
||||||
|
// McpSyncClient message dispatch works unchanged.
|
||||||
|
Sinks.Many<McpSchema.JSONRPCMessage> inboundSink = getPrivateField("inboundSink");
|
||||||
|
Sinks.Many<String> errorSink = getPrivateField("errorSink");
|
||||||
|
|
||||||
|
if (inboundSink != null) {
|
||||||
|
inboundSink.asFlux()
|
||||||
|
.flatMap(msg -> Mono.just(msg).transform(handler))
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
if (errorSink != null) {
|
||||||
|
errorSink.asFlux().subscribe(line -> log.info("MCP stdio stderr: {}", line));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and start the server process (reuses parent's ProcessBuilder hook)
|
||||||
|
List<String> fullCommand = new ArrayList<>();
|
||||||
|
fullCommand.add(params().getCommand());
|
||||||
|
fullCommand.addAll(params().getArgs());
|
||||||
|
|
||||||
|
ProcessBuilder processBuilder = getProcessBuilder();
|
||||||
|
processBuilder.command(fullCommand);
|
||||||
|
processBuilder.environment().putAll(params().getEnv());
|
||||||
|
|
||||||
|
Process process;
|
||||||
|
try {
|
||||||
|
process = processBuilder.start();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to start MCP process: " + fullCommand, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.getInputStream() == null || process.getOutputStream() == null) {
|
||||||
|
process.destroy();
|
||||||
|
throw new RuntimeException("MCP process input or output stream is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Resilient inbound reader (the key fix) ---
|
||||||
|
Thread inboundThread = new Thread(() -> {
|
||||||
|
try (BufferedReader reader = new BufferedReader(
|
||||||
|
new InputStreamReader(process.getInputStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = reader.readLine()) != null) {
|
||||||
|
try {
|
||||||
|
McpSchema.JSONRPCMessage message =
|
||||||
|
McpSchema.deserializeJsonRpcMessage(jsonMapper(), line);
|
||||||
|
if (inboundSink != null && !inboundSink.tryEmitNext(message).isSuccess()) {
|
||||||
|
log.error("Failed to enqueue inbound MCP message");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Non-JSON line from server (debug output, etc.) — skip it.
|
||||||
|
log.debug("MCP server stdout non-JSON line (skipped): {}", line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("MCP inbound reader error", e);
|
||||||
|
} finally {
|
||||||
|
if (inboundSink != null) inboundSink.tryEmitComplete();
|
||||||
|
if (errorSink != null) errorSink.tryEmitComplete();
|
||||||
|
}
|
||||||
|
}, "mcp-inbound-resilient");
|
||||||
|
inboundThread.setDaemon(true);
|
||||||
|
inboundThread.start();
|
||||||
|
|
||||||
|
// Outbound writer — delegate to parent infrastructure
|
||||||
|
startOutboundFromConnect(process);
|
||||||
|
|
||||||
|
// Stderr reader
|
||||||
|
Thread errThread = new Thread(() -> {
|
||||||
|
try (BufferedReader errReader = new BufferedReader(
|
||||||
|
new InputStreamReader(process.getErrorStream()))) {
|
||||||
|
String line;
|
||||||
|
while ((line = errReader.readLine()) != null) {
|
||||||
|
if (errorSink != null) errorSink.tryEmitNext(line);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("MCP stderr reader error", e);
|
||||||
|
}
|
||||||
|
}, "mcp-stderr");
|
||||||
|
errThread.setDaemon(true);
|
||||||
|
errThread.start();
|
||||||
|
|
||||||
|
log.info("MCP server started (resilient mode).");
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a private field from the parent {@link StdioClientTransport}. */
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private <T> T getPrivateField(String name) {
|
||||||
|
try {
|
||||||
|
Field field = StdioClientTransport.class.getDeclaredField(name);
|
||||||
|
field.setAccessible(true);
|
||||||
|
return (T) field.get(this);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("Cannot access parent field {}: {}", name, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accessor for the jsonMapper (needed by the resilient inbound reader). */
|
||||||
|
private io.modelcontextprotocol.json.McpJsonMapper jsonMapper() {
|
||||||
|
try {
|
||||||
|
Field f = StdioClientTransport.class.getDeclaredField("jsonMapper");
|
||||||
|
f.setAccessible(true);
|
||||||
|
return (io.modelcontextprotocol.json.McpJsonMapper) f.get(this);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Cannot access jsonMapper", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accessor for the ServerParameters (needed by connect). */
|
||||||
|
private io.modelcontextprotocol.client.transport.ServerParameters params() {
|
||||||
|
try {
|
||||||
|
Field f = StdioClientTransport.class.getDeclaredField("params");
|
||||||
|
f.setAccessible(true);
|
||||||
|
return (io.modelcontextprotocol.client.transport.ServerParameters) f.get(this);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Cannot access params", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire up outbound message writing from the parent's outboundSink.
|
||||||
|
* Reads from the sink's flux and writes JSON to the process stdout.
|
||||||
|
*/
|
||||||
|
private void startOutboundFromConnect(Process process) {
|
||||||
|
try {
|
||||||
|
Field f = StdioClientTransport.class.getDeclaredField("outboundSink");
|
||||||
|
f.setAccessible(true);
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Sinks.Many<McpSchema.JSONRPCMessage> outboundSink =
|
||||||
|
(Sinks.Many<McpSchema.JSONRPCMessage>) f.get(this);
|
||||||
|
if (outboundSink == null) return;
|
||||||
|
|
||||||
|
Thread outThread = new Thread(() -> {
|
||||||
|
try (var writer = new java.io.BufferedWriter(
|
||||||
|
new java.io.OutputStreamWriter(process.getOutputStream()))) {
|
||||||
|
outboundSink.asFlux().subscribe(message -> {
|
||||||
|
try {
|
||||||
|
String json = jsonMapper().writeValueAsString(message);
|
||||||
|
json = json.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n");
|
||||||
|
writer.write(json);
|
||||||
|
writer.newLine();
|
||||||
|
writer.flush();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("MCP outbound write error", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Keep thread alive while process runs
|
||||||
|
process.waitFor();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("MCP outbound writer error", e);
|
||||||
|
}
|
||||||
|
}, "mcp-outbound-resilient");
|
||||||
|
outThread.setDaemon(true);
|
||||||
|
outThread.start();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Cannot wire outbound sink: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected ProcessBuilder getProcessBuilder() {
|
protected ProcessBuilder getProcessBuilder() {
|
||||||
ProcessBuilder builder = super.getProcessBuilder();
|
ProcessBuilder builder = super.getProcessBuilder();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user