This is intentionally a one-shot connection tester (RFC §10.2 Q3
+ * recommended starting order: codex → claude → opencode → qwen). Full
+ * bidirectional session prompting / streaming / permission requests is
+ * a future increment — that needs a proper async bus and ties into the
+ * agent graph layer.
+ *
+ *
Why not the official {@code acp} Python SDK: MateClaw runs on the
+ * JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio (per the
+ * QwenPaw reference at {@code C:/codes/QwenPaw}); the surface we need
+ * for "test connection" is small enough to implement directly.
+ *
+ *
Each {@link AcpStdioClient} instance owns one Process. Use
+ * try-with-resources or call {@link #close()} explicitly.
+ */
+@Slf4j
+public class AcpStdioClient implements AutoCloseable {
+
+ /** ACP protocol version we advertise (matches QwenPaw v1 + Zed agents). */
+ public static final int PROTOCOL_VERSION = 1;
+
+ private final ObjectMapper mapper;
+ private final Process process;
+ private final Writer stdin;
+ private final BufferedReader stdout;
+ private final Thread readerThread;
+ private final AtomicLong nextRequestId = new AtomicLong(1);
+ private final Map> pending = new ConcurrentHashMap<>();
+ private volatile boolean closed = false;
+
+ private AcpStdioClient(ObjectMapper mapper, Process process) {
+ this.mapper = mapper;
+ this.process = process;
+ this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
+ this.stdout = new BufferedReader(
+ new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
+ this.readerThread = new Thread(this::readLoop, "acp-stdio-reader");
+ this.readerThread.setDaemon(true);
+ this.readerThread.start();
+ }
+
+ /**
+ * Spawn the configured agent process. Caller is responsible for
+ * closing the returned client; failure to do so leaks a child
+ * process.
+ */
+ public static AcpStdioClient spawn(ObjectMapper mapper,
+ String command,
+ List args,
+ Map envOverrides,
+ String cwd)
+ throws IOException {
+ if (command == null || command.isBlank()) {
+ throw new IllegalArgumentException("ACP command is required");
+ }
+ java.util.List cmdline = new java.util.ArrayList<>();
+ cmdline.add(command);
+ if (args != null) cmdline.addAll(args);
+ ProcessBuilder pb = new ProcessBuilder(cmdline);
+ Map env = pb.environment();
+ if (envOverrides != null) env.putAll(envOverrides);
+ if (cwd != null && !cwd.isBlank()) {
+ pb.directory(new java.io.File(cwd));
+ }
+ // Keep stderr separate from stdout so we don't poison JSON-RPC
+ // framing when the child agent writes a banner / log line.
+ pb.redirectErrorStream(false);
+ Process proc = pb.start();
+ // Drain stderr in the background — many CLIs print diagnostics
+ // there (e.g. Zed agents print version on startup).
+ Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr");
+ errDrain.setDaemon(true);
+ errDrain.start();
+ return new AcpStdioClient(mapper, proc);
+ }
+
+ private static void drainStream(java.io.InputStream in) {
+ try (BufferedReader br = new BufferedReader(
+ new InputStreamReader(in, StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = br.readLine()) != null) {
+ if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line);
+ }
+ } catch (IOException ignore) {
+ // Process exited; nothing to do.
+ }
+ }
+
+ /**
+ * Send {@code initialize} and wait for the response. Returns the
+ * response payload's {@code result} object, or throws on protocol
+ * mismatch / timeout.
+ */
+ public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException {
+ ObjectNode params = mapper.createObjectNode();
+ params.put("protocolVersion", PROTOCOL_VERSION);
+ // ClientCapabilities — we don't yet implement any client-side
+ // optional features. Send an empty object so strict agents
+ // don't reject the request.
+ params.set("clientCapabilities", mapper.createObjectNode());
+ ObjectNode info = mapper.createObjectNode();
+ info.put("name", "mateclaw-acp-client");
+ info.put("version", "1.0.0");
+ params.set("clientInfo", info);
+ return sendRequest("initialize", params, timeoutMillis);
+ }
+
+ /**
+ * Send {@code session/new} — establishes a session for prompting.
+ * For the connection-test path we don't actually prompt, just
+ * verify the server accepts the handshake.
+ */
+ public JsonNode newSession(String cwd, long timeoutMillis)
+ throws IOException, InterruptedException {
+ ObjectNode params = mapper.createObjectNode();
+ if (cwd != null && !cwd.isBlank()) params.put("cwd", cwd);
+ params.set("mcpServers", mapper.createArrayNode());
+ return sendRequest("session/new", params, timeoutMillis);
+ }
+
+ /**
+ * Lower-level request helper. Synchronously awaits the response
+ * matching the request id. Server-pushed requests (e.g. permission
+ * prompts) are dropped — the test-only connection path doesn't need
+ * to handle them.
+ */
+ public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis)
+ throws IOException, InterruptedException {
+ if (closed) throw new IOException("ACP client is closed");
+ long id = nextRequestId.getAndIncrement();
+ CompletableFuture future = new CompletableFuture<>();
+ pending.put(id, future);
+
+ ObjectNode envelope = mapper.createObjectNode();
+ envelope.put("jsonrpc", "2.0");
+ envelope.put("id", id);
+ envelope.put("method", method);
+ envelope.set("params", params);
+
+ synchronized (stdin) {
+ stdin.write(mapper.writeValueAsString(envelope));
+ stdin.write('\n');
+ stdin.flush();
+ }
+
+ try {
+ return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
+ } catch (java.util.concurrent.ExecutionException e) {
+ Throwable cause = e.getCause();
+ if (cause instanceof IOException io) throw io;
+ throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown"));
+ } catch (java.util.concurrent.TimeoutException e) {
+ pending.remove(id);
+ throw new IOException("ACP request timed out after " + timeoutMillis + "ms");
+ }
+ }
+
+ private void readLoop() {
+ try {
+ String line;
+ while (!closed && (line = stdout.readLine()) != null) {
+ if (line.isEmpty()) continue;
+ try {
+ JsonNode msg = mapper.readTree(line);
+ routeMessage(msg);
+ } catch (Exception e) {
+ log.warn("ACP malformed line, skipping: {}", e.getMessage());
+ }
+ }
+ } catch (IOException e) {
+ if (!closed) {
+ log.debug("ACP stdio reader closed: {}", e.getMessage());
+ }
+ } finally {
+ // If the process exited mid-await, fail every pending future.
+ for (Map.Entry> entry : pending.entrySet()) {
+ entry.getValue().completeExceptionally(
+ new IOException("ACP process exited before responding"));
+ }
+ pending.clear();
+ }
+ }
+
+ private void routeMessage(JsonNode msg) {
+ JsonNode idNode = msg.get("id");
+ if (idNode != null && idNode.isNumber()) {
+ long id = idNode.asLong();
+ CompletableFuture future = pending.remove(id);
+ if (future != null) {
+ JsonNode error = msg.get("error");
+ if (error != null && !error.isNull()) {
+ future.completeExceptionally(
+ new IOException("ACP error: " + error.toString()));
+ } else {
+ future.complete(msg.get("result"));
+ }
+ return;
+ }
+ }
+ // Server-initiated request or notification — for the test path
+ // we only need to politely decline. A future bidirectional
+ // session loop will dispatch these to handlers.
+ if (msg.has("method") && msg.has("id")) {
+ // Reply with a "method not implemented" so the agent doesn't hang.
+ try {
+ ObjectNode reply = mapper.createObjectNode();
+ reply.put("jsonrpc", "2.0");
+ reply.set("id", msg.get("id"));
+ ObjectNode error = mapper.createObjectNode();
+ error.put("code", -32601);
+ error.put("message", "Method not implemented in test client: "
+ + msg.path("method").asText(""));
+ reply.set("error", error);
+ synchronized (stdin) {
+ stdin.write(mapper.writeValueAsString(reply));
+ stdin.write('\n');
+ stdin.flush();
+ }
+ } catch (IOException e) {
+ log.debug("ACP failed to reply to server-initiated request: {}", e.getMessage());
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ closed = true;
+ try {
+ stdin.close();
+ } catch (IOException ignore) {
+ /* best effort */
+ }
+ try {
+ // Give the agent ~1s to exit gracefully after EOF on stdin.
+ if (!process.waitFor(1, TimeUnit.SECONDS)) {
+ process.destroy();
+ if (!process.waitFor(1, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ }
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ process.destroyForcibly();
+ }
+ try {
+ stdout.close();
+ } catch (IOException ignore) {
+ /* best effort */
+ }
+ }
+
+ /** Convenience for callers that just want a fresh empty env map. */
+ public static Map emptyEnv() {
+ return new HashMap<>();
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/acp/controller/AcpEndpointController.java b/mateclaw-server/src/main/java/vip/mate/acp/controller/AcpEndpointController.java
new file mode 100644
index 00000000..5127c5b4
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/acp/controller/AcpEndpointController.java
@@ -0,0 +1,79 @@
+package vip.mate.acp.controller;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import vip.mate.acp.model.AcpEndpointEntity;
+import vip.mate.acp.service.AcpConnectionTester;
+import vip.mate.acp.service.AcpEndpointService;
+import vip.mate.common.result.R;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * RFC-090 Phase 7 — REST surface for managing ACP endpoints.
+ *
+ *
Mirrors the McpServers controller so the frontend page can be a
+ * close cousin of {@code McpServers.vue}.
+ */
+@Tag(name = "ACP Endpoints (RFC-090 Phase 7)")
+@RestController
+@RequestMapping("/api/v1/acp/endpoints")
+@RequiredArgsConstructor
+public class AcpEndpointController {
+
+ private final AcpEndpointService service;
+ private final AcpConnectionTester tester;
+
+ @Operation(summary = "List ACP endpoints")
+ @GetMapping
+ public R> list() {
+ return R.ok(service.list());
+ }
+
+ @Operation(summary = "Get ACP endpoint by id")
+ @GetMapping("/{id}")
+ public R get(@PathVariable Long id) {
+ return R.ok(service.get(id));
+ }
+
+ @Operation(summary = "Create a custom ACP endpoint")
+ @PostMapping
+ public R create(@RequestBody AcpEndpointEntity body) {
+ return R.ok(service.create(body));
+ }
+
+ @Operation(summary = "Update an ACP endpoint")
+ @PutMapping("/{id}")
+ public R update(@PathVariable Long id,
+ @RequestBody AcpEndpointEntity body) {
+ return R.ok(service.update(id, body));
+ }
+
+ @Operation(summary = "Delete an ACP endpoint (builtins are protected)")
+ @DeleteMapping("/{id}")
+ public R delete(@PathVariable Long id) {
+ service.delete(id);
+ return R.ok();
+ }
+
+ @Operation(summary = "Enable / disable an ACP endpoint")
+ @PutMapping("/{id}/toggle")
+ public R toggle(@PathVariable Long id,
+ @RequestParam boolean enabled) {
+ return R.ok(service.toggle(id, enabled));
+ }
+
+ /**
+ * Spawn the configured CLI, run {@code initialize} + {@code
+ * session/new}, persist the outcome, and return diagnostics.
+ */
+ @Operation(summary = "Test ACP endpoint connection (initialize handshake)")
+ @PostMapping("/{id}/test")
+ public R