diff options
| -rw-r--r-- | Cargo.lock | 380 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/catalog.rs | 44 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/host/runtime.rs | 235 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/mod.rs | 1 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/output.rs | 88 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/protocol.rs | 11 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/src/mcp/service.rs | 61 | ||||
| -rw-r--r-- | crates/fidget-spinner-cli/tests/mcp_hardening.rs | 31 |
9 files changed, 704 insertions, 148 deletions
@@ -71,6 +71,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] name = "camino" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -173,6 +179,23 @@ dependencies = [ ] [[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -199,6 +222,7 @@ dependencies = [ "dirs", "fidget-spinner-core", "fidget-spinner-store-sqlite", + "libmcp", "serde", "serde_json", "time", @@ -244,6 +268,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -298,12 +331,114 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] name = "indexmap" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -350,6 +485,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] +name = "libmcp" +version = "1.1.0" +dependencies = [ + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "url", +] + +[[package]] name = "libredox" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -370,6 +517,12 @@ dependencies = [ ] [[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -406,12 +559,33 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -463,6 +637,26 @@ dependencies = [ ] [[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "rusqlite" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -484,6 +678,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -520,6 +739,17 @@ dependencies = [ ] [[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -545,6 +775,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -562,6 +798,17 @@ dependencies = [ ] [[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -613,6 +860,38 @@ dependencies = [ ] [[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -625,6 +904,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -855,6 +1152,89 @@ dependencies = [ ] [[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/crates/fidget-spinner-cli/Cargo.toml b/crates/fidget-spinner-cli/Cargo.toml index 4ca70e9..51d3cd8 100644 --- a/crates/fidget-spinner-cli/Cargo.toml +++ b/crates/fidget-spinner-cli/Cargo.toml @@ -13,6 +13,7 @@ clap.workspace = true dirs.workspace = true fidget-spinner-core = { path = "../fidget-spinner-core" } fidget-spinner-store-sqlite = { path = "../fidget-spinner-store-sqlite" } +libmcp = { path = "../../../libmcp/crates/libmcp" } serde.workspace = true serde_json.workspace = true time.workspace = true diff --git a/crates/fidget-spinner-cli/src/mcp/catalog.rs b/crates/fidget-spinner-cli/src/mcp/catalog.rs index 178b980..ec57a5c 100644 --- a/crates/fidget-spinner-cli/src/mcp/catalog.rs +++ b/crates/fidget-spinner-cli/src/mcp/catalog.rs @@ -1,5 +1,8 @@ +use libmcp::ReplayContract; use serde_json::{Value, json}; +use crate::mcp::output::with_render_property; + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DispatchTarget { Host, @@ -7,12 +10,6 @@ pub(crate) enum DispatchTarget { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ReplayContract { - SafeReplay, - NeverReplay, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct ToolSpec { pub name: &'static str, pub description: &'static str, @@ -32,7 +29,7 @@ impl ToolSpec { pub fn annotation_json(self) -> Value { json!({ "title": self.name, - "readOnlyHint": self.replay == ReplayContract::SafeReplay, + "readOnlyHint": self.replay == ReplayContract::Convergent, "destructiveHint": self.replay == ReplayContract::NeverReplay, "fidgetSpinner": { "dispatch": match self.dispatch { @@ -40,7 +37,8 @@ impl ToolSpec { DispatchTarget::Worker => "worker", }, "replayContract": match self.replay { - ReplayContract::SafeReplay => "safe_replay", + ReplayContract::Convergent => "convergent", + ReplayContract::ProbeRequired => "probe_required", ReplayContract::NeverReplay => "never_replay", }, } @@ -61,25 +59,25 @@ pub(crate) fn tool_spec(name: &str) -> Option<ToolSpec> { name: "project.status", description: "Read local project status, store paths, and git availability for the currently bound project.", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "project.schema" => Some(ToolSpec { name: "project.schema", description: "Read the project-local payload schema and field validation tiers.", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "frontier.list" => Some(ToolSpec { name: "frontier.list", description: "List frontiers for the current project.", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "frontier.status" => Some(ToolSpec { name: "frontier.status", description: "Read one frontier projection, including champion and active candidates.", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "frontier.init" => Some(ToolSpec { name: "frontier.init", @@ -103,13 +101,13 @@ pub(crate) fn tool_spec(name: &str) -> Option<ToolSpec> { name: "node.list", description: "List recent nodes. Archived nodes are hidden unless explicitly requested.", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "node.read" => Some(ToolSpec { name: "node.read", description: "Read one node including payload, diagnostics, and hidden annotations.", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "node.annotate" => Some(ToolSpec { name: "node.annotate", @@ -145,25 +143,25 @@ pub(crate) fn tool_spec(name: &str) -> Option<ToolSpec> { name: "skill.list", description: "List bundled skills shipped with this package.", dispatch: DispatchTarget::Host, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "skill.show" => Some(ToolSpec { name: "skill.show", description: "Return one bundled skill text shipped with this package. Defaults to `fidget-spinner` when name is omitted.", dispatch: DispatchTarget::Host, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "system.health" => Some(ToolSpec { name: "system.health", description: "Read MCP host health, session binding, worker generation, rollout state, and the last fault.", dispatch: DispatchTarget::Host, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "system.telemetry" => Some(ToolSpec { name: "system.telemetry", description: "Read aggregate request, retry, restart, and per-operation telemetry for this MCP session.", dispatch: DispatchTarget::Host, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), _ => None, } @@ -175,22 +173,22 @@ pub(crate) fn resource_spec(uri: &str) -> Option<ResourceSpec> { "fidget-spinner://project/config" => Some(ResourceSpec { uri: "fidget-spinner://project/config", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "fidget-spinner://project/schema" => Some(ResourceSpec { uri: "fidget-spinner://project/schema", dispatch: DispatchTarget::Worker, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "fidget-spinner://skill/fidget-spinner" => Some(ResourceSpec { uri: "fidget-spinner://skill/fidget-spinner", dispatch: DispatchTarget::Host, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), "fidget-spinner://skill/frontier-loop" => Some(ResourceSpec { uri: "fidget-spinner://skill/frontier-loop", dispatch: DispatchTarget::Host, - replay: ReplayContract::SafeReplay, + replay: ReplayContract::Convergent, }), _ => None, } @@ -225,7 +223,7 @@ pub(crate) fn tool_definitions() -> Vec<Value> { json!({ "name": spec.name, "description": spec.description, - "inputSchema": input_schema(spec.name), + "inputSchema": with_render_property(input_schema(spec.name)), "annotations": spec.annotation_json(), }) }) diff --git a/crates/fidget-spinner-cli/src/mcp/host/runtime.rs b/crates/fidget-spinner-cli/src/mcp/host/runtime.rs index dd75544..17c26c7 100644 --- a/crates/fidget-spinner-cli/src/mcp/host/runtime.rs +++ b/crates/fidget-spinner-cli/src/mcp/host/runtime.rs @@ -5,6 +5,10 @@ use std::path::PathBuf; use std::process::Command; use std::time::Instant; +use libmcp::{ + FramedMessage, HostSessionKernel, ReplayContract, RequestId, load_snapshot_file_from_env, + remove_snapshot_file, write_snapshot_file, +}; use serde::Serialize; use serde_json::{Value, json}; @@ -14,13 +18,13 @@ use super::{ process::{ProjectBinding, WorkerSupervisor}, }; use crate::mcp::catalog::{ - DispatchTarget, ReplayContract, list_resources, resource_spec, tool_definitions, tool_spec, + DispatchTarget, list_resources, resource_spec, tool_definitions, tool_spec, }; use crate::mcp::fault::{FaultKind, FaultRecord, FaultStage}; +use crate::mcp::output::split_render_mode; use crate::mcp::protocol::{ CRASH_ONCE_ENV, FORCE_ROLLOUT_ENV, HOST_STATE_ENV, HostRequestId, HostStateSeed, - PROTOCOL_VERSION, ProjectBindingSeed, SERVER_NAME, SessionSeed, WorkerOperation, - WorkerSpawnConfig, + PROTOCOL_VERSION, ProjectBindingSeed, SERVER_NAME, WorkerOperation, WorkerSpawnConfig, }; use crate::mcp::telemetry::{ BinaryHealth, BindingHealth, HealthSnapshot, InitializationHealth, ServerTelemetry, @@ -59,7 +63,7 @@ pub(crate) fn run_host( struct HostRuntime { config: HostConfig, binding: Option<ProjectBinding>, - session: SessionSeed, + session_kernel: HostSessionKernel, telemetry: ServerTelemetry, next_request_id: u64, worker: WorkerSupervisor, @@ -73,10 +77,13 @@ struct HostRuntime { impl HostRuntime { fn new(config: HostConfig) -> Result<Self, fidget_spinner_store_sqlite::StoreError> { - let restored = restore_host_state(); - let session = restored + let restored = restore_host_state()?; + let session_kernel = restored .as_ref() - .map_or_else(SessionSeed::default, |seed| seed.session.clone()); + .map(|seed| seed.session_kernel.clone().restore()) + .transpose() + .map_err(fidget_spinner_store_sqlite::StoreError::Io)? + .map_or_else(HostSessionKernel::cold, HostSessionKernel::from_restored); let telemetry = restored .as_ref() .map_or_else(ServerTelemetry::default, |seed| seed.telemetry.clone()); @@ -117,7 +124,7 @@ impl HostRuntime { Ok(Self { config: config.clone(), binding, - session, + session_kernel, telemetry, next_request_id, worker, @@ -131,8 +138,8 @@ impl HostRuntime { } fn handle_line(&mut self, line: &str) -> Option<Value> { - let message = match serde_json::from_str::<Value>(line) { - Ok(message) => message, + let frame = match FramedMessage::parse(line.as_bytes().to_vec()) { + Ok(frame) => frame, Err(error) => { return Some(jsonrpc_error( Value::Null, @@ -145,11 +152,12 @@ impl HostRuntime { )); } }; - self.handle_message(message) + self.handle_frame(frame) } - fn handle_message(&mut self, message: Value) -> Option<Value> { - let Some(object) = message.as_object() else { + fn handle_frame(&mut self, frame: FramedMessage) -> Option<Value> { + self.session_kernel.observe_client_frame(&frame); + let Some(object) = frame.value.as_object() else { return Some(jsonrpc_error( Value::Null, FaultRecord::new( @@ -168,7 +176,7 @@ impl HostRuntime { let started_at = Instant::now(); self.telemetry.record_request(&operation_key); - let response = match self.dispatch(method, params, id.clone()) { + let response = match self.dispatch(&frame, method, params, id.clone()) { Ok(Some(result)) => { self.telemetry .record_success(&operation_key, started_at.elapsed().as_millis()); @@ -206,29 +214,26 @@ impl HostRuntime { fn dispatch( &mut self, + request_frame: &FramedMessage, method: &str, params: Value, request_id: Option<Value>, ) -> Result<Option<Value>, FaultRecord> { match method { - "initialize" => { - self.session.initialize_params = Some(params.clone()); - self.session.initialized = false; - Ok(Some(json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { - "tools": { "listChanged": false }, - "resources": { "listChanged": false, "subscribe": false } - }, - "serverInfo": { - "name": SERVER_NAME, - "version": env!("CARGO_PKG_VERSION") - }, - "instructions": "The DAG is canonical truth. Frontier state is derived. Bind the session with project.bind before project-local DAG operations when the MCP is running unbound." - }))) - } + "initialize" => Ok(Some(json!({ + "protocolVersion": PROTOCOL_VERSION, + "capabilities": { + "tools": { "listChanged": false }, + "resources": { "listChanged": false, "subscribe": false } + }, + "serverInfo": { + "name": SERVER_NAME, + "version": env!("CARGO_PKG_VERSION") + }, + "instructions": "The DAG is canonical truth. Frontier state is derived. Bind the session with project.bind before project-local DAG operations when the MCP is running unbound." + }))), "notifications/initialized" => { - if self.session.initialize_params.is_none() { + if !self.seed_captured() { return Err(FaultRecord::new( FaultKind::NotInitialized, FaultStage::Host, @@ -236,7 +241,6 @@ impl HostRuntime { "received initialized notification before initialize", )); } - self.session.initialized = true; Ok(None) } "notifications/cancelled" => Ok(None), @@ -246,8 +250,14 @@ impl HostRuntime { match other { "tools/list" => Ok(Some(json!({ "tools": tool_definitions() }))), "resources/list" => Ok(Some(json!({ "resources": list_resources() }))), - "tools/call" => Ok(Some(self.dispatch_tool_call(params, request_id)?)), - "resources/read" => Ok(Some(self.dispatch_resource_read(params)?)), + "tools/call" => Ok(Some(self.dispatch_tool_call( + request_frame, + params, + request_id, + )?)), + "resources/read" => { + Ok(Some(self.dispatch_resource_read(request_frame, params)?)) + } _ => Err(FaultRecord::new( FaultKind::InvalidInput, FaultStage::Protocol, @@ -261,6 +271,7 @@ impl HostRuntime { fn dispatch_tool_call( &mut self, + request_frame: &FramedMessage, params: Value, _request_id: Option<Value>, ) -> Result<Value, FaultRecord> { @@ -275,11 +286,17 @@ impl HostRuntime { })?; match spec.dispatch { DispatchTarget::Host => self.handle_host_tool(&envelope.name, envelope.arguments), - DispatchTarget::Worker => self.dispatch_worker_tool(spec, envelope.arguments), + DispatchTarget::Worker => { + self.dispatch_worker_tool(request_frame, spec, envelope.arguments) + } } } - fn dispatch_resource_read(&mut self, params: Value) -> Result<Value, FaultRecord> { + fn dispatch_resource_read( + &mut self, + request_frame: &FramedMessage, + params: Value, + ) -> Result<Value, FaultRecord> { let args = deserialize::<ReadResourceArgs>(params, "resources/read")?; let spec = resource_spec(&args.uri).ok_or_else(|| { FaultRecord::new( @@ -292,6 +309,7 @@ impl HostRuntime { match spec.dispatch { DispatchTarget::Host => Ok(Self::handle_host_resource(spec.uri)), DispatchTarget::Worker => self.dispatch_worker_operation( + request_frame, format!("resources/read:{}", args.uri), spec.replay, WorkerOperation::ReadResource { uri: args.uri }, @@ -301,11 +319,13 @@ impl HostRuntime { fn dispatch_worker_tool( &mut self, + request_frame: &FramedMessage, spec: crate::mcp::catalog::ToolSpec, arguments: Value, ) -> Result<Value, FaultRecord> { let operation = format!("tools/call:{}", spec.name); self.dispatch_worker_operation( + request_frame, operation.clone(), spec.replay, WorkerOperation::CallTool { @@ -317,6 +337,7 @@ impl HostRuntime { fn dispatch_worker_operation( &mut self, + request_frame: &FramedMessage, operation: String, replay: ReplayContract, worker_operation: WorkerOperation, @@ -328,21 +349,34 @@ impl HostRuntime { self.worker.arm_crash_once(); } + self.session_kernel + .record_forwarded_request(request_frame, replay); + let forwarded_request_id = request_id_from_frame(request_frame); let request_id = self.allocate_request_id(); match self.worker.execute(request_id, worker_operation.clone()) { - Ok(result) => Ok(result), + Ok(result) => { + self.complete_forwarded_request(forwarded_request_id.as_ref()); + Ok(result) + } Err(fault) => { - if replay == ReplayContract::SafeReplay && fault.retryable { + if replay == ReplayContract::Convergent && fault.retryable { self.telemetry.record_retry(&operation); self.telemetry.record_worker_restart(); self.worker .restart() .map_err(|restart_fault| restart_fault.mark_retried())?; match self.worker.execute(request_id, worker_operation) { - Ok(result) => Ok(result), - Err(retry_fault) => Err(retry_fault.mark_retried()), + Ok(result) => { + self.complete_forwarded_request(forwarded_request_id.as_ref()); + Ok(result) + } + Err(retry_fault) => { + self.complete_forwarded_request(forwarded_request_id.as_ref()); + Err(retry_fault.mark_retried()) + } } } else { + self.complete_forwarded_request(forwarded_request_id.as_ref()); Err(fault) } } @@ -350,6 +384,8 @@ impl HostRuntime { } fn handle_host_tool(&mut self, name: &str, arguments: Value) -> Result<Value, FaultRecord> { + let operation = format!("tools/call:{name}"); + let (render, arguments) = split_render_mode(arguments, &operation, FaultStage::Host)?; match name { "project.bind" => { let args = deserialize::<ProjectBindArgs>(arguments, "tools/call:project.bind")?; @@ -357,11 +393,14 @@ impl HostRuntime { .map_err(host_store_fault("tools/call:project.bind"))?; self.worker.rebind(resolved.binding.project_root.clone()); self.binding = Some(resolved.binding); - tool_success(&resolved.status) + tool_success(&resolved.status, render) } - "skill.list" => tool_success(&json!({ - "skills": crate::bundled_skill::bundled_skill_summaries(), - })), + "skill.list" => tool_success( + &json!({ + "skills": crate::bundled_skill::bundled_skill_summaries(), + }), + render, + ), "skill.show" => { let args = deserialize::<SkillShowArgs>(arguments, "tools/call:skill.show")?; let skill = args.name.as_deref().map_or_else( @@ -377,31 +416,37 @@ impl HostRuntime { }) }, )?; - tool_success(&json!({ - "name": skill.name, - "description": skill.description, - "resource_uri": skill.resource_uri, - "body": skill.body, - })) + tool_success( + &json!({ + "name": skill.name, + "description": skill.description, + "resource_uri": skill.resource_uri, + "body": skill.body, + }), + render, + ) } - "system.health" => tool_success(&HealthSnapshot { - initialization: InitializationHealth { - ready: self.session.initialized, - seed_captured: self.session.initialize_params.is_some(), - }, - binding: binding_health(self.binding.as_ref()), - worker: WorkerHealth { - worker_generation: self.worker.generation(), - alive: self.worker.is_alive(), - }, - binary: BinaryHealth { - current_executable: self.binary.path.display().to_string(), - launch_path_stable: self.binary.launch_path_stable, - rollout_pending: self.binary.rollout_pending().unwrap_or(false), + "system.health" => tool_success( + &HealthSnapshot { + initialization: InitializationHealth { + ready: self.session_initialized(), + seed_captured: self.seed_captured(), + }, + binding: binding_health(self.binding.as_ref()), + worker: WorkerHealth { + worker_generation: self.worker.generation(), + alive: self.worker.is_alive(), + }, + binary: BinaryHealth { + current_executable: self.binary.path.display().to_string(), + launch_path_stable: self.binary.launch_path_stable, + rollout_pending: self.binary.rollout_pending().unwrap_or(false), + }, + last_fault: self.telemetry.last_fault.clone(), }, - last_fault: self.telemetry.last_fault.clone(), - }), - "system.telemetry" => tool_success(&self.telemetry), + render, + ), + "system.telemetry" => tool_success(&self.telemetry, render), other => Err(FaultRecord::new( FaultKind::InvalidInput, FaultStage::Host, @@ -425,7 +470,7 @@ impl HostRuntime { } fn require_initialized(&self, operation: &str) -> Result<(), FaultRecord> { - if self.session.initialized { + if self.session_initialized() { return Ok(()); } Err(FaultRecord::new( @@ -447,6 +492,22 @@ impl HostRuntime { }) } + fn session_initialized(&self) -> bool { + self.session_kernel + .initialization_seed() + .is_some_and(|seed| seed.initialized_notification.is_some()) + } + + fn seed_captured(&self) -> bool { + self.session_kernel.initialization_seed().is_some() + } + + fn complete_forwarded_request(&mut self, request_id: Option<&RequestId>) { + if let Some(request_id) = request_id { + let _ = self.session_kernel.take_completed_request(request_id); + } + } + fn allocate_request_id(&mut self) -> HostRequestId { let id = HostRequestId(self.next_request_id); self.next_request_id += 1; @@ -466,7 +527,7 @@ impl HostRuntime { fn roll_forward(&mut self) -> Result<(), fidget_spinner_store_sqlite::StoreError> { let state = HostStateSeed { - session: self.session.clone(), + session_kernel: self.session_kernel.snapshot(), telemetry: self.telemetry.clone(), next_request_id: self.next_request_id, binding: self.binding.clone().map(ProjectBindingSeed::from), @@ -474,20 +535,23 @@ impl HostRuntime { force_rollout_consumed: self.force_rollout_consumed, crash_once_consumed: self.crash_once_consumed, }; - let serialized = serde_json::to_string(&state)?; + let state_path = write_snapshot_file("fidget-spinner-mcp-host-reexec", &state) + .map_err(fidget_spinner_store_sqlite::StoreError::Io)?; let mut command = Command::new(&self.binary.path); let _ = command.arg("mcp").arg("serve"); if let Some(project) = self.config.initial_project.as_ref() { let _ = command.arg("--project").arg(project); } - let _ = command.env(HOST_STATE_ENV, serialized); + let _ = command.env(HOST_STATE_ENV, &state_path); #[cfg(unix)] { let error = command.exec(); + let _removed = remove_snapshot_file(&state_path); Err(fidget_spinner_store_sqlite::StoreError::Io(error)) } #[cfg(not(unix))] { + let _removed = remove_snapshot_file(&state_path); return Err(fidget_spinner_store_sqlite::StoreError::Io(io::Error::new( io::ErrorKind::Unsupported, "host rollout requires unix exec support", @@ -605,9 +669,8 @@ impl From<ProjectBinding> for ProjectBindingSeed { } } -fn restore_host_state() -> Option<HostStateSeed> { - let raw = std::env::var(HOST_STATE_ENV).ok()?; - serde_json::from_str::<HostStateSeed>(&raw).ok() +fn restore_host_state() -> Result<Option<HostStateSeed>, fidget_spinner_store_sqlite::StoreError> { + load_snapshot_file_from_env(HOST_STATE_ENV).map_err(fidget_spinner_store_sqlite::StoreError::Io) } fn deserialize<T: for<'de> serde::Deserialize<'de>>( @@ -638,19 +701,17 @@ fn operation_key(method: &str, params: &Value) -> String { } } -fn tool_success(value: &impl Serialize) -> Result<Value, FaultRecord> { - Ok(json!({ - "content": [{ - "type": "text", - "text": crate::to_pretty_json(value).map_err(|error| { - FaultRecord::new(FaultKind::Internal, FaultStage::Host, "tool_success", error.to_string()) - })?, - }], - "structuredContent": serde_json::to_value(value).map_err(|error| { - FaultRecord::new(FaultKind::Internal, FaultStage::Host, "tool_success", error.to_string()) - })?, - "isError": false, - })) +fn request_id_from_frame(frame: &FramedMessage) -> Option<RequestId> { + match frame.classify() { + libmcp::RpcEnvelopeKind::Request { id, .. } => Some(id), + libmcp::RpcEnvelopeKind::Notification { .. } + | libmcp::RpcEnvelopeKind::Response { .. } + | libmcp::RpcEnvelopeKind::Unknown => None, + } +} + +fn tool_success(value: &impl Serialize, render: libmcp::RenderMode) -> Result<Value, FaultRecord> { + crate::mcp::output::tool_success(value, render, FaultStage::Host, "tool_success") } fn host_store_fault( diff --git a/crates/fidget-spinner-cli/src/mcp/mod.rs b/crates/fidget-spinner-cli/src/mcp/mod.rs index adea066..d219e96 100644 --- a/crates/fidget-spinner-cli/src/mcp/mod.rs +++ b/crates/fidget-spinner-cli/src/mcp/mod.rs @@ -1,6 +1,7 @@ mod catalog; mod fault; mod host; +mod output; mod protocol; mod service; mod telemetry; diff --git a/crates/fidget-spinner-cli/src/mcp/output.rs b/crates/fidget-spinner-cli/src/mcp/output.rs new file mode 100644 index 0000000..58f7eb4 --- /dev/null +++ b/crates/fidget-spinner-cli/src/mcp/output.rs @@ -0,0 +1,88 @@ +use libmcp::{JsonPorcelainConfig, RenderMode, render_json_porcelain}; +use serde::Serialize; +use serde_json::{Map, Value, json}; + +use crate::mcp::fault::{FaultKind, FaultRecord, FaultStage}; + +pub(crate) fn split_render_mode( + arguments: Value, + operation: &str, + stage: FaultStage, +) -> Result<(RenderMode, Value), FaultRecord> { + let Value::Object(mut object) = arguments else { + return Ok((RenderMode::Porcelain, arguments)); + }; + let render = object + .remove("render") + .map(|value| { + serde_json::from_value::<RenderMode>(value).map_err(|error| { + FaultRecord::new( + FaultKind::InvalidInput, + stage, + operation, + format!("invalid render mode: {error}"), + ) + }) + }) + .transpose()? + .unwrap_or(RenderMode::Porcelain); + Ok((render, Value::Object(object))) +} + +pub(crate) fn tool_success( + value: &impl Serialize, + render: RenderMode, + stage: FaultStage, + operation: &str, +) -> Result<Value, FaultRecord> { + let structured = serde_json::to_value(value).map_err(|error| { + FaultRecord::new(FaultKind::Internal, stage, operation, error.to_string()) + })?; + tool_success_from_value(structured, render, stage, operation) +} + +pub(crate) fn tool_success_from_value( + structured: Value, + render: RenderMode, + stage: FaultStage, + operation: &str, +) -> Result<Value, FaultRecord> { + let text = match render { + RenderMode::Porcelain => render_json_porcelain(&structured, JsonPorcelainConfig::default()), + RenderMode::Json => crate::to_pretty_json(&structured).map_err(|error| { + FaultRecord::new(FaultKind::Internal, stage, operation, error.to_string()) + })?, + }; + Ok(json!({ + "content": [{ + "type": "text", + "text": text, + }], + "structuredContent": structured, + "isError": false, + })) +} + +pub(crate) fn with_render_property(schema: Value) -> Value { + let Value::Object(mut object) = schema else { + return schema; + }; + + let properties = object + .entry("properties".to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + if let Value::Object(properties) = properties { + let _ = properties.insert( + "render".to_owned(), + json!({ + "type": "string", + "enum": ["porcelain", "json"], + "description": "Output mode. Defaults to porcelain for model-friendly summaries." + }), + ); + } + let _ = object + .entry("additionalProperties".to_owned()) + .or_insert(Value::Bool(false)); + Value::Object(object) +} diff --git a/crates/fidget-spinner-cli/src/mcp/protocol.rs b/crates/fidget-spinner-cli/src/mcp/protocol.rs index 1f24f37..f48d881 100644 --- a/crates/fidget-spinner-cli/src/mcp/protocol.rs +++ b/crates/fidget-spinner-cli/src/mcp/protocol.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use libmcp::HostSessionKernelSnapshot; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -14,15 +15,9 @@ pub(crate) const TRANSIENT_ONCE_ENV: &str = "FIDGET_SPINNER_MCP_TEST_WORKER_TRAN pub(crate) const TRANSIENT_ONCE_MARKER_ENV: &str = "FIDGET_SPINNER_MCP_TEST_WORKER_TRANSIENT_ONCE_MARKER"; -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] -pub(crate) struct SessionSeed { - pub initialize_params: Option<Value>, - pub initialized: bool, -} - -#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, Serialize)] pub(crate) struct HostStateSeed { - pub session: SessionSeed, + pub session_kernel: HostSessionKernelSnapshot, pub telemetry: ServerTelemetry, pub next_request_id: u64, pub binding: Option<ProjectBindingSeed>, diff --git a/crates/fidget-spinner-cli/src/mcp/service.rs b/crates/fidget-spinner-cli/src/mcp/service.rs index a7cae10..6b9c5da 100644 --- a/crates/fidget-spinner-cli/src/mcp/service.rs +++ b/crates/fidget-spinner-cli/src/mcp/service.rs @@ -11,10 +11,12 @@ use fidget_spinner_store_sqlite::{ CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, EdgeAttachment, EdgeAttachmentDirection, ListNodesQuery, ProjectStore, StoreError, }; +use libmcp::RenderMode; use serde::Deserialize; use serde_json::{Map, Value, json}; use crate::mcp::fault::{FaultKind, FaultRecord, FaultStage}; +use crate::mcp::output::split_render_mode; use crate::mcp::protocol::{TRANSIENT_ONCE_ENV, TRANSIENT_ONCE_MARKER_ENV, WorkerOperation}; pub(crate) struct WorkerService { @@ -42,22 +44,28 @@ impl WorkerService { } fn call_tool(&mut self, name: &str, arguments: Value) -> Result<Value, FaultRecord> { + let operation = format!("tools/call:{name}"); + let (render, arguments) = split_render_mode(arguments, &operation, FaultStage::Worker)?; match name { - "project.status" => tool_success(&json!({ - "project_root": self.store.project_root(), - "state_root": self.store.state_root(), - "display_name": self.store.config().display_name, - "schema": self.store.schema().schema_ref(), - "git_repo_detected": crate::run_git(self.store.project_root(), &["rev-parse", "--show-toplevel"]) - .map_err(store_fault("tools/call:project.status"))? - .is_some(), - })), - "project.schema" => tool_success(self.store.schema()), + "project.status" => tool_success( + &json!({ + "project_root": self.store.project_root(), + "state_root": self.store.state_root(), + "display_name": self.store.config().display_name, + "schema": self.store.schema().schema_ref(), + "git_repo_detected": crate::run_git(self.store.project_root(), &["rev-parse", "--show-toplevel"]) + .map_err(store_fault("tools/call:project.status"))? + .is_some(), + }), + render, + ), + "project.schema" => tool_success(self.store.schema(), render), "frontier.list" => tool_success( &self .store .list_frontiers() .map_err(store_fault("tools/call:frontier.list"))?, + render, ), "frontier.status" => { let args = deserialize::<FrontierStatusToolArgs>(arguments)?; @@ -69,6 +77,7 @@ impl WorkerService { .map_err(store_fault("tools/call:frontier.status"))?, ) .map_err(store_fault("tools/call:frontier.status"))?, + render, ) } "frontier.init" => { @@ -124,7 +133,7 @@ impl WorkerService { initial_checkpoint, }) .map_err(store_fault("tools/call:frontier.init"))?; - tool_success(&projection) + tool_success(&projection, render) } "node.create" => { let args = deserialize::<NodeCreateToolArgs>(arguments)?; @@ -156,7 +165,7 @@ impl WorkerService { .map_err(store_fault("tools/call:node.create"))?, }) .map_err(store_fault("tools/call:node.create"))?; - tool_success(&node) + tool_success(&node, render) } "change.record" => { let args = deserialize::<ChangeRecordToolArgs>(arguments)?; @@ -197,7 +206,7 @@ impl WorkerService { .map_err(store_fault("tools/call:change.record"))?, }) .map_err(store_fault("tools/call:change.record"))?; - tool_success(&node) + tool_success(&node, render) } "node.list" => { let args = deserialize::<NodeListToolArgs>(arguments)?; @@ -220,7 +229,7 @@ impl WorkerService { limit: args.limit.unwrap_or(20), }) .map_err(store_fault("tools/call:node.list"))?; - tool_success(&nodes) + tool_success(&nodes, render) } "node.read" => { let args = deserialize::<NodeReadToolArgs>(arguments)?; @@ -238,7 +247,7 @@ impl WorkerService { format!("node {node_id} was not found"), ) })?; - tool_success(&node) + tool_success(&node, render) } "node.annotate" => { let args = deserialize::<NodeAnnotateToolArgs>(arguments)?; @@ -265,7 +274,7 @@ impl WorkerService { annotation, ) .map_err(store_fault("tools/call:node.annotate"))?; - tool_success(&json!({"annotated": args.node_id})) + tool_success(&json!({"annotated": args.node_id}), render) } "node.archive" => { let args = deserialize::<NodeArchiveToolArgs>(arguments)?; @@ -275,7 +284,7 @@ impl WorkerService { .map_err(store_fault("tools/call:node.archive"))?, ) .map_err(store_fault("tools/call:node.archive"))?; - tool_success(&json!({"archived": args.node_id})) + tool_success(&json!({"archived": args.node_id}), render) } "note.quick" => { let args = deserialize::<QuickNoteToolArgs>(arguments)?; @@ -303,7 +312,7 @@ impl WorkerService { .map_err(store_fault("tools/call:note.quick"))?, }) .map_err(store_fault("tools/call:note.quick"))?; - tool_success(&node) + tool_success(&node, render) } "research.record" => { let args = deserialize::<ResearchRecordToolArgs>(arguments)?; @@ -335,7 +344,7 @@ impl WorkerService { .map_err(store_fault("tools/call:research.record"))?, }) .map_err(store_fault("tools/call:research.record"))?; - tool_success(&node) + tool_success(&node, render) } "experiment.close" => { let args = deserialize::<ExperimentCloseToolArgs>(arguments)?; @@ -420,7 +429,7 @@ impl WorkerService { .map_err(store_fault("tools/call:experiment.close"))?, }) .map_err(store_fault("tools/call:experiment.close"))?; - tool_success(&receipt) + tool_success(&receipt, render) } other => Err(FaultRecord::new( FaultKind::InvalidInput, @@ -501,16 +510,8 @@ fn deserialize<T: for<'de> Deserialize<'de>>(value: Value) -> Result<T, FaultRec }) } -fn tool_success(value: &impl serde::Serialize) -> Result<Value, FaultRecord> { - Ok(json!({ - "content": [{ - "type": "text", - "text": crate::to_pretty_json(value).map_err(store_fault("worker.tool_success"))?, - }], - "structuredContent": serde_json::to_value(value) - .map_err(store_fault("worker.tool_success"))?, - "isError": false, - })) +fn tool_success(value: &impl serde::Serialize, render: RenderMode) -> Result<Value, FaultRecord> { + crate::mcp::output::tool_success(value, render, FaultStage::Worker, "worker.tool_success") } fn store_fault<E>(operation: &'static str) -> impl FnOnce(E) -> FaultRecord diff --git a/crates/fidget-spinner-cli/tests/mcp_hardening.rs b/crates/fidget-spinner-cli/tests/mcp_hardening.rs index 8d3cd9d..1c70562 100644 --- a/crates/fidget-spinner-cli/tests/mcp_hardening.rs +++ b/crates/fidget-spinner-cli/tests/mcp_hardening.rs @@ -8,6 +8,7 @@ use clap as _; use dirs as _; use fidget_spinner_core::NonEmptyText; use fidget_spinner_store_sqlite::{ListNodesQuery, ProjectStore}; +use libmcp as _; use serde as _; use serde_json::{Value, json}; use time as _; @@ -160,6 +161,13 @@ fn tool_content(response: &Value) -> &Value { &response["result"]["structuredContent"] } +fn tool_text(response: &Value) -> Option<&str> { + response["result"]["content"] + .as_array() + .and_then(|content| content.first()) + .and_then(|entry| entry["text"].as_str()) +} + #[test] fn cold_start_exposes_health_and_telemetry() -> TestResult { let project_root = temp_project_root("cold_start")?; @@ -214,6 +222,29 @@ fn cold_start_exposes_health_and_telemetry() -> TestResult { } #[test] +fn tool_output_defaults_to_porcelain_and_supports_json_render() -> TestResult { + let project_root = temp_project_root("render_modes")?; + init_project(&project_root)?; + + let mut harness = McpHarness::spawn(None, &[])?; + let _ = harness.initialize()?; + harness.notify_initialized()?; + let bind = harness.bind_project(21, &project_root)?; + assert_eq!(bind["result"]["isError"].as_bool(), Some(false)); + + let porcelain = harness.call_tool(22, "project.status", json!({}))?; + let porcelain_text = must_some(tool_text(&porcelain), "porcelain project.status text")?; + assert!(porcelain_text.contains("project_root:")); + assert!(!porcelain_text.contains("\"project_root\":")); + + let json_render = harness.call_tool(23, "project.status", json!({"render": "json"}))?; + let json_text = must_some(tool_text(&json_render), "json project.status text")?; + assert!(json_text.contains("\"project_root\":")); + assert!(json_text.trim_start().starts_with('{')); + Ok(()) +} + +#[test] fn safe_request_retries_after_worker_crash() -> TestResult { let project_root = temp_project_root("crash_retry")?; init_project(&project_root)?; |