swarm repositories / source
aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authormain <main@swarm.moe>2026-03-19 10:17:07 -0400
committermain <main@swarm.moe>2026-03-19 10:17:07 -0400
commit08a1139eaa7a4862ab8c0e5fb5fc6845fc711208 (patch)
treeded498d30e1d84c17b3e6dbf80594b5b62faa804
downloadlibmcp-08a1139eaa7a4862ab8c0e5fb5fc6845fc711208.zip
Initial libmcp 1.0.0
-rw-r--r--.gitignore1
-rw-r--r--CHANGELOG.md26
-rw-r--r--Cargo.lock850
-rw-r--r--Cargo.toml113
-rw-r--r--README.md34
-rw-r--r--assets/codex-skills/mcp-bootstrap/SKILL.md60
-rw-r--r--assets/codex-skills/mcp-bootstrap/references/bootstrap-fresh.md85
-rw-r--r--assets/codex-skills/mcp-bootstrap/references/bootstrap-retrofit.md27
-rw-r--r--assets/codex-skills/mcp-bootstrap/references/checklist.md15
-rw-r--r--check.py64
-rw-r--r--crates/libmcp-testkit/Cargo.toml15
-rw-r--r--crates/libmcp-testkit/src/lib.rs32
-rw-r--r--crates/libmcp/Cargo.toml22
-rw-r--r--crates/libmcp/src/fault.rs118
-rw-r--r--crates/libmcp/src/health.rs116
-rw-r--r--crates/libmcp/src/jsonrpc.rs337
-rw-r--r--crates/libmcp/src/lib.rs28
-rw-r--r--crates/libmcp/src/normalize.rs133
-rw-r--r--crates/libmcp/src/render.rs138
-rw-r--r--crates/libmcp/src/replay.rs16
-rw-r--r--crates/libmcp/src/telemetry.rs299
-rw-r--r--crates/libmcp/src/types.rs50
-rw-r--r--docs/spec.md235
-rw-r--r--rust-toolchain.toml3
-rwxr-xr-xscripts/link-codex-skills18
25 files changed, 2835 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..8e0ed4f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,26 @@
+# Changelog
+
+## 1.0.0
+
+Initial stable release.
+
+This release establishes `libmcp` as the reusable operational spine for
+hardened MCP servers and the canonical owner of `$mcp-bootstrap`.
+
+Included in `1.0.0`:
+
+- replay contract vocabulary
+- typed fault model
+- JSON-RPC request/frame helpers
+- base health and telemetry payloads
+- append-only JSONL telemetry support
+- model-facing render and normalization helpers
+- versioned `$mcp-bootstrap` skill collateral
+- proof by integration into `adequate_rust_mcp`
+
+Explicitly excluded from `1.0.0`:
+
+- `fidget_spinner`
+- forced runtime adapter crates
+- backend-specific warm-up or routing policy beyond what the first consumer
+ still keeps locally
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..5a5bff3
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,850 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "bitflags"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[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"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+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.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+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"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "leb128fmt"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
+
+[[package]]
+name = "libc"
+version = "0.2.183"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
+
+[[package]]
+name = "libmcp"
+version = "1.0.0"
+dependencies = [
+ "schemars",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "libmcp-testkit"
+version = "1.0.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[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"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[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 = "potential_utf"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[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 = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[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"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+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 = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[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 = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+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 = "wasip2"
+version = "1.0.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+dependencies = [
+ "wit-bindgen-rust-macro",
+]
+
+[[package]]
+name = "wit-bindgen-core"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
+dependencies = [
+ "anyhow",
+ "heck",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-bindgen-rust"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
+dependencies = [
+ "anyhow",
+ "heck",
+ "indexmap",
+ "prettyplease",
+ "syn",
+ "wasm-metadata",
+ "wit-bindgen-core",
+ "wit-component",
+]
+
+[[package]]
+name = "wit-bindgen-rust-macro"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
+dependencies = [
+ "anyhow",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wit-bindgen-core",
+ "wit-bindgen-rust",
+]
+
+[[package]]
+name = "wit-component"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
+dependencies = [
+ "anyhow",
+ "bitflags",
+ "indexmap",
+ "log",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "wasm-encoder",
+ "wasm-metadata",
+ "wasmparser",
+ "wit-parser",
+]
+
+[[package]]
+name = "wit-parser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
+dependencies = [
+ "anyhow",
+ "id-arena",
+ "indexmap",
+ "log",
+ "semver",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "unicode-xid",
+ "wasmparser",
+]
+
+[[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"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..51c9699
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,113 @@
+[workspace]
+members = ["crates/libmcp", "crates/libmcp-testkit"]
+resolver = "3"
+
+[workspace.package]
+categories = ["development-tools", "command-line-utilities"]
+description = "Industrial MCP hardening spine with host/worker recovery, model-facing rendering doctrine, and operational telemetry."
+edition = "2024"
+keywords = ["mcp", "tooling", "ai", "json-rpc", "operations"]
+license = "Apache-2.0"
+readme = "README.md"
+repository = "https://github.com/example/libmcp"
+rust-version = "1.94"
+version = "1.0.0"
+
+[workspace.dependencies]
+assert_matches = "1.5.0"
+schemars = "1.1.0"
+serde = { version = "1.0.228", features = ["derive"] }
+serde_json = "1.0.145"
+tempfile = "3.23.0"
+thiserror = "2.0.17"
+tokio = { version = "1.48.0", features = ["io-util", "macros", "rt", "rt-multi-thread", "sync", "time"] }
+url = "2.5.7"
+
+[workspace.lints.rust]
+elided_lifetimes_in_paths = "deny"
+missing_docs = "deny"
+unexpected_cfgs = "deny"
+unsafe_code = "deny"
+unused_crate_dependencies = "warn"
+unused_lifetimes = "deny"
+unused_qualifications = "deny"
+unused_results = "deny"
+
+[workspace.lints.rustdoc]
+bare_urls = "deny"
+broken_intra_doc_links = "deny"
+
+[workspace.lints.clippy]
+all = { level = "deny", priority = -2 }
+pedantic = { level = "deny", priority = -1 }
+cargo = { level = "warn", priority = -3 }
+
+dbg_macro = "deny"
+expect_used = "deny"
+panic = "deny"
+todo = "deny"
+unimplemented = "deny"
+unwrap_used = "deny"
+allow_attributes_without_reason = "deny"
+
+cargo_common_metadata = "allow"
+missing_errors_doc = "allow"
+missing_panics_doc = "allow"
+multiple_crate_versions = "allow"
+
+items_after_statements = "allow"
+many_single_char_names = "allow"
+match_same_arms = "allow"
+module_name_repetitions = "allow"
+similar_names = "allow"
+struct_field_names = "allow"
+too_many_arguments = "allow"
+too_many_lines = "allow"
+unnested_or_patterns = "allow"
+
+cast_lossless = "allow"
+cast_possible_truncation = "allow"
+cast_possible_wrap = "allow"
+cast_precision_loss = "allow"
+cast_sign_loss = "allow"
+float_cmp = "allow"
+implicit_hasher = "allow"
+manual_let_else = "allow"
+map_unwrap_or = "allow"
+uninlined_format_args = "allow"
+
+ignored_unit_patterns = "allow"
+must_use_candidate = "allow"
+needless_pass_by_value = "allow"
+no_effect_underscore_binding = "allow"
+redundant_closure_for_method_calls = "allow"
+ref_option = "allow"
+return_self_not_must_use = "allow"
+trivially_copy_pass_by_ref = "allow"
+unused_async = "allow"
+used_underscore_binding = "allow"
+
+[workspace.metadata.rust-starter]
+format_command = ["cargo", "fmt", "--all", "--check"]
+clippy_command = [
+ "cargo",
+ "clippy",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ "--",
+ "-D",
+ "warnings",
+]
+test_command = ["cargo", "test", "--workspace", "--all-targets", "--all-features"]
+doc_command = ["cargo", "doc", "--workspace", "--all-features", "--no-deps"]
+fix_command = [
+ "cargo",
+ "clippy",
+ "--fix",
+ "--workspace",
+ "--all-targets",
+ "--all-features",
+ "--allow-dirty",
+ "--allow-staged",
+]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0a857e9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,34 @@
+# libmcp
+
+Industrial MCP hardening spine.
+
+`libmcp` is the shared operational substrate extracted from long-lived MCP
+servers. It owns:
+
+- typed replay and fault contracts
+- JSON-RPC frame and request identity helpers
+- model-facing rendering doctrine, especially porcelain-by-default output
+- normalization utilities for model input friction
+- standard health and telemetry payloads
+- JSONL operational telemetry
+- hardening test support
+
+This repository is also the canonical owner of the `$mcp-bootstrap` Codex
+skill. The installed skill should be a symlink into this repository so the skill
+version tracks the library version and doctrine.
+
+## Status
+
+`libmcp` `1.0.0` is locked against a clean integration with
+`adequate_rust_mcp`.
+
+`fidget_spinner` is intentionally not part of `1.0.0`; it will be revisited
+later once its transport shape is settled.
+
+## Layout
+
+- `docs/spec.md`: normative design and versioning contract
+- `crates/libmcp`: public library crate
+- `crates/libmcp-testkit`: shared hardening fixtures and assertions
+- `assets/codex-skills/mcp-bootstrap`: canonical skill source
+- `scripts/link-codex-skills`: installs the repo-owned skill into `~/.codex`
diff --git a/assets/codex-skills/mcp-bootstrap/SKILL.md b/assets/codex-skills/mcp-bootstrap/SKILL.md
new file mode 100644
index 0000000..19d59d9
--- /dev/null
+++ b/assets/codex-skills/mcp-bootstrap/SKILL.md
@@ -0,0 +1,60 @@
+---
+name: mcp-bootstrap
+description: Bootstrap or retrofit an industrial-grade MCP server with libmcp's host/worker posture, replay contracts, typed faults, porcelain-by-default output, telemetry, and regression tests. Use when creating a new MCP, hardening an existing one, or reviewing an MCP for long-lived session safety, hot rollout, crash recovery, model UX, or worktree/workspace correctness.
+---
+
+# MCP Bootstrap
+
+`libmcp` is the source of truth for the hard posture of long-lived MCPs.
+
+Use this skill when:
+
+- bootstrapping a new MCP
+- retrofitting an existing MCP onto `libmcp`
+- reviewing an MCP for operational hardening or model UX doctrine
+
+Start by classifying the target:
+
+- Fresh bootstrap: the project can adopt the architecture directly.
+- Retrofit: the project already has live behavior or ad hoc recovery logic that
+ must be tightened deliberately.
+
+## Fresh Bootstrap
+
+Read:
+
+- [references/bootstrap-fresh.md](references/bootstrap-fresh.md)
+- [references/checklist.md](references/checklist.md)
+
+Default posture:
+
+- let a stable host own the public MCP transport and session
+- let disposable workers own fragile runtime dependencies
+- make replay legality explicit per request surface
+- ship health, telemetry, and recovery tests before feature sprawl
+- default nontrivial tool output to porcelain
+- keep structured JSON output available where exact consumers need it
+
+## Retrofit
+
+Read:
+
+- [references/bootstrap-retrofit.md](references/bootstrap-retrofit.md)
+- [references/checklist.md](references/checklist.md)
+
+Retrofit in order:
+
+- separate durable transport ownership from fragile execution
+- define typed faults and replay contracts before adding retries
+- adopt the model UX doctrine, especially porcelain-by-default output
+- add rollout, telemetry, and recovery tests before claiming stability
+
+## Guardrails
+
+- Prefer a host/worker split for any long-lived MCP.
+- Do not auto-retry or auto-replay side-effecting requests without an explicit
+ proof of safety.
+- Treat `$mcp-bootstrap` as versioned `libmcp` collateral, not independent lore.
+- Re-open the reference docs when details matter; do not rely on memory.
+
+This skill is intentionally thin. The reference docs are the payload.
diff --git a/assets/codex-skills/mcp-bootstrap/references/bootstrap-fresh.md b/assets/codex-skills/mcp-bootstrap/references/bootstrap-fresh.md
new file mode 100644
index 0000000..bddbc53
--- /dev/null
+++ b/assets/codex-skills/mcp-bootstrap/references/bootstrap-fresh.md
@@ -0,0 +1,85 @@
+# Fresh Bootstrap
+
+Use this when the MCP can still adopt the hard posture directly.
+
+## 1. Durable host, disposable worker
+
+Long-lived MCPs should separate durable session ownership from fragile backend
+execution.
+
+- The host owns the MCP transport, initialization state, request IDs, replay
+ policy, rollout, and user-facing error shaping.
+- The worker owns backend runtimes, backend-specific retries, and tool
+ execution.
+
+If the worker dies, the session should survive.
+
+## 2. Replay as a typed contract
+
+Every request surface needs an explicit replay class:
+
+- `Convergent`
+- `ProbeRequired`
+- `NeverReplay`
+
+Do not add blanket retry or replay logic. The replay class belongs in code, not
+in scattered comments.
+
+## 3. Typed faults
+
+Represent failures as operational faults with recovery semantics.
+
+Baseline classes:
+
+- transport
+- process
+- protocol
+- timeout
+- downstream response
+- resource
+
+Faults should flow through health, telemetry, and user-facing shaping.
+
+## 4. Porcelain by default
+
+Nontrivial tools should default to `render=porcelain`.
+
+Porcelain should be:
+
+- line-oriented
+- deterministic
+- bounded
+- summary-first
+
+Structured `render=json` should remain available.
+
+## 5. Boundary normalization
+
+Normalize model-facing input where it is clearly safe:
+
+- field aliases
+- integer-like strings
+- `file://` URIs
+- stable path style controls
+
+The goal is to eliminate trivial friction, not to hide real ambiguity.
+
+## 6. Health and telemetry
+
+Ship explicit operational tooling:
+
+- health snapshot
+- telemetry snapshot
+- append-only event telemetry
+
+Do this before feature sprawl, not after the first outage.
+
+## 7. Test the failure posture
+
+Build fake runtimes and integration tests that exercise:
+
+- crash recovery
+- replay legality
+- rollout or restart churn
+- model-facing output shaping
+- routing correctness where the backend is root-sensitive
diff --git a/assets/codex-skills/mcp-bootstrap/references/bootstrap-retrofit.md b/assets/codex-skills/mcp-bootstrap/references/bootstrap-retrofit.md
new file mode 100644
index 0000000..5a766a6
--- /dev/null
+++ b/assets/codex-skills/mcp-bootstrap/references/bootstrap-retrofit.md
@@ -0,0 +1,27 @@
+# Retrofit
+
+Use this when the MCP already exists and cannot simply be reimagined from
+scratch.
+
+## Retrofit Order
+
+1. Separate session ownership from fragile execution.
+2. Define typed replay contracts and typed faults.
+3. Replace ad hoc backend dumps with porcelain-by-default output.
+4. Add health, telemetry, and recovery tests.
+5. Only then promise hot rollout or stronger operational guarantees.
+
+## Specific Warnings
+
+- Do not add retries before replay legality is explicit.
+- Do not hide routing bugs behind warm-up masking.
+- Do not call a worker self-healing if the host itself cannot roll forward.
+- Do not let the canonical skill drift away from the actual library contract.
+
+## Doctrine
+
+The retrofit is complete only when:
+
+- the hard posture lives in code
+- the model UX doctrine is visible at the tool surface
+- the skill, spec, and implementation agree
diff --git a/assets/codex-skills/mcp-bootstrap/references/checklist.md b/assets/codex-skills/mcp-bootstrap/references/checklist.md
new file mode 100644
index 0000000..b20a836
--- /dev/null
+++ b/assets/codex-skills/mcp-bootstrap/references/checklist.md
@@ -0,0 +1,15 @@
+# Checklist
+
+Use this checklist when reviewing a `libmcp` consumer.
+
+- Does a stable host own the public session?
+- Is worker fragility isolated behind an explicit replay policy?
+- Are replay contracts typed and local to the request surface?
+- Are faults typed and connected to recovery semantics?
+- Do nontrivial tools default to porcelain output?
+- Is structured JSON still available where exact consumers need it?
+- Are inputs normalized where the semantics are still unambiguous?
+- Are health and telemetry available?
+- Is event telemetry append-only and useful for postmortem analysis?
+- Does the recovery test matrix cover the failure modes actually observed?
+- Is the installed `$mcp-bootstrap` skill sourced from this repository?
diff --git a/check.py b/check.py
new file mode 100644
index 0000000..8306924
--- /dev/null
+++ b/check.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+from __future__ import annotations
+
+import argparse
+import subprocess
+import tomllib
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent
+WORKSPACE_MANIFEST = ROOT / "Cargo.toml"
+
+
+def load_commands() -> dict[str, list[str]]:
+ workspace = tomllib.loads(WORKSPACE_MANIFEST.read_text(encoding="utf-8"))
+ metadata = workspace["workspace"]["metadata"]["rust-starter"]
+ commands: dict[str, list[str]] = {}
+ for key in ("format_command", "clippy_command", "test_command", "doc_command", "fix_command"):
+ value = metadata.get(key)
+ if isinstance(value, list) and value and all(isinstance(part, str) for part in value):
+ commands[key] = value
+ return commands
+
+
+def run(name: str, argv: list[str]) -> None:
+ print(f"[check] {name}: {' '.join(argv)}", flush=True)
+ proc = subprocess.run(argv, cwd=ROOT, check=False)
+ if proc.returncode != 0:
+ raise SystemExit(proc.returncode)
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description="Thin libmcp check runner")
+ parser.add_argument(
+ "mode",
+ nargs="?",
+ choices=("check", "deep", "fix"),
+ default="check",
+ help="Run the fast gate, include docs for the deep gate, or run the fix command.",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ commands = load_commands()
+ args = parse_args()
+
+ if args.mode == "fix":
+ run("fix", commands["fix_command"])
+ return
+
+ run("fmt", commands["format_command"])
+ run("clippy", commands["clippy_command"])
+ run("test", commands["test_command"])
+
+ if args.mode == "deep" and "doc_command" in commands:
+ run("doc", commands["doc_command"])
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except KeyboardInterrupt:
+ raise SystemExit(130)
diff --git a/crates/libmcp-testkit/Cargo.toml b/crates/libmcp-testkit/Cargo.toml
new file mode 100644
index 0000000..b8e68b1
--- /dev/null
+++ b/crates/libmcp-testkit/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "libmcp-testkit"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+description.workspace = true
+readme.workspace = true
+
+[dependencies]
+serde.workspace = true
+serde_json.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/libmcp-testkit/src/lib.rs b/crates/libmcp-testkit/src/lib.rs
new file mode 100644
index 0000000..9f33643
--- /dev/null
+++ b/crates/libmcp-testkit/src/lib.rs
@@ -0,0 +1,32 @@
+//! Shared test helpers for `libmcp` consumers.
+
+use serde::de::DeserializeOwned;
+use std::{
+ fs::File,
+ io::{self, BufRead, BufReader},
+ path::Path,
+};
+
+/// Reads an append-only JSONL file into typed records.
+pub fn read_json_lines<T>(path: &Path) -> io::Result<Vec<T>>
+where
+ T: DeserializeOwned,
+{
+ let file = File::open(path)?;
+ let reader = BufReader::new(file);
+ let mut records = Vec::new();
+ for line in reader.lines() {
+ let line = line?;
+ if line.trim().is_empty() {
+ continue;
+ }
+ let parsed = serde_json::from_str::<T>(line.as_str()).map_err(|error| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("invalid JSONL test record: {error}"),
+ )
+ })?;
+ records.push(parsed);
+ }
+ Ok(records)
+}
diff --git a/crates/libmcp/Cargo.toml b/crates/libmcp/Cargo.toml
new file mode 100644
index 0000000..02ea9db
--- /dev/null
+++ b/crates/libmcp/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "libmcp"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+description.workspace = true
+readme.workspace = true
+
+[dependencies]
+schemars.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+thiserror.workspace = true
+tokio.workspace = true
+url.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true
+
+[lints]
+workspace = true
diff --git a/crates/libmcp/src/fault.rs b/crates/libmcp/src/fault.rs
new file mode 100644
index 0000000..edbd05e
--- /dev/null
+++ b/crates/libmcp/src/fault.rs
@@ -0,0 +1,118 @@
+//! Fault taxonomy and recovery directives.
+
+use crate::types::Generation;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+/// Broad operational fault class.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum FaultClass {
+ /// Underlying transport or I/O failure.
+ Transport,
+ /// Process startup, liveness, or exit failure.
+ Process,
+ /// Protocol or framing failure.
+ Protocol,
+ /// Timeout or deadline failure.
+ Timeout,
+ /// Downstream service returned an error.
+ Downstream,
+ /// Resource budget or queue exhaustion.
+ Resource,
+ /// Replay or recovery budget exhaustion.
+ Replay,
+ /// Rollout or binary handoff failure.
+ Rollout,
+ /// Internal invariant breach.
+ Invariant,
+}
+
+/// Recovery directive for an operational fault.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum RecoveryDirective {
+ /// Retry on the current live process.
+ RetryInPlace,
+ /// Restart or roll forward, then replay if the replay contract allows it.
+ RestartAndReplay,
+ /// Abort the request and surface the failure.
+ AbortRequest,
+}
+
+/// A typed but extensible fault code.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
+#[serde(transparent)]
+pub struct FaultCode(String);
+
+impl FaultCode {
+ /// Constructs a new fault code.
+ ///
+ /// The code must be non-empty and use lowercase ASCII with underscores.
+ pub fn try_new(code: impl Into<String>) -> Result<Self, crate::types::InvariantViolation> {
+ let code = code.into();
+ if code.is_empty()
+ || !code
+ .bytes()
+ .all(|byte| byte.is_ascii_lowercase() || byte == b'_' || byte.is_ascii_digit())
+ {
+ return Err(crate::types::InvariantViolation::new(
+ "fault code must be non-empty lowercase ascii snake_case",
+ ));
+ }
+ Ok(Self(code))
+ }
+
+ /// Returns the code text.
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ self.0.as_str()
+ }
+}
+
+/// Structured operational fault.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct Fault {
+ /// Generation in which the fault happened.
+ pub generation: Generation,
+ /// Broad fault class.
+ pub class: FaultClass,
+ /// Consumer-defined fine-grained code.
+ pub code: FaultCode,
+ /// Recovery directive implied by this fault.
+ pub directive: RecoveryDirective,
+ /// Human-facing detail.
+ pub detail: String,
+}
+
+impl Fault {
+ /// Constructs a new fault.
+ #[must_use]
+ pub fn new(
+ generation: Generation,
+ class: FaultClass,
+ code: FaultCode,
+ directive: RecoveryDirective,
+ detail: impl Into<String>,
+ ) -> Self {
+ Self {
+ generation,
+ class,
+ code,
+ directive,
+ detail: detail.into(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::FaultCode;
+
+ #[test]
+ fn fault_code_rejects_non_snake_case() {
+ assert!(FaultCode::try_new("broken_pipe").is_ok());
+ assert!(FaultCode::try_new("BrokenPipe").is_err());
+ assert!(FaultCode::try_new("").is_err());
+ }
+}
diff --git a/crates/libmcp/src/health.rs b/crates/libmcp/src/health.rs
new file mode 100644
index 0000000..96c4b4a
--- /dev/null
+++ b/crates/libmcp/src/health.rs
@@ -0,0 +1,116 @@
+//! Standard health and telemetry payloads.
+
+use crate::{fault::Fault, types::Generation};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+/// Coarse lifecycle state of the live worker set.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum LifecycleState {
+ /// No worker is currently alive.
+ Cold,
+ /// Startup is in progress.
+ Starting,
+ /// A worker is healthy and serving.
+ Ready,
+ /// Recovery is in progress after a fault.
+ Recovering,
+}
+
+/// Rollout or reload state of the host.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum RolloutState {
+ /// No rollout is pending.
+ Stable,
+ /// A rollout has been detected but not yet executed.
+ Pending,
+ /// A rollout or reexec is in flight.
+ Reloading,
+}
+
+/// Base health snapshot for a hardened MCP host or worker.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct HealthSnapshot {
+ /// Current lifecycle state.
+ pub state: LifecycleState,
+ /// Current generation.
+ pub generation: Generation,
+ /// Process uptime in milliseconds.
+ pub uptime_ms: u64,
+ /// Consecutive failures since the last healthy request.
+ pub consecutive_failures: u32,
+ /// Total restart count.
+ pub restart_count: u64,
+ /// Rollout state when the runtime exposes it.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub rollout: Option<RolloutState>,
+ /// Most recent fault, if any.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_fault: Option<Fault>,
+}
+
+/// Aggregate request totals.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct TelemetryTotals {
+ /// Total requests observed.
+ pub request_count: u64,
+ /// Requests that completed successfully.
+ pub success_count: u64,
+ /// Requests that returned downstream response errors.
+ pub response_error_count: u64,
+ /// Requests that failed due to transport or process churn.
+ pub transport_fault_count: u64,
+ /// Requests retried by the runtime.
+ pub retry_count: u64,
+}
+
+/// Per-method telemetry aggregate.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct MethodTelemetry {
+ /// Method name.
+ pub method: String,
+ /// Total requests for this method.
+ pub request_count: u64,
+ /// Successful requests.
+ pub success_count: u64,
+ /// Response errors.
+ pub response_error_count: u64,
+ /// Transport/process faults.
+ pub transport_fault_count: u64,
+ /// Retry count.
+ pub retry_count: u64,
+ /// Most recent latency, if any.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_latency_ms: Option<u64>,
+ /// Maximum latency.
+ pub max_latency_ms: u64,
+ /// Average latency.
+ pub avg_latency_ms: u64,
+ /// Most recent error text, if any.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_error: Option<String>,
+}
+
+/// Base telemetry snapshot for a hardened MCP runtime.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+pub struct TelemetrySnapshot {
+ /// Process uptime in milliseconds.
+ pub uptime_ms: u64,
+ /// Current lifecycle state.
+ pub state: LifecycleState,
+ /// Current generation.
+ pub generation: Generation,
+ /// Consecutive failures since last clean success.
+ pub consecutive_failures: u32,
+ /// Total restart count.
+ pub restart_count: u64,
+ /// Aggregate totals.
+ pub totals: TelemetryTotals,
+ /// Per-method aggregates.
+ pub methods: Vec<MethodTelemetry>,
+ /// Most recent fault, if any.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub last_fault: Option<Fault>,
+}
diff --git a/crates/libmcp/src/jsonrpc.rs b/crates/libmcp/src/jsonrpc.rs
new file mode 100644
index 0000000..a54b243
--- /dev/null
+++ b/crates/libmcp/src/jsonrpc.rs
@@ -0,0 +1,337 @@
+//! Lightweight JSON-RPC frame helpers.
+
+use crate::normalize::normalize_ascii_token;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::io;
+use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
+use url::Url;
+
+/// JSON-RPC request identifier.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum RequestId {
+ /// Numeric identifier preserved as text for round-trip stability.
+ Number(String),
+ /// Text identifier.
+ Text(String),
+}
+
+impl RequestId {
+ /// Parses a request ID from JSON.
+ #[must_use]
+ pub fn from_json_value(value: &Value) -> Option<Self> {
+ match value {
+ Value::Number(number) => Some(Self::Number(number.to_string())),
+ Value::String(text) => Some(Self::Text(text.clone())),
+ Value::Null | Value::Bool(_) | Value::Array(_) | Value::Object(_) => None,
+ }
+ }
+
+ /// Converts the request ID back to JSON.
+ #[must_use]
+ pub fn to_json_value(&self) -> Value {
+ match self {
+ Self::Number(number) => {
+ let parsed = serde_json::from_str::<Value>(number);
+ match parsed {
+ Ok(value @ Value::Number(_)) => value,
+ Ok(_) | Err(_) => Value::String(number.clone()),
+ }
+ }
+ Self::Text(text) => Value::String(text.clone()),
+ }
+ }
+}
+
+/// Parsed JSON-RPC frame.
+#[derive(Debug, Clone)]
+pub struct FramedMessage {
+ /// Original payload bytes.
+ pub payload: Vec<u8>,
+ /// Parsed JSON value.
+ pub value: Value,
+}
+
+impl FramedMessage {
+ /// Parses a JSON-RPC frame payload.
+ pub fn parse(payload: Vec<u8>) -> io::Result<Self> {
+ let value = serde_json::from_slice::<Value>(&payload).map_err(|error| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("invalid JSON-RPC frame payload: {error}"),
+ )
+ })?;
+ if !value.is_object() {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "JSON-RPC frame root must be an object",
+ ));
+ }
+ Ok(Self { payload, value })
+ }
+
+ /// Classifies the envelope shape.
+ #[must_use]
+ pub fn classify(&self) -> RpcEnvelopeKind {
+ let method = self
+ .value
+ .get("method")
+ .and_then(Value::as_str)
+ .map(ToOwned::to_owned);
+ let request_id = self.value.get("id").and_then(RequestId::from_json_value);
+ match (method, request_id) {
+ (Some(method), Some(id)) => RpcEnvelopeKind::Request { id, method },
+ (Some(method), None) => RpcEnvelopeKind::Notification { method },
+ (None, Some(id)) => RpcEnvelopeKind::Response {
+ id,
+ has_error: self.value.get("error").is_some(),
+ },
+ (None, None) => RpcEnvelopeKind::Unknown,
+ }
+ }
+}
+
+/// Coarse JSON-RPC envelope classification.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RpcEnvelopeKind {
+ /// Request with an ID.
+ Request {
+ /// Request identifier.
+ id: RequestId,
+ /// Method name.
+ method: String,
+ },
+ /// Notification without an ID.
+ Notification {
+ /// Method name.
+ method: String,
+ },
+ /// Response with an ID.
+ Response {
+ /// Request identifier.
+ id: RequestId,
+ /// Whether the response carries a JSON-RPC error payload.
+ has_error: bool,
+ },
+ /// Frame shape did not match a recognized envelope.
+ Unknown,
+}
+
+/// Tool call metadata extracted from a generic `tools/call` frame.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ToolCallMeta {
+ /// Tool name.
+ pub tool_name: String,
+ /// Nested LSP method when the tool proxies LSP-style requests.
+ pub lsp_method: Option<String>,
+ /// Best-effort path hint for telemetry grouping.
+ pub path_hint: Option<String>,
+}
+
+/// One result of reading a line-delimited JSON-RPC stream.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum FrameReadOutcome {
+ /// A frame payload was read.
+ Frame(Vec<u8>),
+ /// The stream ended cleanly.
+ EndOfStream,
+}
+
+/// Extracts `tools/call` metadata from a JSON-RPC frame.
+#[must_use]
+pub fn parse_tool_call_meta(frame: &FramedMessage, rpc_method: &str) -> Option<ToolCallMeta> {
+ if rpc_method != "tools/call" {
+ return None;
+ }
+ let params = frame.value.get("params")?.as_object()?;
+ let tool_name = params.get("name")?.as_str()?.to_owned();
+ let tool_arguments = params.get("arguments");
+ let lsp_method = if normalize_ascii_token(tool_name.as_str()) == "advancedlsprequest" {
+ tool_arguments
+ .and_then(Value::as_object)
+ .and_then(|arguments| {
+ arguments
+ .get("method")
+ .or_else(|| arguments.get("lsp_method"))
+ .or_else(|| arguments.get("lspMethod"))
+ })
+ .and_then(Value::as_str)
+ .map(str::to_owned)
+ } else {
+ None
+ };
+ let path_hint = tool_arguments.and_then(extract_path_hint_from_value);
+ Some(ToolCallMeta {
+ tool_name,
+ lsp_method,
+ path_hint,
+ })
+}
+
+/// Reads one line-delimited JSON-RPC frame.
+pub async fn read_frame<R>(reader: &mut BufReader<R>) -> io::Result<FrameReadOutcome>
+where
+ R: AsyncRead + Unpin,
+{
+ loop {
+ let mut line = Vec::<u8>::new();
+ let bytes_read = reader.read_until(b'\n', &mut line).await?;
+ if bytes_read == 0 {
+ return Ok(FrameReadOutcome::EndOfStream);
+ }
+
+ while line
+ .last()
+ .is_some_and(|byte| *byte == b'\n' || *byte == b'\r')
+ {
+ let _popped = line.pop();
+ }
+
+ if line.is_empty() {
+ continue;
+ }
+
+ return Ok(FrameReadOutcome::Frame(line));
+ }
+}
+
+/// Writes one line-delimited JSON-RPC frame.
+pub async fn write_frame<W>(writer: &mut W, payload: &[u8]) -> io::Result<()>
+where
+ W: AsyncWrite + Unpin,
+{
+ writer.write_all(payload).await?;
+ writer.write_all(b"\n").await?;
+ writer.flush().await?;
+ Ok(())
+}
+
+fn extract_path_hint_from_value(value: &Value) -> Option<String> {
+ match value {
+ Value::String(text) => {
+ let parsed = parse_nested_json_value(text)?;
+ extract_path_hint_from_value(&parsed)
+ }
+ Value::Object(_) => {
+ let direct = extract_direct_path_hint(value);
+ if let Some(path) = direct {
+ return Some(normalize_path_hint(path.as_str()));
+ }
+ value
+ .as_object()?
+ .values()
+ .find_map(extract_path_hint_from_value)
+ }
+ Value::Array(items) => items.iter().find_map(extract_path_hint_from_value),
+ Value::Null | Value::Bool(_) | Value::Number(_) => None,
+ }
+}
+
+fn parse_nested_json_value(raw: &str) -> Option<Value> {
+ let trimmed = raw.trim();
+ let first = trimmed.as_bytes().first()?;
+ if !matches!(*first, b'{' | b'[') {
+ return None;
+ }
+ serde_json::from_str(trimmed).ok()
+}
+
+fn extract_direct_path_hint(value: &Value) -> Option<String> {
+ let object = value.as_object()?;
+ for key in ["file_path", "filePath", "path", "uri"] {
+ let path = object.get(key).and_then(Value::as_str);
+ if let Some(path) = path {
+ return Some(path.to_owned());
+ }
+ }
+
+ let text_document = object.get("textDocument").and_then(Value::as_object);
+ if let Some(text_document) = text_document {
+ for key in ["uri", "file_path", "filePath", "path"] {
+ let path = text_document.get(key).and_then(Value::as_str);
+ if let Some(path) = path {
+ return Some(path.to_owned());
+ }
+ }
+ }
+ None
+}
+
+fn normalize_path_hint(raw: &str) -> String {
+ let trimmed = raw.trim();
+ if trimmed.starts_with("file://") {
+ let parsed = Url::parse(trimmed);
+ if let Ok(parsed) = parsed {
+ let to_path = parsed.to_file_path();
+ if let Ok(path) = to_path {
+ return path.display().to_string();
+ }
+ }
+ }
+ trimmed.to_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{FramedMessage, RequestId, RpcEnvelopeKind, parse_tool_call_meta};
+ use serde_json::json;
+
+ #[test]
+ fn request_id_round_trips_numeric_and_textual_values() {
+ let numeric = RequestId::from_json_value(&json!(42));
+ assert!(matches!(numeric, Some(RequestId::Number(ref value)) if value == "42"));
+
+ let textual = RequestId::from_json_value(&json!("abc"));
+ assert!(matches!(textual, Some(RequestId::Text(ref value)) if value == "abc"));
+
+ let round_trip = numeric.map(|value| value.to_json_value());
+ assert_eq!(round_trip, Some(json!(42)));
+ }
+
+ #[test]
+ fn classifies_request_frames() {
+ let frame =
+ FramedMessage::parse(br#"{"jsonrpc":"2.0","id":7,"method":"tools/call"}"#.to_vec());
+ assert!(frame.is_ok());
+ let frame = match frame {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ assert!(matches!(
+ frame.classify(),
+ RpcEnvelopeKind::Request { method, .. } if method == "tools/call"
+ ));
+ }
+
+ #[test]
+ fn extracts_tool_call_meta_with_nested_path_hint() {
+ let payload = br#"{
+ "jsonrpc":"2.0",
+ "id":1,
+ "method":"tools/call",
+ "params":{
+ "name":"advanced_lsp_request",
+ "arguments":{
+ "method":"textDocument/hover",
+ "params":{"textDocument":{"uri":"file:///tmp/example.rs"}}
+ }
+ }
+ }"#
+ .to_vec();
+ let frame = FramedMessage::parse(payload);
+ assert!(frame.is_ok());
+ let frame = match frame {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ let meta = parse_tool_call_meta(&frame, "tools/call");
+ assert!(meta.is_some());
+ let meta = match meta {
+ Some(value) => value,
+ None => return,
+ };
+ assert_eq!(meta.tool_name, "advanced_lsp_request");
+ assert_eq!(meta.lsp_method.as_deref(), Some("textDocument/hover"));
+ assert_eq!(meta.path_hint.as_deref(), Some("/tmp/example.rs"));
+ }
+}
diff --git a/crates/libmcp/src/lib.rs b/crates/libmcp/src/lib.rs
new file mode 100644
index 0000000..b352d09
--- /dev/null
+++ b/crates/libmcp/src/lib.rs
@@ -0,0 +1,28 @@
+//! `libmcp` is the shared operational spine for hardened MCP servers.
+
+pub mod fault;
+pub mod health;
+pub mod jsonrpc;
+pub mod normalize;
+pub mod render;
+pub mod replay;
+pub mod telemetry;
+pub mod types;
+
+pub use fault::{Fault, FaultClass, FaultCode, RecoveryDirective};
+pub use health::{
+ HealthSnapshot, LifecycleState, MethodTelemetry, RolloutState, TelemetrySnapshot,
+ TelemetryTotals,
+};
+pub use jsonrpc::{
+ FrameReadOutcome, FramedMessage, RequestId, RpcEnvelopeKind, ToolCallMeta,
+ parse_tool_call_meta, read_frame, write_frame,
+};
+pub use normalize::{
+ NumericParseError, PathNormalizeError, normalize_ascii_token, normalize_local_path,
+ parse_human_unsigned_u64, saturating_u64_to_usize,
+};
+pub use render::{PathStyle, RenderConfig, RenderMode, TruncatedText, collapse_inline_whitespace};
+pub use replay::ReplayContract;
+pub use telemetry::{TelemetryLog, ToolErrorDetail, ToolOutcome};
+pub use types::{Generation, InvariantViolation};
diff --git a/crates/libmcp/src/normalize.rs b/crates/libmcp/src/normalize.rs
new file mode 100644
index 0000000..ff24067
--- /dev/null
+++ b/crates/libmcp/src/normalize.rs
@@ -0,0 +1,133 @@
+//! Shared normalization helpers for model-facing input.
+
+use std::path::{Path, PathBuf};
+use thiserror::Error;
+use url::Url;
+
+/// A numeric input could not be normalized.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
+pub enum NumericParseError {
+ /// The input was empty.
+ #[error("numeric input must be non-empty")]
+ Empty,
+ /// The input could not be represented as a non-negative integer.
+ #[error("expected a non-negative integer")]
+ Invalid,
+}
+
+/// A path-like value could not be normalized.
+#[derive(Debug, Clone, PartialEq, Eq, Error)]
+pub enum PathNormalizeError {
+ /// The input was empty.
+ #[error("path input must be non-empty")]
+ Empty,
+ /// The `file://` URI was malformed.
+ #[error("file URI is invalid")]
+ InvalidFileUri,
+ /// The URI does not reference a local path.
+ #[error("file URI must resolve to a local path")]
+ NonLocalFileUri,
+}
+
+/// Parses a human-facing unsigned integer.
+///
+/// This accepts:
+///
+/// - integer numbers
+/// - integer-like floating-point spellings such as `42.0`
+/// - numeric strings
+#[must_use]
+pub fn parse_human_unsigned_u64(raw: &str) -> Option<u64> {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+ if let Ok(value) = trimmed.parse::<u64>() {
+ return Some(value);
+ }
+ let parsed_float = trimmed.parse::<f64>().ok()?;
+ if !parsed_float.is_finite() || parsed_float < 0.0 || parsed_float.fract() != 0.0 {
+ return None;
+ }
+ let max = u64::MAX as f64;
+ if parsed_float > max {
+ return None;
+ }
+ Some(parsed_float as u64)
+}
+
+/// Converts `u64` to `usize`, saturating on overflow.
+#[must_use]
+pub fn saturating_u64_to_usize(value: u64) -> usize {
+ usize::try_from(value).unwrap_or(usize::MAX)
+}
+
+/// Normalizes a token by dropping non-alphanumeric ASCII and lowercasing.
+#[must_use]
+pub fn normalize_ascii_token(raw: &str) -> String {
+ raw.chars()
+ .filter(|character| character.is_ascii_alphanumeric())
+ .map(|character| character.to_ascii_lowercase())
+ .collect()
+}
+
+/// Resolves a local path or `file://` URI to an absolute path.
+pub fn normalize_local_path(
+ raw: &str,
+ workspace_root: Option<&Path>,
+) -> Result<PathBuf, PathNormalizeError> {
+ let trimmed = raw.trim();
+ if trimmed.is_empty() {
+ return Err(PathNormalizeError::Empty);
+ }
+ let parsed = if trimmed.starts_with("file://") {
+ let file_url = Url::parse(trimmed).map_err(|_| PathNormalizeError::InvalidFileUri)?;
+ file_url
+ .to_file_path()
+ .map_err(|()| PathNormalizeError::NonLocalFileUri)?
+ } else {
+ PathBuf::from(trimmed)
+ };
+ Ok(if parsed.is_absolute() {
+ parsed
+ } else if let Some(workspace_root) = workspace_root {
+ workspace_root.join(parsed)
+ } else {
+ parsed
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{normalize_ascii_token, normalize_local_path, parse_human_unsigned_u64};
+ use std::path::Path;
+
+ #[test]
+ fn parses_human_unsigned_integers() {
+ assert_eq!(parse_human_unsigned_u64("42"), Some(42));
+ assert_eq!(parse_human_unsigned_u64("42.0"), Some(42));
+ assert_eq!(parse_human_unsigned_u64(" 7 "), Some(7));
+ assert_eq!(parse_human_unsigned_u64("-1"), None);
+ assert_eq!(parse_human_unsigned_u64("7.5"), None);
+ }
+
+ #[test]
+ fn normalizes_ascii_tokens() {
+ assert_eq!(
+ normalize_ascii_token("textDocument/prepareRename"),
+ "textdocumentpreparerename"
+ );
+ assert_eq!(normalize_ascii_token("prepare_rename"), "preparerename");
+ }
+
+ #[test]
+ fn resolves_relative_paths_against_workspace_root() {
+ let root = Path::new("/tmp/example-root");
+ let resolved = normalize_local_path("src/lib.rs", Some(root));
+ assert!(resolved.is_ok());
+ assert_eq!(
+ resolved.ok().as_deref(),
+ Some(root.join("src/lib.rs").as_path())
+ );
+ }
+}
diff --git a/crates/libmcp/src/render.rs b/crates/libmcp/src/render.rs
new file mode 100644
index 0000000..cbf2ae6
--- /dev/null
+++ b/crates/libmcp/src/render.rs
@@ -0,0 +1,138 @@
+//! Model-facing rendering helpers.
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::path::Path;
+
+/// Output render mode.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum RenderMode {
+ /// Model-optimized text output.
+ #[default]
+ #[serde(alias = "text", alias = "plain", alias = "plain_text")]
+ Porcelain,
+ /// Structured JSON output.
+ #[serde(alias = "structured")]
+ Json,
+}
+
+/// Path rendering style.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum PathStyle {
+ /// Render absolute filesystem paths.
+ Absolute,
+ /// Render paths relative to the workspace root when possible.
+ #[default]
+ #[serde(alias = "rel")]
+ Relative,
+}
+
+/// Common render configuration.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct RenderConfig {
+ /// Chosen render mode.
+ pub render: RenderMode,
+ /// Chosen path rendering style.
+ pub path_style: PathStyle,
+}
+
+impl RenderConfig {
+ /// Builds a render configuration from user input, applying the default
+ /// path style implied by the render mode.
+ #[must_use]
+ pub fn from_user_input(render: Option<RenderMode>, path_style: Option<PathStyle>) -> Self {
+ let render = render.unwrap_or(RenderMode::Porcelain);
+ let default_path_style = match render {
+ RenderMode::Porcelain => PathStyle::Relative,
+ RenderMode::Json => PathStyle::Absolute,
+ };
+ Self {
+ render,
+ path_style: path_style.unwrap_or(default_path_style),
+ }
+ }
+}
+
+/// Result of text truncation.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TruncatedText {
+ /// Visible text after truncation.
+ pub text: String,
+ /// Whether text was truncated.
+ pub truncated: bool,
+}
+
+/// Collapses all internal whitespace runs into single spaces.
+#[must_use]
+pub fn collapse_inline_whitespace(raw: &str) -> String {
+ raw.split_whitespace().collect::<Vec<_>>().join(" ")
+}
+
+/// Truncates a string by Unicode scalar count.
+#[must_use]
+pub fn truncate_chars(raw: &str, limit: Option<usize>) -> TruncatedText {
+ let Some(limit) = limit else {
+ return TruncatedText {
+ text: raw.to_owned(),
+ truncated: false,
+ };
+ };
+ let truncated = raw.chars().take(limit).collect::<String>();
+ let visible_len = truncated.chars().count();
+ if raw.chars().count() > visible_len {
+ TruncatedText {
+ text: truncated,
+ truncated: true,
+ }
+ } else {
+ TruncatedText {
+ text: raw.to_owned(),
+ truncated: false,
+ }
+ }
+}
+
+/// Renders a path according to the requested style.
+#[must_use]
+pub fn render_path(path: &Path, style: PathStyle, workspace_root: Option<&Path>) -> String {
+ match style {
+ PathStyle::Absolute => path.display().to_string(),
+ PathStyle::Relative => {
+ if let Some(workspace_root) = workspace_root
+ && let Ok(relative) = path.strip_prefix(workspace_root)
+ {
+ return relative.display().to_string();
+ }
+ path.display().to_string()
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{PathStyle, RenderConfig, RenderMode, collapse_inline_whitespace, render_path};
+ use std::path::Path;
+
+ #[test]
+ fn render_config_uses_mode_specific_defaults() {
+ let porcelain = RenderConfig::from_user_input(None, None);
+ assert_eq!(porcelain.render, RenderMode::Porcelain);
+ assert_eq!(porcelain.path_style, PathStyle::Relative);
+
+ let json = RenderConfig::from_user_input(Some(RenderMode::Json), None);
+ assert_eq!(json.path_style, PathStyle::Absolute);
+ }
+
+ #[test]
+ fn collapses_whitespace_and_renders_relative_paths() {
+ assert_eq!(collapse_inline_whitespace("a b\t c"), "a b c");
+ let root = Path::new("/tmp/repo");
+ let path = Path::new("/tmp/repo/src/lib.rs");
+ assert_eq!(
+ render_path(path, PathStyle::Relative, Some(root)),
+ "src/lib.rs"
+ );
+ }
+}
diff --git a/crates/libmcp/src/replay.rs b/crates/libmcp/src/replay.rs
new file mode 100644
index 0000000..0f318a0
--- /dev/null
+++ b/crates/libmcp/src/replay.rs
@@ -0,0 +1,16 @@
+//! Replay contracts for request surfaces.
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+/// Replay legality for a request surface.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ReplayContract {
+ /// Repeated execution converges on the same observable outcome.
+ Convergent,
+ /// Replay is only legal after a probe or equivalent proof of safety.
+ ProbeRequired,
+ /// Replay is never allowed automatically.
+ NeverReplay,
+}
diff --git a/crates/libmcp/src/telemetry.rs b/crates/libmcp/src/telemetry.rs
new file mode 100644
index 0000000..6cd4cff
--- /dev/null
+++ b/crates/libmcp/src/telemetry.rs
@@ -0,0 +1,299 @@
+//! Append-only JSONL telemetry support.
+
+use crate::{
+ jsonrpc::{RequestId, ToolCallMeta},
+ render::render_path,
+};
+use serde::Serialize;
+use serde_json::Value;
+use std::{
+ collections::HashMap,
+ fs::OpenOptions,
+ io,
+ io::Write,
+ path::{Path, PathBuf},
+ time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+/// Tool completion outcome.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ToolOutcome {
+ /// The request completed successfully.
+ Ok,
+ /// The request completed with an error.
+ Error,
+}
+
+/// Serializable tool error detail.
+#[derive(Debug, Clone, Default)]
+pub struct ToolErrorDetail {
+ /// Error code when one exists.
+ pub code: Option<i64>,
+ /// Structured error kind.
+ pub kind: Option<String>,
+ /// Human-facing error message.
+ pub message: Option<String>,
+}
+
+#[derive(Debug, Default)]
+struct PathAggregate {
+ request_count: u64,
+ error_count: u64,
+ total_latency_ms: u128,
+ max_latency_ms: u64,
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct ToolEventRecord {
+ event: &'static str,
+ ts_unix_ms: u64,
+ repo_root: String,
+ request_id: Value,
+ tool_name: String,
+ lsp_method: Option<String>,
+ path_hint: Option<String>,
+ latency_ms: u64,
+ replay_attempts: u8,
+ outcome: ToolOutcome,
+ error_code: Option<i64>,
+ error_kind: Option<String>,
+ error_message: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct HotPathsSnapshotRecord {
+ event: &'static str,
+ ts_unix_ms: u64,
+ repo_root: String,
+ total_tool_events: u64,
+ hottest_paths: Vec<HotPathLine>,
+ slowest_paths: Vec<HotPathLine>,
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct HotPathLine {
+ path: String,
+ request_count: u64,
+ error_count: u64,
+ avg_latency_ms: u64,
+ max_latency_ms: u64,
+}
+
+/// Append-only telemetry log.
+#[derive(Debug)]
+pub struct TelemetryLog {
+ sink: std::fs::File,
+ repo_root: String,
+ by_path: HashMap<String, PathAggregate>,
+ emitted_tool_events: u64,
+ snapshot_every: u64,
+}
+
+impl TelemetryLog {
+ /// Opens or creates a telemetry log file.
+ pub fn new(path: &Path, repo_root: &Path, snapshot_every: u64) -> io::Result<Self> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let sink = OpenOptions::new().create(true).append(true).open(path)?;
+ let repo_root = render_path(repo_root, crate::render::PathStyle::Absolute, None);
+ Ok(Self {
+ sink,
+ repo_root,
+ by_path: HashMap::new(),
+ emitted_tool_events: 0,
+ snapshot_every: snapshot_every.max(1),
+ })
+ }
+
+ /// Records one tool completion and periodically emits a hot-path snapshot.
+ pub fn record_tool_completion(
+ &mut self,
+ request_id: &RequestId,
+ tool_meta: &ToolCallMeta,
+ latency_ms: u64,
+ replay_attempts: u8,
+ outcome: ToolOutcome,
+ error: ToolErrorDetail,
+ ) -> io::Result<()> {
+ let now = unix_ms_now();
+ let request_id = request_id.to_json_value();
+ let is_error = matches!(outcome, ToolOutcome::Error);
+ let ToolErrorDetail {
+ code: error_code,
+ kind: error_kind,
+ message: error_message,
+ } = error;
+ let record = ToolEventRecord {
+ event: "tool_call",
+ ts_unix_ms: now,
+ repo_root: self.repo_root.clone(),
+ request_id,
+ tool_name: tool_meta.tool_name.clone(),
+ lsp_method: tool_meta.lsp_method.clone(),
+ path_hint: tool_meta.path_hint.clone(),
+ latency_ms,
+ replay_attempts,
+ outcome,
+ error_code,
+ error_kind,
+ error_message,
+ };
+ self.write_json_line(&record)?;
+
+ if let Some(path) = tool_meta.path_hint.as_ref() {
+ let aggregate = self.by_path.entry(path.clone()).or_default();
+ aggregate.request_count = aggregate.request_count.saturating_add(1);
+ aggregate.total_latency_ms = aggregate
+ .total_latency_ms
+ .saturating_add(u128::from(latency_ms));
+ aggregate.max_latency_ms = aggregate.max_latency_ms.max(latency_ms);
+ if is_error {
+ aggregate.error_count = aggregate.error_count.saturating_add(1);
+ }
+ }
+
+ self.emitted_tool_events = self.emitted_tool_events.saturating_add(1);
+ if self.emitted_tool_events.is_multiple_of(self.snapshot_every) {
+ self.write_hot_paths_snapshot()?;
+ }
+ Ok(())
+ }
+
+ /// Emits a hot-path snapshot immediately.
+ pub fn write_hot_paths_snapshot(&mut self) -> io::Result<()> {
+ let mut hottest = self
+ .by_path
+ .iter()
+ .map(|(path, aggregate)| hot_path_line(path.as_str(), aggregate))
+ .collect::<Vec<_>>();
+ hottest.sort_by(|left, right| {
+ right
+ .request_count
+ .cmp(&left.request_count)
+ .then_with(|| right.max_latency_ms.cmp(&left.max_latency_ms))
+ .then_with(|| left.path.cmp(&right.path))
+ });
+ hottest.truncate(12);
+
+ let mut slowest = self
+ .by_path
+ .iter()
+ .filter(|(_, aggregate)| aggregate.request_count > 0)
+ .map(|(path, aggregate)| hot_path_line(path.as_str(), aggregate))
+ .collect::<Vec<_>>();
+ slowest.sort_by(|left, right| {
+ right
+ .avg_latency_ms
+ .cmp(&left.avg_latency_ms)
+ .then_with(|| right.request_count.cmp(&left.request_count))
+ .then_with(|| left.path.cmp(&right.path))
+ });
+ slowest.truncate(12);
+
+ let snapshot = HotPathsSnapshotRecord {
+ event: "hot_paths_snapshot",
+ ts_unix_ms: unix_ms_now(),
+ repo_root: self.repo_root.clone(),
+ total_tool_events: self.emitted_tool_events,
+ hottest_paths: hottest,
+ slowest_paths: slowest,
+ };
+ self.write_json_line(&snapshot)
+ }
+
+ fn write_json_line<T: Serialize>(&mut self, value: &T) -> io::Result<()> {
+ let encoded = serde_json::to_vec(value).map_err(|error| {
+ io::Error::other(format!("telemetry serialization failed: {error}"))
+ })?;
+ self.sink.write_all(&encoded)?;
+ self.sink.write_all(b"\n")?;
+ Ok(())
+ }
+}
+
+fn hot_path_line(path: &str, aggregate: &PathAggregate) -> HotPathLine {
+ let avg_latency_ms = if aggregate.request_count == 0 {
+ 0
+ } else {
+ let avg = aggregate.total_latency_ms / u128::from(aggregate.request_count);
+ u64::try_from(avg).unwrap_or(u64::MAX)
+ };
+ HotPathLine {
+ path: PathBuf::from(path).display().to_string(),
+ request_count: aggregate.request_count,
+ error_count: aggregate.error_count,
+ avg_latency_ms,
+ max_latency_ms: aggregate.max_latency_ms,
+ }
+}
+
+fn unix_ms_now() -> u64 {
+ let since_epoch = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or(Duration::ZERO);
+ let millis = since_epoch.as_millis();
+ u64::try_from(millis).unwrap_or(u64::MAX)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{TelemetryLog, ToolErrorDetail, ToolOutcome};
+ use crate::jsonrpc::{RequestId, ToolCallMeta};
+ use serde_json::Value;
+ use std::fs;
+ use tempfile::tempdir;
+
+ #[test]
+ fn writes_tool_events_and_hot_path_snapshots() {
+ let dir = tempdir();
+ assert!(dir.is_ok());
+ let dir = match dir {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ let log_path = dir.path().join("telemetry.jsonl");
+ let log = TelemetryLog::new(log_path.as_path(), dir.path(), 1);
+ assert!(log.is_ok());
+ let mut log = match log {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ let record = log.record_tool_completion(
+ &RequestId::Text("abc".to_owned()),
+ &ToolCallMeta {
+ tool_name: "hover".to_owned(),
+ lsp_method: None,
+ path_hint: Some("/tmp/example.rs".to_owned()),
+ },
+ 12,
+ 0,
+ ToolOutcome::Ok,
+ ToolErrorDetail::default(),
+ );
+ assert!(record.is_ok());
+ let text = fs::read_to_string(log_path);
+ assert!(text.is_ok());
+ let text = match text {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ let lines = text.lines().collect::<Vec<_>>();
+ assert_eq!(lines.len(), 2);
+ let first = serde_json::from_str::<Value>(lines[0]);
+ assert!(first.is_ok());
+ let first = match first {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ assert_eq!(first["event"], "tool_call");
+ let second = serde_json::from_str::<Value>(lines[1]);
+ assert!(second.is_ok());
+ let second = match second {
+ Ok(value) => value,
+ Err(_) => return,
+ };
+ assert_eq!(second["event"], "hot_paths_snapshot");
+ }
+}
diff --git a/crates/libmcp/src/types.rs b/crates/libmcp/src/types.rs
new file mode 100644
index 0000000..f9a44a5
--- /dev/null
+++ b/crates/libmcp/src/types.rs
@@ -0,0 +1,50 @@
+//! Fundamental library-wide types.
+
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use std::num::NonZeroU64;
+use thiserror::Error;
+
+/// A library invariant was violated.
+#[derive(Debug, Clone, PartialEq, Eq, Error)]
+#[error("libmcp invariant violated: {detail}")]
+pub struct InvariantViolation {
+ detail: &'static str,
+}
+
+impl InvariantViolation {
+ /// Creates a new invariant violation.
+ #[must_use]
+ pub const fn new(detail: &'static str) -> Self {
+ Self { detail }
+ }
+}
+
+/// Monotonic worker generation identifier.
+#[derive(
+ Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
+)]
+#[serde(transparent)]
+pub struct Generation(NonZeroU64);
+
+impl Generation {
+ /// Returns the first generation.
+ #[must_use]
+ pub const fn genesis() -> Self {
+ Self(NonZeroU64::MIN)
+ }
+
+ /// Returns the inner integer value.
+ #[must_use]
+ pub const fn get(self) -> u64 {
+ self.0.get()
+ }
+
+ /// Advances to the next generation, saturating on overflow.
+ #[must_use]
+ pub fn next(self) -> Self {
+ let next = self.get().saturating_add(1);
+ let non_zero = NonZeroU64::new(next).map_or(NonZeroU64::MAX, |value| value);
+ Self(non_zero)
+ }
+}
diff --git a/docs/spec.md b/docs/spec.md
new file mode 100644
index 0000000..e31d073
--- /dev/null
+++ b/docs/spec.md
@@ -0,0 +1,235 @@
+# libmcp Spec
+
+## Status
+
+This document is the normative specification for `libmcp` `1.0.0`.
+
+`libmcp` is the reusable operational spine for hardened MCP servers. It is not
+an application framework, a domain schema repository, or a mandate that every
+MCP use one transport topology.
+
+## Problem Statement
+
+Several MCPs now share the same operational posture:
+
+- stable public host
+- disposable worker
+- explicit replay contracts
+- blue/green rollout and hot reinstall
+- health and telemetry as first-class operational surfaces
+- recovery tests for process churn and replay safety
+- model-facing porcelain output instead of backend dumps
+
+This library exists to make that posture reusable and versioned.
+
+## Scope
+
+`libmcp` owns the shared control plane:
+
+- replay contract vocabulary
+- typed fault taxonomy
+- request identity and JSON-RPC frame helpers
+- health and telemetry base schemas
+- JSONL telemetry support
+- model UX doctrine primitives
+- normalization helpers
+- hardening test support
+- canonical `$mcp-bootstrap` skill
+
+`libmcp` explicitly does not own:
+
+- domain tools
+- backend-specific request routing
+- backend-specific warm-up heuristics
+- a mandatory public transport shape
+- an obligation that every tool batch or support preview modes
+
+## Supported Topologies
+
+The library must support both of these shapes:
+
+1. stable host owning the public MCP session and talking to a private worker RPC
+2. stable host owning the public MCP session and proxying to a worker MCP server
+
+The invariants are standard. The wire shape is not.
+
+## Core Contracts
+
+### Replay
+
+Every request surface must carry an explicit replay contract.
+
+The shared vocabulary is:
+
+- `Convergent`
+- `ProbeRequired`
+- `NeverReplay`
+
+`Convergent` means repeated execution is safe because it converges on the same
+observable result.
+
+`ProbeRequired` means the host may only replay after proving that a previous
+attempt did not already take effect or after reconstructing enough state to make
+the replay safe.
+
+`NeverReplay` means the request must fail rather than run again automatically.
+
+### Faults
+
+Failures must be represented as typed operational faults, not merely as text.
+
+The baseline taxonomy must distinguish at least:
+
+- transport failure
+- process failure
+- protocol failure
+- timeout
+- downstream response failure
+- resource exhaustion
+- replay exhaustion
+- rollout disruption
+- invariant breach
+
+Each fault must carry enough structure to drive:
+
+- retry or restart policy
+- health reporting
+- telemetry aggregation
+- model-facing shaping
+
+### Health
+
+Every `libmcp` consumer should be able to expose a common operational health
+core.
+
+The base health payload includes:
+
+- lifecycle state
+- generation
+- uptime
+- consecutive failures
+- restart count
+- rollout state
+- last fault
+
+Consumers may extend it with domain-specific fields.
+
+### Telemetry
+
+Every `libmcp` consumer should be able to expose a common operational telemetry
+core and emit append-only event telemetry.
+
+The base telemetry payload includes:
+
+- request counts
+- success and error counts
+- retry counts
+- per-method aggregates
+- last method error
+- recent restart-triggering fault
+
+The JSONL event path is part of the support surface because postmortem analysis
+of hot paths and error concentrations is a first-class operational need.
+
+## Model UX Doctrine
+
+The library contract includes model-facing behavior.
+
+### Porcelain by Default
+
+Nontrivial tools should default to `render=porcelain`.
+
+Structured `render=json` must remain available, but it is opt-in unless a tool
+is intrinsically structured and tiny.
+
+Porcelain output should be:
+
+- line-oriented
+- deterministic
+- bounded
+- summary-first
+- suitable for long-running agent loops
+
+The library should therefore provide reusable primitives for:
+
+- render mode selection
+- bounded/truncated text shaping
+- stable note emission
+- path rendering
+- common porcelain patterns
+
+`libmcp` does not require a universal detail taxonomy like
+`summary|compact|full`. Consumers may add extra detail controls when their tool
+surface actually needs them.
+
+### Normalization
+
+The library should reduce trivial model-facing friction where the semantics are
+still unambiguous.
+
+Examples:
+
+- field aliases
+- integer-like strings
+- `file://` URIs
+- consistent path-style normalization
+- method-token normalization where tools have canonical names and common aliases
+
+### Failure Shaping
+
+Transient operational failures should be surfaced in a way that helps a model
+recover:
+
+- concise message
+- typed category underneath
+- retry hint when retry is plausible
+- no raw backend spew by default
+
+### Optional Nice-to-Haves
+
+These are good patterns but are not part of the minimum contract:
+
+- `dry_run` or preview modes
+- batching for tools that do not naturally batch
+- backend-specific uncertainty notes
+
+## Canonical Skill Ownership
+
+This repository is the canonical owner of `$mcp-bootstrap`.
+
+The skill is versioned library collateral, not an external convenience file.
+Maintaining it is part of the `libmcp` contract.
+
+The rule is:
+
+- the source of truth lives in this repository
+- local Codex installation should point at it by symlink
+- the skill must stay aligned with the current public doctrine of the library
+
+## Versioning
+
+`libmcp` `1.0.0` is defined by the first steady integration into
+`adequate_rust_mcp`.
+
+The `1.0.0` contract means:
+
+- the extracted seam proved real in `adequate_rust_mcp`
+- the skill and spec align with the implementation
+- the model UX doctrine is embodied in helpers and tests, not just prose
+- the core contracts are stable enough to be depended on directly
+
+`fidget_spinner` is explicitly out of scope for `1.0.0`.
+
+## Immediate Implementation Sequence
+
+1. Create the library workspace and migrate the design note into this spec.
+2. Implement the MVP core:
+ - replay contracts
+ - fault taxonomy
+ - request identity and JSON-RPC helpers
+ - health and telemetry base types
+ - telemetry log support
+ - render and normalization helpers
+3. Port `adequate_rust_mcp` onto the shared pieces and stabilize.
+4. Lock `libmcp` to `1.0.0`.
+5. Revisit deeper runtime lifting only after the first consumer is truly clean.
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..6a89faa
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,3 @@
+[toolchain]
+channel = "1.94.0"
+components = ["clippy", "rustfmt"]
diff --git a/scripts/link-codex-skills b/scripts/link-codex-skills
new file mode 100755
index 0000000..9c6684f
--- /dev/null
+++ b/scripts/link-codex-skills
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SOURCE_ROOT="$ROOT_DIR/assets/codex-skills"
+DEST_ROOT="${1:-$HOME/.codex/skills}"
+
+install_skill() {
+ local name="$1"
+ local source_dir="$SOURCE_ROOT/$name"
+ local dest_dir="$DEST_ROOT/$name"
+ mkdir -p "$DEST_ROOT"
+ rm -rf "$dest_dir"
+ ln -s "$source_dir" "$dest_dir"
+ printf 'installed skill symlink: %s -> %s\n' "$dest_dir" "$source_dir"
+}
+
+install_skill "mcp-bootstrap"