diff options
| author | main <main@swarm.moe> | 2026-03-19 10:17:07 -0400 |
|---|---|---|
| committer | main <main@swarm.moe> | 2026-03-19 10:17:07 -0400 |
| commit | 08a1139eaa7a4862ab8c0e5fb5fc6845fc711208 (patch) | |
| tree | ded498d30e1d84c17b3e6dbf80594b5b62faa804 | |
| download | libmcp-08a1139eaa7a4862ab8c0e5fb5fc6845fc711208.zip | |
Initial libmcp 1.0.0
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" |