|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""nous-mcp: stdio MCP server exposing Nous campaigns (#126 Phase B). |
| 3 | +
|
| 4 | +Wraps the pure functions in ``orchestrator.campaign_index`` as MCP |
| 5 | +resources and tools so any Claude Code session — terminal, IDE, web — |
| 6 | +can ``@``-reference a campaign or call ``nous.search_principles(...)`` |
| 7 | +without bash plumbing. |
| 8 | +
|
| 9 | +Protocol: JSON-RPC 2.0 over stdio (line-delimited JSON, one request / |
| 10 | +response per line). Compatible with Claude Code's MCP transport when |
| 11 | +registered in ``~/.claude.json`` under ``mcpServers``: |
| 12 | +
|
| 13 | + { |
| 14 | + "mcpServers": { |
| 15 | + "nous": { |
| 16 | + "command": "python", |
| 17 | + "args": ["-u", "/path/to/repo/bin/nous-mcp"], |
| 18 | + "env": {"NOUS_SEARCH_ROOT": "/path/to/parent/of/.nous/"} |
| 19 | + } |
| 20 | + } |
| 21 | + } |
| 22 | +
|
| 23 | +The server is stateless: campaigns live on disk; every request re-walks |
| 24 | +``$NOUS_SEARCH_ROOT`` (or the path passed in the request). |
| 25 | +
|
| 26 | +Methods: |
| 27 | + initialize / shutdown -- MCP handshake |
| 28 | + resources/list -- nous://campaigns and per-campaign URIs |
| 29 | + resources/read -- read a specific resource |
| 30 | + tools/list -- list_campaigns / search_principles / |
| 31 | + get_arm_results / compare_iterations |
| 32 | + tools/call -- invoke a tool by name with arguments |
| 33 | +""" |
| 34 | +from __future__ import annotations |
| 35 | + |
| 36 | +import json |
| 37 | +import os |
| 38 | +import sys |
| 39 | +from pathlib import Path |
| 40 | + |
| 41 | +_HERE = Path(__file__).resolve().parent |
| 42 | +_REPO_ROOT = _HERE.parent |
| 43 | +if str(_REPO_ROOT) not in sys.path: |
| 44 | + sys.path.insert(0, str(_REPO_ROOT)) |
| 45 | + |
| 46 | +from orchestrator.campaign_index import ( # noqa: E402 |
| 47 | + compare_iterations, |
| 48 | + get_arm_results, |
| 49 | + list_campaigns, |
| 50 | + search_principles, |
| 51 | +) |
| 52 | + |
| 53 | + |
| 54 | +_SERVER_INFO = { |
| 55 | + "name": "nous-mcp", |
| 56 | + "version": "0.2.0", |
| 57 | + "description": "Read-only access to Nous campaigns on disk.", |
| 58 | +} |
| 59 | + |
| 60 | +_CAPABILITIES = { |
| 61 | + "resources": {"list": True, "read": True}, |
| 62 | + "tools": {"list": True, "call": True}, |
| 63 | +} |
| 64 | + |
| 65 | + |
| 66 | +_TOOLS = [ |
| 67 | + { |
| 68 | + "name": "nous.list_campaigns", |
| 69 | + "description": ( |
| 70 | + "List all Nous campaigns under the search root. " |
| 71 | + "Optional filters: query (substring on run_id), status (phase), repo." |
| 72 | + ), |
| 73 | + "inputSchema": { |
| 74 | + "type": "object", |
| 75 | + "properties": { |
| 76 | + "search_root": {"type": "string"}, |
| 77 | + "query": {"type": "string"}, |
| 78 | + "status": {"type": "string"}, |
| 79 | + "repo": {"type": "string"}, |
| 80 | + }, |
| 81 | + }, |
| 82 | + }, |
| 83 | + { |
| 84 | + "name": "nous.search_principles", |
| 85 | + "description": ( |
| 86 | + "Search principles across all campaigns by substring. " |
| 87 | + "Hits include the source campaign run_id and path." |
| 88 | + ), |
| 89 | + "inputSchema": { |
| 90 | + "type": "object", |
| 91 | + "properties": { |
| 92 | + "search_root": {"type": "string"}, |
| 93 | + "text": {"type": "string"}, |
| 94 | + "only_active": {"type": "boolean"}, |
| 95 | + }, |
| 96 | + "required": ["text"], |
| 97 | + }, |
| 98 | + }, |
| 99 | + { |
| 100 | + "name": "nous.get_arm_results", |
| 101 | + "description": ( |
| 102 | + "Aggregate per-seed result files for one arm of one iteration." |
| 103 | + ), |
| 104 | + "inputSchema": { |
| 105 | + "type": "object", |
| 106 | + "properties": { |
| 107 | + "campaign_root": {"type": "string"}, |
| 108 | + "iteration": {"type": "integer"}, |
| 109 | + "arm": {"type": "string"}, |
| 110 | + }, |
| 111 | + "required": ["campaign_root", "iteration", "arm"], |
| 112 | + }, |
| 113 | + }, |
| 114 | + { |
| 115 | + "name": "nous.compare_iterations", |
| 116 | + "description": ( |
| 117 | + "Deterministic diff between two iterations of one campaign — " |
| 118 | + "arm-status changes and added principles." |
| 119 | + ), |
| 120 | + "inputSchema": { |
| 121 | + "type": "object", |
| 122 | + "properties": { |
| 123 | + "campaign_root": {"type": "string"}, |
| 124 | + "iter_a": {"type": "integer"}, |
| 125 | + "iter_b": {"type": "integer"}, |
| 126 | + }, |
| 127 | + "required": ["campaign_root", "iter_a", "iter_b"], |
| 128 | + }, |
| 129 | + }, |
| 130 | +] |
| 131 | + |
| 132 | + |
| 133 | +def _default_search_root() -> str: |
| 134 | + return os.environ.get("NOUS_SEARCH_ROOT", str(Path.cwd())) |
| 135 | + |
| 136 | + |
| 137 | +def _resource_list(search_root: str) -> list[dict]: |
| 138 | + """Build the MCP resources/list payload from disk state.""" |
| 139 | + out: list[dict] = [{ |
| 140 | + "uri": "nous://campaigns", |
| 141 | + "name": "All campaigns", |
| 142 | + "description": "Index of every Nous campaign under the search root.", |
| 143 | + "mimeType": "application/json", |
| 144 | + }] |
| 145 | + for campaign in list_campaigns(Path(search_root)): |
| 146 | + run_id = campaign["run_id"] |
| 147 | + out.append({ |
| 148 | + "uri": f"nous://campaigns/{run_id}/state", |
| 149 | + "name": f"{run_id} — state", |
| 150 | + "description": f"Phase + iteration of campaign {run_id}.", |
| 151 | + "mimeType": "application/json", |
| 152 | + }) |
| 153 | + out.append({ |
| 154 | + "uri": f"nous://campaigns/{run_id}/principles", |
| 155 | + "name": f"{run_id} — principles", |
| 156 | + "description": f"Active principles accumulated in {run_id}.", |
| 157 | + "mimeType": "application/json", |
| 158 | + }) |
| 159 | + return out |
| 160 | + |
| 161 | + |
| 162 | +def _read_resource(uri: str, search_root: str) -> dict: |
| 163 | + """Resolve a nous:// URI to its JSON contents.""" |
| 164 | + if uri == "nous://campaigns": |
| 165 | + return {"campaigns": list_campaigns(Path(search_root))} |
| 166 | + |
| 167 | + if not uri.startswith("nous://campaigns/"): |
| 168 | + raise ValueError(f"unknown URI scheme: {uri!r}") |
| 169 | + parts = uri[len("nous://campaigns/"):].split("/") |
| 170 | + if len(parts) < 2: |
| 171 | + raise ValueError(f"malformed campaign URI: {uri!r}") |
| 172 | + run_id, leaf = parts[0], "/".join(parts[1:]) |
| 173 | + |
| 174 | + # Find campaign root by run_id under search_root. |
| 175 | + matching = [c for c in list_campaigns(Path(search_root)) if c["run_id"] == run_id] |
| 176 | + if not matching: |
| 177 | + raise ValueError(f"unknown campaign: {run_id!r}") |
| 178 | + root = Path(matching[0]["path"]) |
| 179 | + |
| 180 | + if leaf == "state": |
| 181 | + return json.loads((root / "state.json").read_text()) |
| 182 | + if leaf == "principles": |
| 183 | + return json.loads((root / "principles.json").read_text()) |
| 184 | + if leaf.startswith("iter/") and leaf.endswith("/findings"): |
| 185 | + n = int(leaf.split("/")[1]) |
| 186 | + return json.loads((root / "runs" / f"iter-{n}" / "findings.json").read_text()) |
| 187 | + raise ValueError(f"unsupported leaf: {leaf!r}") |
| 188 | + |
| 189 | + |
| 190 | +def _call_tool(name: str, args: dict) -> dict: |
| 191 | + """Dispatch a tools/call request to campaign_index.""" |
| 192 | + if name == "nous.list_campaigns": |
| 193 | + return { |
| 194 | + "campaigns": list_campaigns( |
| 195 | + Path(args.get("search_root", _default_search_root())), |
| 196 | + query=args.get("query"), |
| 197 | + status=args.get("status"), |
| 198 | + repo=args.get("repo"), |
| 199 | + ), |
| 200 | + } |
| 201 | + if name == "nous.search_principles": |
| 202 | + return { |
| 203 | + "hits": search_principles( |
| 204 | + Path(args.get("search_root", _default_search_root())), |
| 205 | + args["text"], |
| 206 | + only_active=args.get("only_active", True), |
| 207 | + ), |
| 208 | + } |
| 209 | + if name == "nous.get_arm_results": |
| 210 | + return get_arm_results( |
| 211 | + Path(args["campaign_root"]), |
| 212 | + int(args["iteration"]), |
| 213 | + args["arm"], |
| 214 | + ) |
| 215 | + if name == "nous.compare_iterations": |
| 216 | + return compare_iterations( |
| 217 | + Path(args["campaign_root"]), |
| 218 | + int(args["iter_a"]), |
| 219 | + int(args["iter_b"]), |
| 220 | + ) |
| 221 | + raise ValueError(f"unknown tool: {name!r}") |
| 222 | + |
| 223 | + |
| 224 | +def handle_request(request: dict, *, search_root: str | None = None) -> dict: |
| 225 | + """Process one JSON-RPC request and return the response dict. |
| 226 | +
|
| 227 | + Pure function — testable without stdio. The main loop calls this |
| 228 | + for each line and writes the result back. |
| 229 | + """ |
| 230 | + rid = request.get("id") |
| 231 | + method = request.get("method", "") |
| 232 | + params = request.get("params") or {} |
| 233 | + root = search_root or _default_search_root() |
| 234 | + |
| 235 | + try: |
| 236 | + if method == "initialize": |
| 237 | + result: dict = { |
| 238 | + "protocolVersion": "2024-11-05", |
| 239 | + "capabilities": _CAPABILITIES, |
| 240 | + "serverInfo": _SERVER_INFO, |
| 241 | + } |
| 242 | + elif method == "shutdown": |
| 243 | + result = {} |
| 244 | + elif method == "resources/list": |
| 245 | + result = {"resources": _resource_list(root)} |
| 246 | + elif method == "resources/read": |
| 247 | + uri = params.get("uri", "") |
| 248 | + payload = _read_resource(uri, root) |
| 249 | + result = { |
| 250 | + "contents": [{ |
| 251 | + "uri": uri, |
| 252 | + "mimeType": "application/json", |
| 253 | + "text": json.dumps(payload, indent=2), |
| 254 | + }], |
| 255 | + } |
| 256 | + elif method == "tools/list": |
| 257 | + result = {"tools": _TOOLS} |
| 258 | + elif method == "tools/call": |
| 259 | + name = params.get("name", "") |
| 260 | + args = params.get("arguments", {}) or {} |
| 261 | + payload = _call_tool(name, args) |
| 262 | + result = { |
| 263 | + "content": [{ |
| 264 | + "type": "text", |
| 265 | + "text": json.dumps(payload, indent=2), |
| 266 | + }], |
| 267 | + } |
| 268 | + else: |
| 269 | + return { |
| 270 | + "jsonrpc": "2.0", |
| 271 | + "id": rid, |
| 272 | + "error": {"code": -32601, "message": f"method not found: {method}"}, |
| 273 | + } |
| 274 | + except Exception as exc: |
| 275 | + return { |
| 276 | + "jsonrpc": "2.0", |
| 277 | + "id": rid, |
| 278 | + "error": {"code": -32603, "message": f"{type(exc).__name__}: {exc}"}, |
| 279 | + } |
| 280 | + |
| 281 | + return {"jsonrpc": "2.0", "id": rid, "result": result} |
| 282 | + |
| 283 | + |
| 284 | +def main() -> int: |
| 285 | + for line in sys.stdin: |
| 286 | + line = line.strip() |
| 287 | + if not line: |
| 288 | + continue |
| 289 | + try: |
| 290 | + request = json.loads(line) |
| 291 | + except json.JSONDecodeError as exc: |
| 292 | + sys.stdout.write(json.dumps({ |
| 293 | + "jsonrpc": "2.0", |
| 294 | + "id": None, |
| 295 | + "error": {"code": -32700, "message": f"parse error: {exc}"}, |
| 296 | + }) + "\n") |
| 297 | + sys.stdout.flush() |
| 298 | + continue |
| 299 | + response = handle_request(request) |
| 300 | + sys.stdout.write(json.dumps(response) + "\n") |
| 301 | + sys.stdout.flush() |
| 302 | + return 0 |
| 303 | + |
| 304 | + |
| 305 | +if __name__ == "__main__": |
| 306 | + sys.exit(main()) |
0 commit comments