Skip to content

Commit 317d3cb

Browse files
committed
feat(codex): codex-sync generator + bundled MCP server + compat-layer roadmap
Derives a native Codex plugin layer (.agents/plugins/marketplace.json + plugins/*/.codex-plugin/plugin.json + bundled mcp/) from the canonical Claude Code source. Manifests conform to Codex's own validate_plugin.py; the dependency-free codex-mcp-server.py exposes shared/scripts as MCP tools to cover the CLAUDE_PLUGIN_ROOT gap. Live-verified against Codex CLI 0.140. Author: Enchanter Labs
1 parent b6a5fd1 commit 317d3cb

3 files changed

Lines changed: 589 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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

Comments
 (0)