From 4f31c7202df52ab516f380eaa6e98297346f24f2 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 28 Apr 2026 06:10:26 +0200 Subject: [PATCH 1/2] feat(mcp): add --list-tools and --probe flags for discoverability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rivet mcp --list-tools` walks the registered tool router and prints the catalog (15 tools today) as either a human-readable table or — with `--format json` — the JSON-RPC `tools/list` payload exactly as the stdio server would emit it. Does not start the server and does not need a project to be present, so it works as a fast capability probe even before any artifact files exist. `rivet mcp --probe` runs the in-process equivalent of `tools/call rivet_list` (no args) against the current project and prints the decoded `result.content[0].text` payload — the same envelope an MCP client would observe — without standing up a stdio server. Used as a smoke test for AI integrators verifying their project is reachable through MCP. Both flags reuse the same handlers the wire server dispatches to, so their output cannot drift from what a real client would see. Implements: REQ-007 Refs: FEAT-010 --- rivet-cli/src/main.rs | 49 +++++++++++++-- rivet-cli/src/mcp.rs | 142 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 5 deletions(-) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index e911fe6..82a163b 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -891,7 +891,28 @@ enum Command { Lsp, /// Start the MCP server (stdio transport) - Mcp, + /// + /// With no flags, runs the server on stdio. Use `--list-tools` to + /// dump the registered tool catalog without starting the server, or + /// `--probe` to run an in-process `tools/call rivet_list` smoke test. + Mcp { + /// Print the registered tool catalog (name, description, input + /// schema summary) and exit. Does NOT start the server. + #[arg(long, conflicts_with = "probe")] + list_tools: bool, + + /// Run an in-process `tools/call rivet_list` (no args) against the + /// current project and print the decoded result. Does NOT start a + /// long-running stdio server. + #[arg(long, conflicts_with = "list_tools")] + probe: bool, + + /// Output format for `--list-tools`: "text" (default, human-readable + /// table) or "json" (the JSON-RPC `tools/list` payload exactly as + /// the wire server would return it). + #[arg(short, long, default_value = "text")] + format: String, + }, } #[derive(Subcommand)] @@ -1470,8 +1491,13 @@ fn run(cli: Cli) -> Result { if let Command::Lsp = &cli.command { return cmd_lsp(&cli); } - if let Command::Mcp = &cli.command { - return cmd_mcp(&cli); + if let Command::Mcp { + list_tools, + probe, + format, + } = &cli.command + { + return cmd_mcp(&cli, *list_tools, *probe, format); } match &cli.command { @@ -1481,7 +1507,7 @@ fn run(cli: Cli) -> Result { | Command::Context | Command::CommitMsgCheck { .. } | Command::Lsp - | Command::Mcp => unreachable!(), + | Command::Mcp { .. } => unreachable!(), Command::Stpa { path, schema } => cmd_stpa(path, schema.as_deref(), &cli), Command::Validate { format, @@ -10756,7 +10782,20 @@ fn strip_html_tags(html: &str) -> String { .replace(""", "\"") } -fn cmd_mcp(cli: &Cli) -> Result { +fn cmd_mcp(cli: &Cli, list_tools: bool, probe: bool, format: &str) -> Result { + if list_tools { + validate_format(format, &["text", "json"])?; + let out = mcp::render_tool_catalog(format)?; + print!("{out}"); + return Ok(true); + } + + if probe { + let out = mcp::probe_rivet_list(&cli.project)?; + println!("{out}"); + return Ok(true); + } + let rt = tokio::runtime::Runtime::new().context("creating tokio runtime")?; rt.block_on(mcp::run(cli.project.clone()))?; Ok(true) diff --git a/rivet-cli/src/mcp.rs b/rivet-cli/src/mcp.rs index 1966b33..8374f44 100644 --- a/rivet-cli/src/mcp.rs +++ b/rivet-cli/src/mcp.rs @@ -235,6 +235,27 @@ impl RivetServer { &self.project_dir } + /// Enumerate the tools registered with this server. + /// + /// Returns the static tool catalog (name, description, input schema) that + /// `tools/list` would emit over the wire. Used by `rivet mcp --list-tools` + /// to surface the catalog without speaking JSON-RPC. + pub fn tool_catalog() -> Vec<(String, Option, serde_json::Value)> { + let router = Self::tool_router(); + router + .list_all() + .into_iter() + .map(|t| { + let schema_value = serde_json::Value::Object((*t.input_schema).clone()); + ( + t.name.to_string(), + t.description.as_ref().map(|c| c.to_string()), + schema_value, + ) + }) + .collect() + } + fn err(msg: impl std::fmt::Display) -> McpError { McpError::new( rmcp::model::ErrorCode::INTERNAL_ERROR, @@ -1187,3 +1208,124 @@ pub async fn run(project_dir: PathBuf) -> Result<()> { eprintln!("rivet mcp: shutting down."); Ok(()) } + +// ── Discoverability helpers (--list-tools, --probe) ──────────────────── + +/// Render the registered tool catalog as either a JSON-RPC `tools/list` +/// payload (the same shape the server emits over the wire) or a readable +/// text table. +/// +/// `format` accepts `"text"` (default) or `"json"`. The JSON form mirrors +/// what `tools/list` returns: `{ "jsonrpc": "2.0", "id": 1, "result": { +/// "tools": [...] } }`. +pub fn render_tool_catalog(format: &str) -> Result { + let tools = RivetServer::tool_catalog(); + + match format { + "json" => { + let tools_json: Vec = tools + .iter() + .map(|(name, desc, schema)| { + let mut obj = serde_json::Map::new(); + obj.insert("name".to_string(), Value::String(name.clone())); + if let Some(d) = desc { + obj.insert("description".to_string(), Value::String(d.clone())); + } + obj.insert("inputSchema".to_string(), schema.clone()); + Value::Object(obj) + }) + .collect(); + + let payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "tools": tools_json, + }, + }); + Ok(serde_json::to_string_pretty(&payload)?) + } + "text" => { + let mut out = String::new(); + out.push_str(&format!( + "rivet MCP server — {} registered tools\n\n", + tools.len() + )); + + for (name, desc, schema) in &tools { + out.push_str(&format!(" {name}\n")); + if let Some(d) = desc { + out.push_str(&format!(" {d}\n")); + } + out.push_str(&format!(" params: {}\n", summarize_input_schema(schema))); + out.push('\n'); + } + + out.push_str("Tip: `rivet mcp --list-tools --format json` emits the JSON-RPC\n"); + out.push_str("`tools/list` payload that an MCP client would receive.\n"); + out.push_str("See `rivet docs mcp` for the wire format and handshake.\n"); + Ok(out) + } + other => anyhow::bail!("unknown format '{other}' (expected 'text' or 'json')"), + } +} + +/// One-line summary of a JSON Schema: parameter names with required/optional +/// flag. Used by the text rendering of `--list-tools`. +fn summarize_input_schema(schema: &Value) -> String { + let obj = match schema.as_object() { + Some(o) => o, + None => return "(none)".to_string(), + }; + + let required: std::collections::BTreeSet = obj + .get("required") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + + let props = obj.get("properties").and_then(Value::as_object); + + let Some(props) = props else { + return "(none)".to_string(); + }; + + if props.is_empty() { + return "(none)".to_string(); + } + + let mut parts: Vec = props + .iter() + .map(|(name, _)| { + if required.contains(name) { + name.clone() + } else { + format!("{name}?") + } + }) + .collect(); + parts.sort(); + parts.join(", ") +} + +/// Run the in-process equivalent of `tools/call rivet_list` (with no +/// arguments) and return the decoded text payload. +/// +/// This is the core of `rivet mcp --probe`: a smoke test that verifies the +/// MCP code path returns artifacts for the current project, without ever +/// starting a stdio server or speaking JSON-RPC. +pub fn probe_rivet_list(project_dir: &Path) -> Result { + let project = load_project(project_dir) + .with_context(|| format!("loading project from {}", project_dir.display()))?; + + // tool_list_cached is the exact handler dispatched to by the + // `rivet_list` MCP tool. Calling it here keeps `--probe` a faithful + // reflection of what an MCP client would observe. + let result = tool_list_cached(&project, None, None); + Ok(serde_json::to_string_pretty(&result)?) +} From 681563c17d76cc7984dea50829c013af1919dd16 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Tue, 28 Apr 2026 06:10:39 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(mcp):=20embed=20`rivet=20docs=20mcp`?= =?UTF-8?q?=20topic=20=E2=80=94=20JSON-RPC=20framing,=20tool=20catalog,=20?= =?UTF-8?q?gotchas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an embedded documentation topic for the MCP server, registered in the docs registry so `rivet docs mcp` and the `rivet docs` listing both surface it. Companion to the new `rivet mcp --list-tools` and `rivet mcp --probe` flags. Covers: what the server exposes; the line-delimited JSON-RPC over stdio wire format (and the LSP Content-Length pitfall it is NOT); the 3-message handshake including the easily-forgotten `notifications/initialized` notification; the 15-tool catalog with inputs; the `result.content[0].text` double-parse envelope gotcha; three smoke-test recipes (`--list-tools`, `--probe`, raw bash JSON-RPC); the mutate-then-`rivet_reload` convention; and a pointer to the upstream MCP spec for clients building from scratch. Also amends `rivet docs cli` to mention the new `mcp` subflags and cross-link to `rivet docs mcp`. Trace: skip --- rivet-cli/src/docs.rs | 277 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 276 insertions(+), 1 deletion(-) diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index 7038d08..9f03193 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -151,6 +151,12 @@ const TOPICS: &[DocTopic] = &[ category: "Reference", content: HTML_EXPORT_DOC, }, + DocTopic { + slug: "mcp", + title: "MCP server — wire format, tool catalog, and smoke tests", + category: "Reference", + content: MCP_DOC, + }, // ── Schema topics ────────────────────────────────────────────────── DocTopic { slug: "schemas-overview", @@ -413,11 +419,16 @@ rivet snapshot list List all captured snapshots ## MCP Server ``` -rivet mcp Start the MCP server (stdio transport) +rivet mcp Start the MCP server (stdio transport) +rivet mcp --list-tools Print the registered tool catalog and exit +rivet mcp --list-tools -f json Emit the JSON-RPC tools/list payload +rivet mcp --probe Run an in-process tools/call rivet_list smoke test ``` Exposes rivet tools to AI agents via the Model Context Protocol. The server uses stdio transport and only binds to the local process. +See `rivet docs mcp` for the wire format, the 15-tool catalog, and the +3-message handshake. ## Schema Commands @@ -1463,6 +1474,270 @@ When `--single-page` is used, all reports are combined into a single single-page mode (everything is inline). "#; +// ── MCP server documentation ──────────────────────────────────────────── + +const MCP_DOC: &str = r#"# MCP Server — Wire Format, Tool Catalog, and Smoke Tests + +## Overview + +`rivet mcp` exposes the typed-graph (artifacts, links, schemas, validation, +coverage, snapshots) to MCP-speaking clients — Claude Code, Cursor, custom +agents — via the [Model Context Protocol](https://modelcontextprotocol.io/). +The server runs in-process: it loads the project once, caches the store / +schema / link graph, and serves all subsequent tool calls from that cache. + +The server has no network surface. Transport is stdio: the client launches +`rivet mcp` as a child process and exchanges JSON-RPC messages over the +child's stdin / stdout. Mutations land in the project's YAML files on disk; +the cache is refreshed on demand via the `rivet_reload` tool. + +For a list of every tool the server advertises with one-line summaries, run +`rivet mcp --list-tools`. For a quick "is the server reachable from my +project?" smoke test, run `rivet mcp --probe`. Both are described below. + +## Wire Format + +The wire format is **line-delimited JSON-RPC 2.0** over stdio. Each message +is one line of JSON terminated by `\n`. There is **no** Content-Length +framing of the kind LSP uses — clients that wrap the transport with LSP +framing will see no responses and time out. + +A message is either a request (has `id`), a response (has `id` and either +`result` or `error`), or a notification (no `id`, no response expected). + +``` +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}\n +{"jsonrpc":"2.0","id":1,"result":{...}}\n +{"jsonrpc":"2.0","method":"notifications/initialized"}\n +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n +``` + +Anything emitted on stderr is diagnostic / log output; clients should +forward it to their own logs but never parse it as JSON-RPC. + +## The 3-Message Handshake + +Every session starts with the same handshake. The middle message is a +**notification** — no `id`, no response — and is easy to forget. Servers +that follow the spec strictly will reject `tools/list` until they see it. + +1. **Client → server**: `initialize` request. The client declares its + protocol version and capabilities. + + ```json + { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "my-client", "version": "0.1.0"} + } + } + ``` + +2. **Server → client**: `initialize` response. Lists the server's + capabilities (rivet advertises `tools` and `resources`). + + ```json + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {...}, "resources": {...}}, + "serverInfo": {"name": "rivet", "version": "0.5.0"} + } + } + ``` + +3. **Client → server**: `notifications/initialized` notification. **No + id, no response.** This is the gate — the server treats it as the + client's signal that it is ready to receive tool calls. + + ```json + {"jsonrpc": "2.0", "method": "notifications/initialized"} + ``` + +After the notification, the client may freely send `tools/list`, +`tools/call`, `resources/list`, and `resources/read` requests. + +## The 15-Tool Catalog + +The server registers fifteen tools. The authoritative listing — including +the full input schema for each — is `rivet mcp --list-tools` (text) or +`rivet mcp --list-tools --format json` (the JSON-RPC `tools/list` payload). + +| Tool | Purpose | Inputs (required first) | +|-------------------------|----------------------------------------------------------|----------------------------------------| +| `rivet_validate` | Run validators, return PASS / FAIL with diagnostics | (none) | +| `rivet_list` | List artifacts, optional type / status filters | `type_filter?`, `status_filter?` | +| `rivet_get` | Fetch one artifact (fields, links, metadata) | `id` | +| `rivet_stats` | Counts by type, orphans, broken-link totals | (none) | +| `rivet_coverage` | Per-rule traceability coverage | `rule?` | +| `rivet_schema` | Artifact types, link types, traceability rules | `type?` | +| `rivet_query` | S-expression filter; matches with full bodies | `filter`, `limit?` | +| `rivet_embed` | Resolve a `{{...}}` embed (e.g. `coverage:matrix`) | `query` | +| `rivet_snapshot_capture`| Persist a validation snapshot for delta tracking | `name?` | +| `rivet_add` | Insert a new artifact via CST mutation | `type`, `title`, `status?`, ... | +| `rivet_modify` | Mutate fields / status / tags on an existing artifact | `id`, then any of the setters | +| `rivet_link` | Add a typed link between two artifacts | `source`, `link_type`, `target` | +| `rivet_unlink` | Remove a typed link | `source`, `link_type`, `target` | +| `rivet_remove` | Delete an artifact (refuses if backlinked unless force) | `id`, `force?` | +| `rivet_reload` | Reload the cache from disk after external file changes | (none) | + +The first nine tools are read-only and run against the cache. The next +five mutate YAML on disk and require a `rivet_reload` afterwards (see +"Mutation Convention" below). `rivet_reload` itself is the cache primitive. + +In addition to tools, the server publishes two **resources**: + +- `rivet://diagnostics` — the JSON of the latest validation run. +- `rivet://coverage` — the JSON of the latest coverage report. +- `rivet://artifacts/{id}` — the JSON of a single artifact (computed on read). + +## Response Envelope Gotcha + +`tools/call` replies look like: + +```json +{ + "jsonrpc": "2.0", + "id": 17, + "result": { + "content": [ + {"type": "text", "text": "{\"count\": 759, \"artifacts\": [...]}"} + ], + "isError": false + } +} +``` + +The structured payload — the actual artifact list, diagnostic dump, etc. — +arrives as a **stringified JSON document inside `result.content[0].text`**. +Clients must parse that string a second time to get a usable object. This +is intentional on the MCP side (the `text` content type is reserved for +LLM-readable strings), but it surprises everyone the first time. A +typed-content variant is on the MCP roadmap; until then, every client +that wants structured output writes: + +```python +result = call_tool("rivet_list", {}) +payload = json.loads(result["content"][0]["text"]) +``` + +`rivet mcp --probe` does this parse for you and prints the inner JSON +directly, which is one of the reasons it exists. + +## Smoke-Test Recipes + +Three ways to verify a server is reachable, in order of effort. + +### 1. `rivet mcp --list-tools` + +The fastest sanity check — does not start the server, does not need a +project. Just enumerates the tool catalog the server would advertise. + +``` +$ rivet mcp --list-tools +rivet MCP server — 15 registered tools + + rivet_add + Add a new artifact to the project via CST mutation. Call rivet_reload after. + params: description?, fields?, links?, status?, tags?, title, type + ... +``` + +For the JSON-RPC `tools/list` payload exactly as the wire server would +return it (useful for unit-testing client code without a subprocess): + +``` +$ rivet mcp --list-tools --format json | jq '.result.tools[].name' +"rivet_add" +"rivet_coverage" +... +``` + +### 2. `rivet mcp --probe` + +Runs the in-process equivalent of `tools/call rivet_list` (no arguments) +against the current project and prints the decoded payload. Confirms the +project loads, the schema parses, and the cache populates — i.e. that +the same code path a real MCP client would hit actually returns artifacts. + +``` +$ rivet mcp --probe +{ + "count": 759, + "artifacts": [ + {"id": "REQ-001", "type": "requirement", ...}, + ... + ] +} +``` + +Exits non-zero if the project fails to load. Pair with `--project ` +to probe a project other than the current directory. + +### 3. Bash-Only Wire Test + +For clients that want to verify the wire shape directly, pipe JSON-RPC +into `rivet mcp` and read the responses back out. This is the only +recipe that exercises the actual stdio transport: + +```bash +{ + printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"sh","version":"0"}}}' + printf '%s\n' '{"jsonrpc":"2.0","method":"notifications/initialized"}' + printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' + sleep 0.5 +} | rivet mcp 2>/dev/null | head -3 +``` + +You should see three JSON lines: an `initialize` response, no body for +the notification (the server emits nothing for notifications), then a +`tools/list` response with the fifteen tools embedded in +`result.tools`. The `sleep` is needed because the server reads stdin +until EOF and would otherwise block waiting for the next request. + +## Mutation Convention + +The five mutation tools — `rivet_add`, `rivet_modify`, `rivet_link`, +`rivet_unlink`, `rivet_remove` — write directly to the project's YAML +files via the same CST-preserving mutator the CLI uses. They do **not** +update the in-memory cache that the read tools serve from. + +Right after a successful mutation, the client must call `rivet_reload` +to refresh the cache. Otherwise subsequent `rivet_list`, `rivet_get`, +`rivet_validate`, etc. will return stale data — they will not see the +artifact that was just added, or will still see the link that was just +removed. + +``` +rivet_add { ... } → file changes on disk, cache stale +rivet_reload → cache repopulates from disk +rivet_validate → fresh diagnostics, includes the new artifact +``` + +This split exists by design: the mutator runs in milliseconds, while +`rivet_reload` walks the full project (parser, schema check, link graph +rebuild). Batching N mutations + 1 reload at the end is much cheaper +than reloading after each one. Audit log entries (under +`.rivet/mcp-audit.jsonl`) are written immediately by the mutators +regardless — reload state does not affect the audit trail. + +## Pointers + +- MCP specification: +- Crate used by rivet: `rmcp` — +- Integration tests: `rivet-cli/tests/mcp_integration.rs` +- CLI reference: `rivet docs cli` +- Mutation semantics: `rivet docs mutation` + +Related: [[FEAT-010]], [[REQ-007]], [[REQ-047]] +"#; + // ── Phase 3 documentation topics ──────────────────────────────────────── const MUTATION_DOC: &str = r#"# CLI Mutation Commands