Skip to main content

agent_graph_mcp/
server.rs

1//! MCP server handler using rmcp's #[tool_router] macro.
2//!
3//! Each #[tool] method becomes an MCP tool that Hermes can discover and call.
4//! The rmcp macro auto-generates JSON Schema from the parameter structs in tools.rs.
5
6use std::collections::HashMap;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10use chrono::{SecondsFormat, Utc};
11use rmcp::{
12    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
13    tool, tool_handler, tool_router, ErrorData, Json, ServerHandler,
14};
15use serde_json::Value;
16
17use std::path::PathBuf;
18
19use crate::evidence::{digest, validate_witness_capture, WitnessCapture, WitnessError};
20use crate::run_manager::{initial_state_for_input, RunBudgets, RunManager};
21use crate::spec::{
22    ensure_size, parse_and_validate, validate_max_graphs, GraphSpec, DEFAULT_MAX_GRAPHS,
23    MAX_INPUT_BYTES,
24};
25use crate::store::{
26    ApprovalError, ApprovalRecord, CheckpointError, CheckpointRecord, GraphDeleteResult,
27    PersistentStore,
28};
29use crate::templates;
30use crate::tools::*;
31
32fn internal_error(message: impl Into<std::borrow::Cow<'static, str>>) -> ErrorData {
33    ErrorData::internal_error(message, None)
34}
35
36fn invalid_params(message: impl Into<std::borrow::Cow<'static, str>>) -> ErrorData {
37    ErrorData::invalid_params(message, None)
38}
39
40fn structured_output(value: Value) -> Json<StructuredOutput> {
41    Json(StructuredOutput {
42        ok: true,
43        status: None,
44        data: Some(value),
45        error: None,
46        error_code: None,
47        graph_id: None,
48        graph_version: None,
49        run_id: None,
50    })
51}
52
53fn error_output(message: impl Into<String>, code: impl Into<String>) -> Json<StructuredOutput> {
54    Json(StructuredOutput {
55        ok: false,
56        status: None,
57        data: None,
58        error: Some(message.into()),
59        error_code: Some(code.into()),
60        graph_id: None,
61        graph_version: None,
62        run_id: None,
63    })
64}
65
66fn structured_from_value(value: Value) -> Result<Json<StructuredOutput>, ErrorData> {
67    serde_json::from_value::<StructuredOutput>(value)
68        .map(Json)
69        .map_err(|e| internal_error(format!("cached idempotency decode: {e}")))
70}
71
72fn canonical_request_value(value: &Value) -> Value {
73    match value {
74        Value::String(raw) => serde_json::from_str(raw).unwrap_or_else(|_| value.clone()),
75        _ => value.clone(),
76    }
77}
78
79fn check_idempotency(
80    store: Option<&PersistentStore>,
81    key: Option<&str>,
82    request_digest: &str,
83) -> Result<Option<Json<StructuredOutput>>, ErrorData> {
84    let Some((store, key)) = store.zip(key) else {
85        return Ok(None);
86    };
87    let Some((stored_digest, cached)) = store.check_idempotency(key).map_err(internal_error)?
88    else {
89        return Ok(None);
90    };
91    if stored_digest.as_deref() == Some(request_digest) {
92        return structured_from_value(cached).map(Some);
93    }
94    Ok(Some(error_output(
95        "idempotency key is already bound to different request material",
96        "IDEMPOTENCY_CONFLICT",
97    )))
98}
99
100fn persist_idempotency(
101    store: &PersistentStore,
102    key: &str,
103    request_digest: &str,
104    output: &Json<StructuredOutput>,
105) -> Result<Option<Json<StructuredOutput>>, ErrorData> {
106    let result_json =
107        serde_json::to_string(&output.0).map_err(|e| internal_error(e.to_string()))?;
108    if store
109        .save_idempotency(key, request_digest, &result_json)
110        .map_err(internal_error)?
111    {
112        return Ok(None);
113    }
114    // Another request won the insert. Return its exact cached result so a
115    // concurrent same-key caller cannot observe a result that was not stored.
116    check_idempotency(Some(store), Some(key), request_digest)
117}
118
119fn output_with_meta(
120    data: Value,
121    graph_id: Option<&str>,
122    graph_version: Option<&str>,
123    run_id: Option<&str>,
124) -> Json<StructuredOutput> {
125    Json(StructuredOutput {
126        ok: true,
127        status: None,
128        data: Some(data),
129        error: None,
130        error_code: None,
131        graph_id: graph_id.map(String::from),
132        graph_version: graph_version.map(String::from),
133        run_id: run_id.map(String::from),
134    })
135}
136
137fn checkpoint_error_output(error: CheckpointError) -> Json<StructuredOutput> {
138    error_output(error.message(), error.code())
139}
140
141fn approval_error_output(error: ApprovalError) -> Json<StructuredOutput> {
142    error_output(error.message(), error.code())
143}
144
145fn approval_value(record: &ApprovalRecord) -> Value {
146    serde_json::json!({
147        "approval_id": record.approval_id,
148        "checkpoint_id": record.checkpoint_id,
149        "run_id": record.run_id,
150        "graph_id": record.graph_id,
151        "graph_version": record.graph_version,
152        "checkpoint_digest": record.checkpoint_digest,
153        "audience": record.audience,
154        "prompt_digest": record.prompt_digest,
155        "allowed_decisions": record.allowed_decisions,
156        "approval_digest": record.approval_digest,
157        "status": record.status,
158        "decision": record.decision,
159        "decided_by": record.decided_by,
160        "decided_at": record.decided_at,
161        "expires_at": record.expires_at,
162        "created_at": record.created_at,
163    })
164}
165
166fn checkpoint_value(record: &CheckpointRecord) -> Value {
167    serde_json::json!({
168        "checkpoint_id": record.checkpoint_id,
169        "run_id": record.run_id,
170        "graph_id": record.graph_id,
171        "graph_version": record.graph_version,
172        "next_node_cursor": record.next_node_cursor,
173        "state": record.state,
174        "state_digest": record.state_digest,
175        "budgets": record.budgets,
176        "budget_counters": record.budget_counters,
177        "dependency_summary": record.dependency_summary,
178        "dependency_digest": record.dependency_digest,
179        "terminal_cursor": record.terminal_cursor,
180        "event_cursor": record.event_cursor,
181        "checkpoint_digest": record.checkpoint_digest,
182        "created_at": record.created_at,
183        "consumed_at": record.consumed_at,
184        "status": if record.consumed_at.is_some() { "consumed" } else { "available" },
185        "resume_capability": "deterministic_local_resume",
186    })
187}
188
189#[derive(Clone)]
190struct RegisteredGraph {
191    spec: GraphSpec,
192    normalized: Value,
193    version: String,
194    warnings: Vec<String>,
195}
196
197pub struct AgentGraphServer {
198    tool_router: ToolRouter<Self>,
199    base_url: String,
200    default_model: String,
201    /// Provider API key for http(s) llm-pipeline calls. Attached as a Bearer
202    /// header only for the http(s) path; the codex-app-server:// path carries no
203    /// auth. Never serialized into status, receipts, or logs.
204    api_key: Option<String>,
205    graphs: Mutex<HashMap<String, RegisteredGraph>>,
206    runs: Mutex<RunManager>,
207    store: Option<PersistentStore>,
208    max_graphs: usize,
209}
210
211impl AgentGraphServer {
212    fn graph_requires_witness_store(spec: &GraphSpec) -> bool {
213        spec.nodes.iter().any(|node| node.evidence_required)
214    }
215
216    fn witness_error_output(error: WitnessError) -> Json<StructuredOutput> {
217        error_output(error.message, error.code)
218    }
219
220    pub fn new(
221        base_url: String,
222        default_model: String,
223        data_dir: Option<PathBuf>,
224        integrity_key_path: Option<PathBuf>,
225    ) -> Result<Self, String> {
226        Self::new_with_max_graphs(
227            base_url,
228            default_model,
229            data_dir,
230            integrity_key_path,
231            DEFAULT_MAX_GRAPHS,
232        )
233    }
234
235    pub fn new_with_max_graphs(
236        base_url: String,
237        default_model: String,
238        data_dir: Option<PathBuf>,
239        integrity_key_path: Option<PathBuf>,
240        max_graphs: usize,
241    ) -> Result<Self, String> {
242        Self::new_with_max_graphs_and_key(
243            base_url,
244            default_model,
245            data_dir,
246            integrity_key_path,
247            max_graphs,
248            None,
249        )
250    }
251
252    pub fn new_with_max_graphs_and_key(
253        base_url: String,
254        default_model: String,
255        data_dir: Option<PathBuf>,
256        integrity_key_path: Option<PathBuf>,
257        max_graphs: usize,
258        api_key: Option<String>,
259    ) -> Result<Self, String> {
260        let max_graphs = validate_max_graphs(max_graphs)?;
261        let store = match data_dir {
262            Some(ref dir) => Some(PersistentStore::open_with_integrity_key(
263                dir,
264                integrity_key_path.as_deref(),
265            )?),
266            None => None,
267        };
268        if let Some(ref store) = store {
269            store.recover_incomplete_executions()?;
270        }
271
272        let runs = RunManager::default().with_api_key(api_key.clone());
273        let server = Self {
274            base_url,
275            default_model,
276            api_key,
277            graphs: Mutex::new(HashMap::new()),
278            runs: Mutex::new(runs),
279            store,
280            max_graphs,
281            tool_router: Self::tool_router(),
282        };
283
284        // Restore persisted graphs on startup
285        if let Some(ref store) = server.store {
286            if let Ok(graphs) = store.list_graphs() {
287                for (name, hash, _created) in graphs {
288                    if let Ok(Some((spec_json, _))) = store.load_graph(&name) {
289                        if let Ok(spec) = serde_json::from_str::<GraphSpec>(&spec_json) {
290                            let normalized = serde_json::to_value(&spec).unwrap_or_default();
291                            server.graphs.lock().unwrap().insert(
292                                name,
293                                RegisteredGraph {
294                                    spec,
295                                    normalized,
296                                    version: hash,
297                                    warnings: Vec::new(),
298                                },
299                            );
300                        }
301                    }
302                }
303            }
304        }
305
306        Ok(server)
307    }
308
309    fn safe_provider_label(&self) -> String {
310        let url = &self.base_url;
311        let without_fragment = url.split(['?', '#']).next().unwrap_or(url);
312        if let Some((scheme, rest)) = without_fragment.split_once("://") {
313            let authority_and_path = rest.rsplit_once('@').map(|(_, safe)| safe).unwrap_or(rest);
314            format!("{scheme}://{authority_and_path}")
315        } else {
316            "server-configured".into()
317        }
318    }
319
320    fn persist_terminal(
321        store: Option<PersistentStore>,
322        record: crate::run_manager::RunRecord,
323    ) -> Result<(), String> {
324        let Some(store) = store else {
325            return Ok(());
326        };
327        let final_state = serde_json::to_string(&record.final_state)
328            .map_err(|e| format!("serialize terminal state error: {e}"))?;
329        // Persist one bounded terminal projection atomically. This is not replayable
330        // execution history and does not make the run resumable.
331        let events = record
332            .events
333            .iter()
334            .map(|entry| {
335                let seq = entry.get("cursor").and_then(Value::as_u64).unwrap_or(0);
336                let event = entry.get("event").cloned().unwrap_or_else(|| {
337                    serde_json::json!({"receipt": "terminal event persisted with reduced fidelity"})
338                });
339                let event_type = event
340                    .as_object()
341                    .and_then(|object| object.keys().next().cloned())
342                    .unwrap_or_else(|| "run_event".into());
343                Ok((seq, event_type, event.to_string()))
344            })
345            .collect::<Result<Vec<_>, String>>()?;
346        let mut durable_receipt = record.receipt.clone();
347        if let Some(object) = durable_receipt.as_object_mut() {
348            object.insert(
349                "persistence_status".into(),
350                Value::String("durable_terminal".into()),
351            );
352        }
353        let receipt = serde_json::to_string(&durable_receipt)
354            .map_err(|e| format!("serialize terminal receipt error: {e}"))?;
355        let durable_bundle = crate::evidence::bundle(
356            &record.run_id,
357            &record.graph_version,
358            &record.input,
359            &record.state,
360            &durable_receipt,
361        );
362        let bundle = serde_json::to_string(&durable_bundle)
363            .map_err(|e| format!("serialize terminal bundle error: {e}"))?;
364        store.persist_terminal_projection(
365            &record.run_id,
366            &record.status,
367            &final_state,
368            record.steps.len(),
369            &events,
370            &receipt,
371            &bundle,
372        )?;
373        Ok(())
374    }
375
376    fn persist_terminal_and_mark(
377        runs: crate::run_manager::RunManager,
378        store: Option<PersistentStore>,
379        record: crate::run_manager::RunRecord,
380    ) {
381        if store.is_none() {
382            runs.mark_persistence(&record.run_id, "volatile_no_store", None);
383            return;
384        }
385        match Self::persist_terminal(store, record.clone()) {
386            Ok(()) => runs.mark_persistence(&record.run_id, "durable_terminal", None),
387            Err(error) => {
388                tracing::error!(%error, "terminal run persistence failed; run remains volatile");
389                runs.mark_persistence(&record.run_id, "volatile_persistence_failed", Some(error));
390            }
391        }
392    }
393
394    fn stored_run(&self, run_id: &str) -> Result<Option<Value>, ErrorData> {
395        let Some(store) = &self.store else {
396            return Ok(None);
397        };
398        let Some(mut record) = store.load_execution(run_id).map_err(internal_error)? else {
399            return Ok(None);
400        };
401        if let Some(receipt) = store
402            .load_terminal_receipt(run_id)
403            .map_err(internal_error)?
404            .and_then(|value| value.get("receipt").cloned())
405        {
406            if let Some(object) = record.as_object_mut() {
407                for key in ["budgets", "budget_counters", "budget_exhausted"] {
408                    if let Some(value) = receipt.get(key) {
409                        object.insert(key.into(), value.clone());
410                    }
411                }
412                object.insert("receipt".into(), receipt);
413            }
414        }
415        Ok(Some(record))
416    }
417
418    fn resolve_graph(
419        &self,
420        graph_id: &str,
421        requested_version: Option<&str>,
422    ) -> Result<RegisteredGraph, ErrorData> {
423        let current = self
424            .graphs
425            .lock()
426            .map_err(|e| internal_error(e.to_string()))?
427            .get(graph_id)
428            .cloned()
429            .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
430        let Some(requested_version) = requested_version else {
431            return Ok(current);
432        };
433        if requested_version == current.version {
434            return Ok(current);
435        }
436        let store = self.store.as_ref().ok_or_else(|| {
437            invalid_params("historical graph versions require SQLite persistence")
438        })?;
439        let serialized = store
440            .load_graph_version(graph_id, requested_version)
441            .map_err(internal_error)?
442            .ok_or_else(|| invalid_params("requested graph version was not found"))?;
443        let normalized: Value = serde_json::from_str(&serialized)
444            .map_err(|e| internal_error(format!("stored graph version JSON error: {e}")))?;
445        let spec = parse_and_validate(&normalized)
446            .map_err(|e| internal_error(format!("stored graph version validation error: {e}")))?;
447        let canonical = serde_json::to_value(&spec).map_err(|e| internal_error(e.to_string()))?;
448        let actual_version = digest(&canonical);
449        if actual_version != requested_version {
450            return Err(internal_error(
451                "stored graph version digest does not match its normalized specification",
452            ));
453        }
454        Ok(RegisteredGraph {
455            warnings: spec.warnings(),
456            spec,
457            normalized: canonical,
458            version: actual_version,
459        })
460    }
461
462    fn mermaid(spec: &GraphSpec) -> String {
463        let mut s = String::from("graph TD\n");
464        for edge in &spec.edges {
465            s.push_str(&format!("  {} --> {}\n", edge.from, edge.to));
466        }
467        s
468    }
469
470    fn delete_registered_graph(&self, graph_id: &str) -> Result<Json<StructuredOutput>, ErrorData> {
471        let exists = self
472            .graphs
473            .lock()
474            .map_err(|e| internal_error(e.to_string()))?
475            .contains_key(graph_id);
476        if !exists {
477            return Ok(error_output(
478                format!("graph '{graph_id}' not found"),
479                "GRAPH_NOT_FOUND",
480            ));
481        }
482
483        if let Some(store) = &self.store {
484            match store.delete_graph(graph_id).map_err(internal_error)? {
485                GraphDeleteResult::Deleted => {}
486                GraphDeleteResult::Referenced => {
487                    return Ok(error_output(
488                        format!("graph '{graph_id}' is referenced by a durable execution"),
489                        "GRAPH_REFERENCED",
490                    ));
491                }
492                GraphDeleteResult::ReferencedBySubgraph => {
493                    return Ok(error_output(
494                        format!(
495                            "graph '{graph_id}' is referenced by another graph's subgraph node"
496                        ),
497                        "GRAPH_SUBGRAPH_REFERENCED",
498                    ));
499                }
500                GraphDeleteResult::RetentionApprovalRequired => {
501                    return Ok(error_output(
502                        format!("graph '{graph_id}' requires delete_candidate then delete_approved retention states"),
503                        "RETENTION_APPROVAL_REQUIRED",
504                    ));
505                }
506                GraphDeleteResult::NotFound => {
507                    return Ok(error_output(
508                        format!(
509                            "graph '{graph_id}' is present in memory but missing from durable storage"
510                        ),
511                        "GRAPH_PERSISTENCE_MISMATCH",
512                    ));
513                }
514            }
515        }
516
517        self.graphs
518            .lock()
519            .map_err(|e| internal_error(e.to_string()))?
520            .remove(graph_id);
521        Ok(output_with_meta(
522            serde_json::json!({"status": "deleted"}),
523            Some(graph_id),
524            None,
525            None,
526        ))
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use crate::run_manager::RunManager;
534    use crate::store::PersistentStore;
535
536    fn configure_test_integrity_key() {
537        let path = std::env::temp_dir().join("agent-graph-mcp-unit-integrity.key");
538        std::fs::write(&path, [0x5au8; 32]).expect("test integrity key");
539        std::env::set_var("AGENT_GRAPH_INTEGRITY_KEY_PATH", path);
540    }
541
542    #[test]
543    fn terminal_projection_failure_rolls_back_sqlite_and_marks_run_volatile() {
544        configure_test_integrity_key();
545        let temp = tempfile::tempdir().expect("temp graph database");
546        let store = PersistentStore::open(temp.path()).expect("store");
547        let spec: GraphSpec = serde_json::from_value(serde_json::json!({
548            "name":"fault-injection",
549            "entry":"x",
550            "nodes":[{"id":"x","type":"passthrough"}],
551            "edges":[{"from":"x","to":"END"}]
552        }))
553        .expect("graph spec");
554        let spec_json = serde_json::to_string(&spec).expect("spec JSON");
555        store
556            .save_graph("fault-injection", &spec_json, "version", false)
557            .expect("graph");
558
559        let runs = RunManager::default();
560        let run_id = runs
561            .allocate("fault-injection", "version", serde_json::json!({"x":1}))
562            .expect("run");
563        store
564            .save_execution(
565                &run_id,
566                "fault-injection",
567                "version",
568                "running",
569                "{\"x\":1}",
570            )
571            .expect("execution");
572        runs.execute(
573            &run_id,
574            spec,
575            "http://localhost".into(),
576            "test-model".into(),
577        )
578        .expect("execution completes");
579
580        store.fail_terminal_projection_after_events();
581        AgentGraphServer::persist_terminal_and_mark(
582            runs.clone(),
583            Some(store.clone()),
584            runs.get(&run_id).expect("terminal record"),
585        );
586
587        let public = runs.get(&run_id).expect("volatile record").public();
588        assert_eq!(public["persistence_status"], "volatile_persistence_failed");
589        assert_eq!(public["storage_class"], "volatile");
590
591        let reopened = PersistentStore::open(temp.path()).expect("fresh store");
592        assert_eq!(
593            reopened.load_execution(&run_id).unwrap().unwrap()["status"],
594            "running"
595        );
596        assert!(reopened.load_events(&run_id, 0, 100).unwrap().is_none());
597        assert!(reopened.load_terminal_receipt(&run_id).unwrap().is_none());
598    }
599
600    #[test]
601    fn capacity_is_reserved_before_direct_or_approved_checkpoint_consumption() {
602        configure_test_integrity_key();
603        let temp = tempfile::tempdir().expect("checkpoint database");
604        let server = AgentGraphServer::new(
605            "http://localhost".into(),
606            "test-model".into(),
607            Some(temp.path().to_owned()),
608            None,
609        )
610        .expect("server");
611        server
612            .graph_create(Parameters(GraphCreateParams {
613                spec: Some(serde_json::json!({
614                    "name":"capacity-resume", "entry":"first",
615                    "nodes":[
616                        {"id":"first","type":"passthrough"},
617                        {"id":"second","type":"state_transform","config":{"operations":[{"op":"set","path":"done","value":true}]}}
618                    ],
619                    "edges":[{"from":"first","to":"second"},{"from":"second","to":"END"}]
620                })),
621                action: None,
622                graph_id: None,
623                idempotency_key: None,
624                template: None,
625                overwrite: None,
626            }))
627            .expect("create graph");
628        let checkpoint = |server: &AgentGraphServer| {
629            server
630                .graph_run_start(Parameters(RunStartParams {
631                    graph_id: "capacity-resume".into(),
632                    input: None,
633                    graph_version: None,
634                    thread_id: None,
635                    idempotency_key: None,
636                    budgets: None,
637                    checkpoint: Some(true),
638                }))
639                .expect("checkpoint start")
640                .0
641                .data
642                .unwrap()["checkpoint_id"]
643                .as_str()
644                .unwrap()
645                .to_owned()
646        };
647        let direct_checkpoint = checkpoint(&server);
648        {
649            let runs = server.runs.lock().expect("runs");
650            for index in 0..8 {
651                let run_id = runs
652                    .allocate("capacity", "v1", serde_json::json!({"index":index}))
653                    .expect("slot record");
654                runs.admit_async(&run_id).expect("slot admission");
655            }
656        }
657        let direct = server
658            .graph_run_resume(Parameters(RunResumeParams {
659                checkpoint_id: Some(direct_checkpoint.clone()),
660                run_id: None,
661            }))
662            .expect("resume response");
663        assert_eq!(direct.0.error_code.as_deref(), Some("RUN_CAPACITY"));
664        let store = server.store.as_ref().expect("store");
665        assert!(store
666            .load_resume_checkpoint(Some(&direct_checkpoint), None)
667            .expect("checkpoint")
668            .expect("record")
669            .consumed_at
670            .is_none());
671
672        let approval_checkpoint = checkpoint(&server);
673        let approval = server
674            .graph_approval_request(Parameters(ApprovalRequestParams {
675                checkpoint_id: approval_checkpoint.clone(),
676                audience: "operator".into(),
677                prompt: "approve after capacity is available".into(),
678                allowed_decisions: vec!["approve".into()],
679                expiration: (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(),
680            }))
681            .expect("approval request");
682        let approval_id = approval.0.data.unwrap()["approval_id"]
683            .as_str()
684            .unwrap()
685            .to_owned();
686        let decided = server
687            .graph_approval_decide(Parameters(ApprovalDecideParams {
688                approval_id: approval_id.clone(),
689                decision: "approve".into(),
690                claimed_actor_label: "operator".into(),
691            }))
692            .expect("approval response");
693        assert_eq!(
694            decided.0.error_code.as_deref(),
695            Some("AUTHENTICATED_OPERATOR_REQUIRED")
696        );
697        assert_eq!(
698            store
699                .get_checkpoint_approval(&approval_id)
700                .expect("approval")
701                .expect("approval row")
702                .status,
703            "pending"
704        );
705        assert!(store
706            .load_resume_checkpoint(Some(&approval_checkpoint), None)
707            .expect("checkpoint")
708            .expect("record")
709            .consumed_at
710            .is_none());
711    }
712}
713
714#[tool_router]
715impl AgentGraphServer {
716    // ── graph_create ──────────────────────────────────────────────────
717
718    #[tool(
719        description = "Create, validate, or delete a graph-orchestrated workflow from a JSON spec. Supports template instantiation and idempotency keys."
720    )]
721    fn graph_create(
722        &self,
723        Parameters(GraphCreateParams {
724            spec,
725            action,
726            graph_id,
727            idempotency_key,
728            template,
729            overwrite,
730        }): Parameters<GraphCreateParams>,
731    ) -> Result<Json<StructuredOutput>, ErrorData> {
732        let action = action.as_deref().unwrap_or("create");
733
734        let request_digest = digest(&serde_json::json!({
735            "operation": "graph_create",
736            "action": action,
737            "spec": spec.as_ref().map(canonical_request_value).unwrap_or(Value::Null),
738            "template": template.as_ref().map(canonical_request_value).unwrap_or(Value::Null),
739            "graph_id": graph_id,
740            "overwrite": overwrite.unwrap_or(false),
741        }));
742        if action != "delete" {
743            if let Some(cached) = check_idempotency(
744                self.store.as_ref(),
745                idempotency_key.as_deref(),
746                &request_digest,
747            )? {
748                return Ok(cached);
749            }
750        }
751
752        // Lifecycle deletion is never model-authorized. The authenticated
753        // operator IPC service is the sole mutation boundary and must perform
754        // its own request, peer, nonce, and state-digest validation before it
755        // reaches `delete_registered_graph`.
756        if action == "delete" {
757            return Ok(error_output(
758                "graph lifecycle deletion requires the authenticated operator service",
759                "AUTHENTICATED_OPERATOR_REQUIRED",
760            ));
761        }
762
763        if action != "create" && action != "validate" {
764            return Ok(error_output(
765                format!("unsupported graph_create action '{action}'"),
766                "INVALID_ACTION",
767            ));
768        }
769
770        // ── create / validate ──
771        let raw = if let Some(ref tpl) = template {
772            let tpl_val = if let Value::String(s) = tpl {
773                serde_json::from_str(s).unwrap_or_else(|_| tpl.clone())
774            } else {
775                tpl.clone()
776            };
777            let tpl_id = tpl_val
778                .get("id")
779                .and_then(Value::as_str)
780                .ok_or_else(|| invalid_params("template.id required"))?;
781            let tpl_name = tpl_val
782                .get("name")
783                .and_then(Value::as_str)
784                .or_else(|| graph_id.as_deref())
785                .unwrap_or(tpl_id);
786            templates::instantiate(tpl_id, tpl_name)
787                .map_err(|e| internal_error(format!("template error: {e}")))?
788        } else {
789            let spec = spec
790                .clone()
791                .ok_or_else(|| invalid_params("missing spec for create/validate"))?;
792            if let Value::String(s) = spec {
793                serde_json::from_str(&s)
794                    .map_err(|e| invalid_params(format!("spec string parse error: {e}")))?
795            } else {
796                spec
797            }
798        };
799
800        let original_version = raw
801            .get("spec_version")
802            .and_then(Value::as_str)
803            .unwrap_or("1")
804            .to_owned();
805        let warnings_preview = serde_json::from_value::<GraphSpec>(raw.clone())
806            .ok()
807            .map(|s| s.warnings())
808            .unwrap_or_default();
809        let spec_parsed =
810            parse_and_validate(&raw).map_err(|e| invalid_params(format!("invalid spec: {e}")))?;
811        if let Some(node) = spec_parsed
812            .nodes
813            .iter()
814            .find(|node| crate::spec::GraphSpec::executable_node_type(&node.node_type).is_err())
815        {
816            return Ok(error_output(
817                format!("node '{}' declares an unsupported executable type", node.id),
818                "UNSUPPORTED_NODE_TYPE",
819            ));
820        }
821        let normalized =
822            serde_json::to_value(&spec_parsed).map_err(|e| internal_error(e.to_string()))?;
823        let version = digest(&normalized);
824        let warnings = if original_version == "1" {
825            warnings_preview
826        } else {
827            spec_parsed.warnings()
828        };
829
830        if action == "validate" {
831            let output = output_with_meta(
832                serde_json::json!({
833                    "graph_id": spec_parsed.name,
834                    "graph_version": version,
835                    "digest": version,
836                    "normalized_spec_version": "2",
837                    "warnings": warnings,
838                    "storage_class": "volatile",
839                    "status": "valid"
840                }),
841                Some(&spec_parsed.name),
842                Some(&version),
843                None,
844            );
845            if let Some(ref store) = self.store {
846                if let Some(idem) = idempotency_key {
847                    if let Some(cached) =
848                        persist_idempotency(store, &idem, &request_digest, &output)?
849                    {
850                        return Ok(cached);
851                    }
852                }
853            }
854            return Ok(output);
855        }
856
857        if Self::graph_requires_witness_store(&spec_parsed) && self.store.is_none() {
858            return Ok(error_output(
859                "evidence-required graphs require SQLite witness persistence",
860                "WITNESS_STORE_REQUIRED",
861            ));
862        }
863
864        // ── register ──
865        let mut graphs = self
866            .graphs
867            .lock()
868            .map_err(|e| internal_error(e.to_string()))?;
869        let name = spec_parsed.name.clone();
870        let overwrite = overwrite.unwrap_or(false);
871        if !overwrite && !graphs.contains_key(&name) && graphs.len() >= self.max_graphs {
872            return Ok(error_output(
873                format!("graph capacity ({}) exhausted — {} graphs registered", self.max_graphs, graphs.len()),
874                "CAPACITY_EXHAUSTED",
875            ));
876        }
877
878        let id = name.clone();
879        if let Some(ref store) = self.store {
880            let spec_str = serde_json::to_string(&normalized).unwrap_or_default();
881            if let Err(error) = store.save_graph(&id, &spec_str, &version, overwrite) {
882                return Ok(error_output(error, "GRAPH_VERSION_CONFLICT"));
883            }
884        }
885        graphs.insert(
886            id.clone(),
887            RegisteredGraph {
888                spec: spec_parsed,
889                normalized: normalized.clone(),
890                version: version.clone(),
891                warnings: warnings.clone(),
892            },
893        );
894        drop(graphs);
895
896        let output = output_with_meta(
897            serde_json::json!({
898                "graph_id": id,
899                "graph_version": version,
900                "digest": version,
901                "normalized_spec_version": "2",
902                "warnings": warnings,
903                "storage_class": "volatile",
904                "status": "created"
905            }),
906            Some(&id),
907            Some(&version),
908            None,
909        );
910
911        if let Some(ref store) = self.store {
912            if let Some(idem) = idempotency_key {
913                if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
914                    return Ok(cached);
915                }
916            }
917        }
918        Ok(output)
919    }
920
921    // ── graph_execute ─────────────────────────────────────────────────
922
923    #[tool(
924        description = "Execute a registered graph. Sync mode blocks until completion; async mode returns immediately with a run_id."
925    )]
926    fn graph_execute(
927        &self,
928        Parameters(GraphExecuteParams {
929            graph_id,
930            input,
931            graph_version,
932            thread_id,
933            mode,
934            idempotency_key,
935        }): Parameters<GraphExecuteParams>,
936    ) -> Result<Json<StructuredOutput>, ErrorData> {
937        let input = input.unwrap_or(Value::Null);
938        ensure_size(&input, MAX_INPUT_BYTES, "execution input").map_err(|e| invalid_params(e))?;
939        let sync = mode.as_deref().is_none_or(|m| m == "sync");
940        if sync {
941            tracing::warn!(
942                "graph_execute sync mode is deprecated — use graph_run_start + graph_run_get"
943            );
944        }
945        if let Some(store) = self.store.as_ref() {
946            if !store
947                .graph_execution_allowed(&graph_id)
948                .map_err(internal_error)?
949            {
950                return Ok(error_output(
951                    format!("graph '{graph_id}' is archived or pending deletion"),
952                    "GRAPH_RETIRED",
953                ));
954            }
955        }
956
957        let graph = self.resolve_graph(&graph_id, graph_version.as_deref())?;
958
959        if Self::graph_requires_witness_store(&graph.spec) && self.store.is_none() {
960            return Ok(error_output(
961                "evidence-required graphs require SQLite witness persistence",
962                "WITNESS_STORE_REQUIRED",
963            ));
964        }
965
966        let request_digest = digest(&serde_json::json!({
967            "operation": "graph_execute",
968            "graph_id": graph_id,
969            "graph_spec": graph.normalized,
970            "graph_version": graph.version,
971            "input": input,
972            "mode": mode.clone().unwrap_or_else(|| "sync".into()),
973            "thread_id": thread_id,
974        }));
975
976        let runs = self
977            .runs
978            .lock()
979            .map_err(|e| internal_error(e.to_string()))?;
980        if let Some(idem) = idempotency_key.as_deref() {
981            if let Some(cached) =
982                check_idempotency(self.store.as_ref(), Some(idem), &request_digest)?
983            {
984                return Ok(cached);
985            }
986        }
987
988        let run_id = runs
989            .allocate(&graph_id, &graph.version, input.clone())
990            .map_err(|e| internal_error(e))?;
991
992        if let Err(e) = runs.admit_async(&run_id) {
993            runs.remove(&run_id);
994            return Ok(error_output(e, "RUN_CAPACITY"));
995        }
996        if let Some(ref store) = self.store {
997            let _ = store.save_execution(
998                &run_id,
999                &graph_id,
1000                &graph.version,
1001                "running",
1002                &input.to_string(),
1003            );
1004        }
1005
1006        let is_async = mode.as_deref() == Some("async");
1007        if is_async {
1008            let terminal_store = self.store.clone();
1009            let completion_runs = runs.clone();
1010            runs.start_with_completion_with_store(
1011                run_id.clone(),
1012                graph.spec,
1013                self.base_url.clone(),
1014                self.default_model.clone(),
1015                self.store.clone(),
1016                move |record| {
1017                    Self::persist_terminal_and_mark(completion_runs, terminal_store, record)
1018                },
1019            );
1020            let output = output_with_meta(
1021                serde_json::json!({
1022                    "run_id": run_id,
1023                    "status": "accepted",
1024                    "thread_id": thread_id,
1025                    "storage_class": "volatile",
1026                    "cancellation": "provider_future_best_effort_drop; underlying_request_may_continue"
1027                }),
1028                Some(&graph_id),
1029                Some(&graph.version),
1030                Some(&run_id),
1031            );
1032            if let Some(ref store) = self.store {
1033                if let Some(idem) = idempotency_key {
1034                    if let Some(cached) =
1035                        persist_idempotency(store, &idem, &request_digest, &output)?
1036                    {
1037                        return Ok(cached);
1038                    }
1039                }
1040            }
1041            return Ok(output);
1042        }
1043
1044        let terminal_store = self.store.clone();
1045        let completion_runs = runs.clone();
1046        runs.start_with_completion_with_store(
1047            run_id.clone(),
1048            graph.spec,
1049            self.base_url.clone(),
1050            self.default_model.clone(),
1051            self.store.clone(),
1052            move |record| Self::persist_terminal_and_mark(completion_runs, terminal_store, record),
1053        );
1054
1055        let deadline = Instant::now() + Duration::from_millis(300_000);
1056        let output = loop {
1057            let r = runs
1058                .get(&run_id)
1059                .ok_or_else(|| internal_error(format!("run '{run_id}' not found")))?;
1060            if matches!(r.status.as_str(), "completed" | "failed" | "cancelled") {
1061                break output_with_meta(
1062                    r.public(),
1063                    Some(&graph_id),
1064                    Some(&graph.version),
1065                    Some(&run_id),
1066                );
1067            }
1068            if Instant::now() >= deadline {
1069                let cancellation = runs.cancel(&run_id).unwrap_or_else(
1070                    |_| serde_json::json!({"run_id": run_id, "status": "cancellation_requested"}),
1071                );
1072                break output_with_meta(
1073                    serde_json::json!({
1074                        "run_id": run_id,
1075                        "status": r.status,
1076                        "timed_out": true,
1077                        "completion_unknown": true,
1078                        "cancellation": "requested",
1079                        "cancellation_result": cancellation,
1080                    }),
1081                    Some(&graph_id),
1082                    Some(&graph.version),
1083                    Some(&run_id),
1084                );
1085            }
1086            std::thread::sleep(Duration::from_millis(100));
1087        };
1088
1089        if let Some(ref store) = self.store {
1090            let status = output
1091                .0
1092                .data
1093                .as_ref()
1094                .and_then(|data| data.get("status").and_then(Value::as_str))
1095                .unwrap_or("failed");
1096            let final_state = output
1097                .0
1098                .data
1099                .as_ref()
1100                .and_then(|data| data.get("final_state").cloned())
1101                .map(|v| serde_json::to_string(&v).unwrap_or_default());
1102            let _ = store.save_execution(
1103                &run_id,
1104                &graph_id,
1105                &graph.version,
1106                status,
1107                &input.to_string(),
1108            );
1109            let _ =
1110                store.update_execution_status(&run_id, status, final_state.as_deref(), None, None);
1111        }
1112
1113        if let Some(ref store) = self.store {
1114            if let Some(idem) = idempotency_key {
1115                if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
1116                    return Ok(cached);
1117                }
1118            }
1119        }
1120        Ok(output)
1121    }
1122
1123    // ── Local source witness capture ─────────────────────────────────
1124
1125    #[tool(
1126        description = "Persist caller-supplied UTF-8 source content as a local witness receipt. The locator is metadata only; this tool never fetches or verifies it."
1127    )]
1128    fn graph_source_witness_capture(
1129        &self,
1130        Parameters(WitnessCaptureParams {
1131            locator,
1132            content,
1133            media_type,
1134            authority_class,
1135            retrieved_at,
1136        }): Parameters<WitnessCaptureParams>,
1137    ) -> Result<Json<StructuredOutput>, ErrorData> {
1138        let capture = WitnessCapture {
1139            locator,
1140            content,
1141            media_type,
1142            authority_class,
1143            retrieved_at: retrieved_at
1144                .unwrap_or_else(|| Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true)),
1145        };
1146        if let Err(error) = validate_witness_capture(capture.clone()) {
1147            return Ok(Self::witness_error_output(error));
1148        }
1149        let Some(store) = self.store.as_ref() else {
1150            return Ok(error_output(
1151                "SQLite persistence is required for source witness capture",
1152                "WITNESS_STORE_REQUIRED",
1153            ));
1154        };
1155        match store.capture_witness(capture) {
1156            Ok(record) => Ok(structured_output(serde_json::json!({
1157                "witness_id": record.witness_id,
1158                "digest": record.digest,
1159                "locator_digest": digest(&Value::String(record.locator)),
1160                "media_type": record.media_type,
1161                "authority_class": record.authority_class,
1162                "retrieved_at": record.retrieved_at,
1163                "content_bytes": record.content.len(),
1164                "storage_class": "sqlite_source_witness"
1165            }))),
1166            Err(error) => Ok(Self::witness_error_output(error)),
1167        }
1168    }
1169
1170    #[tool(
1171        description = "Read one exact local source witness ID, verifying its HMAC-SHA256 authentication tag before returning metadata and captured content."
1172    )]
1173    fn graph_source_witness_get(
1174        &self,
1175        Parameters(WitnessGetParams { witness_id }): Parameters<WitnessGetParams>,
1176    ) -> Result<Json<StructuredOutput>, ErrorData> {
1177        let Some(store) = self.store.as_ref() else {
1178            return Ok(error_output(
1179                "SQLite persistence is required for source witness reads",
1180                "WITNESS_STORE_REQUIRED",
1181            ));
1182        };
1183        match store.get_witness(&witness_id) {
1184            Ok(Some(record)) => {
1185                let locator_digest = digest(&Value::String(record.locator.clone()));
1186                Ok(structured_output(serde_json::json!({
1187                    "witness_id": record.witness_id,
1188                    "digest": record.digest,
1189                    "locator": record.locator,
1190                    "locator_digest": locator_digest,
1191                    "content": record.content,
1192                    "media_type": record.media_type,
1193                    "authority_class": record.authority_class,
1194                    "retrieved_at": record.retrieved_at,
1195                    "storage_class": "sqlite_source_witness"
1196                })))
1197            }
1198            Ok(None) => Ok(error_output(
1199                "source witness was not found",
1200                "WITNESS_NOT_FOUND",
1201            )),
1202            Err(error) => Ok(Self::witness_error_output(error)),
1203        }
1204    }
1205
1206    // ── graph_status ──────────────────────────────────────────────────
1207
1208    #[tool(
1209        description = "Query server state, graph details, run status, events, receipts, or templates."
1210    )]
1211    fn graph_status(
1212        &self,
1213        Parameters(GraphStatusParams {
1214            resource,
1215            graph_id,
1216            run_id,
1217            cursor,
1218            limit,
1219        }): Parameters<GraphStatusParams>,
1220    ) -> Result<Json<StructuredOutput>, ErrorData> {
1221        let resource = resource.as_deref();
1222
1223        // Server-level summary (no resource or resource="server")
1224        if resource.is_none() || resource == Some("server") {
1225            let graphs = self
1226                .graphs
1227                .lock()
1228                .map_err(|e| internal_error(e.to_string()))?;
1229            let graph_names: Vec<&String> = graphs.keys().collect();
1230            let runs = self
1231                .runs
1232                .lock()
1233                .map_err(|e| internal_error(e.to_string()))?;
1234            let run_ids = runs.list();
1235            let durable_integrity = self
1236                .store
1237                .as_ref()
1238                .is_some_and(PersistentStore::has_integrity_key);
1239
1240            return Ok(structured_output(serde_json::json!({
1241                "graphs": graph_names,
1242                "graph_count": graphs.len(),
1243                "execution_count": run_ids.len(),
1244                "retained_execution_count": run_ids.len(),
1245                "total_execution_count": run_ids.len(),
1246                "base_url": self.safe_provider_label(),
1247                "default_model": self.default_model,
1248                "storage_class": if self.store.is_none() {
1249                    "process_local"
1250                } else if durable_integrity {
1251                    "persisted_integrity_verified"
1252                } else {
1253                    "persisted_unverified"
1254                },
1255                "capabilities": {
1256                    "runtime": "agent_graph",
1257                    "async_start": true,
1258                    "api_key_configured": self.api_key.is_some(),
1259                    "cancellation": "provider_future_best_effort_drop; underlying_request_may_continue",
1260                    "durable_resume": if durable_integrity {
1261                        Value::String("deterministic_local_resume_only".into())
1262                    } else {
1263                        Value::Bool(false)
1264                    },
1265                    "terminal_persistence": if durable_integrity { "sqlite_projection_only" } else { "disabled_without_integrity_key" },
1266                    "checkpointing": if durable_integrity { "deterministic_local_pre_execution" } else { "unavailable" },
1267                    "events": if self.store.is_some() { "terminal_persisted_projection_with_sqlite_fallback" } else { "volatile_in_memory_only" },
1268                    "event_replay": "not_replayable_execution",
1269                    "restart_recovery": "interrupted_non_resumable",
1270                    "budgets": {
1271                        "max_wall_clock_ms": "enforced",
1272                        "max_nodes": "enforced_at_engine_superstep_boundary",
1273                        "max_llm_calls": "enforced_before_provider_invocation"
1274                    },
1275                    "state_write_conflicts": "rejected_without_explicit_reducer",
1276                    "evidence": "witness_bound_local_capture_only; locators_not_fetched; source_authority_not_verified",
1277                    "evidence_authority": "caller_supplied_unverified_or_local_primary_capture",
1278                    "hitl": if durable_integrity { "checkpoint_bound_durable_approval_only" } else { "unavailable" },
1279                    "replay": "integrity_only"
1280                },
1281                "limits": {"graphs": self.max_graphs},
1282                "capacity_state": if graphs.len() <= self.max_graphs {
1283                    "within_limit"
1284                } else {
1285                    "over_limit_legacy"
1286                }
1287            })));
1288        }
1289
1290        match resource.unwrap() {
1291            "templates" => Ok(structured_output(templates::list())),
1292
1293            "graph" => {
1294                let id = graph_id
1295                    .as_deref()
1296                    .ok_or_else(|| invalid_params("missing graph_id"))?;
1297                let graphs = self
1298                    .graphs
1299                    .lock()
1300                    .map_err(|e| internal_error(e.to_string()))?;
1301                let g = graphs
1302                    .get(id)
1303                    .ok_or_else(|| invalid_params(format!("graph '{id}' not found")))?;
1304                Ok(output_with_meta(
1305                    serde_json::json!({
1306                        "graph_id": id,
1307                        "graph_version": g.version,
1308                        "normalized_spec": g.normalized,
1309                        "mermaid": Self::mermaid(&g.spec),
1310                        "warnings": g.warnings,
1311                        "storage_class": "volatile"
1312                    }),
1313                    Some(id),
1314                    Some(&g.version),
1315                    None,
1316                ))
1317            }
1318
1319            "run" => {
1320                let runs = self
1321                    .runs
1322                    .lock()
1323                    .map_err(|e| internal_error(e.to_string()))?;
1324                if run_id.is_none() {
1325                    // List all runs
1326                    return Ok(structured_output(serde_json::json!({
1327                        "runs": runs.list()
1328                    })));
1329                }
1330                let id = run_id.as_deref().unwrap();
1331                let r = runs
1332                    .get(id)
1333                    .ok_or_else(|| invalid_params(format!("run '{id}' not found")))?;
1334                Ok(structured_output(r.public()))
1335            }
1336
1337            "events" => {
1338                let id = run_id
1339                    .as_deref()
1340                    .ok_or_else(|| invalid_params("missing run_id for events"))?;
1341                let runs = self
1342                    .runs
1343                    .lock()
1344                    .map_err(|e| internal_error(e.to_string()))?;
1345                let cursor_val = cursor.unwrap_or(0);
1346                let limit_val = limit.unwrap_or(100) as usize;
1347                let result = runs
1348                    .events(self.store.as_ref(), id, cursor_val, limit_val)
1349                    .map_err(|e| invalid_params(e))?;
1350                Ok(output_with_meta(result, None, None, Some(id)))
1351            }
1352
1353            "receipt" => {
1354                let id = run_id
1355                    .as_deref()
1356                    .ok_or_else(|| invalid_params("missing run_id for receipt"))?;
1357                let runs = self
1358                    .runs
1359                    .lock()
1360                    .map_err(|e| internal_error(e.to_string()))?;
1361                let r = runs
1362                    .get(id)
1363                    .ok_or_else(|| invalid_params(format!("run '{id}' not found")))?;
1364                Ok(output_with_meta(r.receipt.clone(), None, None, Some(id)))
1365            }
1366
1367            "bundle" => {
1368                let id = run_id
1369                    .as_deref()
1370                    .ok_or_else(|| invalid_params("missing run_id for bundle"))?;
1371                let runs = self
1372                    .runs
1373                    .lock()
1374                    .map_err(|e| internal_error(e.to_string()))?;
1375                let r = runs
1376                    .get(id)
1377                    .ok_or_else(|| invalid_params(format!("run '{id}' not found")))?;
1378                Ok(output_with_meta(r.bundle.clone(), None, None, Some(id)))
1379            }
1380
1381            _ => Ok(error_output(
1382                format!("unknown status resource '{}'", resource.unwrap_or("")),
1383                "INVALID_RESOURCE",
1384            )),
1385        }
1386    }
1387
1388    // ── graph_list (NEW) ──────────────────────────────────────────────
1389
1390    #[tool(
1391        description = "List all registered graphs with metadata (name, node count, edge count, version)."
1392    )]
1393    fn graph_list(
1394        &self,
1395        Parameters(GraphListParams { query, limit }): Parameters<GraphListParams>,
1396    ) -> Result<Json<StructuredOutput>, ErrorData> {
1397        let graphs = self
1398            .graphs
1399            .lock()
1400            .map_err(|e| internal_error(e.to_string()))?;
1401
1402        let mut entries: Vec<Value> = graphs
1403            .iter()
1404            .filter(|(name, _)| {
1405                let visible = self
1406                    .store
1407                    .as_ref()
1408                    .map(|store| store.graph_is_tombstoned(name).map(|v| !v).unwrap_or(false))
1409                    .unwrap_or(true);
1410                query
1411                    .as_ref()
1412                    .map(|q| name.contains(q.as_str()))
1413                    .unwrap_or(true)
1414                    && visible
1415            })
1416            .take(limit.unwrap_or(50) as usize)
1417            .map(|(name, g)| {
1418                let version_history = self
1419                    .store
1420                    .as_ref()
1421                    .and_then(|store| store.list_graph_versions(name).ok())
1422                    .unwrap_or_else(|| vec![g.version.clone()]);
1423                serde_json::json!({
1424                    "name": name,
1425                    "version": g.version,
1426                    "current_version": g.version,
1427                    "version_history": version_history,
1428                    "historical_specs": self.store.is_some(),
1429                    "node_count": g.spec.nodes.len(),
1430                    "edge_count": g.spec.edges.len(),
1431                    "entry": g.spec.entry,
1432                    "warnings": g.warnings,
1433                })
1434            })
1435            .collect();
1436
1437        entries.sort_by(|a, b| {
1438            a.get("name")
1439                .and_then(Value::as_str)
1440                .cmp(&b.get("name").and_then(Value::as_str))
1441        });
1442
1443        Ok(structured_output(serde_json::json!({
1444            "graphs": entries,
1445            "count": entries.len(),
1446        })))
1447    }
1448
1449    #[tool(
1450        description = "Read a durable graph retention inventory with execution, version, and inbound-subgraph reference counts. This tool never changes graph state."
1451    )]
1452    fn graph_retention_review(
1453        &self,
1454        Parameters(GraphRetentionReviewParams {
1455            graph_id,
1456            state,
1457            limit,
1458        }): Parameters<GraphRetentionReviewParams>,
1459    ) -> Result<Json<StructuredOutput>, ErrorData> {
1460        let Some(store) = self.store.as_ref() else {
1461            return Ok(error_output(
1462                "SQLite persistence is required for graph retention review",
1463                "RETENTION_STORE_REQUIRED",
1464            ));
1465        };
1466        let reports = store
1467            .graph_retention_review(
1468                graph_id.as_deref(),
1469                state.as_deref(),
1470                limit.unwrap_or(100).clamp(1, 256) as usize,
1471            )
1472            .map_err(internal_error)?;
1473        let graphs: Vec<Value> = reports
1474            .into_iter()
1475            .map(|report| {
1476                serde_json::json!({
1477                    "graph_id": report.graph_id,
1478                    "state": report.state,
1479                    "reason": report.reason,
1480                    "actor": report.actor,
1481                    "review_after": report.review_after,
1482                    "created_at": report.created_at,
1483                    "updated_at": report.updated_at,
1484                    "version_count": report.version_count,
1485                    "execution_count": report.execution_count,
1486                    "last_execution_at": report.last_execution_at,
1487                    "state_digest": report.state_digest,
1488                    "tombstoned": report.tombstoned,
1489                    "inbound_subgraph_refs": report.inbound_subgraph_refs,
1490                    "deletion_eligible": report.deletion_eligible,
1491                })
1492            })
1493            .collect();
1494        Ok(structured_output(serde_json::json!({
1495            "graphs": graphs,
1496            "count": graphs.len(),
1497            "storage_class": "sqlite_graph_retention",
1498        })))
1499    }
1500
1501    #[tool(
1502        description = "Set an explicit durable graph lifecycle state. delete_approved requires a prior delete_candidate state and no execution or inbound-subgraph references."
1503    )]
1504    fn graph_retention_set(
1505        &self,
1506        Parameters(GraphRetentionSetParams {
1507            graph_id,
1508            state,
1509            reason,
1510            actor,
1511            review_after,
1512        }): Parameters<GraphRetentionSetParams>,
1513    ) -> Result<Json<StructuredOutput>, ErrorData> {
1514        let _ = (graph_id, state, reason, actor, review_after);
1515        Ok(error_output(
1516            "graph lifecycle updates require the authenticated operator service",
1517            "AUTHENTICATED_OPERATOR_REQUIRED",
1518        ))
1519    }
1520
1521    // ── graph_delete (NEW) ────────────────────────────────────────────
1522
1523    #[allow(dead_code)]
1524    fn graph_delete(
1525        &self,
1526        Parameters(GraphDeleteParams { graph_id }): Parameters<GraphDeleteParams>,
1527    ) -> Result<Json<StructuredOutput>, ErrorData> {
1528        self.delete_registered_graph(&graph_id)
1529    }
1530
1531    // ── graph_inspect (NEW) ───────────────────────────────────────────
1532
1533    #[tool(
1534        description = "Get a graph's full topology: nodes, edges, Mermaid diagram, and topology hash."
1535    )]
1536    fn graph_inspect(
1537        &self,
1538        Parameters(GraphInspectParams { graph_id }): Parameters<GraphInspectParams>,
1539    ) -> Result<Json<StructuredOutput>, ErrorData> {
1540        let graphs = self
1541            .graphs
1542            .lock()
1543            .map_err(|e| internal_error(e.to_string()))?;
1544        let g = graphs
1545            .get(&graph_id)
1546            .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
1547
1548        let nodes: Vec<Value> = g
1549            .spec
1550            .nodes
1551            .iter()
1552            .map(|n| {
1553                serde_json::json!({
1554                    "id": n.id,
1555                    "type": n.node_type,
1556                    "config": n.config,
1557                })
1558            })
1559            .collect();
1560
1561        let edges: Vec<Value> = g
1562            .spec
1563            .edges
1564            .iter()
1565            .map(|e| {
1566                serde_json::json!({
1567                    "from": e.from,
1568                    "to": e.to,
1569                })
1570            })
1571            .collect();
1572
1573        Ok(output_with_meta(
1574            serde_json::json!({
1575                "name": graph_id,
1576                "version": g.version,
1577                "current_version": g.version,
1578                "version_history": self.store.as_ref().and_then(|store| store.list_graph_versions(&graph_id).ok()).unwrap_or_else(|| vec![g.version.clone()]),
1579                "historical_specs": self.store.is_some(),
1580                "entry": g.spec.entry,
1581                "max_iterations": g.spec.max_iterations,
1582                "max_parallelism": g.spec.max_parallelism,
1583                "nodes": nodes,
1584                "node_count": nodes.len(),
1585                "edges": edges,
1586                "edge_count": edges.len(),
1587                "mermaid": Self::mermaid(&g.spec),
1588                "topology_hash": g.version,
1589                "reducers": g.spec.reducers,
1590                "warnings": g.warnings,
1591            }),
1592            Some(&graph_id),
1593            Some(&g.version),
1594            None,
1595        ))
1596    }
1597
1598    // ── Approval lifecycle ────────────────────────────────────────────
1599
1600    fn validate_resume_checkpoint(
1601        &self,
1602        store: &PersistentStore,
1603        checkpoint: &CheckpointRecord,
1604    ) -> Result<
1605        (
1606            crate::store::ExecutionContract,
1607            RegisteredGraph,
1608            Option<RunBudgets>,
1609        ),
1610        (String, String),
1611    > {
1612        let contract = store
1613            .load_execution_contract(&checkpoint.run_id)
1614            .map_err(|error| (error, "CHECKPOINT_PERSISTENCE_FAILURE".into()))?
1615            .ok_or_else(|| {
1616                (
1617                    "checkpoint execution contract was not found".into(),
1618                    "CHECKPOINT_INTEGRITY_FAILURE".into(),
1619                )
1620            })?;
1621        if contract.graph_id != checkpoint.graph_id
1622            || contract.graph_version != checkpoint.graph_version
1623            || checkpoint.terminal_cursor != 0
1624            || checkpoint.event_cursor != 0
1625        {
1626            return Err((
1627                "checkpoint integrity validation failed".into(),
1628                "CHECKPOINT_INTEGRITY_FAILURE".into(),
1629            ));
1630        }
1631        let graph = self
1632            .resolve_graph(&checkpoint.graph_id, Some(&checkpoint.graph_version))
1633            .map_err(|_| {
1634                (
1635                    "checkpoint graph version is unavailable".into(),
1636                    "CHECKPOINT_INTEGRITY_FAILURE".into(),
1637                )
1638            })?;
1639        let eligibility = graph.spec.resume_eligibility().map_err(|_| {
1640            (
1641                "checkpoint graph is no longer in the deterministic local resume subset".into(),
1642                "RESUME_INELIGIBLE".into(),
1643            )
1644        })?;
1645        if graph.version != checkpoint.graph_version
1646            || checkpoint.next_node_cursor != eligibility.next_node_cursor
1647            || checkpoint.dependency_summary != eligibility.dependency_summary
1648            || checkpoint.dependency_digest != digest(&eligibility.dependency_summary)
1649            || checkpoint.state != initial_state_for_input(&contract.input)
1650            || checkpoint.budgets != contract.budgets
1651            || checkpoint.budget_counters
1652                != serde_json::json!({"nodes":0,"llm_calls":0,"wall_clock_ms":0})
1653        {
1654            return Err((
1655                "checkpoint integrity validation failed".into(),
1656                "CHECKPOINT_INTEGRITY_FAILURE".into(),
1657            ));
1658        }
1659        let budgets = RunBudgets::parse(Some(&checkpoint.budgets)).map_err(|_| {
1660            (
1661                "checkpoint budgets failed validation".into(),
1662                "CHECKPOINT_INTEGRITY_FAILURE".into(),
1663            )
1664        })?;
1665        Ok((contract, graph, budgets))
1666    }
1667
1668    fn launch_resumed(
1669        &self,
1670        checkpoint: CheckpointRecord,
1671        contract: crate::store::ExecutionContract,
1672        graph: RegisteredGraph,
1673        budgets: Option<RunBudgets>,
1674        approval: Option<Value>,
1675    ) -> Result<Json<StructuredOutput>, ErrorData> {
1676        let runs = self
1677            .runs
1678            .lock()
1679            .map_err(|e| internal_error(e.to_string()))?;
1680        if runs.get(&checkpoint.run_id).is_some() {
1681            let _ = runs.remove(&checkpoint.run_id);
1682        }
1683        let run_id = match runs.allocate_resumed(
1684            &checkpoint.run_id,
1685            &checkpoint.graph_id,
1686            &checkpoint.graph_version,
1687            contract.input,
1688            checkpoint.state.clone(),
1689            budgets,
1690            &checkpoint.checkpoint_id,
1691            &checkpoint.checkpoint_digest,
1692            approval.clone(),
1693        ) {
1694            Ok(run_id) => run_id,
1695            Err(error) => {
1696                runs.release_async_slot();
1697                return Ok(error_output(error, "RUN_CAPACITY"));
1698            }
1699        };
1700        if let Err(error) = runs.admit_reserved_async(&run_id) {
1701            runs.remove(&run_id);
1702            runs.release_async_slot();
1703            return Ok(error_output(error, "RUN_CAPACITY"));
1704        }
1705        self.store
1706            .as_ref()
1707            .expect("resumed launch requires SQLite")
1708            .update_execution_status(&run_id, "running", None, None, None)
1709            .map_err(internal_error)?;
1710        let terminal_store = self.store.clone();
1711        let completion_runs = runs.clone();
1712        runs.start_resumed_with_completion(
1713            run_id.clone(),
1714            graph.spec,
1715            self.base_url.clone(),
1716            self.default_model.clone(),
1717            self.store.clone(),
1718            move |record| Self::persist_terminal_and_mark(completion_runs, terminal_store, record),
1719        );
1720        Ok(output_with_meta(
1721            serde_json::json!({
1722                "run_id": run_id,
1723                "status": "running",
1724                "checkpoint": checkpoint_value(&checkpoint),
1725                "resume_capability": "deterministic_local_resume",
1726                "approval": approval,
1727            }),
1728            Some(&checkpoint.graph_id),
1729            Some(&checkpoint.graph_version),
1730            Some(&run_id),
1731        ))
1732    }
1733
1734    #[tool(
1735        description = "Create a durable approval request bound to one unconsumed deterministic-local checkpoint."
1736    )]
1737    fn graph_approval_request(
1738        &self,
1739        Parameters(ApprovalRequestParams {
1740            checkpoint_id,
1741            audience,
1742            prompt,
1743            allowed_decisions,
1744            expiration,
1745        }): Parameters<ApprovalRequestParams>,
1746    ) -> Result<Json<StructuredOutput>, ErrorData> {
1747        let Some(store) = self.store.as_ref() else {
1748            return Ok(error_output(
1749                "SQLite persistence is required for durable approvals",
1750                "APPROVAL_STORE_REQUIRED",
1751            ));
1752        };
1753        if audience.trim().is_empty() || audience.len() > 256 {
1754            return Ok(error_output(
1755                "audience must be non-empty and at most 256 bytes",
1756                "INVALID_PARAMS",
1757            ));
1758        }
1759        if allowed_decisions.is_empty()
1760            || allowed_decisions
1761                .iter()
1762                .any(|decision| !matches!(decision.as_str(), "approve" | "reject"))
1763        {
1764            return Ok(error_output(
1765                "allowed_decisions must be a non-empty subset of approve and reject",
1766                "INVALID_PARAMS",
1767            ));
1768        }
1769        if chrono::DateTime::parse_from_rfc3339(&expiration).is_err() {
1770            return Ok(error_output("expiration must be RFC3339", "INVALID_PARAMS"));
1771        }
1772        if prompt.len() > 16 * 1024 {
1773            return Ok(error_output(
1774                "prompt exceeds the bounded approval prompt size",
1775                "INVALID_PARAMS",
1776            ));
1777        }
1778        let checkpoint = match store.load_resume_checkpoint(Some(&checkpoint_id), None) {
1779            Ok(Some(checkpoint)) => checkpoint,
1780            Ok(None) => return Ok(checkpoint_error_output(CheckpointError::NotFound)),
1781            Err(error) => return Ok(checkpoint_error_output(error)),
1782        };
1783        if checkpoint.consumed_at.is_some() {
1784            return Ok(checkpoint_error_output(CheckpointError::Consumed));
1785        }
1786        if let Err((message, code)) = self.validate_resume_checkpoint(store, &checkpoint) {
1787            return Ok(error_output(message, code));
1788        }
1789        let prompt_digest = digest(&Value::String(prompt));
1790        let approval = match store.create_checkpoint_approval(
1791            &checkpoint.checkpoint_id,
1792            &checkpoint.graph_id,
1793            &checkpoint.graph_version,
1794            &checkpoint.next_node_cursor,
1795            &checkpoint.state,
1796            &checkpoint.budgets,
1797            &checkpoint.budget_counters,
1798            &checkpoint.dependency_summary,
1799            &audience,
1800            &prompt_digest,
1801            &allowed_decisions,
1802            &expiration,
1803        ) {
1804            Ok(approval) => approval,
1805            Err(error) => return Ok(approval_error_output(error)),
1806        };
1807        Ok(output_with_meta(
1808            approval_value(&approval),
1809            Some(&approval.graph_id),
1810            Some(&approval.graph_version),
1811            Some(&approval.run_id),
1812        ))
1813    }
1814
1815    #[tool(
1816        description = "Read durable checkpoint-bound approval metadata from SQLite without raw prompt or checkpoint state."
1817    )]
1818    fn graph_approval_list(
1819        &self,
1820        Parameters(ApprovalListParams {
1821            run_id,
1822            status,
1823            limit,
1824        }): Parameters<ApprovalListParams>,
1825    ) -> Result<Json<StructuredOutput>, ErrorData> {
1826        let Some(store) = self.store.as_ref() else {
1827            return Ok(error_output(
1828                "SQLite persistence is required for durable approvals",
1829                "APPROVAL_STORE_REQUIRED",
1830            ));
1831        };
1832        let approvals = store
1833            .list_checkpoint_approvals(
1834                run_id.as_deref(),
1835                status.as_deref(),
1836                limit.unwrap_or(50) as usize,
1837            )
1838            .map_err(|error| internal_error(error.message()))?;
1839        Ok(structured_output(serde_json::json!({
1840            "approvals": approvals.iter().map(approval_value).collect::<Vec<_>>(),
1841            "count": approvals.len(),
1842            "storage_class": "sqlite_durable_approval_metadata",
1843        })))
1844    }
1845
1846    #[tool(
1847        description = "Read one durable checkpoint-bound approval's metadata from SQLite without raw prompt or checkpoint state."
1848    )]
1849    fn graph_approval_get(
1850        &self,
1851        Parameters(ApprovalGetParams { approval_id }): Parameters<ApprovalGetParams>,
1852    ) -> Result<Json<StructuredOutput>, ErrorData> {
1853        let Some(store) = self.store.as_ref() else {
1854            return Ok(error_output(
1855                "SQLite persistence is required for durable approvals",
1856                "APPROVAL_STORE_REQUIRED",
1857            ));
1858        };
1859        match store
1860            .get_checkpoint_approval(&approval_id)
1861            .map_err(|error| internal_error(error.message()))?
1862        {
1863            Some(approval) => Ok(output_with_meta(
1864                approval_value(&approval),
1865                Some(&approval.graph_id),
1866                Some(&approval.graph_version),
1867                Some(&approval.run_id),
1868            )),
1869            None => Ok(approval_error_output(ApprovalError::NotFound)),
1870        }
1871    }
1872
1873    #[allow(dead_code)]
1874    fn graph_approval_decide(
1875        &self,
1876        Parameters(ApprovalDecideParams {
1877            approval_id: _,
1878            decision: _,
1879            claimed_actor_label: _,
1880        }): Parameters<ApprovalDecideParams>,
1881    ) -> Result<Json<StructuredOutput>, ErrorData> {
1882        return Ok(error_output(
1883            "approval decisions require authenticated operator transport",
1884            "AUTHENTICATED_OPERATOR_REQUIRED",
1885        ));
1886    }
1887
1888    // ── Async run lifecycle ───────────────────────────────────────────
1889
1890    #[tool(
1891        description = "Start an async graph run. Returns run_id immediately; use graph_run_wait to block on completion. Optional budgets accept only positive integer max_wall_clock_ms, max_nodes, or max_llm_calls fields; max_llm_calls is enforced before each provider invocation."
1892    )]
1893    fn graph_run_start(
1894        &self,
1895        Parameters(RunStartParams {
1896            graph_id,
1897            input,
1898            graph_version,
1899            thread_id,
1900            idempotency_key,
1901            budgets,
1902            checkpoint,
1903        }): Parameters<RunStartParams>,
1904    ) -> Result<Json<StructuredOutput>, ErrorData> {
1905        let requested_budgets = match RunBudgets::parse(budgets.as_ref()) {
1906            Ok(budgets) => budgets,
1907            Err(error) => return Ok(error_output(error, "INVALID_BUDGETS")),
1908        };
1909        let input = input.unwrap_or(Value::Null);
1910        let checkpoint_requested = checkpoint.unwrap_or(false);
1911        ensure_size(&input, MAX_INPUT_BYTES, "execution input").map_err(|e| invalid_params(e))?;
1912
1913        let RegisteredGraph {
1914            spec,
1915            normalized,
1916            version,
1917            ..
1918        } = self.resolve_graph(&graph_id, graph_version.as_deref())?;
1919
1920        if Self::graph_requires_witness_store(&spec) && self.store.is_none() {
1921            return Ok(error_output(
1922                "evidence-required graphs require SQLite witness persistence",
1923                "WITNESS_STORE_REQUIRED",
1924            ));
1925        }
1926
1927        let eligibility = if checkpoint_requested {
1928            match spec.resume_eligibility() {
1929                Ok(eligibility) => Some(eligibility),
1930                Err(reason) => return Ok(error_output(reason, "RESUME_INELIGIBLE")),
1931            }
1932        } else {
1933            None
1934        };
1935
1936        let request_digest = digest(&serde_json::json!({
1937            "operation": "graph_run_start",
1938            "graph_id": graph_id,
1939            "graph_spec": normalized,
1940            "graph_version": version,
1941            "input": input,
1942            "thread_id": thread_id,
1943            "budgets": requested_budgets
1944                .as_ref()
1945                .map(RunBudgets::requested_value)
1946                .unwrap_or(Value::Null),
1947            "checkpoint": checkpoint_requested,
1948        }));
1949
1950        let runs = self
1951            .runs
1952            .lock()
1953            .map_err(|e| internal_error(e.to_string()))?;
1954        if let Some(idem) = idempotency_key.as_deref() {
1955            if let Some(cached) =
1956                check_idempotency(self.store.as_ref(), Some(idem), &request_digest)?
1957            {
1958                return Ok(cached);
1959            }
1960        }
1961
1962        if checkpoint_requested {
1963            let Some(store) = self.store.as_ref() else {
1964                return Ok(error_output(
1965                    "SQLite persistence is required for deterministic checkpoints",
1966                    "CHECKPOINT_STORE_REQUIRED",
1967                ));
1968            };
1969            let eligibility = eligibility.expect("checkpoint eligibility");
1970            let state = initial_state_for_input(&input);
1971            let budgets_value = requested_budgets
1972                .as_ref()
1973                .map(RunBudgets::requested_value)
1974                .unwrap_or(Value::Null);
1975            let counters = serde_json::json!({"nodes":0,"llm_calls":0,"wall_clock_ms":0});
1976            let run_id = runs
1977                .allocate_with_budgets(
1978                    &graph_id,
1979                    &version,
1980                    input.clone(),
1981                    requested_budgets.clone(),
1982                )
1983                .map_err(|e| internal_error(e))?;
1984            if let Err(error) = store.save_execution_with_budgets(
1985                &run_id,
1986                &graph_id,
1987                &version,
1988                "checkpointed",
1989                &input.to_string(),
1990                Some(&budgets_value.to_string()),
1991            ) {
1992                runs.remove(&run_id);
1993                return Ok(error_output(error, "CHECKPOINT_PERSISTENCE_FAILURE"));
1994            }
1995            let checkpoint_record = match store.create_resume_checkpoint(
1996                &run_id,
1997                &graph_id,
1998                &version,
1999                &eligibility.next_node_cursor,
2000                &state,
2001                &budgets_value,
2002                &counters,
2003                &eligibility.dependency_summary,
2004                0,
2005                0,
2006            ) {
2007                Ok(record) => record,
2008                Err(error) => {
2009                    let _ = store.update_execution_status(&run_id, "failed", None, None, None);
2010                    runs.remove(&run_id);
2011                    return Ok(checkpoint_error_output(error));
2012                }
2013            };
2014            runs.mark_checkpointed(
2015                &run_id,
2016                &checkpoint_record.checkpoint_id,
2017                &checkpoint_record.checkpoint_digest,
2018            )
2019            .map_err(internal_error)?;
2020            let output = output_with_meta(
2021                serde_json::json!({
2022                    "run_id": run_id,
2023                    "status": "checkpointed",
2024                    "thread_id": thread_id,
2025                    "checkpoint_id": checkpoint_record.checkpoint_id,
2026                    "checkpoint_digest": checkpoint_record.checkpoint_digest,
2027                    "checkpoint": checkpoint_value(&checkpoint_record),
2028                    "resume_capability": "deterministic_local_resume",
2029                }),
2030                Some(&graph_id),
2031                Some(&version),
2032                Some(&run_id),
2033            );
2034            if let Some(idem) = idempotency_key {
2035                if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
2036                    return Ok(cached);
2037                }
2038            }
2039            return Ok(output);
2040        }
2041
2042        let run_id = runs
2043            .allocate_with_budgets(&graph_id, &version, input.clone(), requested_budgets)
2044            .map_err(|e| internal_error(e))?;
2045        if let Err(e) = runs.admit_async(&run_id) {
2046            runs.remove(&run_id);
2047            return Ok(error_output(e, "RUN_CAPACITY"));
2048        }
2049
2050        if let Some(ref store) = self.store {
2051            let _ =
2052                store.save_execution(&run_id, &graph_id, &version, "running", &input.to_string());
2053        }
2054
2055        let terminal_store = self.store.clone();
2056        let completion_runs = runs.clone();
2057        runs.start_with_completion_with_store(
2058            run_id.clone(),
2059            spec,
2060            self.base_url.clone(),
2061            self.default_model.clone(),
2062            self.store.clone(),
2063            move |record| Self::persist_terminal_and_mark(completion_runs, terminal_store, record),
2064        );
2065
2066        let output = output_with_meta(
2067            serde_json::json!({
2068                "run_id": run_id,
2069                "status": "running",
2070                "thread_id": thread_id,
2071            }),
2072            Some(&graph_id),
2073            Some(&version),
2074            Some(&run_id),
2075        );
2076        if let Some(ref store) = self.store {
2077            if let Some(idem) = idempotency_key {
2078                if let Some(cached) = persist_idempotency(store, &idem, &request_digest, &output)? {
2079                    return Ok(cached);
2080                }
2081            }
2082        }
2083
2084        Ok(output)
2085    }
2086
2087    #[tool(
2088        description = "Read one durable deterministic-local checkpoint, including its integrity-bound state and resume metadata."
2089    )]
2090    fn graph_run_checkpoint(
2091        &self,
2092        Parameters(RunCheckpointParams {
2093            run_id,
2094            checkpoint_id,
2095        }): Parameters<RunCheckpointParams>,
2096    ) -> Result<Json<StructuredOutput>, ErrorData> {
2097        let Some(store) = self.store.as_ref() else {
2098            return Ok(error_output(
2099                "SQLite persistence is required for checkpoint reads",
2100                "CHECKPOINT_STORE_REQUIRED",
2101            ));
2102        };
2103        if run_id.is_none() && checkpoint_id.is_none() {
2104            return Ok(error_output(
2105                "run_id or checkpoint_id is required for checkpoint reads",
2106                "INVALID_PARAMS",
2107            ));
2108        }
2109        match store.load_resume_checkpoint(checkpoint_id.as_deref(), run_id.as_deref()) {
2110            Ok(Some(record))
2111                if run_id
2112                    .as_deref()
2113                    .is_none_or(|run_id| record.run_id == run_id) =>
2114            {
2115                Ok(output_with_meta(
2116                    checkpoint_value(&record),
2117                    Some(&record.graph_id),
2118                    Some(&record.graph_version),
2119                    Some(&record.run_id),
2120                ))
2121            }
2122            Ok(Some(_)) => Ok(checkpoint_error_output(CheckpointError::Integrity)),
2123            Ok(None) => Ok(checkpoint_error_output(CheckpointError::NotFound)),
2124            Err(error) => Ok(checkpoint_error_output(error)),
2125        }
2126    }
2127
2128    #[tool(
2129        description = "Consume one deterministic-local checkpoint atomically and resume its pinned run exactly once."
2130    )]
2131    fn graph_run_resume(
2132        &self,
2133        Parameters(RunResumeParams {
2134            checkpoint_id,
2135            run_id,
2136        }): Parameters<RunResumeParams>,
2137    ) -> Result<Json<StructuredOutput>, ErrorData> {
2138        let Some(store) = self.store.as_ref() else {
2139            return Ok(error_output(
2140                "SQLite persistence is required for deterministic resume",
2141                "CHECKPOINT_STORE_REQUIRED",
2142            ));
2143        };
2144        if checkpoint_id.is_none() && run_id.is_none() {
2145            return Ok(error_output(
2146                "checkpoint_id or run_id is required for resume",
2147                "INVALID_PARAMS",
2148            ));
2149        }
2150        let checkpoint =
2151            match store.load_resume_checkpoint(checkpoint_id.as_deref(), run_id.as_deref()) {
2152                Ok(Some(record)) => record,
2153                Ok(None) => return Ok(checkpoint_error_output(CheckpointError::NotFound)),
2154                Err(error) => return Ok(checkpoint_error_output(error)),
2155            };
2156        if store
2157            .checkpoint_approval_status(&checkpoint.checkpoint_id)
2158            .map_err(|error| internal_error(error.message()))?
2159            .as_deref()
2160            == Some("pending")
2161        {
2162            return Ok(error_output(
2163                "checkpoint resume is pending its durable approval decision",
2164                "APPROVAL_PENDING",
2165            ));
2166        }
2167        if checkpoint.consumed_at.is_some() {
2168            return Ok(checkpoint_error_output(CheckpointError::Consumed));
2169        }
2170        if run_id
2171            .as_deref()
2172            .is_some_and(|run_id| run_id != checkpoint.run_id)
2173        {
2174            return Ok(checkpoint_error_output(CheckpointError::Integrity));
2175        }
2176        let Some(contract) = store
2177            .load_execution_contract(&checkpoint.run_id)
2178            .map_err(internal_error)?
2179        else {
2180            return Ok(checkpoint_error_output(CheckpointError::Integrity));
2181        };
2182        if contract.graph_id != checkpoint.graph_id
2183            || contract.graph_version != checkpoint.graph_version
2184            || checkpoint.terminal_cursor != 0
2185            || checkpoint.event_cursor != 0
2186        {
2187            return Ok(checkpoint_error_output(CheckpointError::Integrity));
2188        }
2189        let graph = match self.resolve_graph(&checkpoint.graph_id, Some(&checkpoint.graph_version))
2190        {
2191            Ok(graph) => graph,
2192            Err(_) => return Ok(checkpoint_error_output(CheckpointError::Integrity)),
2193        };
2194        if graph.version != checkpoint.graph_version {
2195            return Ok(checkpoint_error_output(CheckpointError::Integrity));
2196        }
2197        let eligibility = match graph.spec.resume_eligibility() {
2198            Ok(eligibility) => eligibility,
2199            Err(_) => {
2200                return Ok(error_output(
2201                    "checkpoint graph is no longer in the deterministic local resume subset",
2202                    "RESUME_INELIGIBLE",
2203                ));
2204            }
2205        };
2206        if checkpoint.next_node_cursor != eligibility.next_node_cursor
2207            || checkpoint.dependency_summary != eligibility.dependency_summary
2208            || checkpoint.dependency_digest != digest(&eligibility.dependency_summary)
2209            || checkpoint.state != initial_state_for_input(&contract.input)
2210            || checkpoint.budgets != contract.budgets
2211            || checkpoint.budget_counters
2212                != serde_json::json!({"nodes":0,"llm_calls":0,"wall_clock_ms":0})
2213        {
2214            return Ok(checkpoint_error_output(CheckpointError::Integrity));
2215        }
2216        let budgets = match RunBudgets::parse(Some(&checkpoint.budgets)) {
2217            Ok(budgets) => budgets,
2218            Err(_) => return Ok(checkpoint_error_output(CheckpointError::Integrity)),
2219        };
2220        let reserved_runs = self
2221            .runs
2222            .lock()
2223            .map_err(|e| internal_error(e.to_string()))?;
2224        if let Err(error) = reserved_runs.reserve_async_slot() {
2225            return Ok(error_output(error, "RUN_CAPACITY"));
2226        }
2227        drop(reserved_runs);
2228        let consumed = match store.consume_resume_checkpoint(&checkpoint.checkpoint_id) {
2229            Ok(record) => record,
2230            Err(error) => {
2231                if let Ok(runs) = self.runs.lock() {
2232                    runs.release_async_slot();
2233                }
2234                return Ok(checkpoint_error_output(error));
2235            }
2236        };
2237        self.launch_resumed(consumed, contract, graph, budgets, None)
2238    }
2239
2240    #[tool(description = "Wait for an async run to complete, with optional timeout.")]
2241    fn graph_run_wait(
2242        &self,
2243        Parameters(RunWaitParams { run_id, timeout_ms }): Parameters<RunWaitParams>,
2244    ) -> Result<Json<StructuredOutput>, ErrorData> {
2245        let timeout = Duration::from_millis(timeout_ms.unwrap_or(300_000));
2246        let deadline = Instant::now() + timeout;
2247        loop {
2248            let r = {
2249                let runs = self
2250                    .runs
2251                    .lock()
2252                    .map_err(|e| internal_error(e.to_string()))?;
2253                runs.get(&run_id)
2254                    .ok_or_else(|| invalid_params(format!("run '{run_id}' not found")))?
2255            };
2256            if matches!(r.status.as_str(), "completed" | "failed" | "cancelled") {
2257                let persist = Self::persist_terminal(self.store.clone(), r.clone());
2258                if let Ok(runs) = self.runs.lock() {
2259                    if self.store.is_none() {
2260                        runs.mark_persistence(&run_id, "volatile_no_store", None);
2261                    } else {
2262                        match persist {
2263                            Ok(()) => runs.mark_persistence(&run_id, "durable_terminal", None),
2264                            Err(error) => runs.mark_persistence(
2265                                &run_id,
2266                                "volatile_persistence_failed",
2267                                Some(error),
2268                            ),
2269                        }
2270                    }
2271                }
2272                let public = self
2273                    .runs
2274                    .lock()
2275                    .ok()
2276                    .and_then(|runs| runs.get(&run_id).map(|record| record.public()))
2277                    .unwrap_or_else(|| r.public());
2278                return Ok(output_with_meta(public, None, None, Some(&run_id)));
2279            }
2280            if Instant::now() >= deadline {
2281                return Ok(output_with_meta(
2282                    serde_json::json!({
2283                        "run_id": run_id,
2284                        "status": r.status,
2285                        "timed_out": true,
2286                    }),
2287                    None,
2288                    None,
2289                    Some(&run_id),
2290                ));
2291            }
2292            std::thread::sleep(Duration::from_millis(100));
2293        }
2294    }
2295
2296    #[tool(description = "Cancel a running execution.")]
2297    fn graph_run_cancel(
2298        &self,
2299        Parameters(RunCancelParams { run_id, reason: _ }): Parameters<RunCancelParams>,
2300    ) -> Result<Json<StructuredOutput>, ErrorData> {
2301        let runs = self
2302            .runs
2303            .lock()
2304            .map_err(|e| internal_error(e.to_string()))?;
2305        match runs.cancel(&run_id) {
2306            Ok(_) => {}
2307            Err(error) if error == "RUN_NOT_CANCELLABLE" => {
2308                return Ok(error_output(
2309                    "terminal or checkpointed runs cannot be cancelled",
2310                    "RUN_NOT_CANCELLABLE",
2311                ));
2312            }
2313            Err(error) if error == "run not found" => {
2314                drop(runs);
2315                if self
2316                    .store
2317                    .as_ref()
2318                    .and_then(|store| store.load_execution(&run_id).ok().flatten())
2319                    .is_some_and(|stored| {
2320                        matches!(
2321                            stored.get("status").and_then(Value::as_str),
2322                            Some("completed" | "failed" | "cancelled" | "checkpointed")
2323                        )
2324                    })
2325                {
2326                    return Ok(error_output(
2327                        "terminal or checkpointed runs cannot be cancelled",
2328                        "RUN_NOT_CANCELLABLE",
2329                    ));
2330                }
2331                return Err(invalid_params(error));
2332            }
2333            Err(error) => return Err(invalid_params(error)),
2334        }
2335        Ok(output_with_meta(
2336            serde_json::json!({
2337                "run_id": run_id,
2338                "status": "cancellation_requested",
2339                "cancellation_effect": "best_effort_drop_provider_future",
2340                "provider_request_may_still_be_in_flight": true,
2341                "effective_at": "provider_completion_or_cancellation_observation"
2342            }),
2343            None,
2344            None,
2345            Some(&run_id),
2346        ))
2347    }
2348
2349    #[tool(description = "Get current run status, budget usage, and pending approvals.")]
2350    fn graph_run_get(
2351        &self,
2352        Parameters(RunGetParams { run_id }): Parameters<RunGetParams>,
2353    ) -> Result<Json<StructuredOutput>, ErrorData> {
2354        let runs = self
2355            .runs
2356            .lock()
2357            .map_err(|e| internal_error(e.to_string()))?;
2358        if let Some(r) = runs.get(&run_id) {
2359            return Ok(output_with_meta(r.public(), None, None, Some(&run_id)));
2360        }
2361        drop(runs);
2362        if let Some(record) = self.stored_run(&run_id)? {
2363            return Ok(output_with_meta(record, None, None, Some(&run_id)));
2364        }
2365        Err(invalid_params(format!("run '{run_id}' not found")))
2366    }
2367
2368    #[tool(
2369        description = "Read the in-memory state projection from a live run; use graph_run_checkpoint for a durable checkpoint state."
2370    )]
2371    fn graph_run_state(
2372        &self,
2373        Parameters(RunStateParams {
2374            run_id,
2375            checkpoint_id: _,
2376            json_pointer,
2377        }): Parameters<RunStateParams>,
2378    ) -> Result<Json<StructuredOutput>, ErrorData> {
2379        let runs = self
2380            .runs
2381            .lock()
2382            .map_err(|e| internal_error(e.to_string()))?;
2383        let r = runs
2384            .get(&run_id)
2385            .ok_or_else(|| invalid_params(format!("run '{run_id}' not found")))?;
2386        let state = if let Some(pointer) = json_pointer.as_deref() {
2387            if pointer.is_empty() {
2388                r.state.clone()
2389            } else {
2390                r.state.pointer(pointer).cloned().unwrap_or(Value::Null)
2391            }
2392        } else {
2393            r.state.clone()
2394        };
2395        Ok(output_with_meta(
2396            serde_json::json!({
2397                "state": state,
2398                "run_id": run_id,
2399                "status": r.status,
2400            }),
2401            None,
2402            None,
2403            Some(&run_id),
2404        ))
2405    }
2406
2407    #[tool(
2408        description = "Read bounded events. With SQLite, terminal emitted events remain available as a persisted projection after restart; this is not replayable execution or resume support."
2409    )]
2410    fn graph_run_events(
2411        &self,
2412        Parameters(RunEventsParams {
2413            run_id,
2414            cursor,
2415            limit,
2416        }): Parameters<RunEventsParams>,
2417    ) -> Result<Json<StructuredOutput>, ErrorData> {
2418        let runs = self
2419            .runs
2420            .lock()
2421            .map_err(|e| internal_error(e.to_string()))?;
2422        let result = runs
2423            .events(
2424                self.store.as_ref(),
2425                &run_id,
2426                cursor.unwrap_or(0),
2427                limit.unwrap_or(100) as usize,
2428            )
2429            .map_err(|e| invalid_params(e))?;
2430        Ok(output_with_meta(result, None, None, Some(&run_id)))
2431    }
2432
2433    #[tool(description = "Fetch the canonical execution receipt for a run.")]
2434    fn graph_run_receipt(
2435        &self,
2436        Parameters(RunReceiptParams { run_id }): Parameters<RunReceiptParams>,
2437    ) -> Result<Json<StructuredOutput>, ErrorData> {
2438        if let Some(r) = self
2439            .runs
2440            .lock()
2441            .map_err(|e| internal_error(e.to_string()))?
2442            .get(&run_id)
2443        {
2444            // Canonical wrapper: identical shape to the durable read-back, so
2445            // consumers always read data.receipt. The HMAC receipt_digest is a
2446            // persistence artifact, so it is null until the terminal projection
2447            // is durably stored; storage_class marks the live-resident path.
2448            return Ok(output_with_meta(
2449                serde_json::json!({
2450                    "receipt": r.receipt.clone(),
2451                    "receipt_digest": Value::Null,
2452                    "storage_class": "volatile_live",
2453                    "replay_capability": r
2454                        .receipt
2455                        .get("replay_capability")
2456                        .and_then(Value::as_str)
2457                        .unwrap_or("integrity_only"),
2458                }),
2459                None,
2460                None,
2461                Some(&run_id),
2462            ));
2463        }
2464        if let Some(store) = &self.store {
2465            match store.load_terminal_receipt(&run_id) {
2466                Ok(Some(receipt)) => {
2467                    return Ok(output_with_meta(receipt, None, None, Some(&run_id)));
2468                }
2469                Ok(None) => {}
2470                Err(error) if error == "RECEIPT_INTEGRITY_FAILURE" => {
2471                    return Ok(error_output(
2472                        "terminal receipt integrity validation failed",
2473                        "RECEIPT_INTEGRITY_FAILURE",
2474                    ));
2475                }
2476                Err(error) if error == "INTEGRITY_KEY_REQUIRED" => {
2477                    return Ok(error_output(
2478                        "an external integrity key is required for terminal receipt reads",
2479                        "INTEGRITY_KEY_REQUIRED",
2480                    ));
2481                }
2482                Err(error) => return Err(internal_error(error)),
2483            }
2484        }
2485        Ok(error_output(
2486            format!("run '{run_id}' not found"),
2487            "RUN_NOT_FOUND",
2488        ))
2489    }
2490
2491    // ── Policy + render ───────────────────────────────────────────────
2492
2493    #[tool(description = "Preflight a graph against policy before execution.")]
2494    fn graph_policy_check(
2495        &self,
2496        Parameters(PolicyCheckParams { graph_id, input: _ }): Parameters<PolicyCheckParams>,
2497    ) -> Result<Json<StructuredOutput>, ErrorData> {
2498        let graphs = self
2499            .graphs
2500            .lock()
2501            .map_err(|e| internal_error(e.to_string()))?;
2502        let g = graphs
2503            .get(&graph_id)
2504            .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
2505
2506        let node_count = g.spec.nodes.len();
2507        let edge_count = g.spec.edges.len();
2508        let issues: Vec<String> = Vec::new();
2509
2510        Ok(structured_output(serde_json::json!({
2511            "graph_id": graph_id,
2512            "passed": issues.is_empty(),
2513            "issues": issues,
2514            "stats": {
2515                "node_count": node_count,
2516                "edge_count": edge_count,
2517                "max_iterations": g.spec.max_iterations,
2518                "max_parallelism": g.spec.max_parallelism,
2519            },
2520            "capabilities": {
2521                "models": [self.default_model.clone()],
2522                "tools": [],
2523            }
2524        })))
2525    }
2526
2527    #[tool(description = "Render a graph as Mermaid diagram or JSON topology.")]
2528    fn graph_render(
2529        &self,
2530        Parameters(RenderParams { graph_id, format }): Parameters<RenderParams>,
2531    ) -> Result<Json<StructuredOutput>, ErrorData> {
2532        let graphs = self
2533            .graphs
2534            .lock()
2535            .map_err(|e| internal_error(e.to_string()))?;
2536        let g = graphs
2537            .get(&graph_id)
2538            .ok_or_else(|| invalid_params(format!("graph '{graph_id}' not found")))?;
2539        let fmt = format.as_deref().unwrap_or("mermaid");
2540
2541        match fmt {
2542            "json" => Ok(output_with_meta(
2543                serde_json::json!({
2544                    "name": graph_id,
2545                    "nodes": g.spec.nodes.iter().map(|n| serde_json::json!({
2546                        "id": n.id, "type": n.node_type
2547                    })).collect::<Vec<_>>(),
2548                    "edges": g.spec.edges.iter().map(|e| serde_json::json!({
2549                        "from": e.from, "to": e.to
2550                    })).collect::<Vec<_>>(),
2551                }),
2552                Some(&graph_id),
2553                Some(&g.version),
2554                None,
2555            )),
2556            _ => Ok(output_with_meta(
2557                serde_json::json!({
2558                    "mermaid": Self::mermaid(&g.spec),
2559                    "name": graph_id,
2560                }),
2561                Some(&graph_id),
2562                Some(&g.version),
2563                None,
2564            )),
2565        }
2566    }
2567
2568    // ── Templates ─────────────────────────────────────────────────────
2569
2570    #[tool(description = "List available built-in graph templates.")]
2571    fn graph_template_list(
2572        &self,
2573        Parameters(TemplateListParams { query: _ }): Parameters<TemplateListParams>,
2574    ) -> Result<Json<StructuredOutput>, ErrorData> {
2575        Ok(structured_output(templates::list()))
2576    }
2577
2578    #[tool(
2579        description = "Instantiate a template into a graph spec that can be passed to graph_create."
2580    )]
2581    fn graph_template_instantiate(
2582        &self,
2583        Parameters(TemplateInstantiateParams { template_id, name }): Parameters<
2584            TemplateInstantiateParams,
2585        >,
2586    ) -> Result<Json<StructuredOutput>, ErrorData> {
2587        match templates::instantiate(&template_id, &name) {
2588            Ok(spec) => Ok(structured_output(serde_json::json!({
2589                "template_id": template_id,
2590                "name": name,
2591                "spec": spec,
2592            }))),
2593            Err(e) => Ok(error_output(e, "GRAPH_INVALID")),
2594        }
2595    }
2596    #[tool(description = "Read-only list of template promotion candidates.")]
2597    fn graph_template_candidates(
2598        &self,
2599        Parameters(TemplateCandidatesParams { state: _ }): Parameters<TemplateCandidatesParams>,
2600    ) -> Result<Json<StructuredOutput>, ErrorData> {
2601        Ok(structured_output(serde_json::json!({ "candidates": [] })))
2602    }
2603
2604    #[tool(description = "Read-only list of recorded outcomes for a template.")]
2605    fn graph_template_outcomes(
2606        &self,
2607        Parameters(TemplateOutcomesParams { template_id }): Parameters<TemplateOutcomesParams>,
2608    ) -> Result<Json<StructuredOutput>, ErrorData> {
2609        Ok(structured_output(serde_json::json!({
2610            "template_id": template_id,
2611            "outcomes": [],
2612        })))
2613    }
2614}
2615
2616#[tool_handler(
2617    router = self.tool_router,
2618    name = "agent-graph-mcp",
2619    version = "0.2.0",
2620    instructions = "Graph orchestration for bounded multi-step LLM workflows with parallel fan-out, conditional routing, state transforms, joins, cooperative cancellation, and optional enforced max_wall_clock_ms/max_nodes/max_llm_calls run budgets (max_llm_calls reserves each provider attempt before invocation; failed or timed-out attempts still count). Parallel unordered state writes require an explicit reducer. Cancellation can drop the local provider future on request, best effort; an underlying provider request may continue. Optional SQLite stores terminal projections plus explicit pre-execution checkpoints. Durable checkpoints, approvals, terminal receipts, and source witnesses require an external key file named by AGENT_GRAPH_INTEGRITY_KEY_PATH; without it their operations fail closed with INTEGRITY_KEY_REQUIRED. Deterministic local resume is limited to linear passthrough/state_transform chains and is never generic replay; uncheckpointed or ineligible runs remain interrupted_non_resumable after restart. SQLite-backed approvals can decide only an immutable deterministic-local checkpoint and resume that checkpoint; HumanApproval nodes and arbitrary external actions remain unsupported. Source witnesses are caller-supplied local captures: locators are never fetched, HMAC-authenticated witness integrity and bounded evidence spans are checked against SQLite, and source authority is not independently verified. Receipts provide integrity_only except a successfully resumed deterministic-local path, which reports deterministic_local_resume. Define graphs with graph_create, execute with graph_execute or graph_run_start, checkpoint with checkpoint:true, inspect with graph_run_get/wait/cancel/state/events/receipt/checkpoint, request or decide checkpoint approvals with graph_approval_request/decide, and resume with graph_run_resume."
2621)]
2622impl ServerHandler for AgentGraphServer {}