Skip to content

Commit fa983a9

Browse files
avrabeclaude
andauthored
feat(mcp): spar-mcp crate skeleton + read-only tools (Track E commit 8 / v0.9.0) (#179)
New `spar-mcp` crate exposing spar's hypothetical-rebinding oracle as MCP (Model Context Protocol) tools so LLM agents can drive design-space exploration with spar as the deterministic correctness oracle. Three read-only / idempotent tools: - `spar.verify_move` — single hypothetical-rebinding check - `spar.enumerate_moves` — design-space exploration with multi-objective ranking - `spar.check_chain` — end-to-end latency breakdown for a chain Architecture: - `spar-cli` promoted to lib + bin so verify/enumerate logic is shareable - `spar-mcp` consumes the lib API in-process; no shell-out / re-parsing - stdio JSON-RPC transport (Initialize, tools/list, tools/call) - All tools `readOnlyHint: true` and `idempotentHint: true` per spec - Deterministic apply path stays CLI-exclusive (no `spar.apply_move` over MCP) Tests: 11 (5 in-process + 6 stdio). REQ-MCP-001 + TEST-MCP-* in artifacts/. Closes Track E commit 8/8 of the v0.8.0 migration design carved out as v0.9.0 scope per docs/designs/track-e-migration-research.md §6.5. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e39f065 commit fa983a9

19 files changed

Lines changed: 2152 additions & 45 deletions

Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ members = [
1212
"crates/spar-transform",
1313
"crates/spar-variants",
1414
"crates/spar-cli",
15+
"crates/spar-mcp",
1516
"crates/spar-codegen",
1617
"crates/spar-render",
1718
"crates/spar-solver",

artifacts/requirements.yaml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,4 +1495,30 @@ artifacts:
14951495
status: implemented
14961496
tags: [insight, trace, cli, v090]
14971497

1498+
# ── Track E commit 8/8 — MCP oracle surface (v0.9.0) ──────────────────
1499+
1500+
- id: REQ-MCP-002
1501+
type: requirement
1502+
title: spar-mcp read-only verification-oracle tool surface
1503+
description: >
1504+
System exposes read-only / idempotent MCP (Model Context Protocol)
1505+
tools `spar.verify_move`, `spar.enumerate_moves`, and
1506+
`spar.check_chain` for AI agent integration. The three tools wrap
1507+
the existing `spar moves verify`, `spar moves enumerate`, and
1508+
end-to-end latency-analysis pipelines without printing — each tool
1509+
returns the same structured JSON shape the equivalent CLI
1510+
subcommand emits with `--format json`. The deterministic apply
1511+
path stays CLI-exclusive: there is no `spar.apply_move` tool over
1512+
MCP, ever, by certification design (per Track E migration research
1513+
§6.5). LLM agents propose moves; spar deterministically verifies;
1514+
certification chain stays in spar. The transport is JSON-RPC 2.0
1515+
over stdio per MCP 2025-11-25; the server is reachable as either a
1516+
standalone `spar-mcp` binary or via the `spar mcp serve`
1517+
subcommand. All three tools declare `readOnlyHint: true` and
1518+
`idempotentHint: true` in their MCP annotations and refuse to
1519+
mutate the model file on disk. Track E commit 8/8 of the v0.9.0
1520+
design.
1521+
status: implemented
1522+
tags: [mcp, migration, track-e, v090, integration]
1523+
14981524
# Research findings tracked separately in research/findings.yaml

artifacts/verification.yaml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1952,3 +1952,50 @@ artifacts:
19521952
target: REQ-INSIGHT-001
19531953
- type: satisfies
19541954
target: REQ-INSIGHT-002
1955+
1956+
# ── Track E commit 8/8 — MCP oracle tool surface (v0.9.0) ────────────
1957+
1958+
- id: TEST-MCP-TOOLS
1959+
type: feature
1960+
title: spar-mcp read-only verification-oracle tool surface tests
1961+
description: >
1962+
Integration tests in crates/spar-mcp/tests covering the v0.9.0
1963+
MCP tool surface (Track E commit 8/8). Eleven tests split between
1964+
an in-process Rust API path (5) and a stdio JSON-RPC envelope path
1965+
(6). Coverage:
1966+
(1) `spar.verify_move` returns ok=true and exit_code=0 on an
1967+
admissible mobile-component move;
1968+
(2) `spar.enumerate_moves` enumerates every Processor /
1969+
VirtualProcessor in the instance when no Allowed_Targets is
1970+
set;
1971+
(3) `spar.check_chain` returns the latency-pass diagnostic stream
1972+
(best/worst-case bounds plus per-hop annotations) for a chain
1973+
spanning one compute hop and one connection hop;
1974+
(4) variant scoping (`--variant NAME` over the
1975+
SPAR_VARIANT_TEST_RIVET_OUTPUT seam) populates the report's
1976+
`variant` and `feature_model_hash` audit fields;
1977+
(5) the `--objective` selector accepts the five recognised modes
1978+
and rejects unknown values with a BAD_INPUT error;
1979+
(6) `tools/list` returns exactly three tools with
1980+
`readOnlyHint: true` / `idempotentHint: true`;
1981+
(7-9) `tools/call` for each tool returns valid JSON with the
1982+
documented `structuredContent` shape and `isError: false`;
1983+
(10) an unknown tool name returns the JSON-RPC MethodNotFound
1984+
error code (-32601);
1985+
plus one bonus test confirming unknown JSON-RPC methods (e.g.
1986+
`sampling/createMessage`) also return -32601.
1987+
fields:
1988+
method: automated-test
1989+
steps:
1990+
- run: cargo test -p spar-mcp
1991+
status: passing
1992+
tags: [mcp, migration, track-e, v090, integration]
1993+
links:
1994+
- type: satisfies
1995+
target: REQ-MCP-002
1996+
- type: satisfies
1997+
target: REQ-MIGRATION-005
1998+
- type: satisfies
1999+
target: REQ-MIGRATION-006
2000+
- type: satisfies
2001+
target: REQ-MIGRATION-008

crates/spar-cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ license.workspace = true
66
repository.workspace = true
77
description = "AADL toolchain CLI"
88

9+
[lib]
10+
name = "spar_cli"
11+
path = "src/lib.rs"
12+
913
[[bin]]
1014
name = "spar"
1115
path = "src/main.rs"

crates/spar-cli/src/lib.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//! Public library surface for `spar-cli`.
2+
//!
3+
//! `spar-cli` is primarily a binary crate (`spar`); the library exposes
4+
//! the *internals* needed by sibling crates — currently:
5+
//!
6+
//! - [`moves`] — the verify / enumerate pipelines used by the v0.9.0
7+
//! MCP tool surface ([`spar-mcp`](../spar_mcp/index.html)). The MCP
8+
//! tools call [`moves::verify_pipeline`] and
9+
//! [`moves::enumerate_pipeline`] directly so the wire-format report
10+
//! shape stays a single source of truth across CLI and MCP transport.
11+
//!
12+
//! Everything else (LSP, codegen dispatch, refactor, diff, sarif) is
13+
//! still private to the binary and exposed only via `spar <command>`.
14+
//!
15+
//! # Stability
16+
//!
17+
//! This is an internal-use-only library; the public surface only
18+
//! supports the in-tree `spar-mcp` consumer. External users should
19+
//! shell out to `spar` (or, for v0.9.0+, drive the MCP server) rather
20+
//! than depend on this crate as a Rust library.
21+
22+
pub mod moves;
23+
pub mod variants_bridge;

crates/spar-cli/src/main.rs

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,18 @@ mod assertion;
22
mod diff;
33
mod insight;
44
mod lsp;
5-
mod moves;
65
mod refactor;
76
mod sarif;
8-
mod variants_bridge;
97
mod verify;
108

9+
// Re-export shared modules from the library so the binary keeps using
10+
// the `crate::moves::…` / `crate::variants_bridge::…` paths in its
11+
// existing call sites without duplicating module bodies. The lib
12+
// (spar_cli) is the canonical home; spar-mcp consumes the same surface.
13+
use spar_cli::moves;
14+
#[allow(unused_imports)]
15+
use spar_cli::variants_bridge;
16+
1117
use std::{env, fs, process};
1218

1319
use serde::Serialize;
@@ -44,6 +50,7 @@ fn main() {
4450
"extract" => cmd_sysml2_extract(&args[2..]),
4551
"generate" => cmd_sysml2_generate(&args[2..]),
4652
"lsp" => cmd_lsp(),
53+
"mcp" => cmd_mcp(&args[2..]),
4754
"moves" => moves::cmd_moves_dispatch(&args[2..]),
4855
"insight" => insight::cmd_insight(&args[2..]),
4956
other => {
@@ -72,6 +79,9 @@ fn print_usage() {
7279
);
7380
eprintln!(" insight Discrepancy assistant: compare CTF traces to Expected_BCET/WCET/Mean");
7481
eprintln!(" lsp Start Language Server Protocol server (stdin/stdout)");
82+
eprintln!(
83+
" mcp Start MCP server (read-only verification oracles for AI agent integration)"
84+
);
7585
eprintln!();
7686
eprintln!("Options:");
7787
eprintln!(" parse [--tree] <file...>");
@@ -107,6 +117,102 @@ fn cmd_lsp() {
107117
lsp::run_lsp_server();
108118
}
109119

120+
/// `spar mcp serve` — start the spar-mcp stdio JSON-RPC server.
121+
///
122+
/// The actual server lives in the sibling `spar-mcp` crate. To avoid a
123+
/// Cargo cycle (spar -> spar-mcp -> spar-cli (lib) -> spar binary), we
124+
/// exec the standalone `spar-mcp` binary that ships alongside `spar`.
125+
/// We resolve it via:
126+
///
127+
/// 1. `$SPAR_MCP_BIN` if set;
128+
/// 2. a sibling of the current executable (`<dir>/spar-mcp[.exe]`);
129+
/// 3. the host `$PATH`.
130+
fn cmd_mcp(args: &[String]) {
131+
let usage = || {
132+
eprintln!("Usage: spar mcp serve");
133+
eprintln!();
134+
eprintln!("Subcommands:");
135+
eprintln!(
136+
" serve Start the MCP stdio JSON-RPC server (read-only / idempotent oracle tools)"
137+
);
138+
};
139+
140+
let sub = match args.first().map(String::as_str) {
141+
None | Some("--help") | Some("-h") => {
142+
usage();
143+
process::exit(if args.is_empty() { 1 } else { 0 });
144+
}
145+
Some("serve") => "serve",
146+
Some(other) => {
147+
eprintln!("Unknown mcp subcommand: {other}");
148+
usage();
149+
process::exit(1);
150+
}
151+
};
152+
153+
if sub != "serve" {
154+
// Defensive: should be unreachable given the match above.
155+
usage();
156+
process::exit(1);
157+
}
158+
159+
let bin = match locate_spar_mcp_binary() {
160+
Some(p) => p,
161+
None => {
162+
eprintln!(
163+
"spar-mcp binary not found (searched $SPAR_MCP_BIN, the directory of the \
164+
current spar binary, and $PATH). Build the workspace with `cargo build -p \
165+
spar-mcp` and re-run."
166+
);
167+
process::exit(1);
168+
}
169+
};
170+
171+
// Replace the current process so stdio is shared 1:1 with the
172+
// child — no buffering layer between the JSON-RPC client and the
173+
// server loop.
174+
let err = std::process::Command::new(&bin).args(&args[1..]).status();
175+
match err {
176+
Ok(status) => process::exit(status.code().unwrap_or(1)),
177+
Err(e) => {
178+
eprintln!("failed to execute {}: {e}", bin.display());
179+
process::exit(1);
180+
}
181+
}
182+
}
183+
184+
/// Locate the `spar-mcp` binary on disk. See [`cmd_mcp`] for the
185+
/// resolution order.
186+
fn locate_spar_mcp_binary() -> Option<std::path::PathBuf> {
187+
if let Some(v) = std::env::var_os("SPAR_MCP_BIN") {
188+
let p = std::path::PathBuf::from(v);
189+
if p.is_file() {
190+
return Some(p);
191+
}
192+
}
193+
if let Ok(self_exe) = std::env::current_exe()
194+
&& let Some(dir) = self_exe.parent()
195+
{
196+
for name in ["spar-mcp", "spar-mcp.exe"] {
197+
let candidate = dir.join(name);
198+
if candidate.is_file() {
199+
return Some(candidate);
200+
}
201+
}
202+
}
203+
if let Some(path) = std::env::var_os("PATH") {
204+
for dir in std::env::split_paths(&path) {
205+
for name in ["spar-mcp", "spar-mcp.exe"] {
206+
let candidate = dir.join(name);
207+
if candidate.is_file() {
208+
return Some(candidate);
209+
}
210+
}
211+
}
212+
}
213+
None
214+
}
215+
110216
fn cmd_parse(args: &[String]) {
111217
let mut show_tree = false;
112218
let mut files = Vec::new();

0 commit comments

Comments
 (0)