|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""codex-mcp-server.py — dependency-free MCP stdio server that exposes a plugin's |
| 3 | +bundled scripts as Codex tools. |
| 4 | +
|
| 5 | +This is the CLAUDE_PLUGIN_ROOT workaround: Claude Code injected ${CLAUDE_PLUGIN_ROOT} |
| 6 | +at runtime so skills could shell out to shared/scripts/*.py. Codex has no such |
| 7 | +injection, so codex-sync bundles this server + the scripts into the plugin at |
| 8 | +`plugins/<n>/mcp/` and wires `.mcp.json` to launch it (cwd = plugin root): |
| 9 | +
|
| 10 | + { "mcpServers": { "<name>": { "cwd": ".", "command": "python", |
| 11 | + "args": ["./mcp/server.py"] } } } |
| 12 | +
|
| 13 | +`codex plugin add` copies the whole plugin dir (including ./mcp) into its cache, so |
| 14 | +this server and its ./lib scripts travel with the plugin and resolve relative to |
| 15 | +__file__ — no absolute paths, no plugin-root variable needed. |
| 16 | +
|
| 17 | +Each `lib/*.py` becomes one tool named after the file stem. A tools/call runs |
| 18 | +`python lib/<name>.py <args-split>` and returns stdout. Pure stdlib: implements the |
| 19 | +JSON-RPC 2.0 / MCP stdio handshake (initialize, tools/list, tools/call) over |
| 20 | +newline-delimited JSON. |
| 21 | +""" |
| 22 | +from __future__ import annotations |
| 23 | + |
| 24 | +import json |
| 25 | +import shlex |
| 26 | +import subprocess |
| 27 | +import sys |
| 28 | +from pathlib import Path |
| 29 | + |
| 30 | +LIB = Path(__file__).resolve().parent / "lib" |
| 31 | +PROTOCOL_FALLBACK = "2025-06-18" |
| 32 | + |
| 33 | + |
| 34 | +def tools() -> list[dict]: |
| 35 | + out = [] |
| 36 | + if LIB.is_dir(): |
| 37 | + for p in sorted(LIB.glob("*.py")): |
| 38 | + out.append({ |
| 39 | + "name": p.stem, |
| 40 | + "description": f"Run shared/scripts/{p.name} (bundled). Pass CLI args as a single string.", |
| 41 | + "inputSchema": { |
| 42 | + "type": "object", |
| 43 | + "properties": {"args": {"type": "string", "description": "CLI arguments"}}, |
| 44 | + }, |
| 45 | + }) |
| 46 | + return out |
| 47 | + |
| 48 | + |
| 49 | +def call_tool(name: str, arguments: dict) -> dict: |
| 50 | + script = LIB / f"{name}.py" |
| 51 | + if not script.is_file(): |
| 52 | + return {"content": [{"type": "text", "text": f"unknown tool: {name}"}], "isError": True} |
| 53 | + argstr = (arguments or {}).get("args", "") or "" |
| 54 | + try: |
| 55 | + cmd = [sys.executable, str(script), *shlex.split(argstr)] |
| 56 | + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=str(LIB.parent.parent)) |
| 57 | + text = proc.stdout if proc.returncode == 0 else f"[exit {proc.returncode}]\n{proc.stdout}\n{proc.stderr}" |
| 58 | + return {"content": [{"type": "text", "text": text or "(no output)"}], "isError": proc.returncode != 0} |
| 59 | + except Exception as e: # noqa: BLE001 — surface any launch failure to the caller |
| 60 | + return {"content": [{"type": "text", "text": f"error running {name}: {e}"}], "isError": True} |
| 61 | + |
| 62 | + |
| 63 | +def handle(msg: dict) -> dict | None: |
| 64 | + mid = msg.get("id") |
| 65 | + method = msg.get("method") |
| 66 | + if method == "initialize": |
| 67 | + ver = (msg.get("params") or {}).get("protocolVersion") or PROTOCOL_FALLBACK |
| 68 | + return {"jsonrpc": "2.0", "id": mid, "result": { |
| 69 | + "protocolVersion": ver, |
| 70 | + "capabilities": {"tools": {}}, |
| 71 | + "serverInfo": {"name": "codex-scripts", "version": "1.0.0"}, |
| 72 | + }} |
| 73 | + if method == "tools/list": |
| 74 | + return {"jsonrpc": "2.0", "id": mid, "result": {"tools": tools()}} |
| 75 | + if method == "tools/call": |
| 76 | + params = msg.get("params") or {} |
| 77 | + return {"jsonrpc": "2.0", "id": mid, "result": call_tool(params.get("name", ""), params.get("arguments", {}))} |
| 78 | + if method is not None and method.startswith("notifications/"): |
| 79 | + return None # notifications get no response |
| 80 | + if mid is not None: |
| 81 | + return {"jsonrpc": "2.0", "id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}} |
| 82 | + return None |
| 83 | + |
| 84 | + |
| 85 | +def main() -> int: |
| 86 | + for line in sys.stdin: |
| 87 | + line = line.strip() |
| 88 | + if not line: |
| 89 | + continue |
| 90 | + try: |
| 91 | + msg = json.loads(line) |
| 92 | + except json.JSONDecodeError: |
| 93 | + continue |
| 94 | + resp = handle(msg) |
| 95 | + if resp is not None: |
| 96 | + sys.stdout.write(json.dumps(resp) + "\n") |
| 97 | + sys.stdout.flush() |
| 98 | + return 0 |
| 99 | + |
| 100 | + |
| 101 | +if __name__ == "__main__": |
| 102 | + raise SystemExit(main()) |
0 commit comments