1use std::borrow::Cow;
76use std::sync::Arc;
77use std::time::Duration;
78
79use crate::audit::AuditEvent;
80use crate::compare::{CompareRequest, CompareWeights};
81use crate::earned_value::{BaselineRequest, Calendar, SnapshotFormat, SnapshotRequest};
82use crate::edits::GraphEdit;
83use crate::llm::jev::JevJudge;
84use crate::llm::{ConfigError, JudgmentModel, LlmConfig};
85use crate::monte_carlo::MonteCarloRequest;
86use crate::plan::{
87 AcceptRequest, AcquireRequest, CallerId, Cohort, ComparePlansRequest, DeliverableStatus,
88 ForceReleaseRequest, ForkRequest, HeartbeatRequest, MarkStatusRequest, PlanDefinition,
89 PlanGraph, PlanId, PlanStatus, PlannerError, ReviseRequest, SyncRequest,
90};
91use crate::ports::Planner;
92use crate::project::ProjectRoot;
93use crate::resource_schedule::{ScheduleRequest, resource_schedule};
94use crate::review::{Judge, ReviewReport, ReviewRequest};
95use crate::simulate::SimulateRequest;
96use chrono::{DateTime, Utc};
97use rmcp::ErrorData as McpError;
98use rmcp::model::{
99 CallToolRequestParams, CallToolResponse, CallToolResult, Implementation,
100 InitializeRequestParams, InitializeResult, ListToolsResult, PaginatedRequestParams,
101 ProtocolVersion, ServerCapabilities, ServerConfig, Tool,
102};
103use rmcp::service::{NotificationContext, RequestContext, RoleServer};
104use rmcp::transport::stdio;
105use rmcp::{ServerHandler, ServiceExt};
106use serde::{Deserialize, Serialize};
107use serde_json::{Value, json};
108
109use crate::BasicCpmPlanner;
110
111pub const TOOL_SUBMIT: &str = "plan.submit";
115pub const TOOL_ACQUIRE_COHORT: &str = "plan.acquire_cohort";
116pub const TOOL_HEARTBEAT: &str = "plan.heartbeat";
117pub const TOOL_MARK_STATUS: &str = "plan.mark_status";
118pub const TOOL_STATUS: &str = "plan.status";
119pub const TOOL_GET: &str = "plan.get";
120pub const TOOL_FORCE_RELEASE: &str = "plan.force_release";
121pub const TOOL_ACCEPT: &str = "plan.accept";
122pub const TOOL_LINT: &str = "plan.lint";
123pub const TOOL_SCHEDULE: &str = "plan.schedule";
124pub const TOOL_SIMULATE: &str = "plan.simulate";
125pub const TOOL_SYNC: &str = "plan.sync";
126pub const TOOL_LIST: &str = "plan.list";
127pub const TOOL_EXPORT: &str = "plan.export";
128pub const TOOL_REVISE: &str = "plan.revise";
129pub const TOOL_FORK: &str = "plan.fork";
130pub const TOOL_SELECT: &str = "plan.select";
131pub const TOOL_ARCHIVE: &str = "plan.archive";
132pub const TOOL_COMPARE: &str = "plan.compare";
133pub const TOOL_BASELINE: &str = "plan.baseline";
134pub const TOOL_EV: &str = "plan.ev";
135pub const TOOL_SNAPSHOT: &str = "plan.snapshot";
136pub const TOOL_REVIEW: &str = "plan.review";
137
138pub const MAX_CONCURRENT_REVIEWS: usize = 2;
141
142pub const PLAN_TOOL_NAMES: &[&str] = &[
144 TOOL_SUBMIT,
145 TOOL_ACQUIRE_COHORT,
146 TOOL_HEARTBEAT,
147 TOOL_MARK_STATUS,
148 TOOL_STATUS,
149 TOOL_GET,
150 TOOL_FORCE_RELEASE,
151 TOOL_ACCEPT,
152 TOOL_LINT,
153 TOOL_SCHEDULE,
154 TOOL_SIMULATE,
155 TOOL_SYNC,
156 TOOL_LIST,
157 TOOL_EXPORT,
158 TOOL_REVISE,
159 TOOL_FORK,
160 TOOL_SELECT,
161 TOOL_ARCHIVE,
162 TOOL_COMPARE,
163 TOOL_BASELINE,
164 TOOL_EV,
165 TOOL_SNAPSHOT,
166 TOOL_REVIEW,
167];
168
169#[derive(Debug, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct SubmitArgs {
180 graph: PlanGraph,
181 #[serde(default)]
182 project: Option<String>,
183 #[serde(default)]
184 name: Option<String>,
185 #[serde(default)]
186 variant: Option<String>,
187}
188
189#[derive(Debug, Deserialize)]
190#[serde(deny_unknown_fields)]
191struct AcquireCohortArgs {
192 plan_id: String,
193 caller_id: String,
194 max_count: usize,
195 #[serde(default)]
196 ids: Option<Vec<String>>,
197 #[serde(default)]
198 filter: Option<AcquireFilter>,
199 #[serde(default)]
200 ttl_seconds: Option<u64>,
201}
202
203#[derive(Debug, Deserialize)]
204#[serde(deny_unknown_fields)]
205struct AcquireFilter {
206 #[serde(default)]
207 metadata: Option<serde_json::Map<String, Value>>,
208}
209
210#[derive(Debug, Deserialize)]
211#[serde(deny_unknown_fields)]
212struct AcceptArgs {
213 plan_id: String,
214 deliverable_id: String,
215 accepted_by: String,
216 evidence: String,
217 #[serde(default)]
218 override_lock: bool,
219}
220
221#[derive(Debug, Deserialize)]
222#[serde(deny_unknown_fields)]
223struct HeartbeatArgs {
224 plan_id: String,
225 deliverable_id: String,
226 caller_id: String,
227 #[serde(default)]
228 ttl_seconds: Option<u64>,
229}
230
231#[derive(Debug, Deserialize)]
232#[serde(deny_unknown_fields)]
233struct MarkStatusArgs {
234 plan_id: String,
235 deliverable_id: String,
236 caller_id: String,
237 status: DeliverableStatus,
238 #[serde(default)]
241 earned_pct: Option<f64>,
242 #[serde(default)]
243 actual_effort_hours: Option<f64>,
244 #[serde(default)]
245 evidence: Option<String>,
246}
247
248#[derive(Debug, Deserialize)]
249#[serde(deny_unknown_fields)]
250struct StatusArgs {
251 plan_id: String,
252}
253
254#[derive(Debug, Deserialize)]
255#[serde(deny_unknown_fields)]
256struct GetArgs {
257 plan_id: String,
258}
259
260#[derive(Debug, Deserialize)]
261#[serde(deny_unknown_fields)]
262struct ForceReleaseArgs {
263 plan_id: String,
264 deliverable_id: String,
265 reason: String,
266 #[serde(default)]
267 reset_counters: bool,
268}
269
270#[derive(Debug, Deserialize)]
273#[serde(deny_unknown_fields)]
274struct GraphOrPlanIdArgs {
275 #[serde(default)]
276 graph: Option<PlanGraph>,
277 #[serde(default)]
278 plan_id: Option<String>,
279 #[serde(default)]
280 path: Option<String>,
281}
282
283#[derive(Debug, Deserialize)]
287#[serde(deny_unknown_fields)]
288struct ScheduleToolArgs {
289 #[serde(default)]
290 graph: Option<PlanGraph>,
291 #[serde(default)]
292 plan_id: Option<String>,
293 capacities: std::collections::BTreeMap<String, u32>,
294 #[serde(default = "crate::resource_schedule::default_resource_key")]
295 resource_key: String,
296 #[serde(default = "crate::resource_schedule::default_buffer_pct")]
297 project_buffer_pct: f32,
298}
299
300#[derive(Debug, Deserialize)]
303#[serde(deny_unknown_fields)]
304struct SimulateToolArgs {
305 #[serde(default)]
306 graph: Option<PlanGraph>,
307 #[serde(default)]
308 plan_id: Option<String>,
309 #[serde(default)]
310 path: Option<String>,
311 #[serde(default)]
312 schedule: Option<ScheduleRequest>,
313 #[serde(default)]
314 monte_carlo: Option<MonteCarloRequest>,
315}
316
317#[derive(Debug, Deserialize)]
322#[serde(deny_unknown_fields)]
323struct SyncArgs {
324 #[serde(default)]
325 path: Option<String>,
326 #[serde(default)]
327 graph: Option<PlanGraph>,
328 #[serde(default)]
329 project: Option<String>,
330 #[serde(default)]
331 name: Option<String>,
332 #[serde(default)]
333 variant: Option<String>,
334 #[serde(default)]
335 force: bool,
336}
337
338#[derive(Debug, Deserialize)]
340#[serde(deny_unknown_fields)]
341struct ListArgs {
342 #[serde(default)]
343 project: Option<String>,
344 #[serde(default)]
345 include_archived: bool,
346}
347
348#[derive(Debug, Deserialize)]
350#[serde(deny_unknown_fields)]
351struct ExportArgs {
352 plan_id: String,
353 #[serde(default)]
354 path: Option<String>,
355 #[serde(default)]
356 force: bool,
357}
358
359#[derive(Debug, Deserialize)]
361#[serde(deny_unknown_fields)]
362struct ReviseArgs {
363 plan_id: String,
364 graph: PlanGraph,
365 #[serde(default)]
366 force: bool,
367}
368
369#[derive(Debug, Deserialize)]
372#[serde(deny_unknown_fields)]
373struct ForkArgs {
374 plan_id: String,
375 variant: String,
376 #[serde(default)]
377 edits: Vec<GraphEdit>,
378}
379
380#[derive(Debug, Deserialize)]
382#[serde(deny_unknown_fields)]
383struct SelectArgs {
384 plan_id: String,
385 #[serde(default)]
386 force: bool,
387}
388
389#[derive(Debug, Deserialize)]
391#[serde(deny_unknown_fields)]
392struct ArchiveArgs {
393 name: String,
394 #[serde(default)]
395 variant: Option<String>,
396 #[serde(default)]
397 project: Option<String>,
398 #[serde(default = "default_archived")]
399 archived: bool,
400 #[serde(default)]
401 force: bool,
402}
403
404fn default_archived() -> bool {
405 true
406}
407
408#[derive(Debug, Deserialize)]
410#[serde(deny_unknown_fields)]
411struct CompareArgs {
412 #[serde(default)]
413 plan_ids: Option<Vec<String>>,
414 #[serde(default)]
415 plan: Option<String>,
416 #[serde(default)]
417 project: Option<String>,
418 #[serde(default)]
419 schedule: Option<ScheduleRequest>,
420 #[serde(default)]
421 monte_carlo: Option<MonteCarloRequest>,
422 #[serde(default)]
423 weights: CompareWeights,
424}
425
426#[derive(Debug, Deserialize)]
430#[serde(deny_unknown_fields)]
431struct BaselineArgs {
432 plan_id: String,
433 #[serde(default)]
434 start: Option<DateTime<Utc>>,
435 #[serde(default)]
436 calendar: Option<Calendar>,
437 #[serde(default)]
438 reason: Option<String>,
439}
440
441#[derive(Debug, Deserialize)]
443#[serde(deny_unknown_fields)]
444struct EvArgs {
445 plan_id: String,
446 #[serde(default)]
447 as_of: Option<DateTime<Utc>>,
448}
449
450#[derive(Debug, Deserialize)]
452#[serde(deny_unknown_fields)]
453struct SnapshotArgs {
454 plan_id: String,
455 #[serde(default)]
456 as_of: Option<DateTime<Utc>>,
457 #[serde(default)]
458 format: SnapshotFormat,
459}
460
461#[derive(Debug, Deserialize)]
464#[serde(deny_unknown_fields)]
465struct ReviewToolArgs {
466 #[serde(default)]
467 graph: Option<PlanGraph>,
468 #[serde(default)]
469 plan_id: Option<String>,
470 #[serde(default)]
471 path: Option<String>,
472 #[serde(default)]
473 capacities: Option<std::collections::BTreeMap<String, u32>>,
474 #[serde(default)]
475 resource_key: Option<String>,
476 #[serde(default)]
477 project_buffer_pct: Option<f32>,
478 #[serde(default)]
479 max_questions: Option<u16>,
480}
481
482#[derive(Debug, Serialize)]
487struct SubmitResponse {
488 plan_id: String,
489}
490
491#[derive(Debug, Serialize)]
492struct OkResponse {
493 ok: bool,
494}
495
496impl OkResponse {
497 fn new() -> Self {
498 Self { ok: true }
499 }
500}
501
502#[derive(Debug, Serialize)]
503struct ExportResponse {
504 path: String,
505}
506
507#[derive(Debug, Serialize)]
508struct ReviseResponse {
509 revision: u32,
510 diff: crate::revise::RevisionDiff,
511}
512
513fn graph_schema() -> Value {
523 json!({
524 "type": "object",
525 "properties": {
526 "deliverables": {
527 "type": "array",
528 "maxItems": 5000,
529 "items": {
530 "type": "object",
531 "properties": {
532 "id": { "type": "string" },
533 "owned_files": { "type": "array", "description": "Each item is a path string (exclusive) or an object {path, mode?: exclusive|append}. Append claims on the same path may be leased together.", "items": { "oneOf": [
534 { "type": "string" },
535 { "type": "object", "properties": {
536 "path": { "type": "string" },
537 "mode": { "type": "string", "enum": ["exclusive", "append"] }
538 }, "required": ["path"], "additionalProperties": false }
539 ] } },
540 "prerequisites": { "type": "array", "description": "Each item is a deliverable id string, or an object {id, consumes?, kind?: artifact|interface, lag_hours?}.", "items": { "oneOf": [
541 { "type": "string" },
542 { "type": "object", "properties": {
543 "id": { "type": "string" },
544 "consumes": { "type": "string" },
545 "kind": { "type": "string", "enum": ["artifact", "interface"] },
546 "lag_hours": { "type": "number", "minimum": 0, "maximum": 1000000 }
547 }, "required": ["id"], "additionalProperties": false }
548 ] } },
549 "estimated_effort_hours": { "type": "number", "minimum": 0, "maximum": 1000000, "description": "Effort in hours: the cost basis, and the scheduled length when duration_hours is absent." },
550 "duration_hours": { "type": "number", "minimum": 0, "maximum": 1000000, "description": "Calendar time on the schedule; replaces effort as the scheduled length. Effort stays the cost basis." },
551 "estimate": { "type": "object", "description": "Optional three-point effort estimate (0 <= optimistic <= likely <= pessimistic <= 1000000). Scheduled-length precedence: duration_hours > estimated_effort_hours > estimate.likely > 0 for a milestone > estimator default. Monte Carlo samples the estimate only when neither duration_hours nor estimated_effort_hours is set.", "properties": {
552 "optimistic": { "type": "number", "minimum": 0, "maximum": 1000000 },
553 "likely": { "type": "number", "minimum": 0, "maximum": 1000000 },
554 "pessimistic": { "type": "number", "minimum": 0, "maximum": 1000000 }
555 }, "required": ["optimistic", "likely", "pessimistic"], "additionalProperties": false },
556 "milestone": { "type": "boolean", "description": "Acceptance point; reported in plan.status milestones with its own critical path." },
557 "earning_rule": { "type": "string", "enum": ["zero_hundred", "fifty_fifty", "weighted"], "description": "How earned value credits partial progress: zero_hundred (default; 100% only when complete), fifty_fifty (50% once in progress or any earned_pct is reported), weighted (the reported earned_pct). Part of the plan's identity." },
558 "metadata": {}
559 },
560 "required": ["id", "owned_files", "prerequisites"]
561 }
562 },
563 "max_chained_dispatch": { "type": ["integer", "null"] }
564 },
565 "required": ["deliverables"]
566 })
567}
568
569fn graph_selector_schema() -> Value {
574 let mut schema = graph_schema();
575 if let Some(obj) = schema.as_object_mut() {
576 obj.insert(
577 "description".to_string(),
578 json!(
579 "An inline plan graph to analyse. Provide exactly one of graph, plan_id, or path."
580 ),
581 );
582 }
583 schema
584}
585
586fn plan_id_schema() -> Value {
587 json!({
588 "type": "string",
589 "description": "A stored plan (from plan.submit/plan.sync) to analyse instead of an inline graph. Provide exactly one of graph, plan_id, or path."
590 })
591}
592
593fn path_schema() -> Value {
594 json!({
595 "type": "string",
596 "description": "A plan file path of the form .cpm-planner/plans/<name>/<variant>.json, relative to the project root. Provide exactly one of graph, plan_id, or path."
597 })
598}
599
600fn edits_schema() -> Value {
601 json!({
602 "type": "array",
603 "description": "Structured graph edits applied in order. Each edit is an object tagged by `op`: remove_edge {from,to}, add_edge {from,to,consumes?}, set_effort {id,hours}, set_duration {id,hours?}, set_estimate {id,estimate?}, set_metadata {id,key,value}, remove_deliverable {id}, add_deliverable {deliverable}.",
604 "items": { "type": "object" }
605 })
606}
607
608fn weights_schema() -> Value {
609 let criterion = || json!({ "type": "number", "minimum": 0 });
610 json!({
611 "type": "object",
612 "description": "Per-criterion weights for the combined score (lower is better). Each must be finite and >= 0.",
613 "properties": {
614 "makespan": criterion(),
615 "p80": criterion(),
616 "criticality_risk": criterion(),
617 "total_effort": criterion(),
618 "peak_load": criterion()
619 },
620 "additionalProperties": false
621 })
622}
623
624fn calendar_schema() -> Value {
625 json!({
626 "type": "object",
627 "description": "Working-time calendar: only working hours advance the PV clock. Omitted: wall-clock hours. A workday contributes hours_per_day hours starting at 00:00 local time.",
628 "properties": {
629 "hours_per_day": { "type": "number", "exclusiveMinimum": 0, "maximum": 24, "description": "Default 8." },
630 "workdays": { "type": "array", "minItems": 1, "items": { "type": "string", "enum": ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] }, "description": "Default mon..fri." },
631 "utc_offset_minutes": { "type": "integer", "minimum": -1440, "maximum": 1440, "description": "Local offset from UTC; default 0." }
632 },
633 "additionalProperties": false
634 })
635}
636
637fn capacities_schema() -> Value {
638 json!({
639 "type": "object",
640 "description": "Units available per resource name. Every resource that carries work (a deliverable with nonzero scheduled length) needs a count of at least 1; a missing or zero entry for such a resource is INVALID_CAPACITIES. A resource with only milestones or zero-length work may be omitted or 0.",
641 "additionalProperties": { "type": "integer", "minimum": 0 }
642 })
643}
644
645pub fn plan_tool_definitions() -> Vec<Tool> {
656 vec![
657 Tool::new(
658 Cow::Borrowed(TOOL_SUBMIT),
659 Cow::Borrowed(
660 "Submit a plan graph and receive a plan_id. \
661 Idempotent: identical graphs return the same plan_id. \
662 With `name` (and optional `project`/`variant`, variant \
663 defaults to \"main\"), registers a named variant instead: \
664 project defaults to the discovered project root.",
665 ),
666 schema_object(json!({
667 "type": "object",
668 "properties": {
669 "graph": graph_schema(),
670 "project": { "type": "string", "description": "Project key; required with `name` when no project root is discovered." },
671 "name": { "type": "string", "description": "Plan line name; when present the graph is synced as a named variant." },
672 "variant": { "type": "string", "description": "Variant name of a named plan (default \"main\")." }
673 },
674 "required": ["graph"],
675 "additionalProperties": false
676 })),
677 ),
678 Tool::new(
679 Cow::Borrowed(TOOL_ACQUIRE_COHORT),
680 Cow::Borrowed(
681 "Acquire up to max_count ready deliverables with no conflicting file claims \
682 atomically. Returns the cohort plus per-deliverable locks. \
683 A deliverable explicitly marked failed 3 times is \
684 circuit-broken to failed instead of re-leased; leases lost \
685 environmentally (TTL lapse, no terminal mark) never trip \
686 that breaker but are bounded separately (LAPSE_LIMIT at 10). \
687 Optional ttl_seconds sets the lease TTL (clamped to the \
688 server maximum). Optional ids targets specific deliverables \
689 and filter.metadata narrows by metadata equality; \
690 deliverables with metadata.kind = \"manual\" are never \
691 leased. ids that do not match filter.metadata are ignored \
692 (not reported in blocked). The response carries blocked \
693 [{id, code, reason}], blocked_count, and needs_operator \
694 (true when any blocked code is LAPSE_LIMIT: clear with \
695 plan.force_release {reset_counters: true}).",
696 ),
697 schema_object(json!({
698 "type": "object",
699 "properties": {
700 "plan_id": { "type": "string" },
701 "caller_id": { "type": "string" },
702 "max_count": { "type": "integer", "minimum": 1 },
703 "ids": { "type": "array", "minItems": 1, "items": { "type": "string" } },
704 "ttl_seconds": { "type": "integer", "minimum": 1 },
705 "filter": {
706 "type": "object",
707 "properties": { "metadata": { "type": "object" } },
708 "additionalProperties": false
709 }
710 },
711 "required": ["plan_id", "caller_id", "max_count"]
712 })),
713 ),
714 Tool::new(
715 Cow::Borrowed(TOOL_HEARTBEAT),
716 Cow::Borrowed(
717 "Refresh the TTL on a held lock; LOCK_NOT_HELD or LOCK_EXPIRED on failure. \
718 Optional ttl_seconds sets the new TTL (clamped to the server \
719 maximum); without it the lease is never shortened.",
720 ),
721 schema_object(json!({
722 "type": "object",
723 "properties": {
724 "plan_id": { "type": "string" },
725 "deliverable_id": { "type": "string" },
726 "caller_id": { "type": "string" },
727 "ttl_seconds": { "type": "integer", "minimum": 1 }
728 },
729 "required": ["plan_id", "deliverable_id", "caller_id"]
730 })),
731 ),
732 Tool::new(
733 Cow::Borrowed(TOOL_MARK_STATUS),
734 Cow::Borrowed(
735 "Set a deliverable's status. Complete/Failed releases the lock; \
736 caller_id mismatch yields LOCK_NOT_HELD. Without a lock, \
737 Complete, Ready or InProgress requires all prerequisites \
738 complete (PREREQUISITES_INCOMPLETE) and is audited; other \
739 lockless marks are audited too, and an already-complete deliverable \
740 cannot be changed (LOCK_NOT_HELD). A lockless in_progress persists \
741 across restarts and is not leasable; hand it back with a lockless \
742 ready (or other status) mark. plan.force_release does not affect it. \
743 Optional earned-value progress: \
744 earned_pct (0..100, only with in_progress; ignored with complete), \
745 actual_effort_hours (total so far, 0..1000000; replaces leased hours \
746 as actual cost) and evidence (<= 2048 chars, appended to a list of \
747 at most 100); \
748 violations are INVALID_ACTUALS. Every lease that ends adds its hours \
749 to the deliverable's leased hours.",
750 ),
751 schema_object(json!({
752 "type": "object",
753 "properties": {
754 "plan_id": { "type": "string" },
755 "deliverable_id": { "type": "string" },
756 "caller_id": { "type": "string" },
757 "status": {
758 "type": "object",
759 "description": "Internally-tagged: {\"status\":\"pending|ready|in_progress|complete\"} or {\"status\":\"failed\",\"reason\":\"...\"}"
760 },
761 "earned_pct": {
762 "type": "integer",
763 "minimum": 0,
764 "maximum": 100,
765 "description": "Percent complete (used by the weighted earning rule). Only with status in_progress; accepted and ignored with complete."
766 },
767 "actual_effort_hours": {
768 "type": "number",
769 "minimum": 0,
770 "maximum": 1000000,
771 "description": "Total effort spent so far, in hours; replaces the stored value and takes precedence over leased hours for actual cost."
772 },
773 "evidence": {
774 "type": "string",
775 "maxLength": 2048,
776 "description": "One evidence note, appended to the deliverable's evidence list (at most 100 entries)."
777 }
778 },
779 "required": ["plan_id", "deliverable_id", "caller_id", "status"]
780 })),
781 ),
782 Tool::new(
783 Cow::Borrowed(TOOL_STATUS),
784 Cow::Borrowed(
785 "Read-only snapshot: per-deliverable [id, status, attempt_count, \
786 failure_count, lapse_count] rows, critical_path (one real chain, always from __start__ to __finish__), \
787 critical_ids, per-deliverable schedule (es/ef/ls/lf/float, hours; \
788 synthetic __start__/__finish__ rows have synthetic=true), \
789 the ready set in cohort priority order, plan_complete, milestones (per milestone: id, critical_path from __start__, hours, complete), and held locks.",
790 ),
791 schema_object(json!({
792 "type": "object",
793 "properties": {
794 "plan_id": { "type": "string" }
795 },
796 "required": ["plan_id"]
797 })),
798 ),
799 Tool::new(
800 Cow::Borrowed(TOOL_GET),
801 Cow::Borrowed(
802 "Return the stored PlanGraph (deliverables, estimates, files, \
803 metadata) for a plan_id.",
804 ),
805 schema_object(json!({
806 "type": "object",
807 "properties": {
808 "plan_id": { "type": "string" }
809 },
810 "required": ["plan_id"]
811 })),
812 ),
813 Tool::new(
814 Cow::Borrowed(TOOL_FORCE_RELEASE),
815 Cow::Borrowed(
816 "Operator escape hatch — release a lock regardless of caller. \
817 Emits an audit event carrying `reason`.",
818 ),
819 schema_object(json!({
820 "type": "object",
821 "properties": {
822 "plan_id": { "type": "string" },
823 "deliverable_id": { "type": "string" },
824 "reason": { "type": "string" },
825 "reset_counters": {
826 "type": "boolean",
827 "description": "Also clear lapse and failure counters (revives a circuit-broken deliverable)."
828 }
829 },
830 "required": ["plan_id", "deliverable_id", "reason"]
831 })),
832 ),
833 Tool::new(
834 Cow::Borrowed(TOOL_ACCEPT),
835 Cow::Borrowed(
836 "Manager/owner acceptance: mark a deliverable Complete without \
837 holding its lease. Requires all prerequisites Complete and \
838 evidence; audited. A live lease held by someone else is refused \
839 (LOCK_HELD) unless override_lock is true.",
840 ),
841 schema_object(json!({
842 "type": "object",
843 "properties": {
844 "plan_id": { "type": "string" },
845 "deliverable_id": { "type": "string" },
846 "accepted_by": { "type": "string" },
847 "evidence": { "type": "string" },
848 "override_lock": {
849 "type": "boolean",
850 "description": "Take over a live lease held by another caller."
851 }
852 },
853 "required": ["plan_id", "deliverable_id", "accepted_by", "evidence"]
854 })),
855 ),
856 Tool::new(
857 Cow::Borrowed(TOOL_LINT),
858 Cow::Borrowed(
859 "Lint a graph, stored plan, or plan file without creating one: cycles (with the \
860 loop), redundant edges, edges without rationale, interface edges \
861 not targeting a contract, deliverables feeding no milestone, and \
862 unordered file overlaps. Provide exactly one of graph, plan_id, or path.",
863 ),
864 schema_object(json!({
865 "type": "object",
866 "properties": {
867 "graph": graph_selector_schema(),
868 "plan_id": plan_id_schema(),
869 "path": path_schema()
870 },
871 "additionalProperties": false
872 })),
873 ),
874 Tool::new(
875 Cow::Borrowed(TOOL_SCHEDULE),
876 Cow::Borrowed(
877 "Level a graph or stored plan against resource capacities \
878 (`metadata.owner` by default): makespan, per-deliverable \
879 start/finish, per-resource load, the driving chain (dependency vs \
880 resource waits), and project/feeding buffers. Provide exactly one \
881 of graph or plan_id. capacities is required: every resource that \
882 carries work needs at least 1 unit (otherwise INVALID_CAPACITIES: \
883 lists the missing resources). plan.schedule rejects what \
884 plan.submit rejects (INVALID_GRAPH:).",
885 ),
886 schema_object(json!({
887 "type": "object",
888 "properties": {
889 "graph": graph_selector_schema(),
890 "plan_id": plan_id_schema(),
891 "capacities": capacities_schema(),
892 "resource_key": {
893 "type": "string",
894 "description": "Metadata key naming a deliverable's resource (default \"owner\")."
895 },
896 "project_buffer_pct": {
897 "type": "number",
898 "minimum": 0,
899 "maximum": 100,
900 "description": "Project buffer as a percentage of the driving chain (default 25)."
901 }
902 },
903 "required": ["capacities"],
904 "additionalProperties": false
905 })),
906 ),
907 Tool::new(
908 Cow::Borrowed(TOOL_SIMULATE),
909 Cow::Borrowed(
910 "Read-only what-if for a graph, stored plan, or plan file (nothing is \
911 persisted): lint, critical path, schedule, milestones, optional \
912 resource schedule and Monte Carlo, and the scorecard. Provide exactly \
913 one of graph, plan_id, or path. schedule takes the plan.schedule inputs \
914 (capacities required; INVALID_CAPACITIES: when a working resource \
915 has none). monte_carlo takes iterations (1..50000, default 2000) \
916 and seed (default 0xC0FFEE); iterations × (deliverables + \
917 prerequisite edges) must not exceed 200000000. plan.simulate \
918 rejects what plan.submit rejects; lint errors or an invalid graph \
919 are INVALID_GRAPH:.",
920 ),
921 schema_object(json!({
922 "type": "object",
923 "properties": {
924 "graph": graph_selector_schema(),
925 "plan_id": plan_id_schema(),
926 "path": path_schema(),
927 "schedule": {
928 "type": "object",
929 "description": "Level the plan (same inputs as plan.schedule).",
930 "properties": {
931 "capacities": capacities_schema(),
932 "resource_key": { "type": "string", "description": "Metadata key naming a deliverable's resource (default \"owner\")." },
933 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100, "description": "Project buffer as a percentage of the driving chain (default 25)." }
934 },
935 "required": ["capacities"],
936 "additionalProperties": false
937 },
938 "monte_carlo": {
939 "type": "object",
940 "description": "Seeded Monte Carlo over three-point estimates. iterations × (deliverables + prerequisite edges) must not exceed 200000000.",
941 "properties": {
942 "iterations": { "type": "integer", "minimum": 1, "maximum": 50000, "description": "Simulated schedules (default 2000)." },
943 "seed": { "type": "integer", "minimum": 0, "description": "RNG seed (default 12648430 = 0xC0FFEE); the same seed reproduces the output on the same platform and build." }
944 },
945 "additionalProperties": false
946 }
947 },
948 "additionalProperties": false
949 })),
950 ),
951 Tool::new(
952 Cow::Borrowed(TOOL_SYNC),
953 Cow::Borrowed(
954 "Register or update one variant of a named plan line from a plan \
955 file (`path`) or an inline `graph`. A path is read from \
956 .cpm-planner/plans/<name>/<variant>.json and tracked by content hash \
957 for drift detection. An inline graph requires `name`; `variant` \
958 defaults to \"main\". `project` defaults to the discovered project \
959 root. `force` releases live locks of removed deliverables.",
960 ),
961 schema_object(json!({
962 "type": "object",
963 "properties": {
964 "path": path_schema(),
965 "graph": graph_schema(),
966 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
967 "name": { "type": "string", "description": "Plan line name (required with an inline graph)." },
968 "variant": { "type": "string", "description": "Variant name (default \"main\" for an inline graph)." },
969 "force": { "type": "boolean", "description": "Release live locks of removed deliverables instead of refusing." }
970 },
971 "additionalProperties": false
972 })),
973 ),
974 Tool::new(
975 Cow::Borrowed(TOOL_LIST),
976 Cow::Borrowed(
977 "List every plan line of `project` (default: the discovered project \
978 root), sorted by name with each line's variants sorted by variant. \
979 Archived lines and variants are omitted unless include_archived.",
980 ),
981 schema_object(json!({
982 "type": "object",
983 "properties": {
984 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
985 "include_archived": { "type": "boolean", "description": "Include archived lines and variants (default false)." }
986 },
987 "additionalProperties": false
988 })),
989 ),
990 Tool::new(
991 Cow::Borrowed(TOOL_EXPORT),
992 Cow::Borrowed(
993 "Write the head graph of `plan_id` under the project root and return its \
994 root-relative path: to `path` when given (confined to \
995 .cpm-planner/plans/<name>/<variant>.json), else to its own variant \
996 file. Refuses (INVALID_PATH) another variant's tracked plan file, or \
997 the variant's own file while it holds local edits never synced, \
998 unless force. The written file is not synced; exporting to the \
999 variant's own tracked file records its hash, so drift is false.",
1000 ),
1001 schema_object(json!({
1002 "type": "object",
1003 "properties": {
1004 "plan_id": { "type": "string" },
1005 "path": path_schema(),
1006 "force": { "type": "boolean", "description": "Overwrite another variant's tracked file or unsynced local edits." }
1007 },
1008 "required": ["plan_id"],
1009 "additionalProperties": false
1010 })),
1011 ),
1012 Tool::new(
1013 Cow::Borrowed(TOOL_REVISE),
1014 Cow::Borrowed(
1015 "Replace a plan's graph in place, carrying progress over: unchanged \
1016 deliverables keep status and counters; changed or reopened ones are \
1017 re-derived. Removed deliverables holding live locks are refused \
1018 (LOCK_HELD) unless force. Returns the new revision and the diff.",
1019 ),
1020 schema_object(json!({
1021 "type": "object",
1022 "properties": {
1023 "plan_id": { "type": "string" },
1024 "graph": graph_schema(),
1025 "force": { "type": "boolean", "description": "Release live locks of removed deliverables instead of refusing." }
1026 },
1027 "required": ["plan_id", "graph"],
1028 "additionalProperties": false
1029 })),
1030 ),
1031 Tool::new(
1032 Cow::Borrowed(TOOL_FORK),
1033 Cow::Borrowed(
1034 "Copy the head graph of a named variant, apply `edits` in order, and \
1035 register the result as a new draft (not selected) variant of the same \
1036 line. Fails if the variant already exists.",
1037 ),
1038 schema_object(json!({
1039 "type": "object",
1040 "properties": {
1041 "plan_id": { "type": "string", "description": "The named variant to copy." },
1042 "variant": { "type": "string", "description": "Name of the new draft variant." },
1043 "edits": edits_schema()
1044 },
1045 "required": ["plan_id", "variant"],
1046 "additionalProperties": false
1047 })),
1048 ),
1049 Tool::new(
1050 Cow::Borrowed(TOOL_SELECT),
1051 Cow::Borrowed(
1052 "Make the named variant owning `plan_id` its line's selected (the only \
1053 executable) variant. Progress carries over from the previously \
1054 selected variant. Live locks on the previous variant refuse selection \
1055 with LOCK_HELD unless force releases them (audited).",
1056 ),
1057 schema_object(json!({
1058 "type": "object",
1059 "properties": {
1060 "plan_id": { "type": "string" },
1061 "force": { "type": "boolean", "description": "Release live locks on the previously selected variant." }
1062 },
1063 "required": ["plan_id"],
1064 "additionalProperties": false
1065 })),
1066 ),
1067 Tool::new(
1068 Cow::Borrowed(TOOL_ARCHIVE),
1069 Cow::Borrowed(
1070 "Archive (archived defaults to true) or unarchive a whole plan line \
1071 (`variant` omitted) or one variant. Archived variants stay readable \
1072 but are hidden from plan.list unless include_archived, and refuse sync, \
1073 selection and execution (ARCHIVE_REFUSED).",
1074 ),
1075 schema_object(json!({
1076 "type": "object",
1077 "properties": {
1078 "name": { "type": "string", "description": "Plan line name." },
1079 "variant": { "type": "string", "description": "One variant; omitted archives the whole line." },
1080 "project": { "type": "string", "description": "Project key; defaults to the discovered project root." },
1081 "archived": { "type": "boolean", "description": "False unarchives (default true)." },
1082 "force": { "type": "boolean", "description": "Release live locks on the selected variant when archiving a line." }
1083 },
1084 "required": ["name"],
1085 "additionalProperties": false
1086 })),
1087 ),
1088 Tool::new(
1089 Cow::Borrowed(TOOL_COMPARE),
1090 Cow::Borrowed(
1091 "Compare stored plans on the scorecard, returning the Pareto front, \
1092 weighted rank and a recommended plan. Give exactly one of `plan_ids` \
1093 (2 to 16 distinct plans) or `plan` (a line name in `project`: every \
1094 non-archived variant, at most 16). Read-only. Weights must be finite \
1095 and >= 0. With monte_carlo, iterations × (deliverables + prerequisite \
1096 edges) summed over all variants must not exceed 200000000.",
1097 ),
1098 schema_object(json!({
1099 "type": "object",
1100 "properties": {
1101 "plan_ids": { "type": "array", "minItems": 2, "maxItems": 16, "uniqueItems": true, "items": { "type": "string" } },
1102 "plan": { "type": "string", "description": "Plan line name; compares every live variant." },
1103 "project": { "type": "string", "description": "Project key of `plan`; defaults to the discovered project root." },
1104 "schedule": {
1105 "type": "object",
1106 "description": "Level every variant (same inputs as plan.schedule).",
1107 "properties": {
1108 "capacities": capacities_schema(),
1109 "resource_key": { "type": "string" },
1110 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100 }
1111 },
1112 "required": ["capacities"],
1113 "additionalProperties": false
1114 },
1115 "monte_carlo": {
1116 "type": "object",
1117 "description": "Run Monte Carlo on every variant (P80 then comes from it).",
1118 "properties": {
1119 "iterations": { "type": "integer", "minimum": 1, "maximum": 50000 },
1120 "seed": { "type": "integer", "minimum": 0 }
1121 },
1122 "additionalProperties": false
1123 },
1124 "weights": weights_schema()
1125 },
1126 "additionalProperties": false
1127 })),
1128 ),
1129 Tool::new(
1130 Cow::Borrowed(TOOL_BASELINE),
1131 Cow::Borrowed(
1132 "Freeze the plan's current CPM schedule (earliest start/finish) and budgets \
1133 (effort basis x metadata.cost_rate) as its next numbered baseline for earned \
1134 value. The first baseline is number 1; re-baselining needs a non-blank \
1135 `reason` (INVALID_GRAPH otherwise) and keeps actuals and snapshots. \
1136 Without `calendar`, a re-baseline keeps the previous baseline's calendar \
1137 (the first baseline counts wall-clock hours). Execution-side: the plan must be its line's selected, unarchived variant \
1138 (VARIANT_NOT_SELECTED / ARCHIVE_REFUSED). Audited as plan.ev.baselined.",
1139 ),
1140 schema_object(json!({
1141 "type": "object",
1142 "properties": {
1143 "plan_id": { "type": "string" },
1144 "start": { "type": "string", "format": "date-time", "description": "RFC 3339 instant the PV clock starts from; defaults to now." },
1145 "calendar": calendar_schema(),
1146 "reason": { "type": "string", "maxLength": 2048, "description": "Why the plan is re-baselined; required when a baseline exists." }
1147 },
1148 "required": ["plan_id"],
1149 "additionalProperties": false
1150 })),
1151 ),
1152 Tool::new(
1153 Cow::Borrowed(TOOL_EV),
1154 Cow::Borrowed(
1155 "Earned-value report against the plan's latest baseline as of `as_of` \
1156 (default now). `as_of` is the PV status date; EV and AC reflect progress \
1157 and actuals recorded up to the moment the call runs. Returns BAC, PV, EV, \
1158 AC, SV, CV, SPI, CPI, EAC, ETC, VAC, TCPI, per-deliverable rows (a \
1159 baselined deliverable later removed by plan.revise has status `removed` \
1160 and keeps the percent it had earned: 100 if complete), critical float \
1161 consumed, SPI_BELOW_0_9 / CPI_BELOW_0_9 alerts from the two latest \
1162 stored non-backfilled snapshots (by as_of) of the current baseline, and \
1163 deliverables added since the baseline. AC counts every recorded hour of \
1164 the plan, including removed and unbaselined deliverables (rate 1 when the \
1165 baseline has no row). A \
1166 ratio whose denominator is 0 is null and explained in `undefined`. \
1167 Read-only; works on any variant. \
1168 NOT_BASELINED before plan.baseline.",
1169 ),
1170 schema_object(json!({
1171 "type": "object",
1172 "properties": {
1173 "plan_id": { "type": "string" },
1174 "as_of": { "type": "string", "format": "date-time", "description": "RFC 3339 PV status date; defaults to now. EV and AC are always as recorded when the call runs." }
1175 },
1176 "required": ["plan_id"],
1177 "additionalProperties": false
1178 })),
1179 ),
1180 Tool::new(
1181 Cow::Borrowed(TOOL_SNAPSHOT),
1182 Cow::Borrowed(
1183 "Compute the earned-value report (as plan.ev) and append it as a \
1184 snapshot. `as_of` is the PV status date; EV and AC are the progress \
1185 recorded when the call runs, so a snapshot whose as_of is more than an \
1186 hour before its taken_at is marked `backfilled: true` and ignored by \
1187 alerts. Returns the snapshot summary (alerts consider this snapshot and \
1188 the latest earlier non-backfilled one by as_of of the current baseline) \
1189 and an export \
1190 of the newest 100 snapshots by as_of, oldest first (an older backfill \
1191 is counted but not listed): a list of \
1192 summaries (format json, default) or a Markdown table with columns \
1193 date, PV, EV, AC, SPI, CPI, EAC (format markdown). \
1194 Execution-side like plan.baseline. NOT_BASELINED before plan.baseline.",
1195 ),
1196 schema_object(json!({
1197 "type": "object",
1198 "properties": {
1199 "plan_id": { "type": "string" },
1200 "as_of": { "type": "string", "format": "date-time", "description": "RFC 3339 PV status date; defaults to now. EV and AC are always as recorded when the call runs." },
1201 "format": { "type": "string", "enum": ["json", "markdown"], "description": "Export format (default json)." }
1202 },
1203 "required": ["plan_id"],
1204 "additionalProperties": false
1205 })),
1206 ),
1207 Tool::new(
1208 Cow::Borrowed(TOOL_REVIEW),
1209 Cow::Borrowed(
1210 "Optional AI review of a graph, stored plan, or plan file (read-only; \
1211 provide exactly one of graph, plan_id, or path). Lint always runs; \
1212 lint errors return status invalid_graph without calling the judge. \
1213 Otherwise one batched call to Jev (calibrated judgments, via \
1214 OpenRouter) asks up to max_questions (1..64, default 64) questions \
1215 about likely false or missing dependencies, split and interface-split \
1216 candidates and crash options; findings carry advisory probabilities. \
1217 Proposals are plan.fork edit lists, each verified by simulate (lint \
1218 clean, makespan shorter) and ranked by hours_saved / max(cost, 1). \
1219 Without an OpenRouter key (or with an unusable LLM configuration) \
1220 status is review_unavailable with a reason, plus lint. The plan graph \
1221 (ids, descriptions, metadata) is sent to OpenRouter.",
1222 ),
1223 schema_object(json!({
1224 "type": "object",
1225 "properties": {
1226 "graph": graph_selector_schema(),
1227 "plan_id": plan_id_schema(),
1228 "path": path_schema(),
1229 "capacities": {
1230 "type": "object",
1231 "description": "Optional: level the plan so makespans account for resources (same rules as plan.schedule capacities).",
1232 "additionalProperties": { "type": "integer", "minimum": 0 }
1233 },
1234 "resource_key": { "type": "string", "description": "Metadata key naming a deliverable's resource (default \"owner\"); requires capacities." },
1235 "project_buffer_pct": { "type": "number", "minimum": 0, "maximum": 100, "description": "Project buffer percentage (default 25); requires capacities." },
1236 "max_questions": { "type": "integer", "minimum": 1, "maximum": 64, "description": "Question cap for the single Jev call (default 64); extra candidates are dropped and truncated is true." }
1237 },
1238 "additionalProperties": false
1239 })),
1240 ),
1241 ]
1242}
1243
1244fn schema_object(value: Value) -> Arc<rmcp::model::JsonObject> {
1247 debug_assert!(
1252 value.is_object(),
1253 "schema_object expects an object literal; got non-object"
1254 );
1255 let obj = match value.as_object() {
1256 Some(o) => o.clone(),
1257 None => serde_json::Map::new(),
1258 };
1259 Arc::new(obj)
1260}
1261
1262#[derive(Clone)]
1268enum ReviewJudge {
1269 Model(Arc<dyn JudgmentModel>),
1270 NotConfigured,
1271 Unusable(String),
1273}
1274
1275impl ReviewJudge {
1276 fn as_judge(&self) -> Judge<'_> {
1277 match self {
1278 Self::Model(model) => Judge::Model(model.as_ref()),
1279 Self::NotConfigured => Judge::NotConfigured,
1280 Self::Unusable(reason) => Judge::ConfigError(reason),
1281 }
1282 }
1283
1284 fn configured_model(&self) -> Option<String> {
1285 match self {
1286 Self::Model(model) => model.model_id(),
1287 Self::NotConfigured | Self::Unusable(_) => None,
1288 }
1289 }
1290}
1291
1292#[derive(Clone)]
1294pub struct PlanServer {
1295 planner: Arc<BasicCpmPlanner>,
1296 server_name: String,
1297 server_version: String,
1298 judge: ReviewJudge,
1299 review_slots: Arc<tokio::sync::Semaphore>,
1301}
1302
1303impl PlanServer {
1304 pub fn new(planner: Arc<BasicCpmPlanner>) -> Self {
1306 Self {
1307 planner,
1308 server_name: "cpm-planner".to_string(),
1309 server_version: env!("CARGO_PKG_VERSION").to_string(),
1310 judge: ReviewJudge::NotConfigured,
1311 review_slots: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_REVIEWS)),
1312 }
1313 }
1314
1315 pub fn with_judge(mut self, judge: Option<Arc<dyn JudgmentModel>>) -> Self {
1318 self.judge = judge.map_or(ReviewJudge::NotConfigured, ReviewJudge::Model);
1319 self
1320 }
1321
1322 pub fn with_llm_config(mut self, config: Result<Option<LlmConfig>, ConfigError>) -> Self {
1328 self.judge = match config {
1329 Ok(Some(config)) => {
1330 tracing::info!(
1331 model = config.jev_model(),
1332 endpoint = ?config.jev_endpoint_host(),
1333 "plan.review enabled"
1334 );
1335 ReviewJudge::Model(Arc::new(JevJudge::new(&config)))
1336 }
1337 Ok(None) => {
1338 tracing::info!("no OpenRouter key configured; plan.review is unavailable");
1339 ReviewJudge::NotConfigured
1340 }
1341 Err(err) => {
1342 let reason = err.review_reason();
1345 tracing::warn!(%reason, "LLM configuration unusable; plan.review is unavailable");
1346 ReviewJudge::Unusable(reason)
1347 }
1348 };
1349 self
1350 }
1351
1352 pub fn with_identity(mut self, name: impl Into<String>, version: impl Into<String>) -> Self {
1355 self.server_name = name.into();
1356 self.server_version = version.into();
1357 self
1358 }
1359
1360 pub fn planner(&self) -> &Arc<BasicCpmPlanner> {
1363 &self.planner
1364 }
1365
1366 fn project_root(&self) -> Result<&ProjectRoot, McpError> {
1372 self.planner.project_root().ok_or_else(|| {
1373 McpError::internal_error(
1374 "INVALID_PATH: no project root (set CPM_PROJECT_ROOT or run inside a repo)",
1375 None,
1376 )
1377 })
1378 }
1379
1380 fn default_project(&self) -> Result<String, McpError> {
1382 Ok(self.project_root()?.project_key())
1383 }
1384
1385 async fn resolve_graph(
1389 &self,
1390 graph: Option<PlanGraph>,
1391 plan_id: Option<String>,
1392 path: Option<String>,
1393 ) -> Result<PlanGraph, McpError> {
1394 let supplied = [graph.is_some(), plan_id.is_some(), path.is_some()]
1395 .iter()
1396 .filter(|present| **present)
1397 .count();
1398 if supplied != 1 {
1399 return Err(McpError::invalid_params(
1400 "provide exactly one of graph, plan_id, or path",
1401 None,
1402 ));
1403 }
1404 match (graph, plan_id, path) {
1405 (Some(graph), None, None) => Ok(graph),
1406 (None, Some(plan_id), None) => {
1407 let definition = self
1408 .planner
1409 .get_plan(&PlanId(plan_id))
1410 .await
1411 .map_err(planner_error_to_mcp)?;
1412 Ok(definition.graph)
1413 }
1414 (None, None, Some(path)) => {
1415 let root = self.project_root()?;
1416 let file = root
1417 .resolve_plan_file(&path)
1418 .map_err(planner_error_to_mcp)?;
1419 let (graph, _) = root.read_graph(&file).map_err(planner_error_to_mcp)?;
1420 Ok(graph)
1421 }
1422 _ => Err(McpError::invalid_params(
1424 "provide exactly one of graph, plan_id, or path",
1425 None,
1426 )),
1427 }
1428 }
1429
1430 pub async fn serve_stdio(self) -> anyhow::Result<()> {
1437 let service = self.serve(stdio()).await?;
1438 service.waiting().await?;
1439 Ok(())
1440 }
1441
1442 pub async fn dispatch_call(&self, request: CallToolRequestParams) -> Result<Value, McpError> {
1448 let args: Value = request
1449 .arguments
1450 .as_ref()
1451 .map(|m| Value::Object(m.clone()))
1452 .unwrap_or_else(|| json!({}));
1453
1454 match request.name.as_ref() {
1455 TOOL_SUBMIT => self.handle_submit(args).await,
1456 TOOL_ACQUIRE_COHORT => self.handle_acquire_cohort(args).await,
1457 TOOL_HEARTBEAT => self.handle_heartbeat(args).await,
1458 TOOL_MARK_STATUS => self.handle_mark_status(args).await,
1459 TOOL_STATUS => self.handle_status(args).await,
1460 TOOL_GET => self.handle_get(args).await,
1461 TOOL_FORCE_RELEASE => self.handle_force_release(args).await,
1462 TOOL_ACCEPT => self.handle_accept(args).await,
1463 TOOL_LINT => self.handle_lint(args).await,
1464 TOOL_SCHEDULE => self.handle_schedule(args).await,
1465 TOOL_SIMULATE => self.handle_simulate(args).await,
1466 TOOL_SYNC => self.handle_sync(args).await,
1467 TOOL_LIST => self.handle_list(args).await,
1468 TOOL_EXPORT => self.handle_export(args).await,
1469 TOOL_REVISE => self.handle_revise(args).await,
1470 TOOL_FORK => self.handle_fork(args).await,
1471 TOOL_SELECT => self.handle_select(args).await,
1472 TOOL_ARCHIVE => self.handle_archive(args).await,
1473 TOOL_COMPARE => self.handle_compare(args).await,
1474 TOOL_BASELINE => self.handle_baseline(args).await,
1475 TOOL_EV => self.handle_ev(args).await,
1476 TOOL_SNAPSHOT => self.handle_snapshot(args).await,
1477 TOOL_REVIEW => self.handle_review(args).await,
1478 other => Err(McpError::invalid_params(
1479 format!(
1480 "Unknown tool '{other}'. Available: {}.",
1481 PLAN_TOOL_NAMES.join(", ")
1482 ),
1483 None,
1484 )),
1485 }
1486 }
1487
1488 async fn handle_submit(&self, args: Value) -> Result<Value, McpError> {
1493 let parsed: SubmitArgs = parse_args(args)?;
1494 match parsed.name {
1495 None => {
1496 let plan_id = self
1497 .planner
1498 .submit_plan(parsed.graph)
1499 .await
1500 .map_err(planner_error_to_mcp)?;
1501 to_value(&SubmitResponse { plan_id: plan_id.0 })
1502 }
1503 Some(name) => {
1504 let project = match parsed.project {
1505 Some(project) => project,
1506 None => self.default_project()?,
1507 };
1508 let variant = parsed.variant.unwrap_or_else(|| "main".to_string());
1509 let outcome = self
1510 .planner
1511 .sync_plan(SyncRequest::new(project, name, variant, parsed.graph))
1512 .await
1513 .map_err(planner_error_to_mcp)?;
1514 to_value(&outcome)
1515 }
1516 }
1517 }
1518
1519 async fn handle_acquire_cohort(&self, args: Value) -> Result<Value, McpError> {
1520 let parsed: AcquireCohortArgs = parse_args(args)?;
1521 if parsed.ids.as_ref().is_some_and(Vec::is_empty) {
1522 return Err(McpError::invalid_params(
1523 "ids must be non-empty when provided",
1524 None,
1525 ));
1526 }
1527 let mut request = AcquireRequest::new(
1528 PlanId(parsed.plan_id),
1529 CallerId(parsed.caller_id),
1530 parsed.max_count,
1531 );
1532 if let Some(ids) = parsed.ids {
1533 request = request.with_ids(ids);
1534 }
1535 if let Some(filter) = parsed.filter.and_then(|f| f.metadata) {
1536 request = request.with_metadata_filter(filter);
1537 }
1538 if let Some(ttl) = ttl_from_seconds(parsed.ttl_seconds)? {
1539 request = request.with_ttl(ttl);
1540 }
1541 let cohort: Cohort = self
1542 .planner
1543 .acquire_cohort(request)
1544 .await
1545 .map_err(planner_error_to_mcp)?;
1546 let exhausted = cohort.rows.is_empty();
1553 let blocked_count = cohort.blocked.len();
1554 let needs_operator = cohort.blocked.iter().any(|b| b.code == "LAPSE_LIMIT");
1555 let mut value = to_value(&cohort)?;
1556 if let Some(obj) = value.as_object_mut() {
1557 obj.insert("exhausted".to_string(), Value::Bool(exhausted));
1558 obj.insert("blocked_count".to_string(), json!(blocked_count));
1559 obj.insert("needs_operator".to_string(), Value::Bool(needs_operator));
1560 }
1561 Ok(value)
1562 }
1563
1564 async fn handle_heartbeat(&self, args: Value) -> Result<Value, McpError> {
1565 let parsed: HeartbeatArgs = parse_args(args)?;
1566 let mut request = HeartbeatRequest::new(
1567 PlanId(parsed.plan_id),
1568 parsed.deliverable_id,
1569 CallerId(parsed.caller_id),
1570 );
1571 if let Some(ttl) = ttl_from_seconds(parsed.ttl_seconds)? {
1572 request = request.with_ttl(ttl);
1573 }
1574 self.planner
1575 .heartbeat(request)
1576 .await
1577 .map_err(planner_error_to_mcp)?;
1578 to_value(&OkResponse::new())
1579 }
1580
1581 async fn handle_mark_status(&self, args: Value) -> Result<Value, McpError> {
1582 let parsed: MarkStatusArgs = parse_args(args)?;
1583 crate::planner::validate_actuals(
1584 &parsed.status,
1585 parsed.earned_pct,
1586 parsed.actual_effort_hours,
1587 parsed.evidence.as_deref(),
1588 )
1589 .map_err(planner_error_to_mcp)?;
1590 let mut request = MarkStatusRequest::new(
1591 PlanId(parsed.plan_id),
1592 parsed.deliverable_id,
1593 CallerId(parsed.caller_id),
1594 parsed.status,
1595 );
1596 if let Some(pct) = parsed.earned_pct {
1599 request = request.with_earned_pct(pct as u8);
1600 }
1601 if let Some(hours) = parsed.actual_effort_hours {
1602 request = request.with_actual_effort_hours(hours as f32);
1603 }
1604 if let Some(evidence) = parsed.evidence {
1605 request = request.with_evidence(evidence);
1606 }
1607 self.planner
1608 .mark_status(request)
1609 .await
1610 .map_err(planner_error_to_mcp)?;
1611 to_value(&OkResponse::new())
1612 }
1613
1614 async fn handle_status(&self, args: Value) -> Result<Value, McpError> {
1615 let parsed: StatusArgs = parse_args(args)?;
1616 let status: PlanStatus = self
1617 .planner
1618 .status(&PlanId(parsed.plan_id))
1619 .await
1620 .map_err(planner_error_to_mcp)?;
1621 to_value(&status)
1622 }
1623
1624 async fn handle_get(&self, args: Value) -> Result<Value, McpError> {
1625 let parsed: GetArgs = parse_args(args)?;
1626 let definition: PlanDefinition = self
1627 .planner
1628 .get_plan(&PlanId(parsed.plan_id))
1629 .await
1630 .map_err(planner_error_to_mcp)?;
1631 to_value(&definition)
1632 }
1633
1634 async fn handle_accept(&self, args: Value) -> Result<Value, McpError> {
1635 let parsed: AcceptArgs = parse_args(args)?;
1636 self.planner
1637 .accept(
1638 AcceptRequest::new(
1639 PlanId(parsed.plan_id),
1640 parsed.deliverable_id,
1641 parsed.accepted_by,
1642 parsed.evidence,
1643 )
1644 .override_lock(parsed.override_lock),
1645 )
1646 .await
1647 .map_err(planner_error_to_mcp)?;
1648 to_value(&OkResponse::new())
1649 }
1650
1651 async fn handle_force_release(&self, args: Value) -> Result<Value, McpError> {
1652 let parsed: ForceReleaseArgs = parse_args(args)?;
1653 self.planner
1654 .force_release(
1655 ForceReleaseRequest::new(
1656 PlanId(parsed.plan_id),
1657 parsed.deliverable_id,
1658 parsed.reason,
1659 )
1660 .reset_counters(parsed.reset_counters),
1661 )
1662 .await
1663 .map_err(planner_error_to_mcp)?;
1664 to_value(&OkResponse::new())
1665 }
1666
1667 async fn handle_lint(&self, args: Value) -> Result<Value, McpError> {
1668 let parsed: GraphOrPlanIdArgs = parse_args(args)?;
1669 let graph = self
1670 .resolve_graph(parsed.graph, parsed.plan_id, parsed.path)
1671 .await?;
1672 let report = run_blocking(move || Ok(crate::lint::lint(&graph))).await?;
1673 to_value(&report)
1674 }
1675
1676 async fn handle_schedule(&self, args: Value) -> Result<Value, McpError> {
1677 let parsed: ScheduleToolArgs = parse_args(args)?;
1678 let request = ScheduleRequest {
1679 capacities: parsed.capacities,
1680 resource_key: parsed.resource_key,
1681 project_buffer_pct: parsed.project_buffer_pct,
1682 };
1683 check_schedule_params(&request)?;
1684 let graph = self
1685 .resolve_graph(parsed.graph, parsed.plan_id, None)
1686 .await?;
1687 let schedule = run_blocking(move || resource_schedule(&graph, &request)).await?;
1688 to_value(&schedule)
1689 }
1690
1691 async fn handle_simulate(&self, args: Value) -> Result<Value, McpError> {
1692 let parsed: SimulateToolArgs = parse_args(args)?;
1693 let request = SimulateRequest {
1694 schedule: parsed.schedule,
1695 monte_carlo: parsed.monte_carlo,
1696 };
1697 if let Some(schedule) = &request.schedule {
1698 check_schedule_params(schedule)?;
1699 }
1700 if let Some(mc) = &request.monte_carlo {
1701 crate::monte_carlo::check_iterations(mc.iterations)
1702 .map_err(|reason| McpError::invalid_params(reason, None))?;
1703 }
1704 let graph = self
1705 .resolve_graph(parsed.graph, parsed.plan_id, parsed.path)
1706 .await?;
1707 let result = run_blocking(move || crate::simulate::simulate(&graph, &request)).await?;
1708 to_value(&result)
1709 }
1710
1711 async fn handle_sync(&self, args: Value) -> Result<Value, McpError> {
1714 let parsed: SyncArgs = parse_args(args)?;
1715 let (graph, source) = match (parsed.path, parsed.graph) {
1716 (Some(_), Some(_)) => {
1717 return Err(McpError::invalid_params(
1718 "provide exactly one of path or graph",
1719 None,
1720 ));
1721 }
1722 (Some(path), None) => {
1723 let root = self.project_root()?;
1724 let file = root
1725 .resolve_plan_file(&path)
1726 .map_err(planner_error_to_mcp)?;
1727 let (graph, hash) = root.read_graph(&file).map_err(planner_error_to_mcp)?;
1728 (graph, Some((file, hash)))
1729 }
1730 (None, Some(graph)) => (graph, None),
1731 (None, None) => {
1732 return Err(McpError::invalid_params(
1733 "plan.sync requires exactly one of path or graph",
1734 None,
1735 ));
1736 }
1737 };
1738 let project = match parsed.project {
1739 Some(project) => project,
1740 None => self.default_project()?,
1741 };
1742 let (name, variant) = match (&source, parsed.name, parsed.variant) {
1743 (Some((file, _)), name, variant) => {
1744 let root_key = self.project_root()?.project_key();
1747 if name.as_ref().is_some_and(|n| *n != file.name)
1748 || variant.as_ref().is_some_and(|v| *v != file.variant)
1749 || project != root_key
1750 {
1751 return Err(planner_error_to_mcp(PlannerError::InvalidPath {
1752 reason: "name/variant/project must match the plan file path".to_string(),
1753 }));
1754 }
1755 (file.name.clone(), file.variant.clone())
1756 }
1757 (None, Some(name), variant) => (name, variant.unwrap_or_else(|| "main".to_string())),
1758 (None, None, _) => {
1759 return Err(McpError::invalid_params(
1760 "an inline graph requires name",
1761 None,
1762 ));
1763 }
1764 };
1765 let mut request = SyncRequest::new(project, name, variant, graph).force(parsed.force);
1766 if let Some((file, hash)) = source {
1767 request = request
1768 .with_source_path(file.rel_path)
1769 .with_content_hash(hash);
1770 }
1771 let outcome = self
1772 .planner
1773 .sync_plan(request)
1774 .await
1775 .map_err(planner_error_to_mcp)?;
1776 to_value(&outcome)
1777 }
1778
1779 async fn handle_list(&self, args: Value) -> Result<Value, McpError> {
1780 let parsed: ListArgs = parse_args(args)?;
1781 let project = match parsed.project {
1782 Some(project) => project,
1783 None => self.default_project()?,
1784 };
1785 let lines = self
1786 .planner
1787 .list_plans(&project, parsed.include_archived)
1788 .await
1789 .map_err(planner_error_to_mcp)?;
1790 to_value(&lines)
1791 }
1792
1793 async fn handle_export(&self, args: Value) -> Result<Value, McpError> {
1794 let parsed: ExportArgs = parse_args(args)?;
1795 let root = self.project_root()?;
1796 let path = self
1797 .planner
1798 .export_plan(
1799 &PlanId(parsed.plan_id),
1800 root,
1801 parsed.path.as_deref(),
1802 parsed.force,
1803 )
1804 .await
1805 .map_err(planner_error_to_mcp)?;
1806 to_value(&ExportResponse { path })
1807 }
1808
1809 async fn handle_revise(&self, args: Value) -> Result<Value, McpError> {
1810 let parsed: ReviseArgs = parse_args(args)?;
1811 let (revision, diff) = self
1812 .planner
1813 .revise_plan(
1814 ReviseRequest::new(PlanId(parsed.plan_id), parsed.graph).force(parsed.force),
1815 )
1816 .await
1817 .map_err(planner_error_to_mcp)?;
1818 to_value(&ReviseResponse { revision, diff })
1819 }
1820
1821 async fn handle_fork(&self, args: Value) -> Result<Value, McpError> {
1822 let parsed: ForkArgs = parse_args(args)?;
1823 let outcome = self
1824 .planner
1825 .fork_plan(
1826 ForkRequest::new(PlanId(parsed.plan_id), parsed.variant).with_edits(parsed.edits),
1827 )
1828 .await
1829 .map_err(planner_error_to_mcp)?;
1830 to_value(&outcome)
1831 }
1832
1833 async fn handle_select(&self, args: Value) -> Result<Value, McpError> {
1834 let parsed: SelectArgs = parse_args(args)?;
1835 let outcome = self
1836 .planner
1837 .select_variant(&PlanId(parsed.plan_id), parsed.force)
1838 .await
1839 .map_err(planner_error_to_mcp)?;
1840 to_value(&outcome)
1841 }
1842
1843 async fn handle_archive(&self, args: Value) -> Result<Value, McpError> {
1844 let parsed: ArchiveArgs = parse_args(args)?;
1845 let project = match parsed.project {
1846 Some(project) => project,
1847 None => self.default_project()?,
1848 };
1849 self.planner
1850 .archive(
1851 &project,
1852 &parsed.name,
1853 parsed.variant.as_deref(),
1854 parsed.archived,
1855 parsed.force,
1856 )
1857 .await
1858 .map_err(planner_error_to_mcp)?;
1859 to_value(&OkResponse::new())
1860 }
1861
1862 async fn handle_compare(&self, args: Value) -> Result<Value, McpError> {
1863 let parsed: CompareArgs = parse_args(args)?;
1864 check_compare_weights(&parsed.weights)?;
1865 if let Some(schedule) = &parsed.schedule {
1866 check_schedule_params(schedule)?;
1867 }
1868 if let Some(mc) = &parsed.monte_carlo {
1869 crate::monte_carlo::check_iterations(mc.iterations)
1870 .map_err(|reason| McpError::invalid_params(reason, None))?;
1871 }
1872 let request = CompareRequest {
1873 schedule: parsed.schedule,
1874 monte_carlo: parsed.monte_carlo,
1875 weights: parsed.weights,
1876 };
1877 let compare = match (parsed.plan_ids, parsed.plan) {
1878 (Some(ids), None) => {
1879 let distinct: std::collections::HashSet<&str> =
1880 ids.iter().map(String::as_str).collect();
1881 if distinct.len() != ids.len() {
1882 return Err(McpError::invalid_params("plan_ids must be distinct", None));
1883 }
1884 ComparePlansRequest::by_ids(ids.into_iter().map(PlanId).collect(), request)
1885 }
1886 (None, Some(name)) => {
1887 let project = match parsed.project {
1888 Some(project) => project,
1889 None => self.default_project()?,
1890 };
1891 ComparePlansRequest::by_plan(project, name, request)
1892 }
1893 _ => {
1894 return Err(McpError::invalid_params(
1895 "provide exactly one of plan_ids or plan",
1896 None,
1897 ));
1898 }
1899 };
1900 let inputs = self
1902 .planner
1903 .compare_inputs(&compare)
1904 .map_err(planner_error_to_mcp)?;
1905 let request = compare.request;
1906 let comparison = run_blocking(move || crate::compare::compare(&inputs, &request)).await?;
1907 to_value(&comparison)
1908 }
1909
1910 async fn handle_baseline(&self, args: Value) -> Result<Value, McpError> {
1913 let parsed: BaselineArgs = parse_args(args)?;
1914 let mut request = BaselineRequest::new(PlanId(parsed.plan_id));
1915 request.start = parsed.start;
1916 request.calendar = parsed.calendar;
1917 request.reason = parsed.reason;
1918 let outcome = self
1919 .planner
1920 .baseline(request)
1921 .await
1922 .map_err(planner_error_to_mcp)?;
1923 to_value(&outcome)
1924 }
1925
1926 async fn handle_ev(&self, args: Value) -> Result<Value, McpError> {
1927 let parsed: EvArgs = parse_args(args)?;
1928 let planner = Arc::clone(&self.planner);
1929 let plan_id = PlanId(parsed.plan_id);
1930 let report = run_blocking_planner("ev", async move {
1931 Planner::ev(&*planner, &plan_id, parsed.as_of).await
1932 })
1933 .await?;
1934 to_value(&report)
1935 }
1936
1937 async fn handle_snapshot(&self, args: Value) -> Result<Value, McpError> {
1938 let parsed: SnapshotArgs = parse_args(args)?;
1939 let mut request = SnapshotRequest::new(PlanId(parsed.plan_id)).with_format(parsed.format);
1940 request.as_of = parsed.as_of;
1941 let planner = Arc::clone(&self.planner);
1942 let outcome = run_blocking_planner("snapshot", async move {
1943 Planner::snapshot(&*planner, request).await
1944 })
1945 .await?;
1946 to_value(&outcome)
1947 }
1948
1949 async fn handle_review(&self, args: Value) -> Result<Value, McpError> {
1958 let ReviewToolArgs {
1959 graph,
1960 plan_id,
1961 path,
1962 capacities,
1963 resource_key,
1964 project_buffer_pct,
1965 max_questions,
1966 } = parse_args(args)?;
1967 let request = review_request(capacities, resource_key, project_buffer_pct, max_questions)?;
1968 let audited_plan_id = plan_id.clone();
1969 let graph = match self.resolve_graph(graph, plan_id, path).await {
1970 Ok(graph) => graph,
1971 Err(err) => {
1972 if err.code != rmcp::model::ErrorCode::INVALID_PARAMS {
1975 let event =
1976 review_audit_event(Err(mcp_error_code(&err)), audited_plan_id, None);
1977 self.planner.record_audit(event).await;
1978 }
1979 return Err(err);
1980 }
1981 };
1982 let judge = self.judge.clone();
1983 let configured_model = judge.configured_model();
1984 let slots = self.review_slots.clone();
1985 let runtime = tokio::runtime::Handle::current();
1986 let outcome = spawn_analysis("review", move || {
1987 let calls_judge = matches!(judge, ReviewJudge::Model(_))
1992 && !crate::lint::lint(&graph)
1993 .findings
1994 .iter()
1995 .any(|f| f.severity == crate::lint::Severity::Error);
1996 let _slot = calls_judge
1997 .then(|| runtime.block_on(slots.acquire_owned()).ok())
1998 .flatten();
1999 runtime.block_on(crate::review::review(&graph, &request, judge.as_judge()))
2000 })
2001 .await?;
2002 let audit_outcome = match &outcome {
2003 Ok(report) => Ok(report),
2004 Err(err) => Err(Some(error_code(err))),
2005 };
2006 let event = review_audit_event(audit_outcome, audited_plan_id, configured_model);
2007 self.planner.record_audit(event).await;
2008 to_value(&outcome.map_err(planner_error_to_mcp)?)
2009 }
2010}
2011
2012fn review_request(
2014 capacities: Option<std::collections::BTreeMap<String, u32>>,
2015 resource_key: Option<String>,
2016 project_buffer_pct: Option<f32>,
2017 max_questions: Option<u16>,
2018) -> Result<ReviewRequest, McpError> {
2019 if let Some(cap) = max_questions {
2020 crate::review::check_max_questions(cap)
2021 .map_err(|reason| McpError::invalid_params(reason, None))?;
2022 }
2023 let capacities = match capacities {
2024 Some(capacities) => {
2025 let schedule = ScheduleRequest {
2026 capacities,
2027 resource_key: resource_key
2028 .unwrap_or_else(crate::resource_schedule::default_resource_key),
2029 project_buffer_pct: project_buffer_pct
2030 .unwrap_or_else(crate::resource_schedule::default_buffer_pct),
2031 };
2032 check_schedule_params(&schedule)?;
2033 Some(schedule)
2034 }
2035 None if resource_key.is_some() || project_buffer_pct.is_some() => {
2036 return Err(McpError::invalid_params(
2037 "resource_key and project_buffer_pct require capacities",
2038 None,
2039 ));
2040 }
2041 None => None,
2042 };
2043 Ok(ReviewRequest {
2044 capacities,
2045 max_questions,
2046 })
2047}
2048
2049fn review_audit_event(
2055 outcome: Result<&ReviewReport, Option<String>>,
2056 plan_id: Option<String>,
2057 configured_model: Option<String>,
2058) -> AuditEvent {
2059 let payload = match outcome {
2060 Ok(report) => json!({
2061 "status": report.status,
2062 "code": null,
2063 "failure_class": report.failure_class,
2064 "plan_id": plan_id,
2065 "question_count": report.question_count,
2066 "prompt_hash": report.prompt_hash,
2067 "model": report.model.clone().or(if report.jev_called { configured_model } else { None }),
2068 "endpoint": report.endpoint,
2069 "jev_called": report.jev_called,
2070 }),
2071 Err(code) => json!({
2072 "status": "error",
2073 "code": code,
2074 "failure_class": null,
2075 "plan_id": plan_id,
2076 "question_count": 0,
2077 "prompt_hash": null,
2078 "model": null,
2079 "endpoint": null,
2080 "jev_called": false,
2081 }),
2082 };
2083 AuditEvent::new("plan.review").with_payload(payload)
2084}
2085
2086fn mcp_error_code(err: &McpError) -> Option<String> {
2089 let (prefix, _) = err.message.split_once(':')?;
2090 (!prefix.is_empty() && prefix.chars().all(|c| c.is_ascii_uppercase() || c == '_'))
2091 .then(|| prefix.to_string())
2092}
2093
2094fn error_code(err: &PlannerError) -> String {
2096 let text = err.to_string();
2097 text.split(':')
2098 .next()
2099 .unwrap_or_default()
2100 .trim()
2101 .to_string()
2102}
2103
2104fn check_schedule_params(request: &ScheduleRequest) -> Result<(), McpError> {
2107 crate::resource_schedule::check_buffer_pct(request.project_buffer_pct)
2108 .map_err(|reason| McpError::invalid_params(reason, None))
2109}
2110
2111fn check_compare_weights(weights: &CompareWeights) -> Result<(), McpError> {
2113 let criteria = [
2114 ("makespan", weights.makespan),
2115 ("p80", weights.p80),
2116 ("criticality_risk", weights.criticality_risk),
2117 ("total_effort", weights.total_effort),
2118 ("peak_load", weights.peak_load),
2119 ];
2120 for (name, value) in criteria {
2121 if !value.is_finite() || value < 0.0 {
2122 return Err(McpError::invalid_params(
2123 format!("weights.{name} must be finite and >= 0"),
2124 None,
2125 ));
2126 }
2127 }
2128 Ok(())
2129}
2130
2131async fn run_blocking_planner<T, Fut>(what: &'static str, call: Fut) -> Result<T, McpError>
2138where
2139 T: Send + 'static,
2140 Fut: std::future::Future<Output = Result<T, PlannerError>> + Send + 'static,
2141{
2142 let runtime = tokio::runtime::Handle::current();
2143 spawn_analysis(what, move || runtime.block_on(call))
2144 .await?
2145 .map_err(planner_error_to_mcp)
2146}
2147
2148async fn run_blocking<T, F>(work: F) -> Result<T, McpError>
2150where
2151 T: Send + 'static,
2152 F: FnOnce() -> Result<T, PlannerError> + Send + 'static,
2153{
2154 spawn_analysis("analysis", work)
2155 .await?
2156 .map_err(planner_error_to_mcp)
2157}
2158
2159async fn spawn_analysis<T, F>(what: &'static str, work: F) -> Result<T, McpError>
2163where
2164 T: Send + 'static,
2165 F: FnOnce() -> T + Send + 'static,
2166{
2167 tokio::task::spawn_blocking(work).await.map_err(|e| {
2168 tracing::error!(error = %e, "{what} task failed");
2169 McpError::internal_error(format!("{what} task failed"), None)
2170 })
2171}
2172
2173impl ServerHandler for PlanServer {
2178 fn get_info(&self) -> ServerConfig {
2179 let mut server_info =
2180 Implementation::new(self.server_name.clone(), self.server_version.clone());
2181 server_info.title = Some("cpm-planner".to_string());
2182 server_info.description = Some(
2183 "MCP server exposing the open-source Praxec CPM planner via twenty-three tools."
2184 .to_string(),
2185 );
2186
2187 let mut info = InitializeResult::default();
2188 info.protocol_version = ProtocolVersion::default();
2189 info.capabilities = ServerCapabilities::builder().enable_tools().build();
2190 info.server_info = server_info;
2191 info.instructions = Some(instructions().to_string());
2192 info
2193 }
2194
2195 async fn initialize(
2196 &self,
2197 request: InitializeRequestParams,
2198 context: RequestContext<RoleServer>,
2199 ) -> Result<InitializeResult, McpError> {
2200 if context.peer.peer_info().is_none() {
2201 context.peer.set_peer_info(request);
2202 }
2203 Ok(self.get_info())
2204 }
2205
2206 async fn list_tools(
2207 &self,
2208 _request: Option<PaginatedRequestParams>,
2209 _context: RequestContext<RoleServer>,
2210 ) -> Result<ListToolsResult, McpError> {
2211 Ok(ListToolsResult::with_all_items(plan_tool_definitions()))
2212 }
2213
2214 async fn call_tool(
2215 &self,
2216 request: CallToolRequestParams,
2217 _context: RequestContext<RoleServer>,
2218 ) -> Result<CallToolResponse, McpError> {
2219 self.dispatch_call(request)
2220 .await
2221 .map(|value| CallToolResult::structured(value).into())
2222 }
2223
2224 fn get_tool(&self, name: &str) -> Option<Tool> {
2225 plan_tool_definitions().into_iter().find(|t| t.name == name)
2226 }
2227
2228 async fn on_initialized(&self, _context: NotificationContext<RoleServer>) {
2229 tracing::info!("cpm-planner client initialized");
2230 }
2231}
2232
2233fn parse_args<T: serde::de::DeserializeOwned>(args: Value) -> Result<T, McpError> {
2239 serde_json::from_value(args)
2240 .map_err(|e| McpError::invalid_params(format!("invalid arguments: {e}"), None))
2241}
2242
2243fn ttl_from_seconds(secs: Option<u64>) -> Result<Option<Duration>, McpError> {
2249 match secs {
2250 None => Ok(None),
2251 Some(0) => Err(McpError::invalid_params(
2252 "ttl_seconds must be >= 1 when provided",
2253 None,
2254 )),
2255 Some(s) => Ok(Some(Duration::from_secs(s))),
2256 }
2257}
2258
2259fn to_value<T: Serialize>(value: &T) -> Result<Value, McpError> {
2264 serde_json::to_value(value)
2265 .map_err(|e| McpError::internal_error(format!("response serialisation failed: {e}"), None))
2266}
2267
2268fn planner_error_to_mcp(err: PlannerError) -> McpError {
2277 McpError::internal_error(err.to_string(), None)
2278}
2279
2280fn instructions() -> &'static str {
2283 r#"This is the cpm-planner MCP server — the open-source CPM planner.
2284
2285Tools (twenty-three total, all `plan.<verb>`):
2286 plan.submit — submit a PlanGraph, get a plan_id (idempotent on identical graphs)
2287 a prerequisite is an id string or {id, consumes?, kind?: artifact|interface, lag_hours?}; a deliverable's duration_hours (calendar time; when absent the default is the effort estimate, explicit or estimator-derived) and lag_hours (minimum wait after a prerequisite finishes) drive the schedule
2288 a milestone (milestone: true) is zero-length unless you give it an estimate or duration; it is still an ordinary deliverable someone must complete (accept or mark Complete), and it is not leased if metadata.kind=manual
2289 an optional earning_rule (zero_hundred default: 100% only when complete; fifty_fifty: 50% once in progress or any earned_pct is reported; weighted: the reported earned_pct) sets how earned value credits progress; it is part of the plan's identity
2290 an optional estimate {optimistic, likely, pessimistic} (0 <= optimistic <= likely <= pessimistic) is a three-point effort estimate; scheduled length precedence is duration_hours > estimated_effort_hours > estimate.likely > 0 for a milestone > estimator default, and Monte Carlo samples the estimate only when neither duration_hours nor estimated_effort_hours is set
2291 limits: at most 5000 deliverables; every hour value (effort, duration, lag, estimate) must be finite and between 0 and 1000000 (INVALID_GRAPH)
2292 plan.acquire_cohort — atomically acquire ready deliverables with no conflicting file claims (an owned_files entry may be {path, mode: "append"}: append claims on one path may be co-leased and are listed in the response's shared_paths; exclusive claims never overlap anything at once; plan.submit accepts a shared file only when the claimants are ordered by prerequisites, or all claims are append)
2293 plan.heartbeat — refresh a held lock's TTL
2294 plan.mark_status — set a deliverable's status (Complete/Failed releases the lock); lockless Complete/Ready/InProgress requires complete prerequisites (PREREQUISITES_INCOMPLETE) and is audited; a lockless InProgress persists across restarts and is not leasable until handed back with a lockless Ready (plan.force_release does not affect it)
2295 optional earned-value progress: earned_pct (integer 0..100, only with in_progress; accepted and ignored with complete), actual_effort_hours (total so far, finite 0..1000000; replaces leased hours as actual cost), evidence (<= 2048 chars, appended to the deliverable's list, at most 100 entries); violations are INVALID_ACTUALS. plan.select copies the actuals of every deliverable whose Complete status it carries (leased hours add up; the carried deliverable's reported earned_pct and hours win; the newest 100 evidence entries are kept). Every lease that ends (complete, failed, force_release, expiry, accept override, forced revise/select/archive) adds its hours (up to expiry for a lapsed lease) to the deliverable's leased hours
2296 plan.status — read-only snapshot ([id, status, attempt_count, failure_count, lapse_count] rows, critical_path (one real chain, always __start__ to __finish__), critical_ids, per-deliverable schedule (es/ef/ls/lf/float, hours; synthetic __start__/__finish__ endpoint rows have synthetic=true and critical=true; critical_ids lists only real deliverables), the ready set in acquire/ready priority order (smallest latest start first, i.e. longest remaining tail, then least float, then id), plan_complete, milestones (one row per `milestone: true` deliverable, or metadata.milestone == true: id, critical_path from __start__ to it, hours = its earliest finish, complete), held locks; for a named variant also name, variant, selected and definition_drift). __start__ and __finish__ are reserved deliverable ids (INVALID_GRAPH)
2297 definition_drift: null when unknown (no root for the variant's project, no tracked file, file missing/unreadable/over 8 MiB); false when the file matches the last synced/exported bytes or its graph equals the head graph (re-formatting is not drift); true when the file's graph differs from the head or does not parse — so after an inline revise it is true until plan.export or plan.sync {path}
2298 plan.get — return the submitted PlanGraph (deliverables, estimates, files, metadata) for a plan_id
2299 plan.force_release — operator escape hatch; emits audit event with `reason`; optional reset_counters:true also clears lapse/failure counters and revives a circuit-broken deliverable
2300 plan.accept — a manager/owner marks a deliverable Complete without a lease (audited; evidence required; override_lock to take over a live lease)
2301 plan.lint — static checks without submitting: cycles (with the loop), redundant edges, edges without rationale, interface edges not targeting a contract, deliverables feeding no milestone, unordered file overlaps
2302 plan.schedule — level a graph against resource capacities (`metadata.owner` by default): makespan, per-deliverable start/finish, per-resource load, driving chain (dependency vs resource waits), project and feeding buffers; capacities is required and every resource carrying work needs >= 1 unit (INVALID_CAPACITIES: lists the missing ones); project_buffer_pct 0..100 (default 25)
2303 plan.simulate — read-only what-if: lint, critical path, schedule, milestones, optional resource schedule (schedule: same inputs as plan.schedule) and Monte Carlo (monte_carlo: iterations 1..50000, default 2000; seed, default 0xC0FFEE, reproducible on the same platform and build; iterations × (deliverables + prerequisite edges) must not exceed 200000000), and the scorecard; persists nothing
2304 plan.sync — register or update one variant of a named plan line from a plan file (path: .cpm-planner/plans/<name>/<variant>.json, tracked by content hash for drift; any name/variant/project given must match the path) or an inline graph (requires name; variant defaults to "main"); project defaults to the discovered root; force releases live locks of removed deliverables
2305 plan.list — list every plan line of project (default: discovered root), variants sorted; archived omitted unless include_archived
2306 plan.export — write the head graph of a plan_id to its variant file (or a confined path) and return the root-relative path; refuses another variant's tracked file or unsynced local edits unless force; the file is not synced (exporting to the variant's own tracked file records its hash)
2307 plan.revise — replace a plan's graph in place, carrying progress over; returns the new revision and diff; force releases live locks of removed deliverables
2308 plan.fork — copy a named variant's head graph, apply ordered edits, register it as a new draft (not selected) variant; an archived line is refused (ARCHIVE_REFUSED) before any file is written
2309 plan.select — make a variant its line's selected (only executable) variant, carrying progress over (Complete statuses of identically defined deliverables, with their earned-value actuals); force releases locks on the previous variant
2310 plan.archive — archive (archived defaults true) or unarchive a whole line or one variant; archived variants stay readable but refuse sync/select/execute
2311 plan.compare — compare stored plans (plan_ids: 2..16 distinct ids, or plan line name in project, default the discovered root, for every live variant, at most 16) on the scorecard: Pareto front, weighted rank, recommended; weights must be finite and >= 0; the Monte Carlo budget (200000000) is shared across variants
2312 plan.baseline — freeze the plan's CPM schedule (es/ef) and budgets (effort basis x metadata.cost_rate, default 1) as its next numbered earned-value baseline; optional start (RFC 3339, default now) and calendar {hours_per_day (0 < h <= 24, default 8), workdays (default mon..fri), utc_offset_minutes (default 0)} (omitted: wall-clock hours on the first baseline, the previous baseline's calendar on a re-baseline); re-baselining needs a non-blank reason (<= 2048 chars, INVALID_GRAPH otherwise) and keeps actuals and snapshots; baselines, actuals and snapshots belong to one variant, so a newly selected variant takes its own baseline 1 with no reason needed; selected, unarchived variant only; audited as plan.ev.baselined
2313 plan.ev — earned-value report against the latest baseline as of as_of (RFC 3339, default now; as_of is the PV status date, while EV and AC reflect progress and actuals recorded up to the moment the call runs): bac, pv, ev, ac, sv, cv, spi, cpi, eac, etc, vac, tcpi, per-deliverable rows, critical_float_consumed_hours, alerts (SPI_BELOW_0_9 / CPI_BELOW_0_9 when below 0.9 on the two latest stored non-backfilled snapshots by as_of of the current baseline; the current reading is not one of them), excluded_unbaselined; AC sums every recorded hour of the plan (removed and unbaselined deliverables included, at rate 1 when the baseline has no row); a baselined deliverable removed by plan.revise reports status "removed" and keeps its earned percent at removal (100 if complete), and re-adding it restarts its earned percent (hours keep accumulating); a ratio with a zero denominator is null and explained in `undefined` (never NaN); read-only on any variant; NOT_BASELINED before plan.baseline
2314 plan.snapshot — compute the plan.ev report and append it as a snapshot (as_of is the PV status date; EV and AC are as recorded when the call runs, so a snapshot whose as_of is more than an hour before taken_at is marked backfilled: true, raises no alerts and is skipped by later alerts); returns summary (undefined explains each null ratio; alerts consider only readings up to its own position: this snapshot and the latest earlier one by as_of of the current baseline, so a backfill never takes alerts from newer readings), snapshot_count and export of the newest 100 snapshots by as_of (ties by taken_at), oldest first, so a backfilled as_of lands in date order; a backfill older than those 100 is stored and counted but not listed: a list of summaries (format "json", default) or a Markdown table with columns date, PV, EV, AC, SPI, CPI, EAC (format "markdown"); selected, unarchived variant only; NOT_BASELINED before plan.baseline
2315 plan.review — optional AI review (read-only; graph, plan_id or path; optional capacities/resource_key/project_buffer_pct and max_questions 1..64, default 64): lint always runs and lint errors return status invalid_graph with no judge call; otherwise ONE batched call to Jev (TypeSafe's calibrated-judgment model, via OpenRouter) scores likely false dependencies, missing dependencies, split and interface-split candidates and crash options. Probabilities are advisory, not facts. proposals are plan.fork edit lists, each verified by simulate (lints clean, shortens the makespan) and ranked by hours_saved / max(cost, 1). Without an OpenRouter key, or with an unusable LLM setting, status is review_unavailable with a reason, plus lint; a provider failure is review_unavailable with "<class>: <message>". The plan graph is sent to OpenRouter
2316 plan.submit with a `name` (optional `project`/`variant`, variant defaults to "main") registers a named variant instead of an unnamed plan
2317 plan.lint, plan.simulate and plan.review take exactly one of an inline graph, a stored plan_id, or a plan-file path; plan.schedule takes graph or plan_id; plan.schedule and plan.simulate reject what plan.submit rejects, and plan.lint reports it as findings
2318
2319Plan-as-code workflow: author the graph as
2320.cpm-planner/plans/<name>/<variant>.json, check it with plan.lint {path},
2321then register it with plan.sync {path} and execute its plan_id. Design many
2322variants and pick one: plan.fork creates a draft variant from edits,
2323plan.compare scores every live variant, and plan.select makes exactly one
2324executable. Never keep untracked scratch graphs — the files under
2325.cpm-planner/plans/ are the source of truth for definitions.
2326
2327Review workflow: plan.review {path} on a lint-clean plan; read findings as
2328advisory judgments (check them against what you know), apply a proposal you
2329agree with via plan.fork {edits} (or design the edits for split and
2330missing-dependency findings yourself), then plan.compare and plan.select.
2331
2332Leases default to 5 minutes. Pass ttl_seconds (≤ server max, default 8h)
2333on acquire/heartbeat for long-running work, and heartbeat at least every
2334ttl/3.
2335
2336Errors carry stable prefixes: LOCK_HELD, LOCK_NOT_HELD, LOCK_EXPIRED,
2337OVERLAP_DETECTED, MISSING_PREREQUISITE, PLAN_NOT_FOUND,
2338DELIVERABLE_NOT_FOUND, LAPSE_LIMIT, PREREQUISITES_INCOMPLETE, INVALID_GRAPH,
2339INVALID_CAPACITIES, VARIANT_NOT_SELECTED, ARCHIVE_REFUSED, INVALID_PATH,
2340INVALID_ACTUALS, NOT_BASELINED, BACKEND_ERROR.
2341
2342DeliverableStatus is internally tagged on `status`:
2343 {"status":"pending"} | {"status":"ready"} | {"status":"in_progress"} |
2344 {"status":"complete"} | {"status":"failed","reason":"..."}
2345
2346Circuit-breaker (visible in plan.status): every lease increments
2347attempt_count (telemetry). An EXPLICIT mark_status failed increments
2348failure_count — a ready deliverable with 3 failed attempts is auto-failed
2349by the next acquire_cohort (reason "circuit-break: exceeded 3 failed
2350attempts") instead of being re-leased forever. A lease that lapses via
2351TTL with no terminal mark (driver killed/timed out) increments
2352lapse_count instead: environmental losses never trip the failure breaker,
2353but at 10 lapses acquire_cohort stops re-leasing that deliverable: it is
2354skipped (the rest of the plan stays leasable) and reported in the acquire
2355response's `blocked` list as {id, code:"LAPSE_LIMIT", reason}. Fix the
2356environment, then clear it with plan.force_release {reset_counters: true}.
2357The response also carries scalar `blocked_count` and `needs_operator`.
2358exhausted:true with needs_operator:true means the plan is stalled on
2359lapse-limited deliverables (clear with plan.force_release {reset_counters:
2360true}), not drained.
2361
2362Targeted acquire: plan.acquire_cohort accepts optional `ids` (only those
2363deliverables are considered; unknown id -> DELIVERABLE_NOT_FOUND) and
2364`filter: {"metadata": {key: value}}` (only deliverables whose metadata
2365equals every pair; others are silently skipped). ids that do not match
2366filter.metadata are ignored (not reported in blocked). Deliverables with
2367metadata.kind = "manual" are never leased. With `ids`, each requested id
2368that is not leased appears in `blocked` with code MANUAL, NOT_READY,
2369LOCKED, LAPSE_LIMIT, FILE_CONFLICT or MAX_COUNT.
2370"#
2371}