Skip to content

Commit 9522fb2

Browse files
committed
feat: nous-mcp stdio server (AI-native-Systems-Research#126 Phase B)
Closes the transport gap from AI-native-Systems-Research#142 (Phase A): bin/nous-mcp is a stdio JSON-RPC 2.0 server that wraps the campaign_index pure functions as MCP resources + tools. Resources (resources/list + resources/read): - nous://campaigns (index of all) - nous://campaigns/<run_id>/state (state.json contents) - nous://campaigns/<run_id>/principles (principles.json contents) - nous://campaigns/<run_id>/iter/<N>/findings (findings.json contents) Tools (tools/list + tools/call): - nous.list_campaigns(search_root, query?, status?, repo?) - nous.search_principles(search_root, text, only_active?) - nous.get_arm_results(campaign_root, iteration, arm) - nous.compare_iterations(campaign_root, iter_a, iter_b) The server is intentionally dependency-free — pure stdlib (json + sys) no mcp-python-sdk pin. Compatible with Claude Code's MCP transport via ~/.claude.json: { "mcpServers": { "nous": { "command": "python", "args": ["-u", "/path/to/repo/bin/nous-mcp"], "env": {"NOUS_SEARCH_ROOT": "/path/to/parent/of/.nous/"} } } } handle_request(request, *, search_root) is exposed as a pure function so tests can drive the server with JSON-RPC payloads without spinning up real stdio. 11 behavioral tests cover initialize, resources/list, resources/read for state and principles, unknown campaign -> JSON-RPC error, tools/list returns 4 tools, list_campaigns / search_principles calls, unknown tool -> error, missing required args -> error not crash. The conftest guard from AI-native-Systems-Research#151 ensures none of these tests touch a real network — they read on-disk fixtures only. Closes AI-native-Systems-Research#126.
1 parent 0861823 commit 9522fb2

2 files changed

Lines changed: 525 additions & 0 deletions

File tree

bin/nous-mcp

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

Comments
 (0)