609 lines
17 KiB
Markdown
609 lines
17 KiB
Markdown
# Sub-plan 4 — Tools + MCP Server (Tasks 6–9)
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans.
|
||
|
||
**Goal:** Implement the three tool handlers (`list_backends`, `validate_circuit`, `run_circuit`) and wire everything into a working rmcp stdio MCP server. Smoke-test with a live JSON-RPC call.
|
||
|
||
**Starting state (after sub-plan 3):**
|
||
- All of sub-plans 1–3 files present
|
||
- `src/executor.rs` — `LocalSimulator` fully implemented, all 7 tests pass
|
||
- `src/validator.rs` — `CircuitValidator` fully implemented, all 5 tests pass
|
||
- `cargo test` passes
|
||
|
||
**Deliverable:** `cargo build` produces a working MCP binary. `cargo test` passes with 16+ unit tests. Smoke test shows a valid JSON-RPC `initialize` response. Four commits added.
|
||
|
||
**Key rmcp API:**
|
||
- Macros: `#[tool_router]` on `impl`, `#[tool_handler]` on `impl ServerHandler`, `#[tool(description="...")]` on each method
|
||
- Parameters: `Parameters(MyParams { field }): Parameters<MyParams>` where `MyParams` derives `Deserialize + JsonSchema`
|
||
- Return: `Result<CallToolResult, McpError>` — success via `CallToolResult::success(vec![Content::text("...")])`, error via `McpError::invalid_params("msg", None)`
|
||
- Server field: `tool_router: ToolRouter<Self>` initialised with `Self::tool_router()`
|
||
- stdio: `QuantumBridgeServer::new().serve(stdio()).await?.waiting().await?`
|
||
- Logs go to **stderr** — stdout is the MCP wire
|
||
|
||
**Main plan reference:** `docs/superpowers/plans/2026-04-28-quantum-bridge-mcp-v1.md` Tasks 6–9.
|
||
|
||
---
|
||
|
||
## Task 6: Tool — list_backends
|
||
|
||
**Files:**
|
||
- Create: `src/tools/list_backends.rs`
|
||
- Create: `src/tools/mod.rs` (stub, extended in Task 9)
|
||
|
||
- [ ] **Step 1: Create src/tools/list_backends.rs with failing test**
|
||
|
||
```rust
|
||
use serde_json::json;
|
||
|
||
use crate::executor::{CanIntrospect, LocalSimulator, MAX_LOCAL_QUBITS};
|
||
|
||
pub fn list_backends_response() -> serde_json::Value {
|
||
todo!("implement list_backends_response")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn response_contains_local_simulator() {
|
||
let resp = list_backends_response();
|
||
let backends = resp["backends"].as_array().unwrap();
|
||
assert_eq!(backends.len(), 1);
|
||
assert_eq!(backends[0]["name"], "local_simulator");
|
||
}
|
||
|
||
#[test]
|
||
fn response_contains_max_qubits() {
|
||
let resp = list_backends_response();
|
||
assert_eq!(
|
||
resp["backends"][0]["max_qubits"].as_u64().unwrap(),
|
||
MAX_LOCAL_QUBITS as u64
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn response_lists_supported_gates() {
|
||
let resp = list_backends_response();
|
||
let gates = resp["backends"][0]["supported_gates"].as_array().unwrap();
|
||
assert!(!gates.is_empty());
|
||
assert!(gates.iter().any(|g| g == "h"));
|
||
assert!(gates.iter().any(|g| g == "cx"));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create src/tools/mod.rs (stub)**
|
||
|
||
```rust
|
||
pub mod list_backends;
|
||
pub mod run_circuit;
|
||
pub mod validate_circuit;
|
||
```
|
||
|
||
- [ ] **Step 3: Declare tools module in src/main.rs**
|
||
|
||
Add `mod tools;` to `src/main.rs`.
|
||
|
||
- [ ] **Step 4: Run tests to confirm todo!() panics**
|
||
|
||
```bash
|
||
cargo test tools::list_backends 2>&1 | tail -5
|
||
```
|
||
|
||
- [ ] **Step 5: Implement list_backends_response**
|
||
|
||
Replace `todo!()`:
|
||
|
||
```rust
|
||
pub fn list_backends_response() -> serde_json::Value {
|
||
let sim = LocalSimulator::new();
|
||
json!({
|
||
"backends": [{
|
||
"name": sim.name(),
|
||
"description": "Local statevector simulator (Spinoza engine). No network, no account required.",
|
||
"max_qubits": sim.max_qubits(),
|
||
"supported_gates": sim.supported_gates(),
|
||
"simulation_type": "statevector",
|
||
"notes": "Statevector memory: 2^n × 16 bytes. Max 28 qubits."
|
||
}]
|
||
})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Run tests**
|
||
|
||
```bash
|
||
cargo test tools::list_backends 2>&1
|
||
```
|
||
|
||
Expected: 3 tests pass.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/tools/mod.rs src/tools/list_backends.rs src/main.rs
|
||
git commit -m "feat: implement list_backends tool handler"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Tool — validate_circuit
|
||
|
||
**Files:**
|
||
- Create: `src/tools/validate_circuit.rs`
|
||
|
||
- [ ] **Step 1: Create file with failing test**
|
||
|
||
```rust
|
||
use serde_json::{json, Value};
|
||
|
||
use crate::executor::MAX_LOCAL_QUBITS;
|
||
use crate::types::CircuitSource;
|
||
|
||
pub fn validate_circuit_response(circuit: &str) -> Value {
|
||
todo!("implement validate_circuit_response")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const BELL: &str = r#"OPENQASM 3.0;
|
||
include "stdgates.inc";
|
||
qubit[2] q;
|
||
bit[2] c;
|
||
h q[0];
|
||
cx q[0], q[1];
|
||
c = measure q;"#;
|
||
|
||
#[test]
|
||
fn valid_circuit_returns_is_valid_true() {
|
||
let resp = validate_circuit_response(BELL);
|
||
assert_eq!(resp["is_valid"], true);
|
||
assert_eq!(resp["diagnostics"].as_array().unwrap().len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn valid_circuit_includes_qubit_count() {
|
||
let resp = validate_circuit_response(BELL);
|
||
assert_eq!(resp["num_qubits"].as_u64().unwrap(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_circuit_returns_is_valid_false_with_message() {
|
||
let resp = validate_circuit_response("OPENQASM 3.0;\nnot_valid_qasm;");
|
||
assert_eq!(resp["is_valid"], false);
|
||
assert!(!resp["diagnostics"].as_array().unwrap().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_gate_returns_diagnostic_with_gate_name() {
|
||
let circuit = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[1] q;\nu3(0.1,0.2,0.3) q[0];";
|
||
let resp = validate_circuit_response(circuit);
|
||
assert_eq!(resp["is_valid"], false);
|
||
let msg = resp["diagnostics"][0]["message"].as_str().unwrap();
|
||
assert!(msg.to_lowercase().contains("u3"), "got: {msg}");
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostic_includes_line_and_column() {
|
||
let resp = validate_circuit_response("OPENQASM 3.0;\nnot_valid_qasm;\n");
|
||
assert!(resp["diagnostics"][0]["line"].as_u64().unwrap() >= 1);
|
||
assert!(resp["diagnostics"][0]["column"].as_u64().unwrap() >= 1);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to confirm todo!() failure**
|
||
|
||
```bash
|
||
cargo test tools::validate_circuit 2>&1 | tail -5
|
||
```
|
||
|
||
- [ ] **Step 3: Implement validate_circuit_response**
|
||
|
||
```rust
|
||
pub fn validate_circuit_response(circuit: &str) -> Value {
|
||
use crate::types::DiagnosticSeverity;
|
||
use crate::validator::CircuitValidator;
|
||
|
||
let validator = CircuitValidator::new(MAX_LOCAL_QUBITS);
|
||
match validator.validate(&CircuitSource(circuit.to_string())) {
|
||
Err(e) => json!({
|
||
"is_valid": false,
|
||
"diagnostics": [{"line": 1, "column": 1, "message": e.to_string(), "severity": "error"}],
|
||
"num_qubits": null,
|
||
"num_gates": null,
|
||
}),
|
||
Ok(result) => {
|
||
let diagnostics: Vec<Value> = result.diagnostics.iter().map(|d| {
|
||
json!({
|
||
"line": d.line,
|
||
"column": d.column,
|
||
"message": d.message,
|
||
"severity": match d.severity {
|
||
DiagnosticSeverity::Error => "error",
|
||
DiagnosticSeverity::Warning => "warning",
|
||
}
|
||
})
|
||
}).collect();
|
||
json!({
|
||
"is_valid": result.is_valid,
|
||
"diagnostics": diagnostics,
|
||
"num_qubits": result.num_qubits,
|
||
"num_gates": result.num_gates,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cargo test tools::validate_circuit 2>&1
|
||
```
|
||
|
||
Expected: 5 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/tools/validate_circuit.rs
|
||
git commit -m "feat: implement validate_circuit tool handler"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Tool — run_circuit
|
||
|
||
**Files:**
|
||
- Create: `src/tools/run_circuit.rs`
|
||
|
||
- [ ] **Step 1: Create file with failing test**
|
||
|
||
```rust
|
||
use serde_json::{json, Value};
|
||
|
||
use crate::types::{CircuitSource, ShotCount};
|
||
|
||
pub fn run_circuit_response(circuit: &str, shots: u32, return_statevector: bool) -> Value {
|
||
todo!("implement run_circuit_response")
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
const BELL: &str = r#"OPENQASM 3.0;
|
||
include "stdgates.inc";
|
||
qubit[2] q;
|
||
bit[2] c;
|
||
h q[0];
|
||
cx q[0], q[1];
|
||
c = measure q;"#;
|
||
|
||
const X: &str = r#"OPENQASM 3.0;
|
||
include "stdgates.inc";
|
||
qubit[1] q;
|
||
bit[1] c;
|
||
x q[0];
|
||
c = measure q;"#;
|
||
|
||
#[test]
|
||
fn bell_counts_total_matches_shots() {
|
||
let resp = run_circuit_response(BELL, 1_000, false);
|
||
let total: u64 = resp["counts"].as_object().unwrap().values()
|
||
.map(|v| v.as_u64().unwrap()).sum();
|
||
assert_eq!(total, 1_000);
|
||
}
|
||
|
||
#[test]
|
||
fn bell_only_produces_00_and_11_outcomes() {
|
||
let resp = run_circuit_response(BELL, 1_000, false);
|
||
for key in resp["counts"].as_object().unwrap().keys() {
|
||
assert!(key == "00" || key == "11", "unexpected: {key}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn x_gate_produces_only_one_outcome() {
|
||
let resp = run_circuit_response(X, 100, false);
|
||
assert_eq!(resp["counts"]["1"].as_u64().unwrap(), 100);
|
||
}
|
||
|
||
#[test]
|
||
fn response_includes_shots_and_execution_time() {
|
||
let resp = run_circuit_response(BELL, 512, false);
|
||
assert_eq!(resp["shots"].as_u64().unwrap(), 512);
|
||
assert!(resp["execution_time_ms"].as_f64().unwrap() >= 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn statevector_absent_when_not_requested() {
|
||
assert!(run_circuit_response(BELL, 100, false)["statevector"].is_null());
|
||
}
|
||
|
||
#[test]
|
||
fn statevector_has_correct_length_when_requested() {
|
||
let sv = run_circuit_response(BELL, 100, true)["statevector"].as_array().unwrap().len();
|
||
assert_eq!(sv, 4);
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_circuit_returns_error_field() {
|
||
let resp = run_circuit_response("OPENQASM 3.0;\nnot_valid;", 100, false);
|
||
assert!(resp.get("error").is_some(), "got: {resp}");
|
||
}
|
||
|
||
#[test]
|
||
fn shots_clamped_to_max() {
|
||
let resp = run_circuit_response(BELL, ShotCount::MAX.0 + 1, false);
|
||
let has_error = resp.get("error").is_some();
|
||
let shots = resp["shots"].as_u64().unwrap_or(0);
|
||
assert!(has_error || shots == ShotCount::MAX.0 as u64);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run to confirm todo!() failure**
|
||
|
||
```bash
|
||
cargo test tools::run_circuit 2>&1 | tail -5
|
||
```
|
||
|
||
- [ ] **Step 3: Implement run_circuit_response**
|
||
|
||
```rust
|
||
pub fn run_circuit_response(circuit: &str, shots: u32, return_statevector: bool) -> Value {
|
||
use crate::executor::{CanExecute, LocalSimulator, MAX_LOCAL_QUBITS};
|
||
use crate::validator::CircuitValidator;
|
||
|
||
let shot_count = ShotCount(shots.min(ShotCount::MAX.0));
|
||
|
||
let validator = CircuitValidator::new(MAX_LOCAL_QUBITS);
|
||
let validation = match validator.validate(&CircuitSource(circuit.to_string())) {
|
||
Err(e) => return json!({"error": e.to_string()}),
|
||
Ok(v) => v,
|
||
};
|
||
if !validation.is_valid {
|
||
let msgs: Vec<&str> = validation.diagnostics.iter().map(|d| d.message.as_str()).collect();
|
||
return json!({"error": format!("validation failed: {}", msgs.join("; "))});
|
||
}
|
||
|
||
let sim = LocalSimulator::new();
|
||
match sim.run(&CircuitSource(circuit.to_string()), shot_count, return_statevector) {
|
||
Err(e) => json!({"error": e.to_string()}),
|
||
Ok(result) => {
|
||
let statevector: Value = match result.statevector {
|
||
None => Value::Null,
|
||
Some(sv) => sv.iter().map(|(r, i)| json!([r, i])).collect::<Vec<_>>().into(),
|
||
};
|
||
json!({
|
||
"counts": result.counts,
|
||
"shots": result.shots,
|
||
"execution_time_ms": result.execution_time_ms,
|
||
"statevector": statevector,
|
||
"backend": "local_simulator",
|
||
})
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests**
|
||
|
||
```bash
|
||
cargo test tools::run_circuit 2>&1
|
||
```
|
||
|
||
Expected: 8 tests pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/tools/run_circuit.rs
|
||
git commit -m "feat: implement run_circuit tool handler"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: MCP Server — main.rs + rmcp wiring
|
||
|
||
**Files:**
|
||
- Modify: `src/tools/mod.rs` (replace stub with full QuantumBridgeServer)
|
||
- Modify: `src/main.rs` (replace stub with async tokio main)
|
||
|
||
- [ ] **Step 1: Replace src/tools/mod.rs with full server implementation**
|
||
|
||
```rust
|
||
pub mod list_backends;
|
||
pub mod run_circuit;
|
||
pub mod validate_circuit;
|
||
|
||
use rmcp::{
|
||
ErrorData as McpError,
|
||
ServerHandler,
|
||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||
model::*,
|
||
tool, tool_handler, tool_router,
|
||
};
|
||
use schemars::JsonSchema;
|
||
use serde::Deserialize;
|
||
|
||
use crate::tools::list_backends::list_backends_response;
|
||
use crate::tools::run_circuit::run_circuit_response;
|
||
use crate::tools::validate_circuit::validate_circuit_response;
|
||
|
||
#[derive(Debug, Deserialize, JsonSchema)]
|
||
pub struct ValidateCircuitParams {
|
||
/// OpenQASM 3.0 source string to validate.
|
||
pub circuit: String,
|
||
}
|
||
|
||
#[derive(Debug, Deserialize, JsonSchema)]
|
||
pub struct RunCircuitParams {
|
||
/// OpenQASM 3.0 source string to execute.
|
||
pub circuit: String,
|
||
/// Number of measurement shots (default 1024, max 100 000).
|
||
#[serde(default = "default_shots")]
|
||
pub shots: u32,
|
||
/// Return the full statevector in the response.
|
||
#[serde(default)]
|
||
pub return_statevector: bool,
|
||
}
|
||
|
||
fn default_shots() -> u32 { 1024 }
|
||
|
||
#[derive(Clone)]
|
||
pub struct QuantumBridgeServer {
|
||
tool_router: ToolRouter<QuantumBridgeServer>,
|
||
}
|
||
|
||
#[tool_router]
|
||
impl QuantumBridgeServer {
|
||
pub fn new() -> Self {
|
||
Self { tool_router: Self::tool_router() }
|
||
}
|
||
|
||
#[tool(description = "List available quantum simulation backends and their capabilities.")]
|
||
async fn list_backends(&self) -> Result<CallToolResult, McpError> {
|
||
let json = list_backends_response();
|
||
Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
|
||
}
|
||
|
||
#[tool(description = "Validate an OpenQASM 3.0 circuit. Returns structured diagnostics with line/column.")]
|
||
async fn validate_circuit(
|
||
&self,
|
||
Parameters(ValidateCircuitParams { circuit }): Parameters<ValidateCircuitParams>,
|
||
) -> Result<CallToolResult, McpError> {
|
||
let json = validate_circuit_response(&circuit);
|
||
Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
|
||
}
|
||
|
||
#[tool(description = "Execute an OpenQASM 3.0 circuit on the local statevector simulator. Returns counts, execution time, and optionally the full statevector.")]
|
||
async fn run_circuit(
|
||
&self,
|
||
Parameters(RunCircuitParams { circuit, shots, return_statevector }): Parameters<RunCircuitParams>,
|
||
) -> Result<CallToolResult, McpError> {
|
||
let json = run_circuit_response(&circuit, shots, return_statevector);
|
||
if json.get("error").is_some() {
|
||
return Err(McpError::invalid_params(
|
||
json["error"].as_str().unwrap_or("unknown error"),
|
||
None,
|
||
));
|
||
}
|
||
Ok(CallToolResult::success(vec![Content::text(json.to_string())]))
|
||
}
|
||
}
|
||
|
||
#[tool_handler]
|
||
impl ServerHandler for QuantumBridgeServer {
|
||
fn get_info(&self) -> ServerInfo {
|
||
ServerInfo::new(
|
||
ServerCapabilities::builder().enable_tools().build(),
|
||
)
|
||
.with_server_info(Implementation::from_build_env())
|
||
.with_protocol_version(ProtocolVersion::V_2024_11_05)
|
||
.with_instructions(
|
||
"Quantum circuit simulator. Accepts OpenQASM 3.0. \
|
||
Run validate_circuit first to get actionable errors.".to_string(),
|
||
)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn quantum_bridge_server_can_be_constructed() {
|
||
let _server = QuantumBridgeServer::new();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run compile test**
|
||
|
||
```bash
|
||
cargo test tools::tests 2>&1
|
||
```
|
||
|
||
Expected: passes. If macro names are wrong (`#[tool_router]` / `#[tool_handler]`), check `~/.cargo/git/checkouts/rust-sdk-*/examples/servers/src/common/` for the canonical pattern.
|
||
|
||
- [ ] **Step 3: Replace src/main.rs with async server entry point**
|
||
|
||
```rust
|
||
mod error;
|
||
mod executor;
|
||
mod tools;
|
||
mod types;
|
||
mod validator;
|
||
|
||
use anyhow::Result;
|
||
use rmcp::{ServiceExt, transport::stdio};
|
||
use tools::QuantumBridgeServer;
|
||
|
||
#[tokio::main]
|
||
async fn main() -> Result<()> {
|
||
// Logs go to stderr — stdout is the MCP JSON-RPC channel.
|
||
tracing_subscriber::fmt()
|
||
.with_writer(std::io::stderr)
|
||
.with_ansi(false)
|
||
.with_env_filter(
|
||
tracing_subscriber::EnvFilter::from_default_env()
|
||
.add_directive(tracing::Level::INFO.into()),
|
||
)
|
||
.init();
|
||
|
||
tracing::info!("quantum-bridge-mcp starting");
|
||
|
||
let service = QuantumBridgeServer::new().serve(stdio()).await?;
|
||
service.waiting().await?;
|
||
Ok(())
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Build the binary**
|
||
|
||
```bash
|
||
cargo build 2>&1
|
||
```
|
||
|
||
Expected: compiles without errors.
|
||
|
||
- [ ] **Step 5: Smoke test**
|
||
|
||
```bash
|
||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"0.0.1"}}}' \
|
||
| cargo run 2>/dev/null
|
||
```
|
||
|
||
Expected: JSON response with `"result"."serverInfo"."name":"quantum-bridge-mcp"`. Press Ctrl+C to stop.
|
||
|
||
- [ ] **Step 6: Run full test suite**
|
||
|
||
```bash
|
||
cargo test 2>&1
|
||
```
|
||
|
||
Expected: all tests pass (3 list_backends + 5 validate_circuit + 8 run_circuit + 1 server construction + 7 executor + 5 validator).
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/main.rs src/tools/mod.rs
|
||
git commit -m "feat: wire rmcp stdio server with list_backends/validate_circuit/run_circuit tools"
|
||
```
|
||
|
||
---
|
||
|
||
## Final verification
|
||
|
||
```bash
|
||
cargo fmt --check && cargo clippy -- -D warnings && cargo test
|
||
```
|
||
|
||
Expected: all green. Sub-plan 4 complete — hand off to sub-plan 5.
|