1use 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
63pub 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
73pub 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#[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#[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
156pub 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
290fn schema_object(value: Value) -> Arc<rmcp::model::JsonObject> {
293 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#[derive(Clone)]
314pub struct PlanServer {
315 planner: Arc<BasicCpmPlanner>,
316 server_name: String,
317 server_version: String,
318}
319
320impl PlanServer {
321 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 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 pub fn planner(&self) -> &Arc<BasicCpmPlanner> {
341 &self.planner
342 }
343
344 pub async fn serve_stdio(self) -> anyhow::Result<()> {
351 let service = self.serve(stdio()).await?;
352 service.waiting().await?;
353 Ok(())
354 }
355
356 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 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
464impl 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
523fn 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
533fn 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
542fn planner_error_to_mcp(err: PlannerError) -> McpError {
551 McpError::internal_error(err.to_string(), None)
552}
553
554fn 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}