Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .derived/codebase-index/by-spec/003-auth-api-client.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "c5fef062ac8d9429215bef31321e37f07fd7c74fdb018ea77bca55f76230d39f"
"shardHash": "ca56147cbcadfa7d1939c6a721277f0d812698189366e7f36eabfe4beb32986f"
}
6 changes: 3 additions & 3 deletions .derived/codebase-index/by-spec/004-governance-verbs.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
{
"file": "src/main.rs",
"span": {
"endLine": 12,
"startLine": 12
"endLine": 13,
"startLine": 13
}
}
],
Expand All @@ -32,5 +32,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "3314a5176288efdc33f1dcb517735e6ff3c09f8074431c7a51d77ff9fb1c39fc"
"shardHash": "3a8b9f30bdcbc6fc77507d7be059f9e25feeaaaab8208d8950eb68572cd29386"
}
28 changes: 16 additions & 12 deletions .derived/codebase-index/by-spec/005-mcp-server.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -28,5 +32,5 @@
"specStatus": "approved"
},
"schemaVersion": "1.1.0",
"shardHash": "e008833142ffaa2dcd19e8e7523beadc84a57e2c6ebe45abb6f043e14de4d7a6"
"shardHash": "e70bccf4edf182edfbbbc8a646914cc844164100db692ccdea182dfa83f46c52"
}
8 changes: 5 additions & 3 deletions .derived/spec-registry/by-spec/005-mcp-server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
65 changes: 64 additions & 1 deletion specs/005-mcp-server/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 ->
Expand All @@ -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.
18 changes: 17 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Self> {
match token {
"none" => Some(Posture::None),
"assisted" => Some(Posture::Assisted),
"autonomous" => Some(Posture::Autonomous),
_ => None,
}
}
}

#[derive(Debug, Subcommand)]
Expand Down
10 changes: 8 additions & 2 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(())
Expand Down
11 changes: 9 additions & 2 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },

Expand Down Expand Up @@ -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<String>, spec: &'static str) -> Self {
AppError::NotImplemented {
command: command.into(),
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod cli;
mod commands;
mod config;
mod error;
mod mcp;
mod output;
mod verbs;

Expand Down
Loading
Loading