Skip to main content

cpm_planner/
server.rs

1// SPEC §33 PA4 — MCP server façade for `BasicCpmPlanner`.
2//
3// Production-code lint surface (consistent with the rest of the workspace —
4// `#![cfg_attr(not(test), warn(clippy::unwrap_used))]` is declared at the
5// crate root in `lib.rs`).
6
7//! MCP tool surface for the open-source CPM planner.
8//!
9//! [`PlanServer`] wraps an `Arc<BasicCpmPlanner>` and exposes the six
10//! [`Planner`] trait methods as MCP tools so any MCP-speaking agent
11//! (Claude Code, Cursor, custom orchestrator, or the §33 LLM executor)
12//! can drive the planner over the standard MCP protocol.
13//!
14//! # Tool surface
15//!
16//! | Tool name                | Trait method                  |
17//! |--------------------------|-------------------------------|
18//! | `plan.submit`            | [`Planner::submit_plan`]      |
19//! | `plan.acquire_cohort`    | [`Planner::acquire_cohort`]   |
20//! | `plan.heartbeat`         | [`Planner::heartbeat`]        |
21//! | `plan.mark_status`       | [`Planner::mark_status`]      |
22//! | `plan.status`            | [`Planner::status`]           |
23//! | `plan.force_release`     | [`Planner::force_release`]    |
24//!
25//! # Error mapping
26//!
27//! [`PlannerError`] variants are surfaced as MCP `internal_error`
28//! responses whose `message` is the variant's `Display` output. The
29//! variant prefixes (`LOCK_HELD:`, `LOCK_NOT_HELD:`, `LOCK_EXPIRED:`,
30//! `OVERLAP_DETECTED:`, `MISSING_PREREQUISITE:`, `PLAN_NOT_FOUND:`,
31//! `DELIVERABLE_NOT_FOUND:`, `INVALID_GRAPH:`, `BACKEND_ERROR:`) are
32//! stable machine-parseable signals — see `core::plan` for the contract.
33//! Malformed arguments yield `invalid_params` with the serde error.
34//!
35//! # Testing pattern
36//!
37//! [`PlanServer::dispatch_call`] is the transport-free entry point used
38//! by integration tests, mirroring the pattern in
39//! `mcp-flowgate-mcp-server`. The `ServerHandler::call_tool` impl is a
40//! thin wrapper that wraps the result in `CallToolResult::structured`.
41
42use std::borrow::Cow;
43use std::sync::Arc;
44
45use crate::plan::{
46    CallerId, Cohort, DeliverableStatus, PlanGraph, PlanId, PlanStatus, PlannerError,
47};
48use crate::ports::Planner;
49use rmcp::model::{
50    CallToolRequestParams, CallToolResult, Implementation, InitializeRequestParams,
51    InitializeResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, ServerCapabilities,
52    ServerInfo, Tool,
53};
54use rmcp::service::{NotificationContext, RequestContext, RoleServer};
55use rmcp::transport::stdio;
56use rmcp::ErrorData as McpError;
57use rmcp::{ServerHandler, ServiceExt};
58use serde::{Deserialize, Serialize};
59use serde_json::{json, Value};
60
61use crate::BasicCpmPlanner;
62
63/// SPEC §33 PA4 — tool names. Dot notation `plan.<verb>` matches the
64/// convention used elsewhere in the workspace (`flowgate.query`,
65/// `flowgate.command`).
66pub const TOOL_SUBMIT: &str = "plan.submit";
67pub const TOOL_ACQUIRE_COHORT: &str = "plan.acquire_cohort";
68pub const TOOL_HEARTBEAT: &str = "plan.heartbeat";
69pub const TOOL_MARK_STATUS: &str = "plan.mark_status";
70pub const TOOL_STATUS: &str = "plan.status";
71pub const TOOL_FORCE_RELEASE: &str = "plan.force_release";
72
73/// All six MCP tool names exposed by [`PlanServer`], in declaration order.
74pub const PLAN_TOOL_NAMES: &[&str] = &[
75    TOOL_SUBMIT,
76    TOOL_ACQUIRE_COHORT,
77    TOOL_HEARTBEAT,
78    TOOL_MARK_STATUS,
79    TOOL_STATUS,
80    TOOL_FORCE_RELEASE,
81];
82
83// ---------------------------------------------------------------------------
84// Per-tool argument structs
85// ---------------------------------------------------------------------------
86
87// `deny_unknown_fields` on every wire-arg struct: unknown keys are a caller
88// bug, not something to ignore. Fail-fast surfaces typos/drift at the
89// `parse_args` boundary instead of silently dropping them.
90
91#[derive(Debug, Deserialize)]
92#[serde(deny_unknown_fields)]
93struct SubmitArgs {
94    graph: PlanGraph,
95}
96
97#[derive(Debug, Deserialize)]
98#[serde(deny_unknown_fields)]
99struct AcquireCohortArgs {
100    plan_id: String,
101    caller_id: String,
102    max_count: usize,
103}
104
105#[derive(Debug, Deserialize)]
106#[serde(deny_unknown_fields)]
107struct HeartbeatArgs {
108    plan_id: String,
109    deliverable_id: String,
110    caller_id: String,
111}
112
113#[derive(Debug, Deserialize)]
114#[serde(deny_unknown_fields)]
115struct MarkStatusArgs {
116    plan_id: String,
117    deliverable_id: String,
118    caller_id: String,
119    status: DeliverableStatus,
120}
121
122#[derive(Debug, Deserialize)]
123#[serde(deny_unknown_fields)]
124struct StatusArgs {
125    plan_id: String,
126}
127
128#[derive(Debug, Deserialize)]
129#[serde(deny_unknown_fields)]
130struct ForceReleaseArgs {
131    plan_id: String,
132    deliverable_id: String,
133    reason: String,
134}
135
136// ---------------------------------------------------------------------------
137// Per-tool response shapes
138// ---------------------------------------------------------------------------
139
140#[derive(Debug, Serialize)]
141struct SubmitResponse {
142    plan_id: String,
143}
144
145#[derive(Debug, Serialize)]
146struct OkResponse {
147    ok: bool,
148}
149
150impl OkResponse {
151    fn new() -> Self {
152        Self { ok: true }
153    }
154}
155
156// `Cohort` and `PlanStatus` already derive `Serialize` (PA1) — return them
157// directly.
158
159// ---------------------------------------------------------------------------
160// Tool-list construction
161// ---------------------------------------------------------------------------
162
163/// Build the six `Tool` definitions advertised in `list_tools`.
164///
165/// Each tool carries an inline JSON Schema describing its arguments. The
166/// schemas are hand-written rather than derived because the workspace's
167/// `schemars` version is pinned at 0.8 (matching `mcp-flowgate-mcp-server`)
168/// and the wire types here (`PlanGraph`, `DeliverableStatus`) live in
169/// `mcp-flowgate-core`, which currently does not derive `JsonSchema`. Adding
170/// the derive workspace-wide is out of scope for PA4; the hand-written
171/// schemas are explicit and reviewable.
172pub fn plan_tool_definitions() -> Vec<Tool> {
173    vec![
174        Tool::new(
175            Cow::Borrowed(TOOL_SUBMIT),
176            Cow::Borrowed(
177                "Submit a plan graph and receive a plan_id. \
178                 Idempotent: identical graphs return the same plan_id.",
179            ),
180            schema_object(json!({
181                "type": "object",
182                "properties": {
183                    "graph": {
184                        "type": "object",
185                        "properties": {
186                            "deliverables": {
187                                "type": "array",
188                                "items": {
189                                    "type": "object",
190                                    "properties": {
191                                        "id":                    { "type": "string" },
192                                        "owned_files":           { "type": "array", "items": { "type": "string" } },
193                                        "prerequisites":         { "type": "array", "items": { "type": "string" } },
194                                        "estimated_effort_hours": { "type": "number" },
195                                        "metadata":              {}
196                                    },
197                                    "required": ["id", "owned_files", "prerequisites"]
198                                }
199                            },
200                            "max_chained_dispatch": { "type": ["integer", "null"] }
201                        },
202                        "required": ["deliverables"]
203                    }
204                },
205                "required": ["graph"],
206                "additionalProperties": false
207            })),
208        ),
209        Tool::new(
210            Cow::Borrowed(TOOL_ACQUIRE_COHORT),
211            Cow::Borrowed(
212                "Acquire up to max_count ready, file-disjoint deliverables \
213                 atomically. Returns the cohort plus per-deliverable locks.",
214            ),
215            schema_object(json!({
216                "type": "object",
217                "properties": {
218                    "plan_id":   { "type": "string" },
219                    "caller_id": { "type": "string" },
220                    "max_count": { "type": "integer", "minimum": 1 }
221                },
222                "required": ["plan_id", "caller_id", "max_count"]
223            })),
224        ),
225        Tool::new(
226            Cow::Borrowed(TOOL_HEARTBEAT),
227            Cow::Borrowed(
228                "Refresh the TTL on a held lock; LOCK_NOT_HELD or LOCK_EXPIRED on failure.",
229            ),
230            schema_object(json!({
231                "type": "object",
232                "properties": {
233                    "plan_id":        { "type": "string" },
234                    "deliverable_id": { "type": "string" },
235                    "caller_id":      { "type": "string" }
236                },
237                "required": ["plan_id", "deliverable_id", "caller_id"]
238            })),
239        ),
240        Tool::new(
241            Cow::Borrowed(TOOL_MARK_STATUS),
242            Cow::Borrowed(
243                "Set a deliverable's status. Complete/Failed releases the lock; \
244                 caller_id mismatch yields LOCK_NOT_HELD.",
245            ),
246            schema_object(json!({
247                "type": "object",
248                "properties": {
249                    "plan_id":        { "type": "string" },
250                    "deliverable_id": { "type": "string" },
251                    "caller_id":      { "type": "string" },
252                    "status": {
253                        "type": "object",
254                        "description": "Internally-tagged: {\"status\":\"pending|ready|in_progress|complete\"} or {\"status\":\"failed\",\"reason\":\"...\"}"
255                    }
256                },
257                "required": ["plan_id", "deliverable_id", "caller_id", "status"]
258            })),
259        ),
260        Tool::new(
261            Cow::Borrowed(TOOL_STATUS),
262            Cow::Borrowed("Read-only snapshot: per-deliverable status, critical path, held locks."),
263            schema_object(json!({
264                "type": "object",
265                "properties": {
266                    "plan_id": { "type": "string" }
267                },
268                "required": ["plan_id"]
269            })),
270        ),
271        Tool::new(
272            Cow::Borrowed(TOOL_FORCE_RELEASE),
273            Cow::Borrowed(
274                "Operator escape hatch — release a lock regardless of caller. \
275                 Emits an audit event carrying `reason`.",
276            ),
277            schema_object(json!({
278                "type": "object",
279                "properties": {
280                    "plan_id":        { "type": "string" },
281                    "deliverable_id": { "type": "string" },
282                    "reason":         { "type": "string" }
283                },
284                "required": ["plan_id", "deliverable_id", "reason"]
285            })),
286        ),
287    ]
288}
289
290/// Convert a `serde_json::Value` (always built from an object literal in
291/// this file) into the `Arc<JsonObject>` rmcp expects for `input_schema`.
292fn schema_object(value: Value) -> Arc<rmcp::model::JsonObject> {
293    // Invariant: every caller passes a `json!({ ... })` object literal.
294    // `debug_assert!` so dev/test builds crash loudly if a future edit drops
295    // a non-object literal here; production retains the no-panic fallback
296    // to satisfy `clippy::unwrap_used`.
297    debug_assert!(
298        value.is_object(),
299        "schema_object expects an object literal; got non-object"
300    );
301    let obj = match value.as_object() {
302        Some(o) => o.clone(),
303        None => serde_json::Map::new(),
304    };
305    Arc::new(obj)
306}
307
308// ---------------------------------------------------------------------------
309// PlanServer
310// ---------------------------------------------------------------------------
311
312/// MCP server façade exposing a [`BasicCpmPlanner`] over six tools.
313#[derive(Clone)]
314pub struct PlanServer {
315    planner: Arc<BasicCpmPlanner>,
316    server_name: String,
317    server_version: String,
318}
319
320impl PlanServer {
321    /// Build a server backed by the supplied planner.
322    pub fn new(planner: Arc<BasicCpmPlanner>) -> Self {
323        Self {
324            planner,
325            server_name: "mcp-flowgate-plan".to_string(),
326            server_version: env!("CARGO_PKG_VERSION").to_string(),
327        }
328    }
329
330    /// Override the advertised server identity. Defaults to
331    /// `("mcp-flowgate-plan", CARGO_PKG_VERSION)`.
332    pub fn with_identity(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
333        self.server_name = name.into();
334        self.server_version = version.into();
335        self
336    }
337
338    /// Borrow the inner planner. Tests use this to set up state directly
339    /// (e.g. submit a plan, then drive `acquire_cohort` via MCP).
340    pub fn planner(&self) -> &Arc<BasicCpmPlanner> {
341        &self.planner
342    }
343
344    /// Serve the MCP surface over stdio. Blocks until the peer disconnects.
345    ///
346    /// No SIGINT/drain handling is wired here yet; a future revision can
347    /// adopt the cancellation-token pattern from
348    /// `crates/mcp-flowgate/src/main.rs::serve` if graceful shutdown becomes
349    /// necessary for operators spawning this binary as a long-running child.
350    pub async fn serve_stdio(self) -> anyhow::Result<()> {
351        let service = self.serve(stdio()).await?;
352        service.waiting().await?;
353        Ok(())
354    }
355
356    /// Transport-free dispatch entry point. Tests call this directly to
357    /// exercise each tool without spinning up a stdio transport.
358    ///
359    /// Behaviour matches what `ServerHandler::call_tool` does, minus the
360    /// `CallToolResult` wrapping.
361    pub async fn dispatch_call(&self, request: CallToolRequestParams) -> Result<Value, McpError> {
362        let args: Value = request
363            .arguments
364            .as_ref()
365            .map(|m| Value::Object(m.clone()))
366            .unwrap_or_else(|| json!({}));
367
368        match request.name.as_ref() {
369            TOOL_SUBMIT => self.handle_submit(args).await,
370            TOOL_ACQUIRE_COHORT => self.handle_acquire_cohort(args).await,
371            TOOL_HEARTBEAT => self.handle_heartbeat(args).await,
372            TOOL_MARK_STATUS => self.handle_mark_status(args).await,
373            TOOL_STATUS => self.handle_status(args).await,
374            TOOL_FORCE_RELEASE => self.handle_force_release(args).await,
375            other => Err(McpError::invalid_params(
376                format!(
377                    "Unknown tool '{other}'. Available: {}.",
378                    PLAN_TOOL_NAMES.join(", ")
379                ),
380                None,
381            )),
382        }
383    }
384
385    // -------------------------------------------------------------------
386    // Per-tool handlers
387    // -------------------------------------------------------------------
388
389    async fn handle_submit(&self, args: Value) -> Result<Value, McpError> {
390        let parsed: SubmitArgs = parse_args(args)?;
391        let plan_id = self
392            .planner
393            .submit_plan(parsed.graph)
394            .await
395            .map_err(planner_error_to_mcp)?;
396        to_value(&SubmitResponse { plan_id: plan_id.0 })
397    }
398
399    async fn handle_acquire_cohort(&self, args: Value) -> Result<Value, McpError> {
400        let parsed: AcquireCohortArgs = parse_args(args)?;
401        let cohort: Cohort = self
402            .planner
403            .acquire_cohort(
404                &PlanId(parsed.plan_id),
405                &CallerId(parsed.caller_id),
406                parsed.max_count,
407            )
408            .await
409            .map_err(planner_error_to_mcp)?;
410        to_value(&cohort)
411    }
412
413    async fn handle_heartbeat(&self, args: Value) -> Result<Value, McpError> {
414        let parsed: HeartbeatArgs = parse_args(args)?;
415        self.planner
416            .heartbeat(
417                &PlanId(parsed.plan_id),
418                &parsed.deliverable_id,
419                &CallerId(parsed.caller_id),
420            )
421            .await
422            .map_err(planner_error_to_mcp)?;
423        to_value(&OkResponse::new())
424    }
425
426    async fn handle_mark_status(&self, args: Value) -> Result<Value, McpError> {
427        let parsed: MarkStatusArgs = parse_args(args)?;
428        self.planner
429            .mark_status(
430                &PlanId(parsed.plan_id),
431                &parsed.deliverable_id,
432                &CallerId(parsed.caller_id),
433                parsed.status,
434            )
435            .await
436            .map_err(planner_error_to_mcp)?;
437        to_value(&OkResponse::new())
438    }
439
440    async fn handle_status(&self, args: Value) -> Result<Value, McpError> {
441        let parsed: StatusArgs = parse_args(args)?;
442        let status: PlanStatus = self
443            .planner
444            .status(&PlanId(parsed.plan_id))
445            .await
446            .map_err(planner_error_to_mcp)?;
447        to_value(&status)
448    }
449
450    async fn handle_force_release(&self, args: Value) -> Result<Value, McpError> {
451        let parsed: ForceReleaseArgs = parse_args(args)?;
452        self.planner
453            .force_release(
454                &PlanId(parsed.plan_id),
455                &parsed.deliverable_id,
456                &parsed.reason,
457            )
458            .await
459            .map_err(planner_error_to_mcp)?;
460        to_value(&OkResponse::new())
461    }
462}
463
464// ---------------------------------------------------------------------------
465// ServerHandler impl
466// ---------------------------------------------------------------------------
467
468impl ServerHandler for PlanServer {
469    fn get_info(&self) -> ServerInfo {
470        let mut server_info =
471            Implementation::new(self.server_name.clone(), self.server_version.clone());
472        server_info.title = Some("mcp-flowgate-plan".to_string());
473        server_info.description = Some(
474            "MCP server exposing the open-source Flowgate CPM planner via six tools.".to_string(),
475        );
476
477        let mut info = InitializeResult::default();
478        info.protocol_version = ProtocolVersion::default();
479        info.capabilities = ServerCapabilities::builder().enable_tools().build();
480        info.server_info = server_info;
481        info.instructions = Some(instructions().to_string());
482        info
483    }
484
485    async fn initialize(
486        &self,
487        request: InitializeRequestParams,
488        context: RequestContext<RoleServer>,
489    ) -> Result<InitializeResult, McpError> {
490        if context.peer.peer_info().is_none() {
491            context.peer.set_peer_info(request);
492        }
493        Ok(self.get_info())
494    }
495
496    async fn list_tools(
497        &self,
498        _request: Option<PaginatedRequestParams>,
499        _context: RequestContext<RoleServer>,
500    ) -> Result<ListToolsResult, McpError> {
501        Ok(ListToolsResult::with_all_items(plan_tool_definitions()))
502    }
503
504    async fn call_tool(
505        &self,
506        request: CallToolRequestParams,
507        _context: RequestContext<RoleServer>,
508    ) -> Result<CallToolResult, McpError> {
509        self.dispatch_call(request)
510            .await
511            .map(CallToolResult::structured)
512    }
513
514    fn get_tool(&self, name: &str) -> Option<Tool> {
515        plan_tool_definitions().into_iter().find(|t| t.name == name)
516    }
517
518    async fn on_initialized(&self, _context: NotificationContext<RoleServer>) {
519        tracing::info!("mcp-flowgate-plan client initialized");
520    }
521}
522
523// ---------------------------------------------------------------------------
524// Helpers
525// ---------------------------------------------------------------------------
526
527/// Parse tool arguments, mapping serde failures to `invalid_params`.
528fn parse_args<T: serde::de::DeserializeOwned>(args: Value) -> Result<T, McpError> {
529    serde_json::from_value(args)
530        .map_err(|e| McpError::invalid_params(format!("invalid arguments: {e}"), None))
531}
532
533/// Serialise a response into a JSON `Value`, mapping serde failures to
534/// `internal_error`. Each response type is a small struct or a wire type
535/// that already derives `Serialize`; this fallible boundary exists so the
536/// crate-level `clippy::unwrap_used` lint stays clean.
537fn to_value<T: Serialize>(value: &T) -> Result<Value, McpError> {
538    serde_json::to_value(value)
539        .map_err(|e| McpError::internal_error(format!("response serialisation failed: {e}"), None))
540}
541
542/// Map a [`PlannerError`] into an MCP `internal_error` whose message is
543/// the variant's `Display` output. The error message starts with the
544/// stable code prefix (e.g. `LOCK_HELD:`, `LOCK_NOT_HELD:`,
545/// `INVALID_GRAPH:`) so clients can pattern-match on the prefix to drive
546/// retry / triage logic without relying on free-form text.
547///
548/// Per SPEC §33 PA4 FMECA F2: operators need structured error codes, not
549/// generic strings.
550fn planner_error_to_mcp(err: PlannerError) -> McpError {
551    McpError::internal_error(err.to_string(), None)
552}
553
554/// `instructions()` is surfaced via `InitializeResult.instructions` so a
555/// connecting agent gets a one-shot orientation to the tool surface.
556fn instructions() -> &'static str {
557    r#"This is the mcp-flowgate-plan MCP server — the open-source CPM planner.
558
559Tools (six total, all `plan.<verb>`):
560  plan.submit          — submit a PlanGraph, get a plan_id (idempotent on identical graphs)
561  plan.acquire_cohort  — atomically acquire ready, file-disjoint deliverables
562  plan.heartbeat       — refresh a held lock's TTL
563  plan.mark_status     — set a deliverable's status (Complete/Failed releases the lock)
564  plan.status          — read-only snapshot (statuses, critical path, held locks)
565  plan.force_release   — operator escape hatch; emits audit event with `reason`
566
567Errors carry stable prefixes: LOCK_HELD, LOCK_NOT_HELD, LOCK_EXPIRED,
568OVERLAP_DETECTED, MISSING_PREREQUISITE, PLAN_NOT_FOUND,
569DELIVERABLE_NOT_FOUND, INVALID_GRAPH, BACKEND_ERROR.
570
571DeliverableStatus is internally tagged on `status`:
572  {"status":"pending"} | {"status":"ready"} | {"status":"in_progress"} |
573  {"status":"complete"} | {"status":"failed","reason":"..."}
574"#
575}