#!/usr/bin/env python3 # MCP stdio server exposing one tool ("ask") that opens a live ACP # (Agent Client Protocol) session against a peer Kilo workspace over TCP and # returns its final response. This exists because kilo serve has no MCP # server endpoint of its own (confirmed by dumping every route in the kilo # binary) — kilo acp is a real, tool-capable agent session, but it only # speaks JSON-RPC over stdio, so acp-bridge-listen.sh exposes it via socat # and this script bridges that into something kilo (as an MCP client) can # call as an ordinary "local" MCP server. import argparse import json import os import socket import sys import time TOOL_NAME = "ask" def read_stdin_messages(): for line in sys.stdin: line = line.strip() if not line: continue try: yield json.loads(line) except json.JSONDecodeError: continue def write_message(obj): sys.stdout.write(json.dumps(obj) + "\n") sys.stdout.flush() def send_response(msg_id, result=None, error=None): msg = {"jsonrpc": "2.0", "id": msg_id} if error is not None: msg["error"] = error else: msg["result"] = result write_message(msg) class PeerConnectionError(Exception): pass class AcpPeer: def __init__(self, host, acp_port, info_port, model, timeout, token): self.host = host self.acp_port = acp_port self.info_port = info_port self.model = model self.timeout = timeout self.token = token def _fetch_project_dir(self): try: with socket.create_connection((self.host, self.info_port), timeout=10) as s: wf = s.makefile("w") wf.write(self.token + "\n") wf.flush() f = s.makefile("r") line = f.readline().strip() return line or "/home/coder" except OSError as e: raise PeerConnectionError(f"could not reach peer info port {self.host}:{self.info_port}: {e}") def ask(self, prompt_text): cwd = self._fetch_project_dir() deadline = time.time() + self.timeout try: sock = socket.create_connection((self.host, self.acp_port), timeout=15) except OSError as e: raise PeerConnectionError(f"could not reach peer ACP port {self.host}:{self.acp_port}: {e}") with sock: sock.settimeout(self.timeout) rf = sock.makefile("r") wf = sock.makefile("w") wf.write(self.token + "\n") wf.flush() def send(msg): wf.write(json.dumps(msg) + "\n") wf.flush() def recv(): line = rf.readline() if not line: return None return json.loads(line) def await_response(expected_id, allow_error=False): while True: obj = recv() if obj is None: raise PeerConnectionError("peer connection closed unexpectedly") if obj.get("id") == expected_id: if "error" in obj and not allow_error: raise PeerConnectionError(f"peer error: {obj['error']}") return obj # ignore interleaved notifications while waiting for this response send({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": 1, "clientCapabilities": {}}}) await_response(1) send({"jsonrpc": "2.0", "id": 2, "method": "session/new", "params": {"cwd": cwd, "mcpServers": []}}) resp = await_response(2) session_id = resp["result"]["sessionId"] send({"jsonrpc": "2.0", "id": 3, "method": "session/set_model", "params": {"sessionId": session_id, "modelId": self.model}}) await_response(3, allow_error=True) send({"jsonrpc": "2.0", "id": 4, "method": "session/prompt", "params": {"sessionId": session_id, "prompt": [{"type": "text", "text": prompt_text}]}}) text_parts = [] tool_log = [] while True: if time.time() > deadline: raise TimeoutError(f"peer did not finish within {self.timeout}s") obj = recv() if obj is None: raise PeerConnectionError("peer connection closed unexpectedly") method = obj.get("method") if method == "session/update": upd = obj["params"]["update"] su = upd.get("sessionUpdate") if su == "agent_message_chunk": content = upd.get("content", {}) if content.get("type") == "text": text_parts.append(content["text"]) elif su == "tool_call_update" and upd.get("status") == "completed": tool_log.append(upd.get("title") or upd.get("kind") or "tool") elif method == "session/request_permission": options = obj.get("params", {}).get("options", []) chosen = None for pref in ("allow_always", "allow_once", "allow"): for opt in options: if pref in str(opt.get("kind", "")): chosen = opt["optionId"] break if chosen: break if chosen is None and options: chosen = options[0]["optionId"] send({"jsonrpc": "2.0", "id": obj["id"], "result": {"outcome": {"outcome": "selected", "optionId": chosen}}}) elif obj.get("id") == 4: break # else: ignore anything else (other notifications, stray responses) reply = "".join(text_parts).strip() if tool_log: reply += "\n\n[peer actions: " + ", ".join(tool_log) + "]" return reply or "(peer produced no text response)" def main(): ap = argparse.ArgumentParser() ap.add_argument("--host", required=True) ap.add_argument("--acp-port", type=int, default=4097) ap.add_argument("--info-port", type=int, default=4098) ap.add_argument("--model", required=True) ap.add_argument("--peer-name", required=True) ap.add_argument("--timeout", type=int, default=600) args = ap.parse_args() # Read from the environment (inherited from the agent's own env, same as # acp-bridge-auth.py) rather than a CLI flag, so the shared secret never # ends up in kilo.jsonc's command array or in `ps` output. token = os.environ.get("PEER_BRIDGE_TOKEN", "") if not token: print(json.dumps({"jsonrpc": "2.0", "error": {"code": -32000, "message": "PEER_BRIDGE_TOKEN not set"}})) sys.exit(1) peer = AcpPeer(args.host, args.acp_port, args.info_port, args.model, args.timeout, token) for msg in read_stdin_messages(): method = msg.get("method") msg_id = msg.get("id") if method == "initialize": client_version = msg.get("params", {}).get("protocolVersion", "2024-11-05") send_response(msg_id, result={ "protocolVersion": client_version, "capabilities": {"tools": {}}, "serverInfo": {"name": f"peer-bridge-{args.peer_name}", "version": "1.0.0"}, }) elif method == "notifications/initialized": pass elif method == "tools/list": send_response(msg_id, result={"tools": [{ "name": TOOL_NAME, "description": ( f"Ask the '{args.peer_name}' workspace's Kilo agent a question about its " "project, or hand it a task to work on. It runs with full autonomy in its " "own project directory (reads/edits/writes files, runs bash) and returns " "its final response once done. Each call starts a fresh session on the " "peer's side — include any needed context in the prompt itself." ), "inputSchema": { "type": "object", "properties": { "prompt": {"type": "string", "description": "The question or task for the peer agent."}, }, "required": ["prompt"], }, }]}) elif method == "tools/call": params = msg.get("params", {}) if params.get("name") != TOOL_NAME: send_response(msg_id, result={ "content": [{"type": "text", "text": f"Unknown tool: {params.get('name')}"}], "isError": True, }) continue prompt_text = params.get("arguments", {}).get("prompt", "") try: reply = peer.ask(prompt_text) send_response(msg_id, result={"content": [{"type": "text", "text": reply}], "isError": False}) except Exception as e: send_response(msg_id, result={ "content": [{"type": "text", "text": f"Peer request failed: {e}"}], "isError": True, }) elif msg_id is not None: send_response(msg_id, error={"code": -32601, "message": f"Method not found: {method}"}) # else: unrecognized notification, ignore if __name__ == "__main__": main()