Files
quantum-bridge-mcp/docs/superpowers/plans/sub4-tools-server.md
T
Vincent Bourdon 9af114e391 Initial import
2026-06-09 16:14:55 +02:00

17 KiB
Raw Blame History

Sub-plan 4 — Tools + MCP Server (Tasks 69)

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 13 files present
  • src/executor.rsLocalSimulator fully implemented, all 7 tests pass
  • src/validator.rsCircuitValidator 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 69.


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

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)
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
cargo test tools::list_backends 2>&1 | tail -5
  • Step 5: Implement list_backends_response

Replace todo!():

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
cargo test tools::list_backends 2>&1

Expected: 3 tests pass.

  • Step 7: Commit
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

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
cargo test tools::validate_circuit 2>&1 | tail -5
  • Step 3: Implement validate_circuit_response
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
cargo test tools::validate_circuit 2>&1

Expected: 5 tests pass.

  • Step 5: Commit
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

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
cargo test tools::run_circuit 2>&1 | tail -5
  • Step 3: Implement run_circuit_response
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
cargo test tools::run_circuit 2>&1

Expected: 8 tests pass.

  • Step 5: Commit
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

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
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
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
cargo build 2>&1

Expected: compiles without errors.

  • Step 5: Smoke test
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
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
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

cargo fmt --check && cargo clippy -- -D warnings && cargo test

Expected: all green. Sub-plan 4 complete — hand off to sub-plan 5.