Files

243 lines
9.6 KiB
Python

#!/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()