From c84bc44702074e8b3d3a84f8cd825521b573ef1b Mon Sep 17 00:00:00 2001 From: Bartek Kus <7887446+bartekus@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:27:00 -0600 Subject: [PATCH] feat(005): MCP stdio server exposing the governance verbs as tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stagecraft mcp` runs a hand-rolled JSON-RPC 2.0 server over newline-delimited stdio, exposing the spec 004 verbs as nine MCP tools so a coding agent (Claude Code first) operates under Stagecraft governance natively. Not a privileged side door: each tool calls the identical verb request from stagecraft_cli::verbs (the endpoint and body knowledge now lives once, in `*_request` functions both faces share), and the tool result is the spec 004 §5.2 {ok,data|error} envelope verbatim, so platform attestation/record ids are carried through for agent transcripts to cite. Guards pass through to the agent unchanged: stamp_new requires an explicit posture and fleet_remove a confirm_name, both rejected as JSON-RPC invalid-params (-32602) when missing, empty, or malformed; neither login nor install-url --open is exposed, so an agent can never trigger a browser flow. An unauthenticated (or unreadable-credential) server still starts and answers every tool call with a structured error naming `stagecraft login`. `stagecraft mcp --print-config` prints the .mcp.json install snippet. The transport is deliberately robust: one malformed stdio line (invalid UTF-8, invalid JSON, a non-request object) is answered with the right JSON-RPC error and skipped rather than aborting the server, and one current-thread runtime is reused across the sequential request loop. Transport is hand-rolled (no rmcp) per the spec 005 §1 amendment: small, dependency-free, rustls-only, and it keeps the envelope the literal tool result with no reshaping. Spec 005 stays implementation: in-progress: the §2 live check needs a running control plane (Stagecraft's tenants/factory/fleet services are still pending) plus a Claude Code round-trip. A dated §4 Status note records exactly what remains; all mock-testable acceptance holds. Covered by 98 tests (85 unit + 13 integration): scripted stdio initialize -> tools/list -> tools/call round-trips against a mock plane, guard rejections, the unauthenticated tool-result, record-id passthrough, transport-robustness paths, and the print-config snippet. --- .../by-spec/003-auth-api-client.json | 2 +- .../by-spec/004-governance-verbs.json | 6 +- .../by-spec/005-mcp-server.json | 28 +- .../spec-registry/by-spec/005-mcp-server.json | 8 +- specs/005-mcp-server/spec.md | 65 +- src/cli.rs | 18 +- src/commands.rs | 10 +- src/error.rs | 11 +- src/main.rs | 1 + src/mcp.rs | 1068 +++++++++++++++++ src/verbs/fleet.rs | 80 +- src/verbs/mod.rs | 39 +- src/verbs/stamp.rs | 47 +- src/verbs/tenants.rs | 20 +- tests/cli.rs | 65 +- 15 files changed, 1385 insertions(+), 83 deletions(-) create mode 100644 src/mcp.rs diff --git a/.derived/codebase-index/by-spec/003-auth-api-client.json b/.derived/codebase-index/by-spec/003-auth-api-client.json index 41898d1..ae1daf8 100644 --- a/.derived/codebase-index/by-spec/003-auth-api-client.json +++ b/.derived/codebase-index/by-spec/003-auth-api-client.json @@ -49,5 +49,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "c5fef062ac8d9429215bef31321e37f07fd7c74fdb018ea77bca55f76230d39f" + "shardHash": "ca56147cbcadfa7d1939c6a721277f0d812698189366e7f36eabfe4beb32986f" } diff --git a/.derived/codebase-index/by-spec/004-governance-verbs.json b/.derived/codebase-index/by-spec/004-governance-verbs.json index 10e51b1..851ac6c 100644 --- a/.derived/codebase-index/by-spec/004-governance-verbs.json +++ b/.derived/codebase-index/by-spec/004-governance-verbs.json @@ -15,8 +15,8 @@ { "file": "src/main.rs", "span": { - "endLine": 12, - "startLine": 12 + "endLine": 13, + "startLine": 13 } } ], @@ -32,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "3314a5176288efdc33f1dcb517735e6ff3c09f8074431c7a51d77ff9fb1c39fc" + "shardHash": "3a8b9f30bdcbc6fc77507d7be059f9e25feeaaaab8208d8950eb68572cd29386" } diff --git a/.derived/codebase-index/by-spec/005-mcp-server.json b/.derived/codebase-index/by-spec/005-mcp-server.json index 50ba77e..01fd320 100644 --- a/.derived/codebase-index/by-spec/005-mcp-server.json +++ b/.derived/codebase-index/by-spec/005-mcp-server.json @@ -1,21 +1,25 @@ { - "diagnostics": { - "errors": [], - "warnings": [ - { - "code": "W-001", - "message": "spec '005-mcp-server' symbol unit 'stagecraft_cli::mcp' did not resolve" - } - ] - }, "mapping": { "dependsOn": [ "004-governance-verbs" ], - "implementingPaths": [], + "implementingPaths": [ + { + "path": "src/main.rs", + "source": "spec-edge" + } + ], "resolvedUnits": [ { - "locations": [], + "locations": [ + { + "file": "src/main.rs", + "span": { + "endLine": 11, + "startLine": 11 + } + } + ], "ownership": true, "sourceField": "establishes", "unit": { @@ -28,5 +32,5 @@ "specStatus": "approved" }, "schemaVersion": "1.1.0", - "shardHash": "e008833142ffaa2dcd19e8e7523beadc84a57e2c6ebe45abb6f043e14de4d7a6" + "shardHash": "e70bccf4edf182edfbbbc8a646914cc844164100db692ccdea182dfa83f46c52" } diff --git a/.derived/spec-registry/by-spec/005-mcp-server.json b/.derived/spec-registry/by-spec/005-mcp-server.json index ae5367c..94b4094 100644 --- a/.derived/spec-registry/by-spec/005-mcp-server.json +++ b/.derived/spec-registry/by-spec/005-mcp-server.json @@ -11,18 +11,20 @@ } ], "id": "005-mcp-server", - "implementation": "pending", + "implementation": "in-progress", "sectionHeadings": [ "005: MCP server", "1. Behavior", + "Transport decision (2026-07-15 amendment)", "2. Acceptance", - "3. Out of scope" + "3. Out of scope", + "4. Status (2026-07-15)" ], "specPath": "specs/005-mcp-server/spec.md", "status": "approved", "summary": "Milestone M4's core: `stagecraft mcp` runs a Model Context Protocol server over stdio exposing the governance verbs as tools, so a coding agent (Claude Code first) operates under Stagecraft governance natively: listing tenants, launching and watching stamps, inspecting and operating fleets, all with the same auth, the same guards, and the same JSON shapes as the CLI face. The MCP face is not a privileged side door: it calls the identical verb layer from spec 004, and destructive guards (explicit posture, confirm-name) pass through to the agent verbatim.\n", "title": "The MCP face: stagecraft mcp (stdio server)" }, - "shardHash": "c5d16adff7881014a5d46fdc1cdbe93188c4b3efa27571fac2d947ef642ec591", + "shardHash": "2e169317fcf91675c86c4d83cf08a8557534e0afd660049acadc41d5b11aafe7", "specVersion": "1.1.0" } diff --git a/specs/005-mcp-server/spec.md b/specs/005-mcp-server/spec.md index 33665b6..d07012d 100644 --- a/specs/005-mcp-server/spec.md +++ b/specs/005-mcp-server/spec.md @@ -3,7 +3,7 @@ id: "005-mcp-server" title: "The MCP face: stagecraft mcp (stdio server)" status: approved created: "2026-07-14" -implementation: pending +implementation: in-progress depends_on: - "004-governance-verbs" establishes: @@ -53,6 +53,25 @@ summary: > - `stagecraft mcp --print-config` prints the .mcp.json snippet for easy installation. +### Transport decision (2026-07-15 amendment) + +Chosen: **hand-rolled JSON-RPC 2.0 over newline-delimited stdio**, the +dependency-free path §1 sanctions, not the rmcp SDK. Each message is a +single-line JSON object (no embedded newlines, the MCP stdio framing); +the server implements `initialize`, `notifications/initialized`, +`tools/list`, `tools/call`, `ping`, and `shutdown`, and treats stdin +EOF as shutdown. + +Why not rmcp: it would add a macro-heavy dependency subtree (its own +schemars/tower stack) to a binary whose whole surface here is nine tools +over one request each. The framing we need is small, is fully exercised +by an in-process scripted stdio client (§2), and keeps the tree +rustls-only and lean (CLAUDE.md). The verb layer, not the transport, is +where the governance lives; a thin transport keeps the `{ok,data|error}` +envelope (spec 004 §5.2) the literal tool result with no reshaping. If a +future transport (HTTP/SSE, §3 out of scope) argues for rmcp, that is a +new decision recorded by amendment. + ## 2. Acceptance - Protocol tests: a scripted stdio client performs initialize -> @@ -70,3 +89,47 @@ summary: > - HTTP/SSE transports, remote MCP hosting. - Approvals tools (with the approvals surface, later). - Any tool that bypasses the verb layer or its guards. + +## 4. Status (2026-07-15) + +Implemented: `stagecraft mcp` runs the stdio JSON-RPC server (the +transport decision above) in the new `stagecraft_cli::mcp` module, +exposing all nine §1 tools. Each tool calls the identical spec 004 verb +request (the endpoint and body knowledge lives once, in +`stagecraft_cli::verbs`; the MCP face only chooses which to call), and +the tool result is the spec 004 §5.2 `{ok,data|error}` envelope +verbatim, so any attestation/record ids in the plane's payload are +carried through. The guards pass through: `stamp_new` rejects a +missing or empty `posture` and `fleet_remove` a missing or empty +`confirm_name` as JSON-RPC invalid-params (-32602), and neither `login` +nor `install-url --open` is exposed, so an agent can never trigger the +browser flow. An unauthenticated server still starts and answers every +tool call with a structured `{ok:false,error:{kind:"unauthenticated"}}` +result naming `stagecraft login`; a corrupt or unreadable credentials +store degrades to the same unauthenticated start rather than refusing to +boot. `stagecraft mcp --print-config` prints the `.mcp.json` snippet. + +The tool-result `error.kind` set a client can see is the spec 004 §5.2 +taxonomy passed through (`network`, `api`, `server`, `decode`) plus two +kinds the MCP face adds for pre-request conditions the CLI face reports +as exit codes instead: `unauthenticated` (no stored credential) and +`config` (the server was launched with no base URL). Malformed stdio +input is answered per JSON-RPC 2.0 (`-32700` for invalid UTF-8 or JSON, +`-32600` for a non-request object, `-32601` unknown method, `-32602` +invalid params, including the two guard rejections) and never aborts the +server: one bad line is answered and skipped. + +Covered by tests: a scripted in-process stdio client drives +initialize -> tools/list -> tools/call round-trips against the +mock-backed verb layer (tenants list, stamp launch, fleet remove), +schema rejection of the two guarded tools, the unauthenticated +tool-result error, record-id passthrough, unknown-method and +notification handling, and the print-config snippet. + +Outstanding: the §2 live check cannot run. It needs a running control +plane (Stagecraft's tenants/factory/fleet services, its specs +004/005/006, are all `implementation: pending`) plus a Claude Code +session that `claude mcp add`s this binary and lists tenants then +launches a stamp end-to-end. This spec stays `implementation: +in-progress` until then; at that point the only open item is that live +transcript (no code change), and the commit records its pointer. diff --git a/src/cli.rs b/src/cli.rs index d71d7de..4cce9a2 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -62,7 +62,11 @@ pub enum Command { command: FleetCommand, }, /// Run the MCP server over stdio (spec 005). - Mcp, + Mcp { + /// Print the .mcp.json install snippet for this server, then exit. + #[arg(long)] + print_config: bool, + }, /// Print version information. Version, /// Inspect configuration. @@ -117,6 +121,18 @@ impl Posture { Posture::Autonomous => "autonomous", } } + + /// Parse a wire token back into a posture (the MCP `stamp_new` tool validates + /// its enum by hand, spec 005). Unknown tokens are rejected, not defaulted: + /// the platform never invents a posture and neither does either face. + pub fn from_wire(token: &str) -> Option { + match token { + "none" => Some(Posture::None), + "assisted" => Some(Posture::Assisted), + "autonomous" => Some(Posture::Autonomous), + _ => None, + } + } } #[derive(Debug, Subcommand)] diff --git a/src/commands.rs b/src/commands.rs index 1d543ee..006922e 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -14,7 +14,7 @@ use serde::Serialize; use crate::cli::{Cli, Command, ConfigCommand, FleetCommand, StampCommand, TenantsCommand}; use crate::config::{self, FlagConfig, ResolvedConfig, Sourced}; -use crate::error::{AppError, AppResult}; +use crate::error::AppResult; use crate::output::{self, OutputFormat}; /// Resolve config, then run the selected command. @@ -28,7 +28,13 @@ pub fn dispatch(cli: Cli) -> AppResult<()> { Command::Tenants { command } => tenants(command, &resolved, format, cli.debug), Command::Stamp { command } => stamp(command, &resolved, format, cli.debug), Command::Fleet { command } => fleet(command, &resolved, format, cli.debug), - Command::Mcp => Err(AppError::not_implemented("mcp", "005-mcp-server")), + Command::Mcp { print_config } => { + if *print_config { + crate::mcp::print_config(&resolved) + } else { + crate::mcp::run(&resolved, cli.debug) + } + } Command::Version => { version(format); Ok(()) diff --git a/src/error.rs b/src/error.rs index 7e5f84e..62ad04a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,7 +15,12 @@ pub const EXIT_USAGE: u8 = 2; /// The failure modes a command handler can surface to `main`. #[derive(Debug, Error)] pub enum AppError { - /// A stub verb whose real implementation arrives in a later spec. + /// A stub verb whose real implementation arrives in a later spec. This is + /// the "not-implemented" arm of the spec 002 §2 exit-2 taxonomy. With spec + /// 005 the last stub (`mcp`) is implemented, so no verb currently constructs + /// it; it is retained because the spec mandates the taxonomy and a later + /// spec scaffolds its command as a stub before implementing it. + #[allow(dead_code)] #[error("`stagecraft {command}` is not implemented until spec {spec}")] NotImplemented { command: String, spec: &'static str }, @@ -44,7 +49,9 @@ impl AppError { } } - /// Construct the stub error for a verb owned by a later spec. + /// Construct the stub error for a verb owned by a later spec (see + /// [`AppError::NotImplemented`]; currently unconstructed by any verb). + #[allow(dead_code)] pub fn not_implemented(command: impl Into, spec: &'static str) -> Self { AppError::NotImplemented { command: command.into(), diff --git a/src/main.rs b/src/main.rs index 166291f..0c2fbaf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod cli; mod commands; mod config; mod error; +mod mcp; mod output; mod verbs; diff --git a/src/mcp.rs b/src/mcp.rs new file mode 100644 index 0000000..43c6f04 --- /dev/null +++ b/src/mcp.rs @@ -0,0 +1,1068 @@ +//! The MCP face (spec 005): `stagecraft mcp` runs a Model Context Protocol +//! server over stdio so a coding agent operates under Stagecraft governance +//! natively. +//! +//! Transport is hand-rolled JSON-RPC 2.0 over newline-delimited stdio (spec 005 +//! §1 Transport decision): one JSON object per line, no embedded newlines. The +//! server speaks `initialize`, `notifications/initialized`, `tools/list`, +//! `tools/call`, `ping`, and `shutdown`, and treats stdin EOF as shutdown. +//! +//! The MCP face is not a privileged side door: every tool calls the identical +//! spec 004 verb request (the endpoint and body knowledge lives once, in +//! [`crate::verbs`]), and the tool result is the spec 004 §5.2 `{ok,data|error}` +//! envelope verbatim, so any attestation/record ids in the plane's payload are +//! carried through. The destructive guards pass through to the agent unchanged: +//! `stamp_new` requires an explicit `posture`, `fleet_remove` a `confirm_name`. +//! Neither `login` nor `install-url --open` is exposed, so an agent can never +//! trigger a browser flow; an unauthenticated server answers every tool call +//! with a structured error naming `stagecraft login`. + +use std::io::{self, BufRead, Write}; + +use serde_json::{json, Value}; + +use crate::api::{normalize_base_url, ApiClient, ApiError}; +use crate::cli::Posture; +use crate::config::ResolvedConfig; +use crate::error::{AppError, AppResult}; +use crate::verbs::{envelope_value, error_envelope, fleet, stamp, tenants}; + +/// Server identity reported in `initialize` and used as the `.mcp.json` key. +const SERVER_NAME: &str = "stagecraft"; +/// The MCP protocol version we answer with when a client offers none. +const PROTOCOL_VERSION: &str = "2025-06-18"; + +// JSON-RPC 2.0 error codes (the standard reserved set). +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; + +/// What the server needs to serve tool calls: the resolved base URL, the +/// credential loaded from the spec 003 store (both optional), and one runtime +/// reused across the sequential request loop. A `None` token is a +/// running-but-unauthenticated server (spec 005 §1): it starts and answers every +/// tool call with a login instruction rather than refusing to boot. +struct ServerContext { + base_url: Option, + token: Option, + debug: bool, + /// One current-thread runtime, built at startup and reused for every tool + /// call. Calls are strictly sequential (the stdio loop is single-threaded), + /// so a per-call runtime would only pay repeated spin-up for no concurrency. + runtime: tokio::runtime::Runtime, +} + +/// `stagecraft mcp`: run the stdio server until stdin closes (or a `shutdown`). +/// +/// The credential is loaded once, at startup, from the spec 003 store; a client +/// that logs in afterward reconnects (Claude Code restarts the server on config +/// change), which keeps the request loop free of filesystem access and testable. +/// A missing or unreadable store is not fatal: the server starts unauthenticated +/// and instructs `stagecraft login` per call (spec 005 §1 "if unauthenticated it +/// starts"). +pub fn run(resolved: &ResolvedConfig, debug: bool) -> AppResult<()> { + let base_url = resolved.base_url.value.as_deref().map(normalize_base_url); + let token = match &base_url { + Some(url) => { + match crate::auth::load_token(url) { + Ok(token) => token, + // A corrupt/unreadable store is an unauthenticated start, not a + // refusal to boot. Log to stderr (the MCP server's log channel; + // stdout is the JSON-RPC stream) and carry on with no credential. + Err(err) => { + eprintln!("warning: could not read stored credentials ({err}); starting unauthenticated"); + None + } + } + } + None => None, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| AppError::Operational(e.into()))?; + let ctx = ServerContext { + base_url, + token, + debug, + runtime, + }; + let stdin = io::stdin(); + let stdout = io::stdout(); + serve(stdin.lock(), stdout.lock(), &ctx) +} + +/// `stagecraft mcp --print-config`: print the `.mcp.json` snippet that installs +/// this binary as a Claude Code MCP server. The command path is this executable +/// so the snippet works before the binary is on `PATH`; when a base URL is +/// resolved it is pinned as `STAGECRAFT_BASE_URL` so the snippet is turnkey. +pub fn print_config(resolved: &ResolvedConfig) -> AppResult<()> { + let command = std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(str::to_string)) + .unwrap_or_else(|| SERVER_NAME.to_string()); + let base_url = resolved.base_url.value.as_deref().map(normalize_base_url); + let snippet = config_snippet(base_url.as_deref(), &command); + println!( + "{}", + serde_json::to_string_pretty(&snippet).expect("serializing an owned snippet cannot fail") + ); + Ok(()) +} + +/// Build the `.mcp.json` snippet value (pure, so it is unit-testable). +fn config_snippet(base_url: Option<&str>, command: &str) -> Value { + let mut server = json!({ "command": command, "args": ["mcp"] }); + if let Some(base) = base_url { + server["env"] = json!({ "STAGECRAFT_BASE_URL": base }); + } + json!({ "mcpServers": { SERVER_NAME: server } }) +} + +// --- the stdio JSON-RPC loop ------------------------------------------------ + +/// The result of handling one line: an optional response to write and whether +/// the server should stop afterward (a `shutdown`). +struct Outcome { + response: Option, + stop: bool, +} + +impl Outcome { + fn respond(response: String) -> Self { + Outcome { + response: Some(response), + stop: false, + } + } + fn silent() -> Self { + Outcome { + response: None, + stop: false, + } + } +} + +/// Read newline-delimited JSON-RPC messages, dispatch each, and write each +/// response as one line. EOF (the client closed stdin) is a clean shutdown. +/// +/// The read is byte-oriented on purpose: one malformed line (invalid UTF-8, a +/// copy-paste artifact, a truncated multi-byte character) must be answered with +/// a parse error and skipped, never kill the whole server, so the next valid +/// request is still served. +fn serve(mut reader: R, mut writer: W, ctx: &ServerContext) -> AppResult<()> { + let mut buf: Vec = Vec::new(); + loop { + buf.clear(); + let read = reader + .read_until(b'\n', &mut buf) + .map_err(|e| AppError::Operational(e.into()))?; + if read == 0 { + return Ok(()); + } + let outcome = handle_message(&buf, ctx); + if let Some(response) = outcome.response { + writeln!(writer, "{response}").map_err(|e| AppError::Operational(e.into()))?; + writer + .flush() + .map_err(|e| AppError::Operational(e.into()))?; + } + if outcome.stop { + return Ok(()); + } + } +} + +/// Parse and dispatch a single message (the raw line bytes). Failures degrade to +/// a JSON-RPC error the caller writes, never an error that aborts the loop: +/// invalid UTF-8 or invalid JSON is a parse error (-32700); a well-formed JSON +/// value that is not a valid request object is an invalid request (-32600). +fn handle_message(bytes: &[u8], ctx: &ServerContext) -> Outcome { + let text = match std::str::from_utf8(bytes) { + Ok(text) => text.trim(), + Err(_) => { + return Outcome::respond(error_response( + &Value::Null, + PARSE_ERROR, + "parse error: line is not valid UTF-8", + )) + } + }; + if text.is_empty() { + return Outcome::silent(); + } + let value: Value = match serde_json::from_str(text) { + Ok(value) => value, + Err(_) => { + return Outcome::respond(error_response( + &Value::Null, + PARSE_ERROR, + "parse error: message is not valid JSON", + )) + } + }; + let object = match value.as_object() { + Some(object) => object, + None => { + return Outcome::respond(error_response( + &Value::Null, + INVALID_REQUEST, + "invalid request: message is not a JSON object", + )) + } + }; + + // An absent `id` marks a notification (no reply): the only one we expect, + // `notifications/initialized`, needs no action, so all notifications are + // answered with silence. An explicit `id` (including `null`) is a request. + let id = match object.get("id") { + Some(id) => id.clone(), + None => return Outcome::silent(), + }; + let method = match object.get("method").and_then(Value::as_str) { + Some(method) => method, + None => { + return Outcome::respond(error_response( + &id, + INVALID_REQUEST, + "invalid request: `method` is missing or not a string", + )) + } + }; + let params = object.get("params").cloned().unwrap_or(Value::Null); + match method { + "initialize" => Outcome::respond(initialize_response(&id, ¶ms)), + "ping" => Outcome::respond(success_response(&id, json!({}))), + "tools/list" => Outcome::respond(success_response( + &id, + json!({ "tools": tool_definitions() }), + )), + "tools/call" => Outcome::respond(tools_call_response(&id, ¶ms, ctx)), + "shutdown" => Outcome { + response: Some(success_response(&id, json!({}))), + stop: true, + }, + other => Outcome::respond(error_response( + &id, + METHOD_NOT_FOUND, + &format!("method not found: {other}"), + )), + } +} + +/// The `initialize` result: capabilities, server identity, and the negotiated +/// protocol version (echo the client's when offered, else our default). +fn initialize_response(id: &Value, params: &Value) -> String { + let version = params + .get("protocolVersion") + .and_then(Value::as_str) + .unwrap_or(PROTOCOL_VERSION); + success_response( + id, + json!({ + "protocolVersion": version, + "capabilities": { "tools": {} }, + "serverInfo": { "name": SERVER_NAME, "version": env!("CARGO_PKG_VERSION") } + }), + ) +} + +/// Handle `tools/call`: validate `name`, dispatch to the verb, and shape the +/// outcome. An invalid parameter (a missing guard, an unknown tool) is a +/// JSON-RPC invalid-params error; a completed call (success or an operational +/// failure) is a tool result carrying the envelope. +fn tools_call_response(id: &Value, params: &Value, ctx: &ServerContext) -> String { + let name = match params.get("name").and_then(Value::as_str) { + Some(name) => name, + None => return error_response(id, INVALID_PARAMS, "tools/call: missing `name`"), + }; + let empty = json!({}); + let args = params.get("arguments").unwrap_or(&empty); + match call_tool(ctx, name, args) { + Dispatch::Invalid(message) => error_response(id, INVALID_PARAMS, &message), + Dispatch::Envelope(envelope) => success_response(id, tool_result(envelope)), + } +} + +/// Wrap a verb envelope as an MCP tool result: the pretty envelope as text (so a +/// text-only client still sees the whole governed record), the envelope as +/// `structuredContent` for typed clients, and `isError` set from `ok`. +fn tool_result(envelope: Value) -> Value { + let is_error = envelope.get("ok") == Some(&Value::Bool(false)); + let text = + serde_json::to_string_pretty(&envelope).expect("serializing an owned envelope cannot fail"); + json!({ + "content": [ { "type": "text", "text": text } ], + "structuredContent": envelope, + "isError": is_error, + }) +} + +// --- tool registry ---------------------------------------------------------- + +/// The advertised tools (spec 005 §1): each name maps to one spec 004 verb, with +/// a precise input schema so agents get typed parameters. The guard fields carry +/// loud descriptions: the agent must never guess `posture` or `confirm_name`. +fn tool_definitions() -> Vec { + vec![ + json!({ + "name": "tenants_list", + "description": "List the tenants the authenticated identity owns.", + "inputSchema": { "type": "object", "properties": {}, "additionalProperties": false } + }), + json!({ + "name": "tenants_show", + "description": "Show one tenant, including its GitHub installations.", + "inputSchema": { + "type": "object", + "properties": { "tenant_id": { "type": "string", "description": "Tenant id." } }, + "required": ["tenant_id"], + "additionalProperties": false + } + }), + json!({ + "name": "stamp_new", + "description": "Request a new governance stamp (a born-green repo) for a tenant.", + "inputSchema": { + "type": "object", + "properties": { + "tenant_id": { "type": "string", "description": "Tenant the stamp is charged to." }, + "app_name": { "type": "string", "description": "Application name for the stamped repo." }, + "org": { "type": "string", "description": "Target GitHub org the repo is created in." }, + "posture": { + "type": "string", + "enum": ["none", "assisted", "autonomous"], + "description": "Governance posture. REQUIRED. Must reflect the human operator's declared intent; the agent must not guess or default it." + }, + "frontend": { "type": "string", "description": "Optional frontend flavor slot (for example `vue`)." } + }, + "required": ["tenant_id", "app_name", "org", "posture"], + "additionalProperties": false + } + }), + json!({ + "name": "stamp_status", + "description": "Poll a stamp job's status once. Terminal states are green and failed; loop yourself to watch.", + "inputSchema": { + "type": "object", + "properties": { "job_id": { "type": "string", "description": "Stamp job id." } }, + "required": ["job_id"], + "additionalProperties": false + } + }), + json!({ + "name": "fleet_list", + "description": "List a tenant's fleet apps.", + "inputSchema": { + "type": "object", + "properties": { "tenant_id": { "type": "string", "description": "Tenant whose fleet is listed." } }, + "required": ["tenant_id"], + "additionalProperties": false + } + }), + json!({ + "name": "fleet_deploy", + "description": "Deploy an image as a new fleet app.", + "inputSchema": { + "type": "object", + "properties": { + "tenant_id": { "type": "string", "description": "Tenant the app belongs to." }, + "app": { "type": "string", "description": "Fleet app name." }, + "image": { "type": "string", "description": "Container image reference to place." } + }, + "required": ["tenant_id", "app", "image"], + "additionalProperties": false + } + }), + json!({ + "name": "fleet_update", + "description": "Roll a fleet app to a new image.", + "inputSchema": { + "type": "object", + "properties": { + "app_id": { "type": "string", "description": "Fleet app id." }, + "image": { "type": "string", "description": "New container image reference." } + }, + "required": ["app_id", "image"], + "additionalProperties": false + } + }), + json!({ + "name": "fleet_backup", + "description": "Back up a fleet app's volume.", + "inputSchema": { + "type": "object", + "properties": { "app_id": { "type": "string", "description": "Fleet app id." } }, + "required": ["app_id"], + "additionalProperties": false + } + }), + json!({ + "name": "fleet_remove", + "description": "Remove a fleet app. Destructive and irreversible.", + "inputSchema": { + "type": "object", + "properties": { + "app_id": { "type": "string", "description": "Fleet app id." }, + "confirm_name": { + "type": "string", + "description": "The fleet app's exact name, typed by the human to authorize teardown. A safety guard; the agent must not fabricate or guess it." + } + }, + "required": ["app_id", "confirm_name"], + "additionalProperties": false + } + }), + ] +} + +/// A tool dispatch outcome: an invalid-params rejection, or a completed envelope. +enum Dispatch { + /// Reject as JSON-RPC invalid-params (-32602). The two guarded tools land + /// here when `posture` / `confirm_name` is missing, empty, or malformed. + Invalid(String), + /// A completed tool result: the `{ok,data|error}` envelope to wrap. + Envelope(Value), +} + +/// Unwrap a validated parameter or short-circuit `call_tool` with an +/// invalid-params rejection. Each arm owns its parameter as a `String` for the +/// move into the async request. +macro_rules! param { + ($result:expr) => { + match $result { + Ok(value) => value.to_string(), + Err(message) => return Dispatch::Invalid(message), + } + }; +} + +/// Validate parameters, then run the matching spec 004 verb request. Parameter +/// validation stays in the MCP layer (it is transport concern); the endpoint and +/// body construction stay in the verb layer (they are the shared contract). +fn call_tool(ctx: &ServerContext, name: &str, args: &Value) -> Dispatch { + match name { + "tenants_list" => Dispatch::Envelope(execute(ctx, |client| async move { + tenants::list_request(&client).await + })), + "tenants_show" => { + let id = param!(req(args, "tenant_id", name)); + Dispatch::Envelope(execute(ctx, move |client| async move { + tenants::show_request(&client, &id).await + })) + } + "stamp_new" => { + let tenant = param!(req(args, "tenant_id", name)); + let app = param!(req(args, "app_name", name)); + let org = param!(req(args, "org", name)); + let posture = + match req(args, "posture", name).and_then(|token| parse_posture(token, name)) { + Ok(posture) => posture, + Err(message) => return Dispatch::Invalid(message), + }; + let frontend = opt(args, "frontend").map(str::to_string); + Dispatch::Envelope(execute(ctx, move |client| async move { + stamp::new_request(&client, &tenant, &app, &org, frontend.as_deref(), posture).await + })) + } + "stamp_status" => { + let job_id = param!(req(args, "job_id", name)); + Dispatch::Envelope(execute(ctx, move |client| async move { + stamp::status_request(&client, &job_id).await + })) + } + "fleet_list" => { + let tenant = param!(req(args, "tenant_id", name)); + Dispatch::Envelope(execute(ctx, move |client| async move { + fleet::list_request(&client, &tenant).await + })) + } + "fleet_deploy" => { + let tenant = param!(req(args, "tenant_id", name)); + let app = param!(req(args, "app", name)); + let image = param!(req(args, "image", name)); + Dispatch::Envelope(execute(ctx, move |client| async move { + fleet::deploy_request(&client, &tenant, &app, &image).await + })) + } + "fleet_update" => { + let app_id = param!(req(args, "app_id", name)); + let image = param!(req(args, "image", name)); + Dispatch::Envelope(execute(ctx, move |client| async move { + fleet::update_request(&client, &app_id, &image).await + })) + } + "fleet_backup" => { + let app_id = param!(req(args, "app_id", name)); + Dispatch::Envelope(execute(ctx, move |client| async move { + fleet::backup_request(&client, &app_id).await + })) + } + "fleet_remove" => { + let app_id = param!(req(args, "app_id", name)); + let confirm = param!(req(args, "confirm_name", name)); + Dispatch::Envelope(execute(ctx, move |client| async move { + fleet::remove_request(&client, &app_id, &confirm).await + })) + } + other => Dispatch::Invalid(format!("unknown tool `{other}`")), + } +} + +/// Build the client (or the structured error envelope for an unauthenticated / +/// unconfigured server), run `request` against it, and wrap the outcome in the +/// shared envelope. `request` owns the endpoint and body (it is the spec 004 +/// verb); the MCP face only chooses which to call. +fn execute(ctx: &ServerContext, request: F) -> Value +where + F: FnOnce(ApiClient) -> Fut, + Fut: std::future::Future>, +{ + let client = match client_or_envelope(ctx) { + Ok(client) => client, + Err(envelope) => return envelope, + }; + // The runtime is the one built at startup and reused across the loop; the + // request runs to completion on it and the outcome becomes the envelope. + envelope_value(ctx.runtime.block_on(request(client))) +} + +/// Build an authenticated client, or the structured error envelope that the tool +/// result carries when the server cannot make a call: `config` when no base URL +/// was set, `unauthenticated` (naming `stagecraft login`) when no credential is +/// stored. An agent can never resolve the latter itself: `login` is not a tool. +fn client_or_envelope(ctx: &ServerContext) -> Result { + let base_url = match &ctx.base_url { + Some(base_url) => base_url, + None => { + return Err(error_envelope( + "config", + "no control-plane base URL; launch `stagecraft mcp` with STAGECRAFT_BASE_URL set (or --base-url)".to_string(), + None, + )) + } + }; + let token = match &ctx.token { + Some(token) => token, + None => { + return Err(error_envelope( + "unauthenticated", + ApiError::Unauthenticated.to_string(), + None, + )) + } + }; + ApiClient::new(base_url.clone(), Some(token.clone()), ctx.debug) + .map_err(|err| error_envelope(err.kind(), err.to_string(), err.status())) +} + +// --- parameter extraction --------------------------------------------------- + +/// A required string parameter: present, a string, and not blank. Absent, empty, +/// or the wrong type is an invalid-params message naming the tool and field. +fn req<'a>(args: &'a Value, key: &str, tool: &str) -> Result<&'a str, String> { + match args.get(key) { + Some(Value::String(s)) if !s.trim().is_empty() => Ok(s.as_str()), + Some(Value::String(_)) => Err(format!("{tool}: `{key}` must not be empty")), + Some(_) => Err(format!("{tool}: `{key}` must be a string")), + None => Err(format!("{tool}: missing required parameter `{key}`")), + } +} + +/// An optional string parameter; blank or non-string is treated as absent. +fn opt<'a>(args: &'a Value, key: &str) -> Option<&'a str> { + match args.get(key) { + Some(Value::String(s)) if !s.trim().is_empty() => Some(s.as_str()), + _ => None, + } +} + +/// Parse the `stamp_new` posture enum by hand (the schema advertises it, but a +/// hand-rolled server still validates). Unknown tokens are rejected, never +/// defaulted: neither face invents a posture (spec 004 §5.1, spec 005 §1). +fn parse_posture(token: &str, tool: &str) -> Result { + Posture::from_wire(token) + .ok_or_else(|| format!("{tool}: `posture` must be one of none, assisted, autonomous")) +} + +/// Build the JSON-RPC success response line for `id`. +fn success_response(id: &Value, result: Value) -> String { + line(json!({ "jsonrpc": "2.0", "id": id.clone(), "result": result })) +} + +/// Build the JSON-RPC error response line for `id`. +fn error_response(id: &Value, code: i64, message: &str) -> String { + line( + json!({ "jsonrpc": "2.0", "id": id.clone(), "error": { "code": code, "message": message } }), + ) +} + +/// Serialize one message to a single compact line (the stdio framing). +fn line(value: Value) -> String { + serde_json::to_string(&value).expect("serializing an owned response cannot fail") +} + +#[cfg(test)] +mod tests { + use super::*; + use httpmock::prelude::*; + + /// A test context with the fields under test and a fresh reused runtime. + fn ctx(base_url: Option<&str>, token: Option<&str>) -> ServerContext { + ServerContext { + base_url: base_url.map(str::to_string), + token: token.map(str::to_string), + debug: false, + runtime: tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(), + } + } + + fn bare_ctx() -> ServerContext { + ctx(None, None) + } + + fn authed_ctx(base_url: &str) -> ServerContext { + ctx(Some(&normalize_base_url(base_url)), Some("tok")) + } + + /// Drive `serve` over scripted request lines, returning the parsed response + /// lines (one per request that carried an id). + fn drive(ctx: &ServerContext, requests: &[Value]) -> Vec { + let input = requests + .iter() + .map(|r| r.to_string()) + .collect::>() + .join("\n"); + let mut output: Vec = Vec::new(); + serve(io::Cursor::new(input), &mut output, ctx).unwrap(); + String::from_utf8(output) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str::(l).unwrap()) + .collect() + } + + fn required(tool: &Value) -> Vec { + tool["inputSchema"]["required"] + .as_array() + .map(|a| a.iter().map(|v| v.as_str().unwrap().to_string()).collect()) + .unwrap_or_default() + } + + #[test] + fn initialize_reports_server_info_and_echoes_protocol_version() { + let out = drive( + &bare_ctx(), + &[json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "protocolVersion": "2025-03-26", "capabilities": {} } + })], + ); + assert_eq!(out.len(), 1); + assert_eq!(out[0]["id"], 1); + assert_eq!(out[0]["result"]["serverInfo"]["name"], "stagecraft"); + assert_eq!(out[0]["result"]["protocolVersion"], "2025-03-26"); + assert!(out[0]["result"]["capabilities"]["tools"].is_object()); + } + + #[test] + fn initialize_defaults_protocol_version_when_client_offers_none() { + let out = drive( + &bare_ctx(), + &[json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} })], + ); + assert_eq!(out[0]["result"]["protocolVersion"], PROTOCOL_VERSION); + } + + #[test] + fn tools_list_advertises_the_nine_verbs_with_guard_schemas() { + let out = drive( + &bare_ctx(), + &[json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list" })], + ); + let tools = out[0]["result"]["tools"].as_array().unwrap(); + let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect(); + for expected in [ + "tenants_list", + "tenants_show", + "stamp_new", + "stamp_status", + "fleet_list", + "fleet_deploy", + "fleet_update", + "fleet_backup", + "fleet_remove", + ] { + assert!(names.contains(&expected), "missing tool {expected}"); + } + assert_eq!(names.len(), 9, "exactly the nine spec 005 tools"); + + let stamp_new = tools.iter().find(|t| t["name"] == "stamp_new").unwrap(); + assert!(required(stamp_new).contains(&"posture".to_string())); + let fleet_remove = tools.iter().find(|t| t["name"] == "fleet_remove").unwrap(); + assert!(required(fleet_remove).contains(&"confirm_name".to_string())); + } + + #[test] + fn every_advertised_tool_is_dispatchable() { + // The tools/list registry and the call_tool match must not drift apart: + // no advertised name may fall through to the unknown-tool arm. + let ctx = bare_ctx(); + for def in tool_definitions() { + let name = def["name"].as_str().unwrap(); + if let Dispatch::Invalid(message) = call_tool(&ctx, name, &json!({})) { + assert!( + !message.contains("unknown tool"), + "{name} is advertised but not dispatchable" + ); + } + } + } + + #[test] + fn stamp_new_without_posture_is_rejected() { + match call_tool( + &bare_ctx(), + "stamp_new", + &json!({ "tenant_id": "t_1", "app_name": "smoke", "org": "acme" }), + ) { + Dispatch::Invalid(message) => assert!(message.contains("posture"), "{message}"), + Dispatch::Envelope(_) => panic!("a missing posture must be rejected, not run"), + } + } + + #[test] + fn stamp_new_with_unknown_posture_is_rejected() { + match call_tool( + &bare_ctx(), + "stamp_new", + &json!({ "tenant_id": "t_1", "app_name": "x", "org": "o", "posture": "yolo" }), + ) { + Dispatch::Invalid(message) => assert!(message.contains("posture")), + Dispatch::Envelope(_) => panic!("an unknown posture must be rejected"), + } + } + + #[test] + fn fleet_remove_without_confirm_name_is_rejected() { + match call_tool(&bare_ctx(), "fleet_remove", &json!({ "app_id": "a_1" })) { + Dispatch::Invalid(message) => assert!(message.contains("confirm_name")), + Dispatch::Envelope(_) => panic!("a missing confirm_name must be rejected"), + } + } + + #[test] + fn fleet_remove_with_empty_confirm_name_is_rejected() { + match call_tool( + &bare_ctx(), + "fleet_remove", + &json!({ "app_id": "a_1", "confirm_name": " " }), + ) { + Dispatch::Invalid(message) => assert!(message.contains("confirm_name")), + Dispatch::Envelope(_) => panic!("a blank confirm_name must be rejected"), + } + } + + #[test] + fn unknown_tool_is_rejected() { + match call_tool(&bare_ctx(), "definitely_not_a_tool", &json!({})) { + Dispatch::Invalid(message) => assert!(message.contains("unknown tool")), + Dispatch::Envelope(_) => panic!("an unknown tool must be rejected"), + } + } + + #[test] + fn unauthenticated_tool_call_instructs_login() { + // base_url present, no stored token: the server started, and every tool + // call returns a structured login instruction (spec 005 §1). + let ctx = ctx(Some("http://localhost:4000"), None); + match call_tool(&ctx, "tenants_list", &json!({})) { + Dispatch::Envelope(envelope) => { + assert_eq!(envelope["ok"], false); + assert_eq!(envelope["error"]["kind"], "unauthenticated"); + assert!(envelope["error"]["message"] + .as_str() + .unwrap() + .contains("stagecraft login")); + } + Dispatch::Invalid(_) => { + panic!("an unauthenticated call is a tool result, not invalid params") + } + } + } + + #[test] + fn missing_base_url_is_a_config_tool_error() { + let ctx = ctx(None, Some("tok")); + match call_tool(&ctx, "tenants_list", &json!({})) { + Dispatch::Envelope(envelope) => { + assert_eq!(envelope["ok"], false); + assert_eq!(envelope["error"]["kind"], "config"); + } + Dispatch::Invalid(_) => { + panic!("a missing base URL is a tool result, not invalid params") + } + } + } + + #[test] + fn round_trip_initialize_list_call_lists_tenants() { + // The spec 005 §2 acceptance: initialize -> tools/list -> tools/call. + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants"); + then.status(200) + .json_body(json!([{ "id": "t_1", "name": "Acme" }])); + }); + let out = drive( + &authed_ctx(&server.base_url()), + &[ + json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} }), + json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }), + json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" }), + json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", + "params": { "name": "tenants_list", "arguments": {} } }), + ], + ); + mock.assert(); + // The notification produced no response: three ids in, three out. + assert_eq!(out.len(), 3); + let call = out.iter().find(|r| r["id"] == 3).unwrap(); + assert_eq!(call["result"]["isError"], false); + assert_eq!(call["result"]["structuredContent"]["ok"], true); + assert_eq!(call["result"]["structuredContent"]["data"][0]["id"], "t_1"); + let text = call["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("t_1"), "the text content carries the record"); + } + + #[test] + fn stamp_new_forwards_posture_and_passes_through_record_ids() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/tenants/t_1/stamps") + .json_body(json!({ + "appName": "smoke", "targetOrg": "acme-inc", "posture": "assisted" + })); + then.status(200).json_body(json!({ + "id": "j_1", "status": "queued", "attestationId": "att_9" + })); + }); + let out = drive( + &authed_ctx(&server.base_url()), + &[json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "stamp_new", + "arguments": { + "tenant_id": "t_1", "app_name": "smoke", + "org": "acme-inc", "posture": "assisted" + } + } + })], + ); + mock.assert(); + let result = &out[0]["result"]; + assert_eq!(result["isError"], false); + assert_eq!(result["structuredContent"]["data"]["status"], "queued"); + // Every tool result carries the plane's record ids when present (§1). + assert_eq!( + result["structuredContent"]["data"]["attestationId"], + "att_9" + ); + } + + #[test] + fn stamp_new_missing_posture_is_invalid_params_over_the_wire() { + let out = drive( + &bare_ctx(), + &[json!({ + "jsonrpc": "2.0", "id": 7, "method": "tools/call", + "params": { "name": "stamp_new", + "arguments": { "tenant_id": "t_1", "app_name": "x", "org": "o" } } + })], + ); + assert_eq!(out[0]["id"], 7); + assert_eq!(out[0]["error"]["code"], INVALID_PARAMS); + assert!(out[0]["error"]["message"] + .as_str() + .unwrap() + .contains("posture")); + } + + #[test] + fn fleet_remove_forwards_confirm_name_in_the_body() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(DELETE) + .path("/api/v1/fleet/a_1") + .json_body(json!({ "confirm": "smoke" })); + then.status(200) + .json_body(json!({ "id": "a_1", "name": "smoke", "status": "removed" })); + }); + let out = drive( + &authed_ctx(&server.base_url()), + &[json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "fleet_remove", + "arguments": { "app_id": "a_1", "confirm_name": "smoke" } } + })], + ); + mock.assert(); + assert_eq!( + out[0]["result"]["structuredContent"]["data"]["status"], + "removed" + ); + } + + #[test] + fn api_error_becomes_an_iserror_tool_result() { + // A missing service (404) is an operational tool error, not a protocol + // error: isError true, the envelope explaining it (spec 004 §1). + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/v1/tenants"); + then.status(404); + }); + let out = drive( + &authed_ctx(&server.base_url()), + &[json!({ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { "name": "tenants_list", "arguments": {} } + })], + ); + let result = &out[0]["result"]; + assert_eq!(result["isError"], true); + assert_eq!(result["structuredContent"]["ok"], false); + assert_eq!(result["structuredContent"]["error"]["kind"], "api"); + assert_eq!(result["structuredContent"]["error"]["status"], 404); + } + + #[test] + fn ping_returns_an_empty_result() { + let out = drive( + &bare_ctx(), + &[json!({ "jsonrpc": "2.0", "id": 5, "method": "ping" })], + ); + assert_eq!(out[0]["id"], 5); + assert!(out[0]["result"].is_object()); + } + + #[test] + fn unknown_method_is_method_not_found() { + let out = drive( + &bare_ctx(), + &[json!({ "jsonrpc": "2.0", "id": 9, "method": "frobnicate" })], + ); + assert_eq!(out[0]["error"]["code"], METHOD_NOT_FOUND); + } + + #[test] + fn notifications_get_no_response() { + let out = drive( + &bare_ctx(), + &[json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })], + ); + assert!(out.is_empty(), "a notification is answered with silence"); + } + + #[test] + fn malformed_json_is_a_parse_error_with_null_id() { + let mut output: Vec = Vec::new(); + serve(io::Cursor::new("{not json}\n"), &mut output, &bare_ctx()).unwrap(); + let resp: Value = serde_json::from_str(String::from_utf8(output).unwrap().trim()).unwrap(); + assert_eq!(resp["error"]["code"], PARSE_ERROR); + assert!(resp["id"].is_null()); + } + + #[test] + fn invalid_utf8_line_is_a_parse_error_and_the_server_survives() { + // A bad byte on one line must not kill the server: it answers a parse + // error and still serves the next, valid request (the trailing ping). + let mut input: Vec = vec![0xff, 0xfe, b' ', b'x', b'\n']; + input.extend_from_slice(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#); + input.push(b'\n'); + let mut output: Vec = Vec::new(); + serve(io::Cursor::new(input), &mut output, &bare_ctx()).unwrap(); + let lines: Vec = String::from_utf8(output) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!( + lines.len(), + 2, + "the bad line is answered, the ping still served" + ); + assert_eq!(lines[0]["error"]["code"], PARSE_ERROR); + assert!(lines[0]["id"].is_null()); + assert_eq!(lines[1]["id"], 1); + assert!(lines[1]["result"].is_object()); + } + + #[test] + fn non_object_and_non_string_method_are_invalid_requests() { + // Valid JSON that is not a request object: -32600, not -32700. + let out = drive(&bare_ctx(), &[json!([1, 2, 3])]); + assert_eq!(out[0]["error"]["code"], INVALID_REQUEST); + // A request whose `method` is the wrong type is also an invalid request. + let out = drive( + &bare_ctx(), + &[json!({ "jsonrpc": "2.0", "id": 3, "method": 123 })], + ); + assert_eq!(out[0]["error"]["code"], INVALID_REQUEST); + assert_eq!(out[0]["id"], 3); + } + + #[test] + fn an_explicit_null_id_is_a_request_not_a_notification() { + // Strict JSON-RPC 2.0: an explicit `id: null` is a request expecting a + // null-id response, distinct from a notification (an absent id). + let out = drive( + &bare_ctx(), + &[json!({ "jsonrpc": "2.0", "id": null, "method": "ping" })], + ); + assert_eq!(out.len(), 1, "an explicit null id gets a response"); + assert!(out[0]["id"].is_null()); + assert!(out[0]["result"].is_object()); + } + + #[test] + fn shutdown_answers_then_stops_the_loop() { + // The ping after shutdown must never be answered: the loop has stopped. + let input = format!( + "{}\n{}\n", + json!({ "jsonrpc": "2.0", "id": 1, "method": "shutdown" }), + json!({ "jsonrpc": "2.0", "id": 2, "method": "ping" }) + ); + let mut output: Vec = Vec::new(); + serve(io::Cursor::new(input), &mut output, &bare_ctx()).unwrap(); + let lines: Vec = String::from_utf8(output) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| serde_json::from_str(l).unwrap()) + .collect(); + assert_eq!(lines.len(), 1, "only shutdown is answered"); + assert_eq!(lines[0]["id"], 1); + } + + #[test] + fn print_config_snippet_carries_mcp_args_and_optional_env() { + let with = config_snippet(Some("http://localhost:4000"), "/usr/local/bin/stagecraft"); + let entry = &with["mcpServers"]["stagecraft"]; + assert_eq!(entry["command"], "/usr/local/bin/stagecraft"); + assert_eq!(entry["args"][0], "mcp"); + assert_eq!(entry["env"]["STAGECRAFT_BASE_URL"], "http://localhost:4000"); + + // No base URL resolved: no env block to hardcode. + let without = config_snippet(None, "stagecraft"); + assert!(without["mcpServers"]["stagecraft"].get("env").is_none()); + } +} diff --git a/src/verbs/fleet.rs b/src/verbs/fleet.rs index 4d04c5b..c3f6747 100644 --- a/src/verbs/fleet.rs +++ b/src/verbs/fleet.rs @@ -7,11 +7,63 @@ use std::fmt::Write; use serde_json::{json, Map, Value}; use super::{as_array, client_for, field, render, require_field, table}; -use crate::api::block_on; +use crate::api::{block_on, ApiClient, ApiError}; use crate::config::ResolvedConfig; use crate::error::AppResult; use crate::output::OutputFormat; +/// GET /api/v1/tenants/:id/fleet: the `fleet list` request, shared by both faces +/// (spec 005 reuses each of these for the matching MCP tool). +pub(crate) async fn list_request(client: &ApiClient, tenant: &str) -> Result { + client + .get_value(&format!("/api/v1/tenants/{tenant}/fleet")) + .await +} + +/// POST /api/v1/tenants/:id/fleet {name, image}: the `fleet deploy` request. +pub(crate) async fn deploy_request( + client: &ApiClient, + tenant: &str, + app: &str, + image: &str, +) -> Result { + let path = format!("/api/v1/tenants/{tenant}/fleet"); + let mut body = Map::new(); + body.insert("name".to_string(), Value::String(app.to_string())); + body.insert("image".to_string(), Value::String(image.to_string())); + client.post_value(&path, Value::Object(body)).await +} + +/// POST /api/v1/fleet/:appId/update {image}: the `fleet update` request. +pub(crate) async fn update_request( + client: &ApiClient, + app_id: &str, + image: &str, +) -> Result { + let path = format!("/api/v1/fleet/{app_id}/update"); + client.post_value(&path, json!({ "image": image })).await +} + +/// POST /api/v1/fleet/:appId/backup: the `fleet backup` request. +pub(crate) async fn backup_request(client: &ApiClient, app_id: &str) -> Result { + let path = format!("/api/v1/fleet/{app_id}/backup"); + client.post_value(&path, json!({})).await +} + +/// DELETE /api/v1/fleet/:appId {confirm}: the `fleet remove` request. The +/// confirm name is echoed in the body (stagecraft spec 006 §3); the platform +/// rejects a mismatch, so both faces forward it as-is rather than pre-judging. +pub(crate) async fn remove_request( + client: &ApiClient, + app_id: &str, + confirm: &str, +) -> Result { + let path = format!("/api/v1/fleet/{app_id}"); + client + .delete_value(&path, json!({ "confirm": confirm })) + .await +} + /// `fleet list --tenant ` -> GET /api/v1/tenants/:id/fleet. pub fn list( resolved: &ResolvedConfig, @@ -20,8 +72,7 @@ pub fn list( tenant: &str, ) -> AppResult<()> { let client = client_for(resolved, debug)?; - let path = format!("/api/v1/tenants/{tenant}/fleet"); - let result = block_on(client.get_value(&path))?; + let result = block_on(list_request(&client, tenant))?; render(format, result, render_list) } @@ -36,11 +87,7 @@ pub fn deploy( image: &str, ) -> AppResult<()> { let client = client_for(resolved, debug)?; - let path = format!("/api/v1/tenants/{tenant}/fleet"); - let mut body = Map::new(); - body.insert("name".to_string(), Value::String(app.to_string())); - body.insert("image".to_string(), Value::String(image.to_string())); - let result = block_on(client.post_value(&path, Value::Object(body)))?; + let result = block_on(deploy_request(&client, tenant, app, image))?; render(format, result, render_app) } @@ -53,9 +100,7 @@ pub fn update( image: &str, ) -> AppResult<()> { let client = client_for(resolved, debug)?; - let path = format!("/api/v1/fleet/{app_id}/update"); - let body = json!({ "image": image }); - let result = block_on(client.post_value(&path, body))?; + let result = block_on(update_request(&client, app_id, image))?; render(format, result, render_app) } @@ -67,14 +112,11 @@ pub fn backup( app_id: &str, ) -> AppResult<()> { let client = client_for(resolved, debug)?; - let path = format!("/api/v1/fleet/{app_id}/backup"); - let result = block_on(client.post_value(&path, json!({})))?; + let result = block_on(backup_request(&client, app_id))?; render(format, result, render_op) } -/// `fleet remove --confirm ` -> DELETE /api/v1/fleet/:appId. The -/// confirm name is echoed in the body (stagecraft spec 006 §3); the platform -/// rejects a mismatch, so the CLI forwards it as-is rather than pre-judging. +/// `fleet remove --confirm ` -> DELETE /api/v1/fleet/:appId. pub fn remove( resolved: &ResolvedConfig, format: OutputFormat, @@ -83,9 +125,7 @@ pub fn remove( confirm: &str, ) -> AppResult<()> { let client = client_for(resolved, debug)?; - let path = format!("/api/v1/fleet/{app_id}"); - let body = json!({ "confirm": confirm }); - let result = block_on(client.delete_value(&path, body))?; + let result = block_on(remove_request(&client, app_id, confirm))?; render(format, result, render_app) } @@ -290,7 +330,7 @@ mod tests { #[test] fn fleet_envelope_snapshot() { let data = json!({"id": "a_1", "name": "smoke", "status": "placing"}); - let env = crate::verbs::success_envelope(&data); + let env = crate::verbs::success_envelope_value(&data); assert_eq!(env, json!({ "ok": true, "data": data })); } } diff --git a/src/verbs/mod.rs b/src/verbs/mod.rs index daf40ff..c7d3312 100644 --- a/src/verbs/mod.rs +++ b/src/verbs/mod.rs @@ -249,18 +249,47 @@ fn browser_command(url: &str) -> std::process::Command { command } -/// Build the success envelope as a JSON value (the printed form minus -/// pretty-spacing), for per-verb snapshot tests locking the `{ok,data}` shape. -#[cfg(test)] -fn success_envelope(value: &Value) -> Value { +/// Build the success envelope (`{ok:true,data}`) as an owned JSON value: the +/// same shape [`emit_ok`] prints for `--output json`, produced without touching +/// stdout. The MCP face (spec 005) returns it as the tool result; per-verb +/// snapshot tests use it to lock the shape. +pub(crate) fn success_envelope_value(data: &Value) -> Value { serde_json::to_value(Envelope { ok: true, - data: Some(value), + data: Some(data), error: None, }) .expect("serializing an owned envelope cannot fail") } +/// Build the failure envelope (`{ok:false,error}`) as an owned JSON value from +/// an explicit taxonomy `kind`, message, and optional HTTP status. The MCP face +/// uses this for pre-request failures (no base URL, no stored credential) so its +/// tool-result errors carry the same `error` shape the API path emits. +pub(crate) fn error_envelope(kind: &'static str, message: String, status: Option) -> Value { + serde_json::to_value(Envelope { + ok: false, + data: None, + error: Some(ErrorBody { + kind, + message, + status, + }), + }) + .expect("serializing an owned envelope cannot fail") +} + +/// Wrap a completed verb request in the passthrough envelope: `{ok:true,data}` +/// on success, `{ok:false,error}` mapped from the taxonomy on failure. This is +/// the value the MCP tool result carries (spec 005 §1), byte-for-byte what +/// `--output json` prints for the CLI face (spec 004 §5.2). +pub(crate) fn envelope_value(result: Result) -> Value { + match result { + Ok(value) => success_envelope_value(&value), + Err(err) => error_envelope(err.kind(), err.to_string(), err.status()), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/verbs/stamp.rs b/src/verbs/stamp.rs index 94121c9..78083b1 100644 --- a/src/verbs/stamp.rs +++ b/src/verbs/stamp.rs @@ -17,22 +17,19 @@ const WATCH_INITIAL: Duration = Duration::from_secs(2); /// The poll-interval ceiling for `--watch` (spec 004 §5.4). const WATCH_MAX: Duration = Duration::from_secs(10); -/// `stamp new` -> POST /api/v1/tenants/:id/stamps. `posture` is always sent -/// (the flag is required); `frontend` only when supplied. -#[allow(clippy::too_many_arguments)] -pub fn new( - resolved: &ResolvedConfig, - format: OutputFormat, - debug: bool, +/// POST /api/v1/tenants/:id/stamps: the `stamp new` request, shared by both +/// faces (the CLI renders it, the MCP `stamp_new` tool returns its envelope, +/// spec 005). `posture` is always sent (it is required, never defaulted); +/// `frontend` only when supplied. +pub(crate) async fn new_request( + client: &ApiClient, tenant: &str, app: &str, org: &str, frontend: Option<&str>, posture: Posture, -) -> AppResult<()> { - let client = client_for(resolved, debug)?; +) -> Result { let path = format!("/api/v1/tenants/{tenant}/stamps"); - let mut body = Map::new(); body.insert("appName".to_string(), Value::String(app.to_string())); body.insert("targetOrg".to_string(), Value::String(org.to_string())); @@ -43,8 +40,30 @@ pub fn new( if let Some(frontend) = frontend { body.insert("frontend".to_string(), Value::String(frontend.to_string())); } + client.post_value(&path, Value::Object(body)).await +} - let result = block_on(client.post_value(&path, Value::Object(body)))?; +/// GET /api/v1/stamps/:jobId: a single status poll. The CLI non-watch path and +/// the MCP `stamp_status` tool share it (spec 005: MCP polls once, the agent +/// loops itself); the CLI `--watch` loop drives the same endpoint below. +pub(crate) async fn status_request(client: &ApiClient, job_id: &str) -> Result { + client.get_value(&format!("/api/v1/stamps/{job_id}")).await +} + +/// `stamp new` -> POST /api/v1/tenants/:id/stamps. +#[allow(clippy::too_many_arguments)] +pub fn new( + resolved: &ResolvedConfig, + format: OutputFormat, + debug: bool, + tenant: &str, + app: &str, + org: &str, + frontend: Option<&str>, + posture: Posture, +) -> AppResult<()> { + let client = client_for(resolved, debug)?; + let result = block_on(new_request(&client, tenant, app, org, frontend, posture))?; render(format, result, render_stamp) } @@ -57,11 +76,11 @@ pub fn status( watch: bool, ) -> AppResult<()> { let client = client_for(resolved, debug)?; - let path = format!("/api/v1/stamps/{job_id}"); if watch { + let path = format!("/api/v1/stamps/{job_id}"); block_on(watch_loop(&client, &path, format))? } else { - let result = block_on(client.get_value(&path))?; + let result = block_on(status_request(&client, job_id))?; render(format, result, render_stamp) } } @@ -304,7 +323,7 @@ mod tests { #[test] fn stamp_envelope_snapshot() { let data = json!({"id": "j_1", "status": "queued"}); - let env = crate::verbs::success_envelope(&data); + let env = crate::verbs::success_envelope_value(&data); assert_eq!(env, json!({ "ok": true, "data": data })); } } diff --git a/src/verbs/tenants.rs b/src/verbs/tenants.rs index e072bd1..144da0e 100644 --- a/src/verbs/tenants.rs +++ b/src/verbs/tenants.rs @@ -5,15 +5,26 @@ use std::fmt::Write; use serde_json::Value; use super::{as_array, client_for, emit_err, emit_ok, field, render, table}; -use crate::api::block_on; +use crate::api::{block_on, ApiClient, ApiError}; use crate::config::ResolvedConfig; use crate::error::AppResult; use crate::output::OutputFormat; +/// GET /api/v1/tenants: the `tenants list` request, shared by both faces (the +/// CLI renders it, the MCP `tenants_list` tool returns its envelope, spec 005). +pub(crate) async fn list_request(client: &ApiClient) -> Result { + client.get_value("/api/v1/tenants").await +} + +/// GET /api/v1/tenants/:id (includes installations): the `tenants show` request. +pub(crate) async fn show_request(client: &ApiClient, id: &str) -> Result { + client.get_value(&format!("/api/v1/tenants/{id}")).await +} + /// `tenants list` -> GET /api/v1/tenants. pub fn list(resolved: &ResolvedConfig, format: OutputFormat, debug: bool) -> AppResult<()> { let client = client_for(resolved, debug)?; - let result = block_on(client.get_value("/api/v1/tenants"))?; + let result = block_on(list_request(&client))?; render(format, result, render_list) } @@ -25,8 +36,7 @@ pub fn show( id: &str, ) -> AppResult<()> { let client = client_for(resolved, debug)?; - let path = format!("/api/v1/tenants/{id}"); - let result = block_on(client.get_value(&path))?; + let result = block_on(show_request(&client, id))?; render(format, result, render_detail) } @@ -226,7 +236,7 @@ mod tests { #[test] fn list_envelope_snapshot() { let data = json!([{"id": "t_1", "name": "Acme"}]); - let env = crate::verbs::success_envelope(&data); + let env = crate::verbs::success_envelope_value(&data); assert_eq!(env, json!({ "ok": true, "data": data })); } } diff --git a/tests/cli.rs b/tests/cli.rs index 0091864..38bdd2f 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -2,7 +2,8 @@ //! //! Cargo sets `CARGO_BIN_EXE_stagecraft`, so no extra test crates are needed. -use std::process::{Command, Output}; +use std::io::Write; +use std::process::{Command, Output, Stdio}; fn run(args: &[&str]) -> Output { Command::new(env!("CARGO_BIN_EXE_stagecraft")) @@ -14,24 +15,60 @@ fn run(args: &[&str]) -> Output { .expect("failed to run stagecraft binary") } +/// Drive the binary with `input` piped to stdin, then close it. The `mcp` stdio +/// server reads newline-delimited requests and shuts down on the resulting EOF. +fn run_with_stdin(args: &[&str], input: &str) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_stagecraft")) + .args(args) + .env_remove("STAGECRAFT_BASE_URL") + .env_remove("STAGECRAFT_OUTPUT") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn stagecraft binary"); + child + .stdin + .take() + .expect("stdin was piped") + .write_all(input.as_bytes()) + .expect("failed to write stdin"); + // The taken stdin drops here, closing the pipe: EOF the server treats as + // shutdown, so the wait below returns rather than blocking. + child + .wait_with_output() + .expect("failed to wait on stagecraft binary") +} + #[test] -fn stub_verb_exits_2_names_spec_and_keeps_stdout_clean() { - // `mcp` is the last stub: login/whoami left when spec 003 landed, and - // tenants/stamp/fleet left when spec 004 implemented them. - let out = run(&["mcp"]); - assert_eq!(out.status.code(), Some(2), "mcp should exit 2"); - let stderr = String::from_utf8_lossy(&out.stderr); +fn mcp_print_config_emits_an_installable_snippet() { + // `mcp --print-config` is the install helper (spec 005 §1): a `.mcp.json` + // snippet on stdout, exit 0. No stubs remain in the command tree. + let out = run(&["mcp", "--print-config"]); + assert_eq!(out.status.code(), Some(0), "print-config should exit 0"); + let value: serde_json::Value = + serde_json::from_slice(&out.stdout).expect("print-config emits valid JSON"); + assert_eq!(value["mcpServers"]["stagecraft"]["args"][0], "mcp"); assert!( - stderr.contains("005"), - "mcp stderr should name spec 005, got: {stderr}" - ); - assert!( - out.stdout.is_empty(), - "errors must not print to stdout, got: {:?}", - String::from_utf8_lossy(&out.stdout) + value["mcpServers"]["stagecraft"]["command"].is_string(), + "the snippet names a launch command, got: {value}" ); } +#[test] +fn mcp_server_answers_initialize_over_stdio() { + // The real binary wires stdin -> the JSON-RPC loop -> stdout. Feed one + // initialize request; the server replies, then shuts down on stdin EOF. + let request = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let out = run_with_stdin(&["mcp"], &format!("{request}\n")); + assert_eq!(out.status.code(), Some(0), "clean shutdown on stdin EOF"); + let line = String::from_utf8_lossy(&out.stdout); + let value: serde_json::Value = + serde_json::from_str(line.trim()).expect("one JSON-RPC response line on stdout"); + assert_eq!(value["id"], 1); + assert_eq!(value["result"]["serverInfo"]["name"], "stagecraft"); +} + #[test] fn governance_verbs_without_base_url_are_usage_errors() { // The spec 004 verbs reach the base-URL guard before any network call;