Skip to main content

zenith_cli/mcp/
mod.rs

1//! `zenith mcp` — a token-efficient MCP server over stdio.
2//!
3//! Speaks JSON-RPC 2.0 line-delimited on stdin/stdout (the MCP stdio transport)
4//! and exposes the `zenith` command surface as MCP tools. It is hand-rolled on
5//! `serde_json` (already a dependency) rather than pulling an async MCP SDK, so
6//! the binary stays small.
7//!
8//! Design (not a thin CLI wrapper):
9//! - every tool returns a **trimmed structured object** (`structuredContent` plus
10//!   a compact-JSON text mirror), never raw human stdout;
11//! - all node/op/surface schema detail is fetched on demand via the single
12//!   `zenith_schema` meta-tool (progressive disclosure);
13//! - large or binary artifacts are returned as `resources` links backed by the
14//!   content-addressed session store, never inlined;
15//! - documents are addressable by `doc-id`, and the scratch/candidate/promote/
16//!   finalize workspace loop is drivable end-to-end.
17//!
18//! Logs go to stderr; stdout is reserved for the JSON-RPC framing.
19
20mod base64;
21mod doc_ref;
22mod exec;
23#[cfg(feature = "http")]
24mod http;
25mod protocol;
26mod resources;
27mod serialize;
28mod tools;
29
30use std::io::{self, BufRead, Write};
31
32use serde_json::{Value, json};
33
34use protocol::{error, success};
35
36/// The MCP protocol revision this server defaults to when the client does not
37/// request one.
38const DEFAULT_PROTOCOL: &str = "2025-06-18";
39
40/// Steering shown to clients on connect.
41const INSTRUCTIONS: &str = "Zenith authors, validates, and renders deterministic .zen design \
42documents. If your environment can run the local `zenith` CLI, prefer it (install the binary and \
43the skill, then call commands directly) — this MCP server is for environments where a local binary \
44is not suitable (remote, CI, sandboxed, hosted agents). It is a first-class surface: results are \
45trimmed, schema detail is on demand, and large artifacts come back as resource links. Address a \
46document by its path or its doc-id (returned once identity is attached). Typical loop: zenith_schema \
47(learn node/op shapes on demand) → zenith_tx to edit → zenith_validate (hard Error diagnostics block \
48rendering) → zenith_render (returns a resource link; read it via resources/read). Keep design \
49iterations as scratch candidates and promote the chosen one into the export page rather than editing \
50the deliverable directly. Results are trimmed by default; opt into detail with the documented params.";
51
52/// Run the stdio MCP server until stdin closes. Always returns success.
53pub fn run() -> u8 {
54    let stdin = io::stdin();
55    let mut out = io::stdout();
56    let reader = stdin.lock();
57
58    for line in reader.lines() {
59        let line = match line {
60            Ok(l) => l,
61            Err(e) => {
62                eprintln!("zenith mcp: stdin read error: {e}");
63                break;
64            }
65        };
66        if line.trim().is_empty() {
67            continue;
68        }
69        if let Some(response) = handle_message(&line) {
70            if writeln!(out, "{response}").is_err() {
71                break;
72            }
73            let _ = out.flush();
74        }
75    }
76    0
77}
78
79/// Handle one JSON-RPC message. Returns `Some(response)` for requests and
80/// `None` for notifications (and for messages that need no reply).
81///
82/// Exposed for integration tests so the protocol can be driven without stdio.
83pub fn handle_message(line: &str) -> Option<Value> {
84    let msg: Value = match serde_json::from_str(line) {
85        Ok(v) => v,
86        Err(e) => return Some(error(Value::Null, -32700, &format!("parse error: {e}"))),
87    };
88
89    let id = msg.get("id").cloned();
90    let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
91    let params = msg.get("params").cloned().unwrap_or(Value::Null);
92
93    // Notifications (no id) get no response.
94    let id = id?;
95
96    match method {
97        "initialize" => Some(success(id, initialize_result(&params))),
98        "ping" => Some(success(id, json!({}))),
99        "tools/list" => Some(success(id, tools::list_payload())),
100        "tools/call" => Some(tools_call(id, &params)),
101        "resources/list" => Some(success(id, resources::list_payload())),
102        "resources/read" => Some(resources_read(id, &params)),
103        other => Some(error(id, -32601, &format!("method not found: {other}"))),
104    }
105}
106
107/// Build the `initialize` result, echoing the client's protocol version when valid.
108fn initialize_result(params: &Value) -> Value {
109    let protocol = params
110        .get("protocolVersion")
111        .and_then(Value::as_str)
112        .unwrap_or(DEFAULT_PROTOCOL);
113    json!({
114        "protocolVersion": protocol,
115        "capabilities": {
116            "tools": {},
117            "resources": { "listChanged": false },
118        },
119        "serverInfo": { "name": "zenith", "version": env!("CARGO_PKG_VERSION") },
120        "instructions": INSTRUCTIONS,
121    })
122}
123
124/// Execute a `tools/call`. Tool-execution failures are reported inside the
125/// result (`isError: true`), per the MCP spec — only malformed requests are
126/// JSON-RPC errors.
127fn tools_call(id: Value, params: &Value) -> Value {
128    let Some(name) = params.get("name").and_then(Value::as_str) else {
129        return error(id, -32602, "missing tool name");
130    };
131    let args = params.get("arguments").cloned().unwrap_or(json!({}));
132    success(id, exec::call(name, &args).into_payload())
133}
134
135/// Serve the MCP protocol over native Streamable-HTTP at `addr`. Requires the
136/// `http` Cargo feature; both transports drive [`handle_message`].
137#[cfg(feature = "http")]
138pub fn run_http(addr: &str) -> u8 {
139    http::serve(addr)
140}
141
142/// Fallback when the binary was built without the `http` feature: report and fail.
143#[cfg(not(feature = "http"))]
144pub fn run_http(_addr: &str) -> u8 {
145    eprintln!(
146        "zenith mcp: this binary was built without the `http` feature; rebuild with \
147         `--features http` to use --http, or use the default stdio transport."
148    );
149    1
150}
151
152/// Execute a `resources/read`. A missing/unknown URI is a JSON-RPC error.
153fn resources_read(id: Value, params: &Value) -> Value {
154    let Some(uri) = params.get("uri").and_then(Value::as_str) else {
155        return error(id, -32602, "missing resource uri");
156    };
157    match resources::read_payload(uri) {
158        Ok(result) => success(id, result),
159        Err(message) => error(id, -32002, &message),
160    }
161}