swarm repositories / source
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock380
-rw-r--r--crates/fidget-spinner-cli/Cargo.toml1
-rw-r--r--crates/fidget-spinner-cli/src/mcp/catalog.rs44
-rw-r--r--crates/fidget-spinner-cli/src/mcp/host/runtime.rs235
-rw-r--r--crates/fidget-spinner-cli/src/mcp/mod.rs1
-rw-r--r--crates/fidget-spinner-cli/src/mcp/output.rs88
-rw-r--r--crates/fidget-spinner-cli/src/mcp/protocol.rs11
-rw-r--r--crates/fidget-spinner-cli/src/mcp/service.rs61
-rw-r--r--crates/fidget-spinner-cli/tests/mcp_hardening.rs31
9 files changed, 704 insertions, 148 deletions
diff --git a/Cargo.lock b/Cargo.lock
index aa09bdb..1dc802c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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)?;