diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a97051fd..4cf35026 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -61,7 +61,57 @@ "Bash(npx -y @modelcontextprotocol/conformance list)", "Bash(target/debug/hello-world:*)", "Bash(./target/debug/resources-demo:*)", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(python3 -c \":*)", + "Bash(wc -l /Volumes/Home/git/pulseengine/mcp/mcp-protocol/src/*.rs /Volumes/Home/git/pulseengine/mcp/mcp-server/src/*.rs /Volumes/Home/git/pulseengine/mcp/mcp-transport/src/*.rs)", + "Bash(python3 -m json.tool)", + "Bash(wc -l /Volumes/Home/git/pulseengine/mcp/mcp-*/src/lib.rs)", + "Bash(for crate:*)", + "Bash(do echo:*)", + "Bash(command -v rivet)", + "Bash(rivet --help)", + "Bash(rivet init:*)", + "Bash(rivet stpa:*)", + "Bash(rivet docs:*)", + "Bash(rivet schema:*)", + "Bash(rivet validate:*)", + "Bash(rivet stats:*)", + "Bash(rivet coverage:*)", + "Bash(rivet matrix:*)", + "Bash(rivet list:*)", + "Bash(python3 -c \"import json,sys; [print\\(f[''''name'''']\\) for f in json.loads\\(sys.stdin.read\\(\\)\\)]\")", + "Bash(git commit:*)", + "Bash(rustup toolchain:*)", + "Bash(rustc --version)", + "Bash(pkill -f \"poc-tower-auth\")", + "Bash(echo \"EXIT: $?\")", + "Bash(TRYBUILD=overwrite cargo test -p pulseengine-mcp-macros test_ui_compilation_failures)", + "Bash(TRYBUILD=overwrite cargo test -p pulseengine-mcp-macros --test ui_tests)", + "Bash(TRYBUILD=overwrite cargo test -p pulseengine-mcp-macros --test compilation_and_ui)", + "Bash(git diff:*)", + "Bash(wc -l /Volumes/Home/git/pulseengine/mcp/mcp-auth/src/*.rs /Volumes/Home/git/pulseengine/mcp/mcp-auth/src/**/*.rs)", + "WebFetch(domain:doc.crates.io)", + "WebFetch(domain:blog.rust-lang.org)", + "WebFetch(domain:internals.rust-lang.org)", + "Bash(wc:*)", + "WebFetch(domain:users.rust-lang.org)", + "WebFetch(domain:rustsec.org)", + "Bash(git -C /Volumes/Home/git/pulseengine/mcp/.claude/worktrees/agent-a3118796 log --oneline -5)", + "Bash(git cherry-pick:*)", + "Bash(git checkout:*)", + "Bash(git clean:*)", + "Bash(git branch:*)", + "Bash(for f:*)", + "Read(//Volumes/Home/git/pulseengine/mcp/**)", + "Bash(done)", + "Bash(do sed:*)", + "Bash(git worktree:*)", + "Bash(git mv:*)", + "Bash(git rm:*)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); [print\\(p[''''name''''], p[''''version'''']\\) for p in d[''''packages''''] if p[''''name'''']==''''rmcp'''']\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); [print\\(p[''''name''''], p[''''version'''']\\) for p in d[''''packages''''] if p[''''name'''']==''''rmcp'''']\")", + "Bash(git push:*)", + "Bash(gh pr:*)" ], "deny": [] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..093708dd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ + +# AGENTS.md — Rivet Project Instructions + +> This file was generated by `rivet init --agents`. Re-run the command +> any time artifacts change to keep this file current. + +## Project Overview + +This project uses **Rivet** for SDLC artifact traceability. +- Config: `rivet.yaml` +- Schemas: common, dev, stpa +- Artifacts: 58 across 12 types +- Validation: `rivet validate` (current status: pass) + +## Available Commands + +| Command | Purpose | Example | +|---------|---------|---------| +| `rivet validate` | Check link integrity, coverage, required fields | `rivet validate --format json` | +| `rivet list` | List artifacts with filters | `rivet list --type requirement --format json` | +| `rivet stats` | Show artifact counts by type | `rivet stats --format json` | +| `rivet add` | Create a new artifact | `rivet add -t requirement --title "..." --link "satisfies:SC-1"` | +| `rivet link` | Add a link between artifacts | `rivet link SOURCE -t satisfies --target TARGET` | +| `rivet serve` | Start the dashboard | `rivet serve --port 3000` | +| `rivet export` | Generate HTML reports | `rivet export --format html --output ./dist` | +| `rivet impact` | Show change impact | `rivet impact --since HEAD~1` | +| `rivet coverage` | Show traceability coverage | `rivet coverage --format json` | +| `rivet diff` | Compare artifact versions | `rivet diff --base path/old --head path/new` | + +## Artifact Types + +| Type | Count | Description | +|------|------:|-------------| +| `control-action` | 4 | An action issued by a controller to a controlled process or another controller. | +| `controlled-process` | 2 | A process being controlled — the physical or data transformation acted upon by controllers. | +| `controller` | 3 | A system component (human or automated) responsible for issuing control actions. Each controller has a process model — its internal beliefs about the state of the controlled process. | +| `controller-constraint` | 7 | A constraint on a controller's behavior derived by inverting a UCA. Specifies what the controller must or must not do. | +| `design-decision` | 3 | An architectural or design decision with rationale | +| `feature` | 6 | A user-visible capability or feature | +| `hazard` | 6 | A system state or set of conditions that, together with worst-case environmental conditions, will lead to a loss. | +| `loss` | 5 | An undesired or unplanned event involving something of value to stakeholders. Losses define what the analysis aims to prevent. | +| `loss-scenario` | 5 | A causal pathway describing how a UCA could occur or how the control action could be improperly executed, leading to a hazard. | +| `requirement` | 6 | A functional or non-functional requirement | +| `system-constraint` | 5 | A condition or behavior that must be satisfied to prevent a hazard. Each constraint is the inversion of a hazard. | +| `uca` | 6 | An Unsafe Control Action — a control action that, in a particular context and worst-case environment, leads to a hazard. Four types (provably complete): 1. Not providing the control action leads to a hazard 2. Providing the control action leads to a hazard 3. Providing too early, too late, or in the wrong order 4. Control action stopped too soon or applied too long | +| `sub-hazard` | 0 | A refinement of a hazard into a more specific unsafe condition. | + +## Working with Artifacts + +### File Structure +- Artifacts are stored as YAML files in: `artifacts` +- Schema definitions: `schemas/` directory +- Documents: (none configured) + +### Creating Artifacts +```bash +rivet add -t requirement --title "New requirement" --status draft --link "satisfies:SC-1" +``` + +### Validating Changes +Always run `rivet validate` after modifying artifact YAML files. +Use `rivet validate --format json` for machine-readable output. + +### Link Types + +| Link Type | Description | Inverse | +|-----------|-------------|--------| +| `acts-on` | Control action acts on a process or controller | `acted-on-by` | +| `allocated-to` | Source is allocated to the target (e.g. requirement to architecture component) | `allocated-from` | +| `caused-by-uca` | Loss scenario is caused by an unsafe control action | `causes-scenario` | +| `constrained-by` | Source is constrained by the target | `constrains` | +| `constrains-controller` | Constraint applies to a specific controller | `controller-constrained-by` | +| `depends-on` | Source depends on target being completed first | `depended-on-by` | +| `derives-from` | Source is derived from the target | `derived-into` | +| `implements` | Source implements the target | `implemented-by` | +| `inverts-uca` | Controller constraint inverts (is derived from) an UCA | `inverted-by` | +| `issued-by` | Control action or UCA is issued by a controller | `issues` | +| `leads-to-hazard` | UCA or loss scenario leads to a hazard | `hazard-caused-by` | +| `leads-to-loss` | Hazard leads to a specific loss | `loss-caused-by` | +| `mitigates` | Source mitigates or prevents the target | `mitigated-by` | +| `prevents` | Constraint prevents a hazard | `prevented-by` | +| `refines` | Source is a refinement or decomposition of the target | `refined-by` | +| `satisfies` | Source satisfies or fulfils the target | `satisfied-by` | +| `traces-to` | General traceability link between any two artifacts | `traced-from` | +| `verifies` | Source verifies or validates the target | `verified-by` | + +## Conventions + +- Artifact IDs follow the pattern: PREFIX-NNN (e.g., REQ-001, FEAT-042) +- Use `rivet add` to create artifacts (auto-generates next ID) +- Always include traceability links when creating artifacts +- Run `rivet validate` before committing diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..d915a05f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# CLAUDE.md + +See [AGENTS.md](AGENTS.md) for project instructions. + +Additional Claude Code settings: +- Use `rivet validate` to verify changes to artifact YAML files +- Use `rivet list --format json` for machine-readable artifact queries diff --git a/Cargo.lock b/Cargo.lock index c0cbac24..9c4cd95c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,7 +35,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -185,9 +185,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.88" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", @@ -430,6 +430,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.41" @@ -511,19 +522,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "conformance-server" -version = "0.1.0" -dependencies = [ - "async-trait", - "base64 0.22.1", - "pulseengine-mcp-protocol", - "pulseengine-mcp-server", - "serde", - "serde_json", - "tokio", -] - [[package]] name = "conformance-tests" version = "0.17.0" @@ -576,6 +574,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -636,8 +643,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -654,13 +671,37 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.104", +] + [[package]] name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", "quote", "syn 2.0.104", ] @@ -800,6 +841,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -954,10 +1001,24 @@ checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.2+wasi-0.2.4", ] +[[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 6.0.0", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1023,6 +1084,9 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "foldhash", +] [[package]] name = "heck" @@ -1035,36 +1099,25 @@ name = "hello-world" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", - "pulseengine-mcp-macros", - "pulseengine-mcp-protocol", - "pulseengine-mcp-server", - "pulseengine-mcp-transport", + "rmcp", "schemars 1.0.4", "serde", - "serde_json", - "thiserror 1.0.69", "tokio", - "tracing", "tracing-subscriber", ] [[package]] name = "hello-world-with-auth" -version = "0.17.0" +version = "0.1.0" dependencies = [ "anyhow", - "async-trait", "axum", - "pulseengine-mcp-macros", - "pulseengine-mcp-protocol", - "pulseengine-mcp-security-middleware", - "pulseengine-mcp-server", - "schemars 0.8.22", + "pulseengine-security", + "rmcp", + "schemars 1.0.4", "serde", - "serde_json", "tokio", - "tower 0.4.13", + "tokio-util", "tracing", "tracing-subscriber", ] @@ -1362,6 +1415,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1397,6 +1456,7 @@ checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", "hashbrown", + "serde", ] [[package]] @@ -1537,6 +1597,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[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.174" @@ -1898,6 +1964,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1969,7 +2041,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2008,6 +2080,16 @@ dependencies = [ "yansi", ] +[[package]] +name = "prettyplease" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +dependencies = [ + "proc-macro2", + "syn 2.0.104", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2106,7 +2188,7 @@ dependencies = [ ] [[package]] -name = "pulseengine-mcp-auth" +name = "pulseengine-auth" version = "0.17.0" dependencies = [ "aes-gcm", @@ -2124,7 +2206,6 @@ dependencies = [ "keyring", "libc", "pbkdf2", - "pulseengine-mcp-protocol", "rand 0.8.5", "regex", "reqwest 0.11.27", @@ -2145,6 +2226,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "pulseengine-logging" +version = "0.17.0" +dependencies = [ + "chrono", + "hex", + "once_cell", + "regex", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "pulseengine-mcp-apps" +version = "0.1.0" +dependencies = [ + "rmcp", + "serde_json", +] + [[package]] name = "pulseengine-mcp-client" version = "0.17.0" @@ -2175,7 +2282,7 @@ dependencies = [ "jsonschema", "proptest", "proptest-derive", - "pulseengine-mcp-auth", + "pulseengine-auth", "pulseengine-mcp-protocol", "pulseengine-mcp-server", "pulseengine-mcp-transport", @@ -2205,7 +2312,7 @@ dependencies = [ "assert_matches", "async-trait", "futures", - "pulseengine-mcp-auth", + "pulseengine-auth", "pulseengine-mcp-protocol", "pulseengine-mcp-security", "pulseengine-mcp-server", @@ -2223,34 +2330,16 @@ dependencies = [ "uuid", ] -[[package]] -name = "pulseengine-mcp-logging" -version = "0.17.0" -dependencies = [ - "chrono", - "hex", - "once_cell", - "regex", - "serde", - "serde_json", - "thiserror 2.0.12", - "tokio", - "tracing", - "tracing-appender", - "tracing-subscriber", - "uuid", -] - [[package]] name = "pulseengine-mcp-macros" version = "0.17.0" dependencies = [ "anyhow", "async-trait", - "darling", + "darling 0.20.11", "matchit 0.8.4", "proc-macro2", - "pulseengine-mcp-auth", + "pulseengine-auth", "pulseengine-mcp-protocol", "pulseengine-mcp-server", "pulseengine-mcp-transport", @@ -2274,7 +2363,7 @@ dependencies = [ "async-trait", "chrono", "jsonschema", - "pulseengine-mcp-logging", + "pulseengine-logging", "schemars 0.8.22", "serde", "serde_json", @@ -2284,6 +2373,14 @@ dependencies = [ "validator", ] +[[package]] +name = "pulseengine-mcp-resources" +version = "0.1.0" +dependencies = [ + "matchit 0.8.4", + "rmcp", +] + [[package]] name = "pulseengine-mcp-security" version = "0.17.0" @@ -2307,92 +2404,91 @@ dependencies = [ ] [[package]] -name = "pulseengine-mcp-security-middleware" +name = "pulseengine-mcp-server" version = "0.17.0" dependencies = [ "anyhow", - "assert_matches", "async-trait", "axum", - "base64 0.22.1", + "axum-test", "chrono", - "dirs", "futures", - "hyper 1.6.0", - "jsonwebtoken", - "keyring", - "once_cell", + "prometheus", + "pulseengine-auth", + "pulseengine-logging", "pulseengine-mcp-protocol", - "rand 0.8.5", + "pulseengine-mcp-security", + "pulseengine-mcp-transport", "serde", "serde_json", - "serial_test", - "sha2", + "sysinfo", "tempfile", "thiserror 2.0.12", "tokio", "tokio-test", - "tower 0.4.13", - "tower-http 0.5.2", "tracing", + "tracing-subscriber", "uuid", - "validator", ] [[package]] -name = "pulseengine-mcp-server" +name = "pulseengine-mcp-transport" version = "0.17.0" dependencies = [ "anyhow", + "async-stream", "async-trait", "axum", - "axum-test", "chrono", "futures", - "prometheus", - "pulseengine-mcp-auth", - "pulseengine-mcp-logging", + "futures-util", + "hyper 1.6.0", "pulseengine-mcp-protocol", - "pulseengine-mcp-security", - "pulseengine-mcp-transport", + "regex", "serde", "serde_json", - "sysinfo", - "tempfile", "thiserror 2.0.12", "tokio", "tokio-test", + "tokio-tungstenite 0.20.1", + "tower 0.4.13", + "tower-http 0.5.2", "tracing", "tracing-subscriber", + "tungstenite 0.24.0", "uuid", ] [[package]] -name = "pulseengine-mcp-transport" +name = "pulseengine-security" version = "0.17.0" dependencies = [ "anyhow", - "async-stream", + "assert_matches", "async-trait", "axum", + "base64 0.22.1", "chrono", + "dirs", "futures", - "futures-util", "hyper 1.6.0", - "pulseengine-mcp-protocol", - "regex", + "jsonwebtoken", + "keyring", + "once_cell", + "rand 0.8.5", "serde", "serde_json", + "serial_test", + "sha2", + "tempfile", "thiserror 2.0.12", "tokio", "tokio-test", - "tokio-tungstenite 0.20.1", "tower 0.4.13", "tower-http 0.5.2", "tracing", - "tracing-subscriber", - "tungstenite 0.24.0", "uuid", + "validator", ] [[package]] @@ -2416,6 +2512,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -2437,6 +2539,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2475,6 +2588,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -2677,14 +2796,16 @@ dependencies = [ name = "resources-demo" version = "0.1.0" dependencies = [ - "async-trait", + "anyhow", "matchit 0.8.4", - "pulseengine-mcp-macros", - "pulseengine-mcp-protocol", - "pulseengine-mcp-server", + "pulseengine-mcp-resources", + "rmcp", + "schemars 1.0.4", "serde", "serde_json", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -2701,6 +2822,50 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "chrono", + "futures", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "pastey", + "pin-project-lite", + "rand 0.10.0", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.12", + "tokio", + "tokio-stream", + "tokio-util", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "rmcp-macros" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36ea0e100fadf81be85d7ff70f86cd805c7572601d4ab2946207f36540854b43" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.104", +] + [[package]] name = "rust-multipart-rfc7578_2" version = "0.6.1" @@ -2917,6 +3082,12 @@ dependencies = [ "libc", ] +[[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" @@ -3046,7 +3217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3057,7 +3228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3128,6 +3299,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "sse-stream" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb4dc4d33c68ec1f27d386b5610a351922656e1fdf5c05bbaad930cd1519479a" +dependencies = [ + "bytes", + "futures-util", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3456,6 +3640,8 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", + "hashbrown", "pin-project-lite", "tokio", ] @@ -3736,11 +3922,15 @@ checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" name = "ui-enabled-server" version = "0.1.0" dependencies = [ - "async-trait", - "pulseengine-mcp-protocol", - "pulseengine-mcp-server", + "anyhow", + "pulseengine-mcp-apps", + "rmcp", + "schemars 1.0.4", + "serde", "serde_json", "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -3748,14 +3938,11 @@ name = "ultra-simple" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", - "pulseengine-mcp-macros", - "pulseengine-mcp-protocol", - "pulseengine-mcp-server", + "rmcp", "schemars 1.0.4", "serde", - "serde_json", "tokio", + "tracing-subscriber", ] [[package]] @@ -3776,6 +3963,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -3867,7 +4060,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.11", "once_cell", "proc-macro-error2", "proc-macro2", @@ -3926,6 +4119,24 @@ dependencies = [ "wit-bindgen-rt", ] +[[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-bindgen" version = "0.2.100" @@ -3997,6 +4208,28 @@ dependencies = [ "unicode-ident", ] +[[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 = "wasm-streams" version = "0.4.2" @@ -4010,6 +4243,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.9.1", + "hashbrown", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.77" @@ -4427,6 +4672,26 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[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-rt" version = "0.39.0" @@ -4436,6 +4701,74 @@ dependencies = [ "bitflags 2.9.1", ] +[[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 2.0.104", + "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 2.0.104", + "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 2.9.1", + "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.1" diff --git a/Cargo.toml b/Cargo.toml index 1e5e8506..571d339d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] members = [ "mcp-protocol", - "mcp-logging", - "mcp-auth", + "pulseengine-logging", + "pulseengine-auth", "mcp-security", - "mcp-security-middleware", + "pulseengine-security", "mcp-transport", "mcp-server", "mcp-client", @@ -12,13 +12,14 @@ members = [ "mcp-external-validation", "integration-tests", "conformance-tests", - # Examples (6 total - consolidated from 19) + # Examples (5 total - consolidated from 19) "examples/hello-world", # Minimal starter example "examples/hello-world-with-auth", # Security/auth integration "examples/ultra-simple", # Macro showcase (8 lines) "examples/ui-enabled-server", # MCP Apps Extension (SEP-1865) "examples/resources-demo", # Resource handling patterns - "examples/conformance-server", # MCP conformance test server + "pulseengine-mcp-resources", + "pulseengine-mcp-apps", ] resolver = "2" @@ -103,10 +104,10 @@ serde_yaml = "0.9" # Framework internal dependencies (published versions) pulseengine-mcp-protocol = { version = "0.17.0", path = "mcp-protocol" } -pulseengine-mcp-logging = { version = "0.17.0", path = "mcp-logging" } -pulseengine-mcp-auth = { version = "0.17.0", path = "mcp-auth" } +pulseengine-logging = { version = "0.17.0", path = "pulseengine-logging" } +pulseengine-auth = { version = "0.17.0", path = "pulseengine-auth" } pulseengine-mcp-security = { version = "0.17.0", path = "mcp-security" } -pulseengine-mcp-security-middleware = { version = "0.17.0", path = "mcp-security-middleware" } +pulseengine-security = { version = "0.17.0", path = "pulseengine-security" } pulseengine-mcp-transport = { version = "0.17.0", path = "mcp-transport" } pulseengine-mcp-server = { version = "0.17.0", path = "mcp-server" } pulseengine-mcp-macros = { version = "0.17.0", path = "mcp-macros" } @@ -146,10 +147,10 @@ lto = false [patch.crates-io] # Patch published crates to use local versions for development pulseengine-mcp-protocol = { path = "mcp-protocol" } -pulseengine-mcp-logging = { path = "mcp-logging" } -pulseengine-mcp-auth = { path = "mcp-auth" } +pulseengine-logging = { path = "pulseengine-logging" } +pulseengine-auth = { path = "pulseengine-auth" } pulseengine-mcp-security = { path = "mcp-security" } -pulseengine-mcp-security-middleware = { path = "mcp-security-middleware" } +pulseengine-security = { path = "pulseengine-security" } pulseengine-mcp-transport = { path = "mcp-transport" } pulseengine-mcp-server = { path = "mcp-server" } pulseengine-mcp-macros = { path = "mcp-macros" } diff --git a/Dockerfile.validation b/Dockerfile.validation index db5373fa..c559d10e 100644 --- a/Dockerfile.validation +++ b/Dockerfile.validation @@ -19,16 +19,18 @@ RUN rustup show && rustc --version && cargo --version && cargo clippy --version # Copy workspace files COPY Cargo.toml ./ COPY mcp-protocol ./mcp-protocol/ -COPY mcp-logging ./mcp-logging/ -COPY mcp-auth ./mcp-auth/ +COPY pulseengine-logging ./pulseengine-logging/ +COPY pulseengine-auth ./pulseengine-auth/ COPY mcp-security ./mcp-security/ -COPY mcp-security-middleware ./mcp-security-middleware/ +COPY pulseengine-security ./pulseengine-security/ COPY mcp-transport ./mcp-transport/ COPY mcp-macros ./mcp-macros/ COPY mcp-server ./mcp-server/ COPY mcp-client ./mcp-client/ COPY mcp-external-validation ./mcp-external-validation/ COPY conformance-tests ./conformance-tests/ +COPY pulseengine-mcp-resources ./pulseengine-mcp-resources/ +COPY pulseengine-mcp-apps ./pulseengine-mcp-apps/ COPY examples ./examples/ COPY integration-tests ./integration-tests/ diff --git a/artifacts/migration-plan.yaml b/artifacts/migration-plan.yaml new file mode 100644 index 00000000..27dfe99c --- /dev/null +++ b/artifacts/migration-plan.yaml @@ -0,0 +1,311 @@ +artifacts: + # ══════════════════════════════════════════════════════════════════════ + # Design Decisions — Migration Architecture + # ══════════════════════════════════════════════════════════════════════ + + - id: DD-001 + type: design-decision + title: Use rmcp as protocol/transport base, PulseEngine as extension layer + status: accepted + description: > + Stop maintaining parallel MCP protocol types, server framework, + transport layer, and proc macros. Use rmcp for these. Retain and + evolve only the crates that provide functionality rmcp lacks: + security middleware, auth, observability, resource routing, MCP Apps. + fields: + rationale: > + rmcp has 6.3M downloads, dedicated maintainers, and tracks the + MCP spec within days of changes. Maintaining a parallel + implementation is unsustainable for a small team, especially when + the maintainer is moving focus toward CLI tooling. + alternatives: + - Continue maintaining full parallel stack (rejected: unsustainable) + - Abandon entirely and contribute to rmcp (rejected: loses unique value) + - Fork rmcp and merge our features (rejected: fork maintenance worse than current state) + links: + - type: satisfies + target: REQ-001 + + - id: DD-002 + type: design-decision + title: Target Tower/HTTP layer for security crates, not rmcp types + status: accepted + description: > + Refactored auth and security crates MUST operate at the Tower + middleware or HTTP request/response level, not at the MCP protocol + type level. This makes them framework-agnostic and resilient to + rmcp breaking changes. + fields: + rationale: > + rmcp uses Axum and supports Tower middleware via feature flag. + Operating at the HTTP layer means our security crates work with + rmcp, raw Axum, or any other Tower-compatible server. This also + eliminates the schemars 0.8 vs 1.0 problem since security + middleware doesn't need JSON schema generation. + alternatives: + - Couple directly to rmcp types (rejected: creates new tight coupling per UCA-003) + - Provide both generic and rmcp-specific APIs (rejected: doubles maintenance) + links: + - type: satisfies + target: REQ-030 + + - id: DD-003 + type: design-decision + title: Phased migration with deprecation-last ordering + status: accepted + description: > + Execute migration in 5 phases. Each phase produces a publishable + working state. Old crates are only deprecated in the final phase, + after all replacements are live and documented. + fields: + rationale: > + Per STPA constraints SC-001/SC-003, we must not deprecate before + replacements exist, and each phase must be independently completable. + This ordering minimizes risk of the half-migrated state (H-005). + alternatives: + - Big-bang migration (rejected: too risky, violates SC-003) + - Deprecate first to force migration (rejected: violates SC-001, CC-001) + links: + - type: satisfies + target: REQ-050 + + # ══════════════════════════════════════════════════════════════════════ + # Requirements — Migration Phases + # ══════════════════════════════════════════════════════════════════════ + + - id: REQ-010 + type: requirement + title: "Phase 0: PoC validation of rmcp integration points" + status: draft + description: > + Before any migration work, build minimal proof-of-concepts to + validate that the planned rmcp extensions are actually feasible. + + PoCs needed: + 1. Tower middleware intercepting rmcp streamable HTTP requests + (confirms auth/security can work at HTTP layer) + 2. ServerHandler implementing resource routing with matchit + (confirms resource router is possible) + 3. Content::text() returning HTML for MCP Apps + (confirms UI extension pattern works with rmcp types) + + Gate: All 3 PoCs must compile and demonstrate the pattern before + proceeding to Phase 1. + tags: [migration, phase-0] + fields: + priority: must + category: functional + links: + - type: satisfies + target: SC-004 + + - id: REQ-020 + type: requirement + title: "Phase 1: Extract generic crates (logging, security-middleware)" + status: draft + description: > + Extract the already-generic crates into standalone publishable form. + + Tasks: + 1. pulse-logging: Rename from mcp-logging. Remove any residual MCP + references from docs/types. Publish as standalone observability + crate (structured logging, credential scrubbing, metrics, alerting). + + 2. pulse-security: Rename from mcp-security-middleware. Remove the + unused mcp-protocol dependency from Cargo.toml. Publish as + standalone Axum/Tower security middleware (API key, JWT, CORS, + rate limiting, security headers). + + Neither crate requires any code changes — just Cargo.toml cleanup, + renaming, and documentation updates. + + Gate: Both crates published to crates.io and usable without any + mcp-* or rmcp dependency. + tags: [migration, phase-1] + fields: + priority: must + category: functional + links: + - type: satisfies + target: SC-003 + - type: depends-on + target: REQ-010 + + - id: REQ-030 + type: requirement + title: "Phase 2: Refactor auth crate to Tower layer" + status: draft + description: > + Refactor mcp-auth into pulse-auth, operating at the Tower/HTTP layer. + + Tasks: + 1. Extract generic auth core: AuthManager, RBAC, sessions, permissions, + crypto utilities. These have no MCP dependency. + 2. Replace McpAuthMiddleware (takes mcp Request) with Tower middleware + (takes http::Request). Auth decisions based on HTTP headers, + not MCP protocol content. + 3. Keep: API key management, role-based access, JWT sessions, + rate limiting, audit logging, credential scrubbing. + 4. Drop: MCP Request/Response processing, protocol-level validation + (this is now rmcp's job). + 5. Optional: thin pulse-auth-rmcp adapter crate if any rmcp-specific + integration is needed beyond Tower middleware. + + Gate: pulse-auth works as Tower middleware with a vanilla rmcp + streamable HTTP server. + tags: [migration, phase-2] + fields: + priority: must + category: functional + links: + - type: satisfies + target: SC-002 + - type: depends-on + target: REQ-020 + + - id: REQ-040 + type: requirement + title: "Phase 3: Build rmcp extension crates" + status: draft + description: > + Build the new crates that extend rmcp with unique PulseEngine + functionality. + + Tasks: + 1. pulse-mcp-resources: Resource router for rmcp. Uses matchit for + URI template routing. Implements ServerHandler delegation for + list_resources, read_resource, list_resource_templates. + Fills a real gap in the rmcp ecosystem. + + 2. pulse-mcp-apps: MCP Apps / UI Resources extension. Provides + helpers for serving interactive HTML via MCP resources/tools. + Port of the SEP-1865 implementation. + + Gate: Both crates work with rmcp ~1.3 and have working examples. + tags: [migration, phase-3] + fields: + priority: should + category: functional + links: + - type: depends-on + target: REQ-010 + + - id: REQ-050 + type: requirement + title: "Phase 4: Examples, migration guide, and deprecation" + status: draft + description: > + Finalize the migration and deprecate old crates. + + Tasks: + 1. Rewrite examples using rmcp + pulse-* crates: + - hello-world (rmcp + pulse-logging) + - hello-world-with-auth (rmcp + pulse-auth + pulse-security) + - resources-demo (rmcp + pulse-mcp-resources) + - ui-enabled-server (rmcp + pulse-mcp-apps) + + 2. Write migration guide documenting: + - Which old crate maps to which new crate + - Before/after code examples for common patterns + - What rmcp covers vs what pulse-* covers + + 3. Deprecate old crates on crates.io: + - Publish final version of each old crate with deprecation notice + in lib.rs (#![deprecated]) and README + - Deprecation message includes: replacement crate name, migration + guide URL, and explicit warning about security gaps if applicable + - Deprecate in order: protocol, transport, macros, server, client + (dependencies first) + + Gate: Migration guide reviewed, all examples working, deprecation + notices published. + tags: [migration, phase-4] + fields: + priority: must + category: functional + links: + - type: satisfies + target: SC-001 + - type: satisfies + target: SC-005 + - type: depends-on + target: REQ-020 + - type: depends-on + target: REQ-030 + - type: depends-on + target: REQ-040 + + # ══════════════════════════════════════════════════════════════════════ + # Features — New Crate Structure + # ══════════════════════════════════════════════════════════════════════ + + - id: FEAT-010 + type: feature + title: pulse-logging — standalone structured logging + status: draft + description: > + Structured logging with credential scrubbing, metrics collection, + alerting, correlation IDs, and performance profiling. Works with + any Rust service, not MCP-specific. + fields: + phase: phase-1 + links: + - type: satisfies + target: REQ-020 + + - id: FEAT-020 + type: feature + title: pulse-security — standalone Axum/Tower security middleware + status: draft + description: > + Zero-config security middleware: API key validation, JWT auth, + CORS, rate limiting, security headers. Development/staging/production + profiles. Works with any Axum/Tower service. + fields: + phase: phase-1 + links: + - type: satisfies + target: REQ-020 + + - id: FEAT-030 + type: feature + title: pulse-auth — generic authentication and authorization + status: draft + description: > + Authentication manager, RBAC (Admin/Operator/Monitor/Device/Custom), + session management, permission system, crypto utilities, audit + logging. Tower middleware interface. + fields: + phase: phase-2 + links: + - type: satisfies + target: REQ-030 + + - id: FEAT-040 + type: feature + title: pulse-mcp-resources — resource router for rmcp + status: draft + description: > + matchit-based URI template router for MCP resources. Plugs into + rmcp's ServerHandler. Provides automatic parameter extraction, + resource template registration, and subscription management. + Fills a gap in the rmcp ecosystem (their resource.rs is empty). + fields: + phase: phase-3 + links: + - type: satisfies + target: REQ-040 + + - id: FEAT-050 + type: feature + title: pulse-mcp-apps — MCP Apps / UI Resources for rmcp + status: draft + description: > + MCP Apps extension (SEP-1865) for rmcp. Helpers for serving + interactive HTML UIs via MCP resources and tool responses. + Unique capability not available in any other MCP SDK. + fields: + phase: phase-3 + links: + - type: satisfies + target: REQ-040 diff --git a/artifacts/requirements.yaml b/artifacts/requirements.yaml new file mode 100644 index 00000000..f85e58c7 --- /dev/null +++ b/artifacts/requirements.yaml @@ -0,0 +1,28 @@ +artifacts: + - id: REQ-001 + type: requirement + title: Sustainable MCP ecosystem participation + status: accepted + description: > + PulseEngine must participate in the Rust MCP ecosystem in a way that + is sustainable for a small team, leverages official SDK momentum, + and preserves unique value (security, observability, resource routing, + MCP Apps). + tags: [core, migration] + fields: + priority: must + category: functional + + - id: FEAT-001 + type: feature + title: rmcp-based extension architecture + status: draft + description: > + Replace full parallel MCP implementation with targeted extensions + built on top of the official rmcp SDK, providing production-hardening + features that rmcp lacks. + fields: + phase: phase-1 + links: + - type: satisfies + target: REQ-001 diff --git a/artifacts/stpa-migration.yaml b/artifacts/stpa-migration.yaml new file mode 100644 index 00000000..90690bd6 --- /dev/null +++ b/artifacts/stpa-migration.yaml @@ -0,0 +1,708 @@ +artifacts: + # ══════════════════════════════════════════════════════════════════════ + # STPA Step 1a — Losses + # What do we NOT want to happen during/after the rmcp migration? + # ══════════════════════════════════════════════════════════════════════ + + - id: L-001 + type: loss + title: Loss of downstream users + status: accepted + description: > + Existing users of pulseengine-mcp-* crates (e.g. Loxone MCP server) + are broken by the migration and abandon the ecosystem. + fields: + stakeholders: [crate-consumers, loxone-team] + + - id: L-002 + type: loss + title: Loss of unique functionality + status: accepted + description: > + Functionality that rmcp does NOT provide (security middleware, + resource routing, MCP Apps, credential scrubbing) is lost during + migration and never rebuilt. + fields: + stakeholders: [crate-consumers, maintainers] + + - id: L-003 + type: loss + title: Loss of crate ownership and namespace + status: accepted + description: > + Crates.io names (pulseengine-mcp-*) are deprecated without clear + migration path, confusing users and losing discoverability. + fields: + stakeholders: [crate-consumers, rust-community] + + - id: L-004 + type: loss + title: Prolonged maintenance burden + status: accepted + description: > + Migration stalls halfway, leaving maintainers with BOTH the old + crates and the new rmcp extensions to maintain simultaneously. + fields: + stakeholders: [maintainers] + + - id: L-005 + type: loss + title: Security regression + status: accepted + description: > + Moving from a stack with built-in security (auth, rate limiting, + input validation) to rmcp (which has none) introduces vulnerabilities + in production deployments. + fields: + stakeholders: [crate-consumers, end-users] + + # ══════════════════════════════════════════════════════════════════════ + # STPA Step 1b — Hazards + # System states that lead to losses + # ══════════════════════════════════════════════════════════════════════ + + - id: H-001 + type: hazard + title: API incompatibility between old and new crates + status: accepted + description: > + The new rmcp-based extension crates expose a fundamentally different + API surface, making migration non-trivial for downstream consumers. + fields: + severity: critical + links: + - type: leads-to-loss + target: L-001 + + - id: H-002 + type: hazard + title: rmcp type system mismatch + status: accepted + description: > + rmcp uses schemars v1.0 and its own protocol types. Extension crates + that were built around pulseengine-mcp-protocol types cannot simply + swap the dependency — structural differences cause compile failures + or semantic mismatches. + fields: + severity: critical + links: + - type: leads-to-loss + target: L-002 + - type: leads-to-loss + target: L-004 + + - id: H-003 + type: hazard + title: Deprecation without viable replacement + status: accepted + description: > + Crates are deprecated on crates.io before the replacement rmcp + extensions are published and stable, leaving a gap. + fields: + severity: critical + links: + - type: leads-to-loss + target: L-001 + - type: leads-to-loss + target: L-003 + + - id: H-004 + type: hazard + title: Security features not ported to rmcp layer + status: accepted + description: > + The auth middleware, input validation, rate limiting, and credential + scrubbing logic is tightly coupled to mcp-protocol types and cannot + be straightforwardly adapted to work with rmcp's type system. + fields: + severity: critical + links: + - type: leads-to-loss + target: L-002 + - type: leads-to-loss + target: L-005 + + - id: H-005 + type: hazard + title: Half-migrated state persists indefinitely + status: accepted + description: > + The migration is partially completed but stalls due to complexity, + leaving both old and new crates requiring maintenance. + fields: + severity: marginal + links: + - type: leads-to-loss + target: L-004 + + - id: H-006 + type: hazard + title: Resource router has no rmcp integration point + status: accepted + description: > + rmcp's resource handling is essentially empty (resource.rs is blank). + There is no clear trait or hook to plug a resource router into, + requiring either upstream contribution or workaround. + fields: + severity: marginal + links: + - type: leads-to-loss + target: L-002 + + # ══════════════════════════════════════════════════════════════════════ + # STPA Step 1c — System-level Constraints + # What must be true to prevent each hazard + # ══════════════════════════════════════════════════════════════════════ + + - id: SC-001 + type: system-constraint + title: Publish replacement before deprecation + status: accepted + description: > + New rmcp extension crates MUST be published and documented before + any old crate is deprecated on crates.io. Deprecation notices must + include the replacement crate name and migration guide. + links: + - type: prevents + target: H-003 + + - id: SC-002 + type: system-constraint + title: Security crates must be protocol-agnostic + status: accepted + description: > + Auth, security, and logging crates must be refactored to work at the + HTTP/Tower layer, not the MCP protocol layer. This makes them usable + with rmcp, axum, or any tower-compatible framework. + links: + - type: prevents + target: H-004 + + - id: SC-003 + type: system-constraint + title: Decouple in phases with working state at each step + status: accepted + description: > + Migration must proceed in discrete phases, each producing a working + publishable state. No phase should take more than 1-2 weeks. Old + crates remain functional until replacement is confirmed working. + links: + - type: prevents + target: H-005 + + - id: SC-004 + type: system-constraint + title: Validate rmcp integration points before committing + status: accepted + description: > + Before beginning the migration, build proof-of-concept integrations + with rmcp for each extension type (auth middleware, resource router, + MCP Apps) to confirm feasibility and identify API gaps. + links: + - type: prevents + target: H-002 + - type: prevents + target: H-006 + + - id: SC-005 + type: system-constraint + title: Provide migration guide for downstream consumers + status: accepted + description: > + A clear migration guide mapping old APIs to new must be published + alongside the deprecation. Include code examples showing before/after. + links: + - type: prevents + target: H-001 + + # ══════════════════════════════════════════════════════════════════════ + # STPA Step 2 — Control Structure + # Who/what controls this migration process? + # ══════════════════════════════════════════════════════════════════════ + + - id: CTRL-001 + type: controller + title: Maintainer (migration executor) + status: accepted + description: > + The project maintainer who decides what to migrate, when to deprecate, + and when to publish replacements. + fields: + controller-type: human + process-model: + - Belief about downstream usage patterns + - Assumption about rmcp API stability + - Assessment of own available time for migration + + - id: CTRL-002 + type: controller + title: CI/CD pipeline + status: accepted + description: > + Automated pipeline that builds, tests, and publishes crates. + Controls quality gates before publication. + fields: + controller-type: automated + source-file: .github/workflows/ + process-model: + - Test suite passes + - Cargo clippy clean + - Cargo publish succeeds + + - id: CTRL-003 + type: controller + title: rmcp upstream project + status: accepted + description: > + The official rmcp SDK maintained by the MCP org. Controls the API + surface, type system, and transport layer that extensions must + integrate with. Not under our control. + fields: + controller-type: human-and-automated + process-model: + - Spec compliance decisions + - Breaking change policy (post-1.0 semver) + - Feature prioritization + + - id: CP-001 + type: controlled-process + title: Crate ecosystem (crates.io state) + status: accepted + description: > + The set of published crates on crates.io — their versions, deprecation + status, and dependency relationships. + + - id: CP-002 + type: controlled-process + title: Downstream projects + status: accepted + description: > + Projects that depend on pulseengine-mcp-* crates (e.g. Loxone MCP + server). Their Cargo.toml and code depend on our published API. + + # ── Control Actions ── + + - id: CA-001 + type: control-action + title: Deprecate crate on crates.io + status: accepted + description: > + Mark a crate as deprecated with a message pointing to the replacement. + fields: + action: "cargo owner --add / yank old versions / publish deprecation version" + links: + - type: issued-by + target: CTRL-001 + - type: acts-on + target: CP-001 + + - id: CA-002 + type: control-action + title: Publish new rmcp extension crate + status: accepted + description: > + Publish a new crate (e.g. pulse-mcp-auth) that provides the same + functionality but built on rmcp types. + fields: + action: cargo publish new extension crate + links: + - type: issued-by + target: CTRL-001 + - type: acts-on + target: CP-001 + + - id: CA-003 + type: control-action + title: Release breaking rmcp version + status: accepted + description: > + rmcp upstream releases a new version with breaking changes to traits, + types, or transport APIs that our extensions depend on. + fields: + action: rmcp publishes new major/minor with breaking changes + links: + - type: issued-by + target: CTRL-003 + - type: acts-on + target: CP-002 + + - id: CA-004 + type: control-action + title: Run migration phase CI + status: accepted + description: > + CI pipeline validates that each migration phase compiles, passes tests, + and the old API is still functional. + fields: + action: cargo test --workspace && cargo clippy + links: + - type: issued-by + target: CTRL-002 + - type: acts-on + target: CP-001 + + # ══════════════════════════════════════════════════════════════════════ + # STPA Step 3 — Unsafe Control Actions + # ══════════════════════════════════════════════════════════════════════ + + - id: UCA-001 + type: uca + title: Deprecating before replacement is published + status: accepted + description: > + Maintainer deprecates old crates on crates.io BEFORE the replacement + rmcp extension crates are published and functional. + fields: + uca-type: too-early-too-late + context: > + During migration, maintainer is eager to clean up and deprecates + old crates before new ones are ready. + rationale: > + Downstream users see deprecation warnings but have no alternative + to migrate to, causing confusion and potential abandonment. + links: + - type: issued-by + target: CTRL-001 + - type: leads-to-hazard + target: H-003 + + - id: UCA-002 + type: uca + title: Not providing migration guide + status: accepted + description: > + Maintainer deprecates and publishes new crates but does NOT provide + a migration guide showing how to adapt existing code. + fields: + uca-type: not-providing + context: > + New crates are published, old ones deprecated, but the API + differences are non-trivial and undocumented. + rationale: > + Users cannot figure out how to migrate and either stay on the + deprecated crates or abandon the ecosystem. + links: + - type: issued-by + target: CTRL-001 + - type: leads-to-hazard + target: H-001 + + - id: UCA-003 + type: uca + title: Porting security crate with protocol coupling intact + status: accepted + description: > + Maintainer ports mcp-auth to work with rmcp but keeps the tight + coupling to protocol types, just swapping one protocol crate for + another. This makes the extension fragile to rmcp changes. + fields: + uca-type: providing + context: > + During refactoring of mcp-auth, the expedient path is to replace + mcp-protocol imports with rmcp imports rather than making it generic. + rationale: > + Creates a new tight coupling. When rmcp releases breaking changes, + the extension breaks. Defeats the purpose of decoupling. + links: + - type: issued-by + target: CTRL-001 + - type: leads-to-hazard + target: H-002 + + - id: UCA-004 + type: uca + title: Starting too many migration phases simultaneously + status: accepted + description: > + Maintainer begins refactoring multiple crates at once instead of + completing one phase before starting the next. + fields: + uca-type: too-early-too-late + context: > + Enthusiasm for the migration leads to starting auth, security, + resource router, and MCP Apps refactoring all at once. + rationale: > + No single phase reaches completion. Everything is half-done. + Workspace is in a broken state for extended periods. + links: + - type: issued-by + target: CTRL-001 + - type: leads-to-hazard + target: H-005 + + - id: UCA-005 + type: uca + title: rmcp breaking change with no pinned version + status: accepted + description: > + Extension crates depend on rmcp without pinning a specific version + range. An rmcp update breaks the extensions for all users. + fields: + uca-type: providing + context: > + rmcp is post-1.0 but still iterating rapidly. A minor release + changes trait signatures or type definitions. + rationale: > + All downstream users who run cargo update get broken builds. + links: + - type: issued-by + target: CTRL-003 + - type: leads-to-hazard + target: H-002 + + - id: UCA-006 + type: uca + title: Not validating rmcp integration points upfront + status: accepted + description: > + Maintainer begins full migration without first building PoCs to + confirm that rmcp's ServerHandler, Tower integration, and type + system actually support the needed extension patterns. + fields: + uca-type: not-providing + context: > + rmcp's resource.rs is empty. Tower integration is a feature flag. + Auth is client-side only. These gaps may block the migration. + rationale: > + Discovers blockers mid-migration, after significant work is done. + May need to wait for rmcp upstream fixes or redesign the approach. + links: + - type: issued-by + target: CTRL-001 + - type: leads-to-hazard + target: H-006 + - type: leads-to-hazard + target: H-005 + + # ══════════════════════════════════════════════════════════════════════ + # STPA Step 3b — Controller Constraints + # What must each controller do/not do? (inverse of UCAs) + # ══════════════════════════════════════════════════════════════════════ + + - id: CC-001 + type: controller-constraint + title: Deprecate only after replacement is live + status: accepted + fields: + constraint: > + Maintainer MUST NOT deprecate any crate on crates.io until the + replacement crate is published, documented, and has at least one + working example. + links: + - type: constrains-controller + target: CTRL-001 + - type: inverts-uca + target: UCA-001 + - type: prevents + target: H-003 + + - id: CC-002 + type: controller-constraint + title: Always publish migration guide with deprecation + status: accepted + fields: + constraint: > + Every deprecation notice MUST include a link to a migration guide. + The guide must show before/after code for the most common use cases. + links: + - type: constrains-controller + target: CTRL-001 + - type: inverts-uca + target: UCA-002 + - type: prevents + target: H-001 + + - id: CC-003 + type: controller-constraint + title: Security crates must target Tower/HTTP layer + status: accepted + fields: + constraint: > + When porting security/auth crates, MUST target the Tower middleware + or generic HTTP layer, NOT rmcp-specific types. The crate should + work with any axum/tower service, not just rmcp servers. + links: + - type: constrains-controller + target: CTRL-001 + - type: inverts-uca + target: UCA-003 + - type: prevents + target: H-002 + + - id: CC-004 + type: controller-constraint + title: One phase at a time, working state between phases + status: accepted + fields: + constraint: > + Maintainer MUST complete each migration phase (compilable, tested, + publishable) before starting the next. Maximum one active phase. + links: + - type: constrains-controller + target: CTRL-001 + - type: inverts-uca + target: UCA-004 + - type: prevents + target: H-005 + + - id: CC-005 + type: controller-constraint + title: Pin rmcp dependency to compatible range + status: accepted + fields: + constraint: > + Extension crates MUST pin rmcp to a specific minor version range + (e.g. "~1.3") and test against that range in CI. Bump only after + validating compatibility. + links: + - type: constrains-controller + target: CTRL-001 + - type: inverts-uca + target: UCA-005 + - type: prevents + target: H-002 + + - id: CC-006 + type: controller-constraint + title: Build PoCs before committing to migration + status: accepted + fields: + constraint: > + Before starting any migration phase, build a minimal proof-of-concept + that validates the rmcp integration point works. For resource router: + confirm ServerHandler can delegate resource calls. For auth: confirm + Tower middleware can intercept rmcp HTTP requests. + links: + - type: constrains-controller + target: CTRL-001 + - type: inverts-uca + target: UCA-006 + - type: prevents + target: H-006 + + - id: CC-007 + type: controller-constraint + title: Security extension must ship before old security crate is deprecated + status: accepted + fields: + constraint: > + The pulse-security Tower middleware crate MUST be published and + validated with at least one rmcp-based server before deprecating + mcp-auth or mcp-security. Deprecation notice for security crates + MUST explicitly warn that rmcp has no built-in security and point + to the replacement. + links: + - type: constrains-controller + target: CTRL-001 + - type: inverts-uca + target: UCA-001 + - type: prevents + target: H-004 + + # ══════════════════════════════════════════════════════════════════════ + # STPA Step 4 — Loss Scenarios + # How could each UCA actually happen? + # ══════════════════════════════════════════════════════════════════════ + + - id: LS-001 + type: loss-scenario + title: Deprecation race condition + status: accepted + description: > + Maintainer publishes deprecation version of old crate and new crate + on the same day. Crates.io index propagation delay means some users + see the deprecation but can't find the replacement yet. + fields: + scenario-type: coordination-failure + causal-factors: + - Crates.io index propagation delay + - Publishing old deprecation and new crate simultaneously + links: + - type: caused-by-uca + target: UCA-001 + - type: leads-to-hazard + target: H-003 + + - id: LS-002 + type: loss-scenario + title: rmcp trait sealed or non-extensible + status: accepted + description: > + During PoC phase, discover that rmcp's ServerHandler trait or + resource handling cannot be extended from outside the crate (sealed + traits, private fields, or missing extension points). Resource + router cannot be plugged in. + fields: + scenario-type: inadequate-control-algorithm + causal-factors: + - rmcp resource.rs is empty — no established extension pattern + - ServerHandler may not delegate to external routers + - May need upstream PR to rmcp to add extension points + links: + - type: caused-by-uca + target: UCA-006 + - type: leads-to-hazard + target: H-006 + + - id: LS-003 + type: loss-scenario + title: schemars v0.8 vs v1.0 type incompatibility + status: accepted + description: > + Extension crates that generate or consume JSON schemas find that + schemars 0.8 (used by existing code) and schemars 1.0 (used by + rmcp) produce incompatible schema representations. Types cannot be + shared across the boundary. + fields: + scenario-type: process-model-flaw + causal-factors: + - schemars 1.0 is a major rewrite with different API + - Cannot have both 0.8 and 1.0 in same dependency tree easily + - Auth/security crates may derive JsonSchema for config types + links: + - type: leads-to-hazard + target: H-002 + + - id: LS-004 + type: loss-scenario + title: Migration stalls after phase 1 + status: accepted + description: > + Phase 1 (extract generic logging/security) completes. Maintainer + moves focus to CLI tooling (stated priority shift). Remaining phases + (auth refactor, resource router, MCP Apps) never happen. Old crates + are not deprecated, new extensions not published. + fields: + scenario-type: controller-failure + causal-factors: + - Maintainer explicitly moving away from MCP toward CLI + - Remaining phases are harder (auth refactor, new crate development) + - No external pressure to complete since user base is small + links: + - type: caused-by-uca + target: UCA-004 + - type: leads-to-hazard + target: H-005 + + - id: LS-005 + type: loss-scenario + title: Security gap during transition + status: accepted + description: > + During migration, downstream users adopt rmcp directly (following + deprecation notices) but the security extension crates aren't ready + yet. They deploy MCP servers without auth, rate limiting, or input + validation — the features they had with the old stack. + fields: + scenario-type: inadequate-feedback + causal-factors: + - Deprecation notice says "use rmcp" but rmcp has no security layer + - Users assume rmcp includes equivalent security features + - Security extension crate not yet published + links: + - type: caused-by-uca + target: UCA-001 + - type: caused-by-uca + target: UCA-002 + - type: leads-to-hazard + target: H-004 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 00000000..c53a75e9 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,395 @@ +# Migration Guide: pulseengine-mcp → rmcp-based crates + +This guide covers migrating from the original PulseEngine MCP crates (v0.17 and earlier) +to the new structure built on the official [`rmcp`](https://docs.rs/rmcp) SDK. + +## Overview + +The original crates implemented the full MCP protocol stack from scratch. As `rmcp` +matured into a stable, well-maintained official SDK, maintaining a parallel implementation +became unnecessary. The new structure replaces the protocol/server/transport/macro +layer with `rmcp` directly, while retaining PulseEngine-specific extensions as thin, +focused crates. + +The result is fewer dependencies, less code to maintain, and direct access to `rmcp` +improvements as they land. + +--- + +## Quick Reference + +| Old Crate | Status | Replacement | +|---|---|---| +| `pulseengine-mcp-protocol` | Deprecated | `rmcp` model types | +| `pulseengine-mcp-server` | Deprecated | `rmcp::ServerHandler` trait | +| `pulseengine-mcp-transport` | Deprecated | `rmcp` stdio / streamable HTTP | +| `pulseengine-mcp-macros` | Deprecated | `rmcp` `#[tool]`, `#[tool_router]`, `#[tool_handler]` | +| `pulseengine-mcp-client` | Deprecated | `rmcp` client | +| `pulseengine-mcp-auth` | Renamed | `pulseengine-auth` (API changed, MCP types removed) | +| `pulseengine-mcp-logging` | Renamed | `pulseengine-logging` (name only, no API change) | +| `pulseengine-mcp-security-middleware` | Renamed | `pulseengine-security` (name only, no API change) | +| `pulseengine-mcp-monitoring` | Deprecated | Functionality in `pulseengine-logging` | +| `pulseengine-mcp-cli` | Removed | No replacement | +| `pulseengine-mcp-cli-derive` | Removed | No replacement | +| `pulseengine-mcp-security` | Removed | Merged into `pulseengine-security` | +| `pulseengine-mcp-external-validation` | Removed | Testing infra, not needed with `rmcp` | +| *(new)* `pulseengine-mcp-resources` | New | Resource URI template router for `rmcp` servers | +| *(new)* `pulseengine-mcp-apps` | New | MCP Apps / UI Resources extension for `rmcp` | + +--- + +## Dependency Changes + +### MCP server (tools only) + +**Before:** +```toml +[dependencies] +pulseengine-mcp-macros = "0.17" +pulseengine-mcp-server = "0.17" +schemars = "0.8" +serde = { version = "1", features = ["derive"] } +``` + +**After:** +```toml +[dependencies] +rmcp = { version = "1.3", features = ["server", "transport-io", "macros"] } +schemars = "0.8" +serde = { version = "1", features = ["derive"] } +tokio = { version = "1", features = ["full"] } +``` + +### Auth middleware + +**Before:** +```toml +[dependencies] +pulseengine-mcp-auth = "0.17" +``` + +**After:** +```toml +[dependencies] +pulseengine-auth = "0.18" +``` + +### Logging + +**Before:** +```toml +[dependencies] +pulseengine-mcp-logging = "0.17" +``` + +**After:** +```toml +[dependencies] +pulseengine-logging = "0.18" +``` + +### Security middleware + +**Before:** +```toml +[dependencies] +pulseengine-mcp-security-middleware = "0.17" +``` + +**After:** +```toml +[dependencies] +pulseengine-security = "0.18" +``` + +### Resources + +**Before:** implemented manually in `McpBackend::list_resources` / `read_resource`. + +**After:** +```toml +[dependencies] +rmcp = { version = "1.3", features = ["server"] } +pulseengine-mcp-resources = "0.1" +``` + +### MCP Apps (UI Resources) + +**Before:** required manual `ServerCapabilities` construction and raw content building. + +**After:** +```toml +[dependencies] +rmcp = { version = "1.3", features = ["server"] } +pulseengine-mcp-apps = "0.1" +``` + +--- + +## Code Changes + +### Simple MCP server + +**Before** (`pulseengine-mcp-macros` + `pulseengine-mcp-server`): +```rust +use pulseengine_mcp_macros::{mcp_server, mcp_tools}; +use pulseengine_mcp_server::McpServerBuilder; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GreetParams { + pub name: Option, +} + +#[mcp_server(name = "My Server")] +#[derive(Default, Clone)] +pub struct MyServer; + +#[mcp_tools] +impl MyServer { + /// Greet someone by name + pub async fn greet(&self, params: GreetParams) -> anyhow::Result { + let name = params.name.unwrap_or_else(|| "World".to_string()); + Ok(format!("Hello, {name}!")) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + MyServer::configure_stdio_logging(); + MyServer::with_defaults().serve_stdio().await?.run().await +} +``` + +**After** (`rmcp`): +```rust +use rmcp::{ServerHandler, transport::stdio, tool}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct GreetParams { + pub name: Option, +} + +#[derive(Default, Clone)] +pub struct MyServer; + +#[tool(tool_box)] +impl MyServer { + /// Greet someone by name + #[tool(description = "Greet someone by name")] + pub async fn greet(&self, #[tool(aggr)] params: GreetParams) -> String { + let name = params.name.unwrap_or_else(|| "World".to_string()); + format!("Hello, {name}!") + } +} + +#[tool(tool_box)] +impl ServerHandler for MyServer {} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let transport = stdio(); + MyServer::default().serve(transport).await?.waiting().await?; + Ok(()) +} +``` + +Key differences: +- `#[mcp_server]` + `#[mcp_tools]` → `#[tool(tool_box)]` on the impl block, plus + `impl ServerHandler` with the same attribute +- Tool parameters: use `#[tool(aggr)]` for a struct parameter, or `#[tool(param)]` for + individual named params +- Return type can be bare (no `anyhow::Result`) — rmcp handles the `CallToolResult` + wrapping +- `serve_stdio()` → `serve(stdio())`; await `.waiting()` to run until the client disconnects + +### Auth middleware + +The `McpAuthMiddleware` type (which consumed `pulseengine-mcp-protocol` types) has been +replaced by `pulseengine-auth`, which is transport-agnostic and no longer depends on MCP +protocol types. Integrate it as a Tower layer on your HTTP router. + +**Before:** +```rust +use pulseengine_mcp_auth::middleware::{McpAuthConfig, McpAuthMiddleware}; +use pulseengine_mcp_auth::AuthenticationManager; +use std::sync::Arc; + +let auth_manager = Arc::new(AuthenticationManager::new(config).await?); +let middleware = McpAuthMiddleware::with_default_config(auth_manager); + +// Processes raw MCP Request / Response types +let (sanitized_req, ctx) = middleware.process_request(request, Some(&headers)).await?; +``` + +**After** (Tower layer on Axum): +```rust +use axum::{Router, routing::get, middleware::from_fn}; +use pulseengine_security::{SecurityConfig, SecurityMiddleware}; + +let security_config = SecurityConfig::development(); // or ::production(), etc. +let middleware = security_config.create_middleware().await?; + +let app = Router::new() + .route("/mcp", get(mcp_handler)) + .layer(from_fn(move |req, next| { + let mw = middleware.clone(); + async move { mw.process(req, next).await } + })); +``` + +The `SecurityMiddleware` handles API key validation, JWT verification, rate limiting, +HTTPS enforcement, security headers, and audit logging in one layer. Authentication +context is inserted into Axum request extensions as `AuthContext`. + +### Logging + +Import rename only — no API changes. + +**Before:** +```rust +use pulseengine_mcp_logging::StructuredLogger; +``` + +**After:** +```rust +use pulseengine_logging::StructuredLogger; +``` + +### Security middleware + +Import rename only — no API changes. + +**Before:** +```rust +use pulseengine_mcp_security_middleware::SecurityConfig; +``` + +**After:** +```rust +use pulseengine_security::SecurityConfig; +``` + +### Resources + +Manual resource routing in `McpBackend` is replaced by `ResourceRouter` from +`pulseengine-mcp-resources`, which integrates with `rmcp`'s `ServerHandler` trait. + +**Before** (manual matching in `McpBackend`): +```rust +async fn read_resource(&self, params: ReadResourceRequestParam) + -> Result +{ + match params.uri.as_str() { + s if s.starts_with("user://") => { + let id = s.trim_start_matches("user://"); + // ... fetch and return + } + s if s.starts_with("config://") => { /* ... */ } + _ => Err(CommonMcpError::InvalidParams("not found".into())), + } +} +``` + +**After** (`pulseengine-mcp-resources`): +```rust +use pulseengine_mcp_resources::{ResourceRouter, strip_uri_scheme}; +use rmcp::model::ResourceContents; + +let mut router = ResourceRouter::::new(); + +router.add_resource( + "/user/{id}", // matchit route pattern + "user://{id}", // MCP URI template (advertised to clients) + "user", // resource name + "Get user by ID", // description + Some("application/json"), + |state: &MyState, uri: &str, params: &matchit::Params| { + let id = params.get("id").unwrap_or("unknown"); + ResourceContents::text(state.get_user(id), uri) + }, +); + +// In ServerHandler::read_resource: +if let Some(contents) = router.resolve(&state, ¶ms.uri) { + return Ok(ReadResourceResult { contents: vec![contents] }); +} + +// In ServerHandler::list_resource_templates: +// router.templates() returns Vec +``` + +### MCP Apps (UI Resources) + +**Before** (manual capability + content construction using `pulseengine-mcp-protocol`): +```rust +use pulseengine_mcp_protocol::{ + ServerCapabilities, Resource, ResourceContents, Content, ToolMeta, +}; + +fn get_server_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_resources() + // extensions had to be set manually as raw JSON + .build(), + // ... + } +} + +// In call_tool: +Content::ui_html("ui://dashboard", html) + +// In list_resources: +Resource::ui_resource("ui://dashboard", "Dashboard", "An HTML dashboard") + +// In read_resource: +ResourceContents::html_ui(uri, html) +``` + +**After** (`pulseengine-mcp-apps` + `rmcp`): +```rust +use pulseengine_mcp_apps::{ + mcp_apps_capabilities, html_tool_result, html_resource, app_resource, +}; +use rmcp::model::ServerCapabilities; + +fn get_info(&self) -> ServerInfo { + ServerInfo { + capabilities: ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .enable_extensions_with(mcp_apps_capabilities()) + .build(), + // ... + } +} + +// In call_tool — return HTML content: +return Ok(html_tool_result("

Dashboard

")); + +// In list_resources — advertise the app resource: +app_resource("ui://dashboard", "dashboard", Some("Dashboard"), Some("My dashboard")) + +// In read_resource — serve the HTML: +html_resource("ui://dashboard", "

Dashboard

") +``` + +--- + +## Timeline + +The old crates (`pulseengine-mcp-protocol`, `pulseengine-mcp-server`, +`pulseengine-mcp-transport`, `pulseengine-mcp-macros`, `pulseengine-mcp-client`, +`pulseengine-mcp-auth`, `pulseengine-mcp-logging`, `pulseengine-mcp-security-middleware`) +will receive `#[deprecated]` notices in their `lib.rs` pointing here. They will not be +yanked from crates.io. Patch releases may continue for critical bug fixes, but no new +features will be added. + +The deprecated-with-no-replacement crates (`pulseengine-mcp-monitoring`, +`pulseengine-mcp-cli`, `pulseengine-mcp-cli-derive`, `pulseengine-mcp-security`, +`pulseengine-mcp-external-validation`) are already stale and receive no further updates. diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..334a3347 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,17 @@ +# mcp + +Getting started with your rivet project. + +## Overview + +This project uses [rivet](https://github.com/pulseengine/rivet) for SDLC artifact +traceability and validation. Artifacts are stored as YAML files in `artifacts/` and +validated against schemas listed in `rivet.yaml`. + +## Quick start + +```bash +rivet validate # Validate all artifacts +rivet list # List all artifacts +rivet stats # Show summary statistics +``` diff --git a/docs/superpowers/plans/2026-03-28-rmcp-migration-phase0-phase1.md b/docs/superpowers/plans/2026-03-28-rmcp-migration-phase0-phase1.md new file mode 100644 index 00000000..e410c280 --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-rmcp-migration-phase0-phase1.md @@ -0,0 +1,1182 @@ +# rmcp Migration Phase 0 + Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Validate rmcp integration points with 3 PoCs, then extract the two already-generic crates as standalone packages. + +**Architecture:** Phase 0 builds three minimal proof-of-concept projects in a separate `poc/` workspace that depends on `rmcp ~1.3`. Each validates one extension pattern (Tower auth, resource routing, MCP Apps). Phase 1 renames `mcp-logging` and `mcp-security-middleware` in the main workspace, removing MCP-specific references from package metadata and docs. + +**Tech Stack:** Rust (edition 2024), rmcp 1.3, schemars 1.0, axum 0.7, tower 0.5 (or matching rmcp's version), matchit 0.8, tokio + +**Spec:** `docs/superpowers/specs/2026-03-28-rmcp-migration-phase0-phase1-design.md` +**STPA:** `artifacts/stpa-migration.yaml` + +--- + +## Task 1: Create PoC workspace scaffold + +**Files:** +- Create: `poc/Cargo.toml` +- Create: `poc/tower-auth/Cargo.toml` +- Create: `poc/tower-auth/src/main.rs` +- Create: `poc/resource-router/Cargo.toml` +- Create: `poc/resource-router/src/main.rs` +- Create: `poc/mcp-apps/Cargo.toml` +- Create: `poc/mcp-apps/src/main.rs` + +- [ ] **Step 1: Create the poc workspace Cargo.toml** + +```toml +# poc/Cargo.toml +[workspace] +members = [ + "tower-auth", + "resource-router", + "mcp-apps", +] +resolver = "2" +``` + +- [ ] **Step 2: Create tower-auth Cargo.toml** + +```toml +# poc/tower-auth/Cargo.toml +[package] +name = "poc-tower-auth" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +rmcp = { version = "1.3", features = ["server", "macros", "transport-streamable-http-server"] } +axum = "0.7" +tower = "0.5" +tower-service = "0.3" +tower-layer = "0.3" +http = "1" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } +serde = { version = "1", features = ["derive"] } +schemars = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +anyhow = "1" +``` + +- [ ] **Step 3: Create resource-router Cargo.toml** + +```toml +# poc/resource-router/Cargo.toml +[package] +name = "poc-resource-router" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +rmcp = { version = "1.3", features = ["server", "macros", "transport-io"] } +matchit = "0.8" +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +anyhow = "1" +``` + +- [ ] **Step 4: Create mcp-apps Cargo.toml** + +```toml +# poc/mcp-apps/Cargo.toml +[package] +name = "poc-mcp-apps" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +rmcp = { version = "1.3", features = ["server", "macros", "transport-io"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" +anyhow = "1" +``` + +- [ ] **Step 5: Create placeholder main.rs files** + +Create minimal `fn main() {}` in each `src/main.rs` to verify the workspace compiles: + +```rust +// poc/tower-auth/src/main.rs +fn main() { + println!("poc-tower-auth placeholder"); +} +``` + +```rust +// poc/resource-router/src/main.rs +fn main() { + println!("poc-resource-router placeholder"); +} +``` + +```rust +// poc/mcp-apps/src/main.rs +fn main() { + println!("poc-mcp-apps placeholder"); +} +``` + +- [ ] **Step 6: Verify workspace compiles** + +Run: `cd poc && cargo check` +Expected: successful compilation, rmcp 1.3.x resolved + +- [ ] **Step 7: Commit** + +```bash +git add poc/ +git commit -m "feat: add poc workspace for rmcp migration validation" +``` + +--- + +## Task 2: PoC 1 — Tower Auth Middleware + +**Files:** +- Modify: `poc/tower-auth/src/main.rs` + +- [ ] **Step 1: Write the Tower auth middleware and MCP server** + +Replace `poc/tower-auth/src/main.rs` with: + +```rust +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +use axum::Router; +use http::{Request, Response, StatusCode}; +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolResult, Content, ServerCapabilities, ServerInfo}, + schemars, tool, tool_handler, tool_router, + service::RequestContext, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::local::LocalSessionManager, + }, + ErrorData, RoleServer, ServerHandler, +}; +use tower::{Layer, Service}; +use tower_layer::layer_fn; +use tracing_subscriber::EnvFilter; + +// ── Auth types ────────────────────────────────────────────────────── + +#[derive(Clone, Debug)] +struct AuthContext { + user: String, + role: String, +} + +// ── Tower middleware ──────────────────────────────────────────────── + +#[derive(Clone)] +struct AuthLayer { + token: String, +} + +impl AuthLayer { + fn new(token: impl Into) -> Self { + Self { token: token.into() } + } +} + +impl Layer for AuthLayer { + type Service = AuthService; + + fn layer(&self, inner: S) -> Self::Service { + AuthService { + inner, + token: self.token.clone(), + } + } +} + +#[derive(Clone)] +struct AuthService { + inner: S, + token: String, +} + +impl Service> for AuthService +where + S: Service, Response = Response> + Clone + Send + 'static, + S::Future: Send + 'static, + B: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, mut req: Request) -> Self::Future { + let expected = format!("Bearer {}", self.token); + let auth_header = req + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .map(String::from); + + if auth_header.as_deref() != Some(&expected) { + return Box::pin(async { + Ok(Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(axum::body::Body::from("Unauthorized")) + .unwrap()) + }); + } + + // Auth passed — inject AuthContext into extensions + req.extensions_mut().insert(AuthContext { + user: "admin".to_string(), + role: "operator".to_string(), + }); + + let mut svc = self.inner.clone(); + Box::pin(async move { svc.call(req).await }) + } +} + +// ── MCP Server ───────────────────────────────────────────────────── + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct WhoamiParams {} + +#[derive(Debug, Clone)] +struct AuthDemo { + tool_router: ToolRouter, +} + +#[tool_router] +impl AuthDemo { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + /// Returns the authenticated user's identity from the Tower auth layer. + #[tool(description = "Returns the authenticated user and role")] + fn whoami( + &self, + _params: Parameters, + ctx: RequestContext, + ) -> Result { + // Access http::request::Parts from the RequestContext extensions + let auth = ctx + .extensions + .get::() + .and_then(|parts| parts.extensions.get::()) + .cloned(); + + match auth { + Some(auth) => Ok(CallToolResult::success(vec![Content::text( + format!("user={}, role={}", auth.user, auth.role), + )])), + None => Ok(CallToolResult::success(vec![Content::text( + "no auth context available".to_string(), + )])), + } + } +} + +#[tool_handler] +impl ServerHandler for AuthDemo { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + } +} + +// ── Main ─────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let ct = tokio_util::sync::CancellationToken::new(); + + let mcp_service = StreamableHttpService::new( + || Ok(AuthDemo::new()), + LocalSessionManager::default().into(), + StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), + ); + + // Wrap with auth layer BEFORE mounting in router + let app = Router::new() + .nest_service("/mcp", mcp_service) + .layer(AuthLayer::new("secret-token")); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?; + tracing::info!("PoC 1: Tower Auth — listening on http://127.0.0.1:8080/mcp"); + tracing::info!(" Test: curl -H 'Authorization: Bearer secret-token' http://127.0.0.1:8080/mcp"); + + axum::serve(listener, app) + .with_graceful_shutdown(async move { + tokio::signal::ctrl_c().await.ok(); + ct.cancel(); + }) + .await?; + + Ok(()) +} +``` + +- [ ] **Step 2: Compile the PoC** + +Run: `cd poc && cargo check -p poc-tower-auth` +Expected: compiles successfully. If there are type mismatches with rmcp's API (e.g. `Response` body type, `Service` bounds), fix them — this IS the validation. + +- [ ] **Step 3: Run and test manually** + +Run: `cd poc && cargo run -p poc-tower-auth` + +Test in another terminal: +```bash +# Should return 401 +curl -v http://127.0.0.1:8080/mcp + +# Should get MCP response (SSE or JSON) +curl -v -H "Authorization: Bearer secret-token" \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"0.1"}}}' \ + http://127.0.0.1:8080/mcp +``` + +Expected: First curl returns 401. Second curl returns MCP initialize response. + +- [ ] **Step 4: Record result and commit** + +Document in a comment at the top of main.rs whether: +- ✅ Tower layer intercepts before rmcp +- ✅ AuthContext is accessible in tool handler via RequestContext +- ❌ (and what failed, if anything) + +```bash +git add poc/tower-auth/ +git commit -m "feat(poc): validate Tower auth middleware with rmcp" +``` + +--- + +## Task 3: PoC 2 — Resource Router + +**Files:** +- Modify: `poc/resource-router/src/main.rs` + +- [ ] **Step 1: Write the resource router and MCP server** + +Replace `poc/resource-router/src/main.rs` with: + +```rust +use std::{collections::HashMap, sync::Arc}; + +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ + Annotated, CallToolResult, Content, ListResourceTemplatesResult, + ListResourcesResult, PaginatedRequestParams, RawResourceTemplate, + ReadResourceRequestParams, ReadResourceResult, ResourceContents, + ServerCapabilities, ServerInfo, + }, + schemars, tool, tool_handler, tool_router, + service::RequestContext, + ErrorData, RoleServer, ServerHandler, ServiceExt, +}; +use tracing_subscriber::EnvFilter; + +// ── Resource Router ──────────────────────────────────────────────── + +type ResourceHandler = Arc ResourceContents + Send + Sync>; + +struct ResourceRoute { + template: Annotated, + handler: ResourceHandler, +} + +struct ResourceRouter { + router: matchit::Router, + routes: Vec, +} + +impl ResourceRouter { + fn new() -> Self { + Self { + router: matchit::Router::new(), + routes: Vec::new(), + } + } + + fn add( + &mut self, + uri_template: &str, + name: &str, + description: &str, + handler: impl Fn(&matchit::Params) -> ResourceContents + Send + Sync + 'static, + ) { + let idx = self.routes.len(); + // matchit uses {param} syntax, MCP uses {param} in URI templates — compatible + self.router.insert(uri_template, idx).expect("valid route pattern"); + self.routes.push(ResourceRoute { + template: Annotated::from(RawResourceTemplate { + uri_template: uri_template.to_string(), + name: name.to_string(), + title: None, + description: Some(description.to_string()), + mime_type: Some("text/plain".to_string()), + icons: None, + }), + handler: Arc::new(handler), + }); + } + + fn templates(&self) -> Vec> { + self.routes.iter().map(|r| r.template.clone()).collect() + } + + fn resolve(&self, uri: &str) -> Option { + let matched = self.router.at(uri).ok()?; + let route = &self.routes[*matched.value]; + Some((route.handler)(&matched.params)) + } +} + +// ── MCP Server ───────────────────────────────────────────────────── + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct PingParams {} + +#[derive(Clone)] +struct ResourceDemo { + tool_router: ToolRouter, + resources: Arc, +} + +impl std::fmt::Debug for ResourceDemo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResourceDemo").finish() + } +} + +#[tool_router] +impl ResourceDemo { + fn new() -> Self { + let mut resources = ResourceRouter::new(); + + resources.add( + "/files/{path}", + "file", + "Read a file by path", + |params| { + let path = params.get("path").unwrap_or("unknown"); + ResourceContents::TextResourceContents { + uri: format!("file:///{path}"), + mime_type: Some("text/plain".to_string()), + text: format!("Contents of file: {path}"), + meta: None, + } + }, + ); + + resources.add( + "/config/{section}/{key}", + "config", + "Read a config value", + |params| { + let section = params.get("section").unwrap_or("default"); + let key = params.get("key").unwrap_or("unknown"); + ResourceContents::TextResourceContents { + uri: format!("config://{section}/{key}"), + mime_type: Some("application/json".to_string()), + text: format!(r#"{{"section":"{section}","key":"{key}","value":"mock-value"}}"#), + meta: None, + } + }, + ); + + Self { + tool_router: Self::tool_router(), + resources: Arc::new(resources), + } + } + + #[tool(description = "Simple ping tool")] + fn ping(&self, _params: Parameters) -> String { + "pong".to_string() + } +} + +#[tool_handler] +impl ServerHandler for ResourceDemo { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + } + + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: self.resources.templates(), + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + // Strip scheme and authority to get the matchit-compatible path + let uri = &request.uri; + let path = uri_to_matchit_path(uri); + + match self.resources.resolve(&path) { + Some(contents) => Ok(ReadResourceResult { + contents: vec![contents], + meta: None, + }), + None => Err(ErrorData::resource_not_found( + format!("No resource matches URI: {uri}"), + None, + )), + } + } +} + +/// Convert an MCP URI like "file:///README.md" or "config://db/host" +/// to a matchit-compatible path like "/files/README.md" or "/config/db/host". +/// +/// Strategy: strip the scheme, normalize to a routable path. +fn uri_to_matchit_path(uri: &str) -> String { + if let Some(rest) = uri.strip_prefix("file:///") { + format!("/files/{rest}") + } else if let Some(rest) = uri.strip_prefix("config://") { + format!("/config/{rest}") + } else { + // Fallback: treat the whole URI as a path + format!("/{uri}") + } +} + +// ── Main ─────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let server = ResourceDemo::new(); + + // Test the resource router directly before running stdio + tracing::info!("Testing resource router..."); + + let path1 = uri_to_matchit_path("file:///README.md"); + let result1 = server.resources.resolve(&path1); + tracing::info!(?result1, "file:///README.md"); + + let path2 = uri_to_matchit_path("config://database/host"); + let result2 = server.resources.resolve(&path2); + tracing::info!(?result2, "config://database/host"); + + let path3 = uri_to_matchit_path("unknown://foo"); + let result3 = server.resources.resolve(&path3); + tracing::info!(?result3, "unknown://foo (should be None)"); + + tracing::info!("Resource router validation complete."); + tracing::info!("Starting stdio MCP server — connect with MCP Inspector or Claude Desktop."); + + let transport = rmcp::transport::io::stdio(); + let service = server.serve(transport).await?; + service.waiting().await?; + + Ok(()) +} +``` + +- [ ] **Step 2: Compile the PoC** + +Run: `cd poc && cargo check -p poc-resource-router` +Expected: compiles. Key things that might need adjustment: +- `ResourceContents` variant names (may be `TextResourceContents` as a struct variant or via a constructor) +- `Annotated::from` — verify this works or use `Annotated { raw: ..., annotations: None }` +- `ErrorData::resource_not_found` — check if this constructor exists or use `ErrorData::new` +- `ListResourceTemplatesResult` field names + +Fix any compilation errors — discovering these is the point of the PoC. + +- [ ] **Step 3: Run and verify output** + +Run: `cd poc && RUST_LOG=info cargo run -p poc-resource-router` + +Expected output: +``` +Testing resource router... +file:///README.md → Some(TextResourceContents { text: "Contents of file: README.md", ... }) +config://database/host → Some(TextResourceContents { text: "{...database...host...}", ... }) +unknown://foo (should be None) → None +Resource router validation complete. +Starting stdio MCP server... +``` + +- [ ] **Step 4: Record result and commit** + +```bash +git add poc/resource-router/ +git commit -m "feat(poc): validate resource router with rmcp ServerHandler" +``` + +--- + +## Task 4: PoC 3 — MCP Apps / UI Resources + +**Files:** +- Modify: `poc/mcp-apps/src/main.rs` + +- [ ] **Step 1: Write the MCP Apps server** + +Replace `poc/mcp-apps/src/main.rs` with: + +```rust +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ + Annotated, CallToolResult, Content, ListResourcesResult, + PaginatedRequestParams, RawResource, ReadResourceRequestParams, + ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, + }, + schemars, tool, tool_handler, tool_router, + service::RequestContext, + ErrorData, RoleServer, ServerHandler, ServiceExt, +}; +use serde_json::json; +use tracing_subscriber::EnvFilter; + +// ── MCP Apps Server ──────────────────────────────────────────────── + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct RenderChartParams { + /// Chart title + title: String, +} + +#[derive(Debug, Clone)] +struct McpAppsDemo { + tool_router: ToolRouter, +} + +const DASHBOARD_HTML: &str = r#" + + + MCP Dashboard + + + +

MCP Server Dashboard

+
+

Active Connections

+
42
+
+
+

Tools Called

+
1,337
+
+ +"#; + +#[tool_router] +impl McpAppsDemo { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } + + /// Renders an HTML chart with the given title. + #[tool(description = "Render an interactive HTML chart")] + fn render_chart( + &self, + Parameters(params): Parameters, + ) -> Result { + let html = format!( + r#"
+

{}

+ + + + + +
"#, + params.title + ); + Ok(CallToolResult::success(vec![Content::text(html)])) + } +} + +#[tool_handler] +impl ServerHandler for McpAppsDemo { + fn get_info(&self) -> ServerInfo { + let mut caps = ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(); + + // Declare MCP Apps extension capability + let mut extensions = serde_json::Map::new(); + extensions.insert( + "io.modelcontextprotocol/ui".to_string(), + json!({ "mimeTypes": ["text/html"] }), + ); + caps.extensions = Some(extensions); + + ServerInfo::new(caps) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![Annotated::from(RawResource { + uri: "ui://dashboard".to_string(), + name: "Dashboard".to_string(), + title: Some("Server Dashboard".to_string()), + description: Some("Interactive HTML dashboard".to_string()), + mime_type: Some("text/html".to_string()), + size: None, + icons: None, + meta: None, + })], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + if request.uri == "ui://dashboard" { + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri: "ui://dashboard".to_string(), + mime_type: Some("text/html".to_string()), + text: DASHBOARD_HTML.to_string(), + meta: None, + }], + meta: None, + }) + } else { + Err(ErrorData::resource_not_found( + format!("Unknown resource: {}", request.uri), + None, + )) + } + } +} + +// ── Main ─────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + tracing::info!("PoC 3: MCP Apps — starting stdio server"); + + // Quick validation: check that our types work + let server = McpAppsDemo::new(); + let info = server.get_info(); + tracing::info!(?info.capabilities.extensions, "MCP Apps capability declared"); + + let transport = rmcp::transport::io::stdio(); + let service = server.serve(transport).await?; + service.waiting().await?; + + Ok(()) +} +``` + +- [ ] **Step 2: Compile the PoC** + +Run: `cd poc && cargo check -p poc-mcp-apps` +Expected: compiles. Key things to validate: +- `ServerCapabilities.extensions` field type matches our `serde_json::Map` usage +- `Annotated::from(RawResource { ... })` works +- `ResourceContents::TextResourceContents` variant syntax +- `ErrorData::resource_not_found` constructor + +Fix compilation errors as needed. + +- [ ] **Step 3: Run and verify output** + +Run: `cd poc && RUST_LOG=info cargo run -p poc-mcp-apps` + +Expected: logs show MCP Apps capability declared with `"io.modelcontextprotocol/ui"` extension. Server starts on stdio. + +- [ ] **Step 4: Record result and commit** + +```bash +git add poc/mcp-apps/ +git commit -m "feat(poc): validate MCP Apps UI resources with rmcp" +``` + +--- + +## Task 5: Phase 0 Gate — Assess PoC Results + +**Files:** +- Create: `poc/RESULTS.md` + +- [ ] **Step 1: Create results document** + +After all 3 PoCs, create `poc/RESULTS.md` summarizing: + +```markdown +# PoC Results — rmcp Migration Validation + +## PoC 1: Tower Auth Middleware +- [ ] Tower layer intercepts HTTP requests before rmcp +- [ ] 401 returned for unauthenticated requests +- [ ] AuthContext accessible in tool handler via RequestContext.extensions +- Notes: (any API adjustments needed) + +## PoC 2: Resource Router +- [ ] matchit routes MCP URIs after scheme normalization +- [ ] list_resource_templates returns registered templates +- [ ] read_resource dispatches to correct handler with extracted params +- [ ] Unknown URIs return proper error +- Notes: (any API adjustments needed) + +## PoC 3: MCP Apps +- [ ] HTML content served via ResourceContents with text/html mime type +- [ ] HTML content returned from tool via Content::text() +- [ ] MCP Apps extension declared in ServerCapabilities +- Notes: (any API adjustments needed) + +## Gate Decision +- [ ] All 3 PoCs pass → proceed to Phase 1 +- [ ] Blockers found → document and reassess +``` + +- [ ] **Step 2: Fill in results based on PoC outcomes** + +Update each checkbox and notes section with actual results. + +- [ ] **Step 3: Commit** + +```bash +git add poc/RESULTS.md +git commit -m "docs(poc): record rmcp migration PoC results" +``` + +- [ ] **Step 4: Evaluate gate** + +If all 3 pass → proceed to Task 6 (Phase 1). +If any blocker → stop, document the issue, and discuss with maintainer. + +--- + +## Task 6: Phase 1a — Rename mcp-logging to pulseengine-logging + +**Files:** +- Modify: `mcp-logging/Cargo.toml` +- Modify: `mcp-logging/src/lib.rs` +- Modify: `Cargo.toml` (workspace root) + +- [ ] **Step 1: Update mcp-logging/Cargo.toml** + +Change: +```toml +name = "pulseengine-mcp-logging" +description = "Structured logging framework for MCP servers - PulseEngine MCP Framework" +documentation = "https://docs.rs/pulseengine-mcp-logging" +keywords = ["mcp", "logging", "structured", "metrics", "tracing"] +``` + +To: +```toml +name = "pulseengine-logging" +description = "Structured logging with credential scrubbing, metrics, alerting, and correlation IDs" +documentation = "https://docs.rs/pulseengine-logging" +keywords = ["logging", "structured", "metrics", "tracing", "security"] +``` + +- [ ] **Step 2: Update lib.rs doc comment** + +Change the top doc comment in `mcp-logging/src/lib.rs` from: +```rust +//! Structured logging framework for MCP servers +//! +//! This crate provides comprehensive logging capabilities for MCP servers including: +``` + +To: +```rust +//! Structured logging framework with security-aware features +//! +//! This crate provides comprehensive logging capabilities including: +``` + +And update the example import from: +```rust +//! use pulseengine_mcp_logging::{MetricsCollector, StructuredLogger}; +``` + +To: +```rust +//! use pulseengine_logging::{MetricsCollector, StructuredLogger}; +``` + +- [ ] **Step 3: Update lib name in Cargo.toml** + +Add explicit lib section if not present: +```toml +[lib] +name = "pulseengine_logging" +path = "src/lib.rs" +``` + +- [ ] **Step 4: Update workspace root Cargo.toml** + +In the `[workspace.dependencies]` section, change: +```toml +pulseengine-mcp-logging = { version = "0.17.0", path = "mcp-logging" } +``` +To: +```toml +pulseengine-logging = { version = "0.17.0", path = "mcp-logging" } +``` + +And in `[patch.crates-io]`, change: +```toml +pulseengine-mcp-logging = { path = "mcp-logging" } +``` +To: +```toml +pulseengine-logging = { path = "mcp-logging" } +``` + +- [ ] **Step 5: Update all workspace crates that depend on mcp-logging** + +Search for `pulseengine-mcp-logging` in all Cargo.toml files and update to `pulseengine-logging`. Also search for `pulseengine_mcp_logging` in all `.rs` files and update to `pulseengine_logging`. + +Run: +```bash +grep -r "pulseengine.mcp.logging" --include="*.toml" --include="*.rs" -l +``` + +Update each file found. + +- [ ] **Step 6: Verify compilation** + +Run: `cargo check --workspace` +Expected: compiles. All references to the old name resolved. + +- [ ] **Step 7: Run tests** + +Run: `cargo test -p pulseengine-logging` +Expected: all tests pass. + +- [ ] **Step 8: Verify no "MCP" in public docs** + +Run: `cargo doc -p pulseengine-logging --no-deps 2>&1 | grep -i "mcp"` — should be empty or only in internal comments. + +- [ ] **Step 9: Commit** + +```bash +git add -A +git commit -m "refactor: rename mcp-logging to pulseengine-logging + +Generic structured logging crate — not MCP-specific. +Provides credential scrubbing, metrics, alerting, correlation IDs." +``` + +--- + +## Task 7: Phase 1b — Rename mcp-security-middleware to pulseengine-security + +**Files:** +- Modify: `mcp-security-middleware/Cargo.toml` +- Modify: `mcp-security-middleware/src/lib.rs` +- Modify: `Cargo.toml` (workspace root) + +- [ ] **Step 1: Update mcp-security-middleware/Cargo.toml** + +Change: +```toml +name = "pulseengine-mcp-security-middleware" +keywords = ["mcp", "security", "middleware", "authentication", "framework"] +description = "Zero-configuration security middleware for MCP servers with Axum integration" +documentation = "https://docs.rs/pulseengine-mcp-security-middleware" +``` + +To: +```toml +name = "pulseengine-security" +keywords = ["security", "middleware", "authentication", "axum", "tower"] +description = "Zero-configuration security middleware for Axum/Tower with API key, JWT, CORS, and rate limiting" +documentation = "https://docs.rs/pulseengine-security" +``` + +Remove the unused dependency: +```toml +# DELETE this line: +pulseengine-mcp-protocol = { workspace = true } +``` + +Update the lib section: +```toml +[lib] +name = "pulseengine_security" +path = "src/lib.rs" +``` + +- [ ] **Step 2: Update lib.rs doc comment** + +Change the top of `mcp-security-middleware/src/lib.rs`: +```rust +//! # PulseEngine MCP Security Middleware +//! +//! Zero-configuration security middleware for MCP servers with Axum integration. +``` + +To: +```rust +//! # PulseEngine Security Middleware +//! +//! Zero-configuration security middleware for Axum/Tower services. +``` + +Update `- **MCP Compliance**: Follows 2025 MCP security best practices` to: +`- **Standards Compliant**: Follows OWASP security best practices` + +Update the example import from: +```rust +//! use pulseengine_mcp_security_middleware::*; +``` + +To: +```rust +//! use pulseengine_security::*; +``` + +- [ ] **Step 3: Update workspace root Cargo.toml** + +In `[workspace.dependencies]`: +```toml +pulseengine-security = { version = "0.17.0", path = "mcp-security-middleware" } +``` + +In `[patch.crates-io]`: +```toml +pulseengine-security = { path = "mcp-security-middleware" } +``` + +Remove the old entries for `pulseengine-mcp-security-middleware`. + +- [ ] **Step 4: Update all workspace crates that depend on this** + +Search and update: +```bash +grep -r "pulseengine.mcp.security.middleware" --include="*.toml" --include="*.rs" -l +``` + +Update each file found — both Cargo.toml dependency names and Rust `use`/`extern crate` statements. + +- [ ] **Step 5: Verify compilation** + +Run: `cargo check --workspace` +Expected: compiles without the mcp-protocol dependency. + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p pulseengine-security` +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor: rename mcp-security-middleware to pulseengine-security + +Generic Axum/Tower security middleware — not MCP-specific. +Remove unused mcp-protocol dependency." +``` + +--- + +## Task 8: Final Validation + +**Files:** (none modified) + +- [ ] **Step 1: Full workspace build** + +Run: `cargo check --workspace` +Expected: clean build, no warnings about missing crates. + +- [ ] **Step 2: Full test suite** + +Run: `cargo test --workspace` +Expected: all tests pass. + +- [ ] **Step 3: Verify no broken cross-references** + +Run: +```bash +# Check for any remaining references to old crate names +grep -r "pulseengine.mcp.logging" --include="*.toml" --include="*.rs" +grep -r "pulseengine.mcp.security.middleware" --include="*.toml" --include="*.rs" +``` + +Expected: no results (all references updated). + +- [ ] **Step 4: Validate rivet artifacts** + +Run: `rivet validate` +Expected: PASS + +- [ ] **Step 5: Commit any fixes** + +If any issues were found and fixed: +```bash +git add -A +git commit -m "fix: resolve remaining references to old crate names" +``` diff --git a/docs/superpowers/specs/2026-03-28-rmcp-migration-phase0-phase1-design.md b/docs/superpowers/specs/2026-03-28-rmcp-migration-phase0-phase1-design.md new file mode 100644 index 00000000..3d921051 --- /dev/null +++ b/docs/superpowers/specs/2026-03-28-rmcp-migration-phase0-phase1-design.md @@ -0,0 +1,218 @@ +# rmcp Migration — Phase 0 + Phase 1 Design + +## Context + +PulseEngine MCP currently maintains 12 crates implementing the full MCP protocol +stack. The official Rust SDK (`rmcp` v1.3.0, 6.3M downloads) now covers the +protocol, transport, and macro layers well. This design covers the first two +migration phases: validating rmcp integration points (Phase 0) and extracting +the already-generic crates as standalone packages (Phase 1). + +STPA analysis: `artifacts/stpa-migration.yaml` +Migration plan: `artifacts/migration-plan.yaml` + +## Naming Convention + +New crates use `pulseengine-` prefix, dropping `-mcp-` for generic crates: + +| Old Name | New Name | Reason | +|---|---|---| +| `pulseengine-mcp-logging` | `pulseengine-logging` | Not MCP-specific | +| `pulseengine-mcp-security-middleware` | `pulseengine-security` | Not MCP-specific | +| `pulseengine-mcp-auth` | `pulseengine-auth` | Not MCP-specific (future phase) | +| (new) | `pulseengine-mcp-resources` | MCP-specific rmcp extension | +| (new) | `pulseengine-mcp-apps` | MCP-specific rmcp extension | + +## Phase 0 — PoC Validation + +### Purpose + +Validate that rmcp's API surface supports the three extension patterns we need +before committing to migration work. Per STPA constraint SC-004 / CC-006. + +Gate: all three PoCs compile and demonstrate the integration pattern. If any +fails, stop and reassess. + +### Structure + +``` +poc/ +├── Cargo.toml (workspace with 3 members) +├── tower-auth/ (PoC 1) +│ ├── Cargo.toml +│ └── src/main.rs +├── resource-router/ (PoC 2) +│ ├── Cargo.toml +│ └── src/main.rs +└── mcp-apps/ (PoC 3) + ├── Cargo.toml + └── src/main.rs +``` + +The `poc/` directory is a separate workspace (not part of the main workspace) to +avoid polluting the existing build with rmcp dependencies. + +### PoC 1: Tower Auth Middleware (`poc/tower-auth/`) + +**Validates:** Security middleware can intercept rmcp HTTP requests at the Tower +layer without touching MCP protocol types. + +**Dependencies:** `rmcp` (features: server, transport-streamable-http-server, +macros), `axum`, `tower`, `tokio`, `serde`, `schemars` + +**Implementation:** + +1. Define a simple `AuthLayer` / `AuthService` Tower middleware that: + - Extracts `Authorization: Bearer ` from request headers + - Returns 401 if missing or invalid (hardcoded token for PoC) + - Inserts `AuthContext { user: String, role: String }` into + `http::Extensions` if valid + +2. Define a minimal MCP server with one tool (`whoami`) that: + - Reads `AuthContext` from `RequestContext` extensions + - Returns the authenticated user's name and role + +3. Wire up: + ```rust + let mcp_service = StreamableHttpService::new(factory, session_mgr, config); + let app = Router::new() + .nest_service("/mcp", mcp_service) + .layer(AuthLayer::new("secret-token")); + ``` + +**Success criteria:** +- Unauthenticated request to `/mcp` returns 401 +- Authenticated request reaches the tool handler +- Tool handler can read `AuthContext` from the request context + +### PoC 2: Resource Router (`poc/resource-router/`) + +**Validates:** Resource URI template routing works via `ServerHandler` override, +using `matchit` for pattern matching. + +**Dependencies:** `rmcp` (features: server, transport-io, macros), `matchit`, +`tokio`, `serde`, `schemars` + +**Implementation:** + +1. Define a `ResourceRouter` struct that wraps `matchit::Router`: + - `ResourceHandler` is a boxed async closure: `Box ResourceContents>` + - Registration: `.add_template("file:///{path}", handler)` — the full URI + template is stored for `list_resource_templates`, but `matchit` routes on + the scheme + path combined (e.g. `"file:///{path}"` routes as-is since + matchit treats `://` as literal path segments). If matchit rejects URI + schemes, fall back to: strip scheme, route on path, store scheme separately. + - Matching: `.route(uri) -> Option<(handler, params)>` + +2. Define a `ServerHandler` impl that: + - Stores a `ResourceRouter` and a list of `ResourceTemplate` metadata + - `list_resource_templates()` returns the registered templates + - `read_resource()` matches the request URI against the router, extracts + params, calls the handler + - Also has `#[tool_router]` tools for comparison + +3. Register 2-3 example resources: + - `file:///{path}` — returns mock file contents + - `config://{section}/{key}` — returns mock config values + +**Success criteria:** +- `list_resource_templates` returns registered templates +- `read_resource("file:///README.md")` matches the template and returns content +- `read_resource("config://database/host")` extracts section=database, key=host +- Unknown URIs return an error + +### PoC 3: MCP Apps / UI Resources (`poc/mcp-apps/`) + +**Validates:** Interactive HTML can be served via rmcp's type system using the +MCP Apps extension pattern. + +**Dependencies:** `rmcp` (features: server, transport-io, macros), `tokio`, +`serde`, `schemars` + +**Implementation:** + +1. Define a `ServerHandler` impl with: + - A resource `ui://dashboard` that returns HTML via + `ResourceContents::text("text/html", html_string)` + - A tool `render_chart` that returns HTML via `Content::text(html)` + - MCP Apps capability declared in `ServerCapabilities.extensions`: + `"io.modelcontextprotocol/ui": { "mimeTypes": ["text/html"] }` + +2. The HTML content is a simple self-contained dashboard (inline CSS/JS, + no external deps) showing mock data. + +**Success criteria:** +- `read_resource("ui://dashboard")` returns HTML with `text/html` mime type +- `call_tool("render_chart")` returns HTML in a text content block +- Server capabilities include the MCP Apps extension declaration +- rmcp's type system doesn't reject or mangle the HTML content + +## Phase 1 — Extract Generic Crates + +### Prerequisites + +- All Phase 0 PoCs pass (gate) +- This confirms rmcp integration is viable before we touch the main workspace + +### 1a: `pulseengine-logging` (from `mcp-logging`) + +**Current state:** 8,677 LOC. Zero internal mcp-* dependencies. Already fully +generic. Provides structured logging, credential scrubbing, metrics, alerting, +correlation IDs, performance profiling. + +**Changes needed:** + +| File | Change | +|---|---| +| `Cargo.toml` | `name = "pulseengine-logging"`, remove any workspace version inheritance if publishing standalone, update description to remove "MCP" references | +| `lib.rs` | Update module docs to describe as generic structured logging crate | +| `README.md` | Rewrite: standalone crate, not MCP-specific. Usage examples without MCP context | + +**No code changes required.** The implementation is already protocol-agnostic. + +**Validation:** `cargo test -p pulseengine-logging` passes, `cargo doc` builds +cleanly, no references to "mcp" in public API docs. + +### 1b: `pulseengine-security` (from `mcp-security-middleware`) + +**Current state:** 3,045 LOC. Pure Axum/Tower HTTP middleware. The +`mcp-protocol` dependency in Cargo.toml is never used in code (false dependency). +Provides API key validation, JWT auth, CORS, rate limiting, security headers, +dev/staging/prod profiles. + +**Changes needed:** + +| File | Change | +|---|---| +| `Cargo.toml` | `name = "pulseengine-security"`, remove `pulseengine-mcp-protocol` dependency, update description | +| `lib.rs` | Update module docs | +| `README.md` | Rewrite as standalone security middleware crate | + +**No code changes required** beyond removing the unused dependency. + +**Validation:** `cargo test -p pulseengine-security` passes, `cargo doc` builds +cleanly, confirm no compile errors after removing mcp-protocol dep. + +### Deprecation (deferred) + +Old crates (`pulseengine-mcp-logging`, `pulseengine-mcp-security-middleware`) +are NOT deprecated in Phase 1. Per STPA constraint CC-001, deprecation happens +only in Phase 4 after all replacements are live and documented. The old crates +continue to work — they're just frozen at v0.17.0. + +## Out of Scope + +- Phase 2 (auth refactor to Tower layer) +- Phase 3 (new rmcp extension crates) +- Phase 4 (examples, migration guide, deprecation) +- Publishing to crates.io (that happens after the spec is validated) +- Changes to the existing mcp-* crates beyond Phase 1 renaming + +## Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| rmcp's `RequestContext` doesn't expose HTTP extensions | PoC 1 validates this explicitly. Research confirms `http::request::Parts` are injected. | +| `matchit` URI template syntax doesn't match MCP URI templates | PoC 2 tests real MCP URIs. Fallback: use regex-based matching. | +| rmcp rejects HTML content or strips mime types | PoC 3 validates end-to-end. `Content::text()` is a simple string wrapper. | +| Phase 1 crates have hidden MCP dependencies we missed | Validation step: grep for "mcp" in compiled output and public docs. | diff --git a/examples/conformance-server/Cargo.toml b/examples/conformance-server/Cargo.toml deleted file mode 100644 index f3a04e0f..00000000 --- a/examples/conformance-server/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "conformance-server" -version = "0.1.0" -edition = "2021" -description = "MCP conformance test server implementing all required fixtures" - -[[bin]] -name = "conformance-server" -path = "src/main.rs" - -[dependencies] -pulseengine-mcp-server = { path = "../../mcp-server" } -pulseengine-mcp-protocol = { path = "../../mcp-protocol" } -tokio = { version = "1.0", features = ["full"] } -async-trait = "0.1" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -base64 = "0.22" diff --git a/examples/conformance-server/src/main.rs b/examples/conformance-server/src/main.rs deleted file mode 100644 index 4e9d7f51..00000000 --- a/examples/conformance-server/src/main.rs +++ /dev/null @@ -1,739 +0,0 @@ -//! MCP Conformance Test Server -//! -//! This server implements all fixtures required by the official -//! `@modelcontextprotocol/conformance` test suite. -//! -//! Run with: cargo run --bin conformance-server -//! Test with: npx @modelcontextprotocol/conformance server --url http://localhost:3000/mcp - -use async_trait::async_trait; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use pulseengine_mcp_protocol::*; -use pulseengine_mcp_server::common_backend::CommonMcpError; -use pulseengine_mcp_server::{ - try_current_context, CreateMessageRequest, ElicitationRequest, McpBackend, McpServer, - SamplingContent, SamplingMessage, SamplingRole, ServerConfig, TransportConfig, -}; - -/// Minimal 1x1 red PNG image (base64 encoded) -const MINIMAL_PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; - -/// Minimal WAV audio (44 bytes: RIFF header + minimal data) -fn minimal_wav_base64() -> String { - // Minimal valid WAV: 44-byte header with 0 data samples - let wav_bytes: Vec = vec![ - 0x52, 0x49, 0x46, 0x46, // "RIFF" - 0x24, 0x00, 0x00, 0x00, // File size - 8 - 0x57, 0x41, 0x56, 0x45, // "WAVE" - 0x66, 0x6D, 0x74, 0x20, // "fmt " - 0x10, 0x00, 0x00, 0x00, // Subchunk1Size (16) - 0x01, 0x00, // AudioFormat (1 = PCM) - 0x01, 0x00, // NumChannels (1) - 0x44, 0xAC, 0x00, 0x00, // SampleRate (44100) - 0x88, 0x58, 0x01, 0x00, // ByteRate - 0x02, 0x00, // BlockAlign - 0x10, 0x00, // BitsPerSample (16) - 0x64, 0x61, 0x74, 0x61, // "data" - 0x00, 0x00, 0x00, 0x00, // Subchunk2Size (0) - ]; - BASE64.encode(&wav_bytes) -} - -#[derive(Clone)] -struct ConformanceBackend; - -#[async_trait] -impl McpBackend for ConformanceBackend { - type Error = CommonMcpError; - type Config = (); - - async fn initialize(_config: Self::Config) -> std::result::Result { - Ok(Self) - } - - fn get_server_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::default(), - capabilities: ServerCapabilities::builder() - .enable_tools() - .enable_resources() - .enable_prompts() - .enable_logging() - .build(), - server_info: Implementation::new("MCP Conformance Test Server", "1.0.0"), - instructions: Some("Conformance test server for MCP protocol validation".to_string()), - } - } - - async fn health_check(&self) -> std::result::Result<(), Self::Error> { - Ok(()) - } - - // ==================== TOOLS ==================== - - async fn list_tools( - &self, - _params: PaginatedRequestParam, - ) -> std::result::Result { - let empty_schema = serde_json::json!({ - "type": "object", - "properties": {} - }); - - Ok(ListToolsResult { - tools: vec![ - // tools-call-simple-text - Tool { - name: "test_simple_text".to_string(), - title: Some("Simple Text Tool".to_string()), - description: "Returns simple text content".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-image - Tool { - name: "test_image_content".to_string(), - title: Some("Image Content Tool".to_string()), - description: "Returns image content".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-audio - Tool { - name: "test_audio_content".to_string(), - title: Some("Audio Content Tool".to_string()), - description: "Returns audio content".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-embedded-resource - Tool { - name: "test_embedded_resource".to_string(), - title: Some("Embedded Resource Tool".to_string()), - description: "Returns embedded resource content".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-mixed-content (test_multiple_content_types) - Tool { - name: "test_multiple_content_types".to_string(), - title: Some("Multiple Content Types Tool".to_string()), - description: "Returns multiple content types".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-with-logging - Tool { - name: "test_tool_with_logging".to_string(), - title: Some("Tool With Logging".to_string()), - description: "Tool that emits log notifications".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-error - Tool { - name: "test_error_handling".to_string(), - title: Some("Error Handling Tool".to_string()), - description: "Tool that returns an error".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-with-progress - Tool { - name: "test_tool_with_progress".to_string(), - title: Some("Tool With Progress".to_string()), - description: "Tool that emits progress notifications".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-sampling - Tool { - name: "test_sampling".to_string(), - title: Some("Sampling Tool".to_string()), - description: "Tool that requests LLM sampling".to_string(), - input_schema: empty_schema.clone(), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - // tools-call-elicitation - Tool { - name: "test_elicitation".to_string(), - title: Some("Elicitation Tool".to_string()), - description: "Tool that requests user input".to_string(), - input_schema: empty_schema, - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, - }, - ], - next_cursor: None, - }) - } - - async fn call_tool( - &self, - request: CallToolRequestParam, - ) -> std::result::Result { - match request.name.as_str() { - "test_simple_text" => Ok(CallToolResult { - content: vec![Content::text( - "This is a simple text response from the test tool.", - )], - is_error: Some(false), - structured_content: None, - _meta: None, - }), - - "test_image_content" => Ok(CallToolResult { - content: vec![Content::image(MINIMAL_PNG, "image/png")], - is_error: Some(false), - structured_content: None, - _meta: None, - }), - - "test_audio_content" => Ok(CallToolResult { - content: vec![Content::audio(minimal_wav_base64(), "audio/wav")], - is_error: Some(false), - structured_content: None, - _meta: None, - }), - - "test_embedded_resource" => Ok(CallToolResult { - content: vec![Content::resource( - "test://static-text", - Some("text/plain".to_string()), - Some("Embedded resource content".to_string()), - )], - is_error: Some(false), - structured_content: None, - _meta: None, - }), - - "test_multiple_content_types" => Ok(CallToolResult { - content: vec![ - Content::text("Text content"), - Content::image(MINIMAL_PNG, "image/png"), - Content::resource( - "test://static-text", - Some("text/plain".to_string()), - Some("Resource content".to_string()), - ), - ], - is_error: Some(false), - structured_content: None, - _meta: None, - }), - - "test_tool_with_logging" => { - // Send log notifications if context is available - if let Some(ctx) = try_current_context() { - // Send a few log messages at different levels - let _ = ctx - .send_log( - LogLevel::Info, - Some("conformance_test"), - serde_json::json!({"message": "Starting tool execution"}), - ) - .await; - let _ = ctx - .send_log( - LogLevel::Debug, - Some("conformance_test"), - serde_json::json!({"step": 1, "action": "processing"}), - ) - .await; - let _ = ctx - .send_log( - LogLevel::Info, - Some("conformance_test"), - serde_json::json!({"message": "Tool execution completed"}), - ) - .await; - } - Ok(CallToolResult { - content: vec![Content::text("Tool executed with logging")], - is_error: Some(false), - structured_content: None, - _meta: None, - }) - } - - "test_error_handling" => Ok(CallToolResult { - content: vec![Content::text("This is an error message from the tool")], - is_error: Some(true), - structured_content: None, - _meta: None, - }), - - "test_tool_with_progress" => { - // Send progress notifications if context is available - if let Some(ctx) = try_current_context() { - // Simulate progress over a few steps - let total = 10u64; - for i in 0..=total { - let _ = ctx.send_progress(i, Some(total)).await; - // Small delay to make progress visible in tests - tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; - } - } - Ok(CallToolResult { - content: vec![Content::text("Tool executed with progress")], - is_error: Some(false), - structured_content: None, - _meta: None, - }) - } - - "test_sampling" => { - // Request LLM sampling if context is available - if let Some(ctx) = try_current_context() { - let sampling_request = CreateMessageRequest { - messages: vec![SamplingMessage { - role: SamplingRole::User, - content: SamplingContent::Text { - text: "What is 2 + 2? Answer with just the number.".to_string(), - }, - }], - system_prompt: Some("You are a helpful assistant.".to_string()), - max_tokens: 100, - temperature: Some(0.0), - ..Default::default() - }; - - match ctx - .request_sampling(sampling_request, std::time::Duration::from_secs(30)) - .await - { - Ok(response) => { - let response_text = match &response.content { - SamplingContent::Text { text } => text.clone(), - SamplingContent::Image { .. } => "Image response".to_string(), - }; - return Ok(CallToolResult { - content: vec![Content::text(format!( - "LLM response: {} (model: {})", - response_text, response.model - ))], - is_error: Some(false), - structured_content: None, - _meta: None, - }); - } - Err(e) => { - return Ok(CallToolResult { - content: vec![Content::text(format!("Sampling error: {e}"))], - is_error: Some(true), - structured_content: None, - _meta: None, - }); - } - } - } - // No context available - return a message indicating this - Ok(CallToolResult { - content: vec![Content::text("Sampling not available (no context)")], - is_error: Some(false), - structured_content: None, - _meta: None, - }) - } - - "test_elicitation" => { - // Request user input via elicitation if context is available - if let Some(ctx) = try_current_context() { - let elicitation_request = ElicitationRequest { - message: "Please provide your name:".to_string(), - requested_schema: serde_json::json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Your name" - } - }, - "required": ["name"] - }), - meta: None, - }; - - match ctx - .request_elicitation( - elicitation_request, - std::time::Duration::from_secs(60), - ) - .await - { - Ok(response) => { - let user_input = response - .content - .as_ref() - .and_then(|c| c.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let action = match &response.action { - pulseengine_mcp_server::ElicitationAction::Accept => "accepted", - pulseengine_mcp_server::ElicitationAction::Decline => "declined", - pulseengine_mcp_server::ElicitationAction::Cancel => "cancelled", - }; - return Ok(CallToolResult { - content: vec![Content::text(format!( - "User {action} with name: {user_input}" - ))], - is_error: Some(false), - structured_content: None, - _meta: None, - }); - } - Err(e) => { - return Ok(CallToolResult { - content: vec![Content::text(format!("Elicitation error: {e}"))], - is_error: Some(true), - structured_content: None, - _meta: None, - }); - } - } - } - // No context available - return a message indicating this - Ok(CallToolResult { - content: vec![Content::text("Elicitation not available (no context)")], - is_error: Some(false), - structured_content: None, - _meta: None, - }) - } - - _ => Err(CommonMcpError::InvalidParams(format!( - "Unknown tool: {}", - request.name - ))), - } - } - - // ==================== RESOURCES ==================== - - async fn list_resources( - &self, - _params: PaginatedRequestParam, - ) -> std::result::Result { - Ok(ListResourcesResult { - resources: vec![ - // resources-read-text - Resource { - uri: "test://static-text".to_string(), - name: "Static Text Resource".to_string(), - title: None, - description: Some("A static text resource for conformance testing".to_string()), - mime_type: Some("text/plain".to_string()), - annotations: None, - icons: None, - raw: None, - _meta: None, - }, - // resources-read-binary - Resource { - uri: "test://static-binary".to_string(), - name: "Static Binary Resource".to_string(), - title: None, - description: Some("A static binary resource (PNG image)".to_string()), - mime_type: Some("image/png".to_string()), - annotations: None, - icons: None, - raw: None, - _meta: None, - }, - // resources-subscribe / resources-unsubscribe - Resource { - uri: "test://watched-resource".to_string(), - name: "Watched Resource".to_string(), - title: None, - description: Some("A resource that can be subscribed to".to_string()), - mime_type: Some("text/plain".to_string()), - annotations: None, - icons: None, - raw: None, - _meta: None, - }, - ], - next_cursor: None, - }) - } - - async fn list_resource_templates( - &self, - _params: PaginatedRequestParam, - ) -> std::result::Result { - Ok(ListResourceTemplatesResult { - resource_templates: vec![ - // resources-templates-read - ResourceTemplate { - uri_template: "test://template/{id}/data".to_string(), - name: "Template Resource".to_string(), - description: Some("A parameterized template resource".to_string()), - mime_type: Some("text/plain".to_string()), - }, - ], - next_cursor: None, - }) - } - - async fn read_resource( - &self, - params: ReadResourceRequestParam, - ) -> std::result::Result { - let uri = ¶ms.uri; - - // Handle static resources - if uri == "test://static-text" { - return Ok(ReadResourceResult { - contents: vec![ResourceContents { - uri: uri.clone(), - mime_type: Some("text/plain".to_string()), - text: Some("This is the content of the static text resource.".to_string()), - blob: None, - _meta: None, - }], - }); - } - - if uri == "test://static-binary" { - return Ok(ReadResourceResult { - contents: vec![ResourceContents { - uri: uri.clone(), - mime_type: Some("image/png".to_string()), - text: None, - blob: Some(MINIMAL_PNG.to_string()), - _meta: None, - }], - }); - } - - if uri == "test://watched-resource" { - return Ok(ReadResourceResult { - contents: vec![ResourceContents { - uri: uri.clone(), - mime_type: Some("text/plain".to_string()), - text: Some("Watched resource content".to_string()), - blob: None, - _meta: None, - }], - }); - } - - // Handle template resources: test://template/{id}/data - if uri.starts_with("test://template/") && uri.ends_with("/data") { - let id = uri - .strip_prefix("test://template/") - .and_then(|s| s.strip_suffix("/data")) - .unwrap_or("unknown"); - - return Ok(ReadResourceResult { - contents: vec![ResourceContents { - uri: uri.clone(), - mime_type: Some("text/plain".to_string()), - text: Some(format!("Template resource data for id: {id}")), - blob: None, - _meta: None, - }], - }); - } - - Err(CommonMcpError::InvalidParams(format!( - "Resource not found: {uri}" - ))) - } - - // ==================== PROMPTS ==================== - - async fn list_prompts( - &self, - _params: PaginatedRequestParam, - ) -> std::result::Result { - Ok(ListPromptsResult { - prompts: vec![ - // prompts-get-simple - Prompt { - name: "test_simple_prompt".to_string(), - title: Some("Simple Test Prompt".to_string()), - description: Some("A simple prompt without arguments".to_string()), - arguments: None, - icons: None, - }, - // prompts-get-with-args - Prompt { - name: "test_prompt_with_arguments".to_string(), - title: Some("Prompt With Arguments".to_string()), - description: Some("A prompt that requires arguments".to_string()), - arguments: Some(vec![ - PromptArgument { - name: "arg1".to_string(), - description: Some("First argument".to_string()), - required: Some(true), - }, - PromptArgument { - name: "arg2".to_string(), - description: Some("Second argument".to_string()), - required: Some(true), - }, - ]), - icons: None, - }, - // prompts-get-embedded-resource - Prompt { - name: "test_prompt_with_embedded_resource".to_string(), - title: Some("Prompt With Embedded Resource".to_string()), - description: Some("A prompt that includes an embedded resource".to_string()), - arguments: Some(vec![PromptArgument { - name: "resourceUri".to_string(), - description: Some("URI of the resource to embed".to_string()), - required: Some(true), - }]), - icons: None, - }, - // prompts-get-with-image - Prompt { - name: "test_prompt_with_image".to_string(), - title: Some("Prompt With Image".to_string()), - description: Some("A prompt that includes an image".to_string()), - arguments: None, - icons: None, - }, - ], - next_cursor: None, - }) - } - - async fn get_prompt( - &self, - params: GetPromptRequestParam, - ) -> std::result::Result { - match params.name.as_str() { - "test_simple_prompt" => Ok(GetPromptResult { - description: Some("A simple test prompt".to_string()), - messages: vec![PromptMessage::new_text( - PromptMessageRole::User, - "This is a simple test prompt message.", - )], - }), - - "test_prompt_with_arguments" => { - let arg1 = params - .arguments - .as_ref() - .and_then(|a| a.get("arg1")) - .map(|s| s.as_str()) - .unwrap_or("default1"); - let arg2 = params - .arguments - .as_ref() - .and_then(|a| a.get("arg2")) - .map(|s| s.as_str()) - .unwrap_or("default2"); - - Ok(GetPromptResult { - description: Some("A prompt with arguments".to_string()), - messages: vec![PromptMessage::new_text( - PromptMessageRole::User, - format!("Prompt with arg1={arg1} and arg2={arg2}"), - )], - }) - } - - "test_prompt_with_embedded_resource" => { - // Return actual embedded resource content - let resource_uri = params - .arguments - .as_ref() - .and_then(|a| a.get("resourceUri")) - .map(|s| s.as_str()) - .unwrap_or("test://static-text"); - - Ok(GetPromptResult { - description: Some("A prompt with an embedded resource".to_string()), - messages: vec![PromptMessage::new_resource( - PromptMessageRole::User, - resource_uri, - Some("text/plain".to_string()), - Some("This is the embedded resource content.".to_string()), - )], - }) - } - - "test_prompt_with_image" => Ok(GetPromptResult { - description: Some("A prompt with an image".to_string()), - messages: vec![PromptMessage::new_image( - PromptMessageRole::User, - MINIMAL_PNG, - "image/png", - )], - }), - - _ => Err(CommonMcpError::InvalidParams(format!( - "Unknown prompt: {}", - params.name - ))), - } - } -} - -#[tokio::main] -async fn main() -> std::result::Result<(), Box> { - let backend = ConformanceBackend::initialize(()).await?; - - let port = std::env::var("PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(3000); - - let mut config = ServerConfig::default(); - config.auth_config.enabled = false; - config.transport_config = TransportConfig::StreamableHttp { port, host: None }; - - let mut server = McpServer::new(backend, config).await?; - - eprintln!("MCP Conformance Test Server running on http://localhost:{port}"); - eprintln!(); - eprintln!("Test with:"); - eprintln!(" npx @modelcontextprotocol/conformance server --url http://localhost:{port}/mcp"); - eprintln!(); - - server.run().await?; - Ok(()) -} diff --git a/examples/hello-world-with-auth/Cargo.toml b/examples/hello-world-with-auth/Cargo.toml index 3877e614..202bc07b 100644 --- a/examples/hello-world-with-auth/Cargo.toml +++ b/examples/hello-world-with-auth/Cargo.toml @@ -1,33 +1,18 @@ [package] name = "hello-world-with-auth" -version.workspace = true -rust-version.workspace = true -edition.workspace = true +version = "0.1.0" +edition = "2021" +description = "MCP Server with pulseengine-security middleware (rmcp + Axum)" publish = false [dependencies] -# Core async runtime -tokio = { workspace = true } -async-trait = { workspace = true } - -# Logging -tracing = { workspace = true } -tracing-subscriber = { workspace = true } - -# Error handling -anyhow = { workspace = true } - -# Serialization (required by mcp_tools macro) -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -schemars = { workspace = true, features = ["derive"] } - -# HTTP server -axum = { workspace = true } -tower = { workspace = true } - -# Framework dependencies -pulseengine-mcp-macros = { workspace = true } -pulseengine-mcp-server = { workspace = true } -pulseengine-mcp-security-middleware = { workspace = true } -pulseengine-mcp-protocol = { workspace = true } +rmcp = { version = "1.3", features = ["server", "macros", "transport-streamable-http-server"] } +pulseengine-security = { path = "../../pulseengine-security" } +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7", features = ["rt"] } +serde = { version = "1", features = ["derive"] } +schemars = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" diff --git a/examples/hello-world-with-auth/src/main.rs b/examples/hello-world-with-auth/src/main.rs index 06b379f2..d5912cda 100644 --- a/examples/hello-world-with-auth/src/main.rs +++ b/examples/hello-world-with-auth/src/main.rs @@ -1,166 +1,123 @@ -//! Hello World MCP Server with Authentication +//! # Hello World MCP Server with Authentication //! -//! This example demonstrates how to add zero-config authentication to an MCP server. -//! It builds on the basic hello-world example by adding security middleware. +//! Demonstrates an rmcp MCP server wrapped in `pulseengine-security` middleware +//! via Axum's Streamable HTTP transport. //! -//! Key features demonstrated: -//! - Development security profile (permissive settings) -//! - Auto-generated API keys for easy development -//! - Simple API key authentication -//! - Request logging and audit trails -//! -//! ## Running the Example +//! ## Running //! //! ```bash -//! cargo run --bin hello-world-with-auth +//! cargo run -p hello-world-with-auth //! ``` //! -//! The server will start with auto-generated API keys. Check the logs for the generated API key. -//! -//! ## Testing Authentication +//! ## Testing //! //! ```bash -//! # Without authentication (should work in development mode) -//! curl http://localhost:8080/mcp/tools/list -//! -//! # With API key (check logs for generated key) -//! curl -H "Authorization: ApiKey mcp_generated_key_here" http://localhost:8080/mcp/tools/call +//! # The dev profile generates an API key — check the logs for it, then: +//! curl -H "Authorization: ApiKey " http://127.0.0.1:8080/mcp //! ``` -use pulseengine_mcp_macros::{mcp_server, mcp_tools}; -use pulseengine_mcp_security_middleware::*; +use std::sync::Arc; + +use axum::middleware::from_fn; +use axum::Router; +use pulseengine_security::SecurityConfig; +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{Implementation, ServerCapabilities, ServerInfo}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; +use rmcp::{schemars, tool, tool_handler, tool_router, ServerHandler}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use tracing::{info, warn}; +use serde::Deserialize; +use tracing::info; +use tracing_subscriber::EnvFilter; -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SayHelloParams { +// ── Tool parameters ─────────────────────────────────────────────── + +#[derive(Debug, Deserialize, JsonSchema)] +struct SayHelloParams { /// The name to greet (optional) - pub name: Option, + name: Option, } -#[mcp_server(name = "Hello World with Auth")] -#[derive(Default, Clone)] -pub struct HelloWorldAuth; - -#[mcp_tools] -impl HelloWorldAuth { - /// Say hello to someone (with authentication) - /// - /// This tool demonstrates how authentication context can be used in tools. - /// In development mode, authentication is optional but logged when present. - pub async fn say_hello(&self, params: SayHelloParams) -> anyhow::Result { - let name = params - .name - .unwrap_or_else(|| "Authenticated World".to_string()); - - info!("Hello tool called with name: {}", name); - Ok(format!( - "Hello, {name}! 🔐 (Secured with MCP Security Middleware)" - )) - } +// ── MCP server ──────────────────────────────────────────────────── - /// Get authentication status - /// - /// This tool shows information about the current authentication state. - pub async fn auth_status(&self) -> anyhow::Result { - // In development mode, this will work without authentication - // In production mode, it would require valid credentials +struct HelloWorldAuth { + tool_router: ToolRouter, +} - info!("Auth status requested"); - Ok("Authentication: Development mode - optional auth enabled".to_string()) +#[tool_router] +impl HelloWorldAuth { + /// Say hello to someone (secured by pulseengine-security) + #[tool] + fn say_hello(&self, Parameters(params): Parameters) -> String { + let name = params.name.as_deref().unwrap_or("Authenticated World"); + format!("Hello, {name}!") } +} - /// Protected tool (demonstrates different security levels) - /// - /// This tool would require authentication in all modes except development. - pub async fn protected_operation(&self) -> anyhow::Result { - // This would be protected in staging/production modes - warn!("Protected operation accessed - ensure proper authentication in production!"); - Ok("This is a protected operation - authentication recommended".to_string()) +#[tool_handler] +impl ServerHandler for HelloWorldAuth { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("hello-world-with-auth", "0.1.0")) } } +// ── Main ────────────────────────────────────────────────────────── + #[tokio::main] -async fn main() -> Result<(), Box> { - // Initialize logging +async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() - .with_env_filter("hello_world_with_auth=info,pulseengine_mcp_security_middleware=info") + .with_env_filter( + EnvFilter::try_from_default_env() + .unwrap_or_else(|_| EnvFilter::new("info,hello_world_with_auth=debug")), + ) .init(); - info!("Starting Hello World MCP Server with Authentication"); - - // Create development security configuration - // This enables authentication but with permissive settings for development - let security_config = SecurityConfig::development(); - - // Log the configuration for demonstration - let summary = security_config.summary(); - info!("Security configuration:\n{}", summary); + // 1. Security setup — zero-config development profile + let security = SecurityConfig::development(); + info!("Security profile: {:?}", security.profile); - // Create the security middleware - let security_middleware = security_config.create_middleware().await?; - - // Log the generated API key for testing - if let Some(ref api_key) = security_config.api_key { - info!("🔑 Generated API key for development: {}", api_key); - info!( - "💡 Test with: curl -H 'Authorization: ApiKey {}' http://localhost:8080/", - api_key - ); + if let Some(ref key) = security.api_key { + info!("Development API key: {key}"); + info!("Test with: curl -H 'Authorization: ApiKey {key}' http://127.0.0.1:8080/mcp"); } - // Create the MCP server with security middleware - let _server_backend = HelloWorldAuth; - - // Note: This is a simplified example. In the actual implementation, - // you would integrate the security middleware with the MCP server's HTTP transport - info!("🚀 Server would start here with security middleware integrated"); - info!("📡 Available endpoints:"); - info!(" - GET /health (health check)"); - info!(" - POST /mcp/initialize (MCP initialization)"); - info!(" - POST /mcp/tools/list (list available tools)"); - info!(" - POST /mcp/tools/call (call a tool)"); - - info!("🔒 Security Features:"); - info!(" - Development profile: Authentication optional but logged"); - info!( - " - Auto-generated API key: {}", - security_config - .api_key - .as_ref() - .unwrap_or(&"None".to_string()) - ); - info!(" - Rate limiting: Disabled (development mode)"); - info!(" - CORS: Permissive (development mode)"); - info!(" - Audit logging: Enabled"); + let middleware = security.create_middleware().await?; - // For demonstration, just show what the middleware would do - demonstrate_security_features(&security_middleware).await?; + // 2. Build the rmcp Streamable HTTP service + let ct = tokio_util::sync::CancellationToken::new(); - info!("Example completed successfully!"); - info!("In a real implementation, this would be integrated with the full MCP server"); - - Ok(()) -} + let mcp_service = StreamableHttpService::new( + || { + Ok(HelloWorldAuth { + tool_router: HelloWorldAuth::tool_router(), + }) + }, + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default().with_cancellation_token(ct.child_token()), + ); -/// Demonstrate security middleware features -async fn demonstrate_security_features(_middleware: &SecurityMiddleware) -> anyhow::Result<()> { - info!("🔍 Demonstrating security middleware features..."); - - // Create a mock request for demonstration - let _test_request = axum::http::Request::builder() - .method(axum::http::Method::GET) - .uri("/test") - .header("host", "localhost:8080") - .body(axum::body::Body::empty())?; - - // This would normally be handled by the middleware in the HTTP server - info!("✅ Request would be processed through security middleware"); - info!("✅ Rate limiting would be checked (disabled in development)"); - info!("✅ Authentication would be verified (optional in development)"); - info!("✅ Security headers would be added to response"); - info!("✅ Request would be logged for audit trail"); + // 3. Mount MCP under /mcp, wrap the whole router with security middleware + let app = Router::new() + .nest_service("/mcp", mcp_service) + .layer(from_fn(move |req, next| { + let mw = middleware.clone(); + async move { mw.process(req, next).await } + })); + + // 4. Serve + let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await?; + info!("Listening on http://127.0.0.1:8080/mcp"); + + axum::serve(listener, app) + .with_graceful_shutdown(async move { + tokio::signal::ctrl_c().await.ok(); + ct.cancel(); + }) + .await?; Ok(()) } diff --git a/examples/hello-world/Cargo.toml b/examples/hello-world/Cargo.toml index 314b02a8..a5318d4b 100644 --- a/examples/hello-world/Cargo.toml +++ b/examples/hello-world/Cargo.toml @@ -2,22 +2,12 @@ name = "hello-world" version = "0.1.0" edition = "2021" -description = "Minimal Hello World MCP Server - Easy as Pi!" +description = "Minimal Hello World MCP Server using rmcp" [dependencies] -# Core MCP Framework (only 4 dependencies!) -pulseengine-mcp-macros = { path = "../../mcp-macros", features = ["stdio-logging"] } -pulseengine-mcp-protocol = { path = "../../mcp-protocol" } -pulseengine-mcp-server = { path = "../../mcp-server" } -pulseengine-mcp-transport = { path = "../../mcp-transport" } - -# Minimal runtime dependencies -tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] } -anyhow = "1.0" -async-trait = "0.1" -thiserror = "1.0" -tracing = "0.1" -tracing-subscriber = "0.3" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -schemars = { version = "1.0", features = ["derive"] } +rmcp = { version = "1.3", features = ["server", "macros", "transport-io"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +schemars = "1.0" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" diff --git a/examples/hello-world/src/main.rs b/examples/hello-world/src/main.rs index 52889233..890e064e 100644 --- a/examples/hello-world/src/main.rs +++ b/examples/hello-world/src/main.rs @@ -1,46 +1,51 @@ -//! Minimal Hello World MCP Server - Easy as Pi! -//! -//! This is the simplest possible MCP server that actually works. -//! Only 25 lines of code, 10 dependencies, works out of the box. - -use pulseengine_mcp_macros::{mcp_server, mcp_tools}; -use pulseengine_mcp_server::McpServerBuilder; +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{Implementation, ServerCapabilities, ServerInfo}; +use rmcp::schemars; +use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SayHelloParams { +#[derive(Debug, Deserialize, JsonSchema)] +struct SayHelloParams { /// The name to greet (optional) - pub name: Option, + name: Option, } -#[mcp_server(name = "Hello World")] -#[derive(Default, Clone)] -pub struct HelloWorld; +struct HelloWorld { + tool_router: ToolRouter, +} -#[mcp_tools] +#[tool_router] impl HelloWorld { /// Say hello to someone - /// - /// AI agents send flat arguments: `{"name": "Alice"}` - /// NOT nested: `{"params": {"name": "Alice"}}` - /// - /// The parameter name "params" is just an internal variable - - /// AI agents see the struct's fields directly in the schema. - pub async fn say_hello(&self, params: SayHelloParams) -> anyhow::Result { - let name = params.name.unwrap_or_else(|| "World".to_string()); - Ok(format!("Hello, {name}!")) + #[tool] + async fn say_hello(&self, Parameters(params): Parameters) -> String { + let name = params.name.unwrap_or_else(|| "World".into()); + format!("Hello, {name}!") } } -#[tokio::main] -async fn main() -> Result<(), Box> { - // Configure logging for STDIO transport - HelloWorld::configure_stdio_logging(); +#[tool_handler] +impl ServerHandler for HelloWorld { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("hello-world", "0.1.0")) + } +} - // Start the server - let mut server = HelloWorld::with_defaults().serve_stdio().await?; - server.run().await?; +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let server = HelloWorld { + tool_router: HelloWorld::tool_router(), + }; + let transport = rmcp::transport::io::stdio(); + let service = server.serve(transport).await?; + service.waiting().await?; Ok(()) } diff --git a/examples/resources-demo/Cargo.toml b/examples/resources-demo/Cargo.toml index 85d40b13..6c560f44 100644 --- a/examples/resources-demo/Cargo.toml +++ b/examples/resources-demo/Cargo.toml @@ -9,11 +9,13 @@ name = "resources-demo" path = "src/main.rs" [dependencies] -pulseengine-mcp-protocol = { path = "../../mcp-protocol" } -pulseengine-mcp-server = { path = "../../mcp-server" } -pulseengine-mcp-macros = { path = "../../mcp-macros" } +rmcp = { version = "1.3", features = ["server", "macros", "transport-io"] } +pulseengine-mcp-resources = { path = "../../pulseengine-mcp-resources" } +tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["full"] } -async-trait = "0.1" +schemars = "1.0" matchit = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" diff --git a/examples/resources-demo/src/main.rs b/examples/resources-demo/src/main.rs index 3a95a5d3..1ddc6313 100644 --- a/examples/resources-demo/src/main.rs +++ b/examples/resources-demo/src/main.rs @@ -1,59 +1,72 @@ //! # MCP Resources Demo //! -//! This example demonstrates how to use the `#[mcp_resource]` attribute inside -//! `#[mcp_tools]` impl blocks to create dynamic, parameterized resources. +//! Demonstrates how to use `pulseengine-mcp-resources` with rmcp to build +//! a server that combines tools (via `#[tool]`) with URI-template-based +//! resources (via `ResourceRouter`). //! //! ## Key Concepts //! -//! 1. **Tools vs Resources**: -//! - Methods WITHOUT `#[mcp_resource]` → become tools -//! - Methods WITH `#[mcp_resource]` → become resources -//! -//! 2. **URI Templates**: -//! - Resources use URI templates with parameters: "scheme://{param1}/{param2}" -//! - Parameters are automatically extracted and passed to methods -//! - Uses matchit library for efficient URI routing -//! -//! 3. **Automatic Integration**: -//! - #[mcp_tools] scans all methods -//! - Implements McpToolsProvider AND McpResourcesProvider -//! - #[mcp_server] generates complete McpBackend implementation -//! - No manual registration needed! +//! 1. **Tools** are defined with `#[tool]` inside a `#[tool_router]` impl block. +//! 2. **Resources** are registered on a `ResourceRouter` with URI template patterns. +//! 3. The `ServerHandler` trait overrides `list_resource_templates` and `read_resource` +//! to wire the router into the MCP protocol. +//! 4. Transport is stdio — connect with MCP Inspector or Claude Desktop. -use pulseengine_mcp_macros::{mcp_server, mcp_tools}; -use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::sync::Arc; + +use pulseengine_mcp_resources::ResourceRouter; +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + Implementation, ListResourceTemplatesResult, PaginatedRequestParams, ReadResourceRequestParams, + ReadResourceResult, ResourceContents, ServerCapabilities, ServerInfo, +}; +use rmcp::service::RequestContext; +use rmcp::{ + schemars, tool, tool_handler, tool_router, ErrorData, RoleServer, ServerHandler, ServiceExt, +}; +use serde::{Deserialize, Serialize}; +use tracing_subscriber::EnvFilter; -/// A server demonstrating resources with URI templates -#[mcp_server(name = "Resources Demo", auth = "disabled")] -#[derive(Default, Clone)] -struct ResourcesDemo { - // In-memory data store for demonstration +// ── State ───────────────────────────────────────────────────────── + +/// Shared server state: an in-memory key/value store. +#[derive(Clone)] +struct State { data: HashMap, } -impl ResourcesDemo { +impl State { fn new() -> Self { let mut data = HashMap::new(); - - // Pre-populate with some demo data data.insert( "1".to_string(), - r#"{"id": "1", "name": "Alice", "role": "admin"}"#.to_string(), + r#"{"id":"1","name":"Alice","role":"admin"}"#.to_string(), ); data.insert( "2".to_string(), - r#"{"id": "2", "name": "Bob", "role": "user"}"#.to_string(), + r#"{"id":"2","name":"Bob","role":"user"}"#.to_string(), ); data.insert( "app".to_string(), - r#"{"theme": "dark", "language": "en"}"#.to_string(), + r#"{"theme":"dark","language":"en"}"#.to_string(), ); - Self { data } } } +// ── Tool params ─────────────────────────────────────────────────── + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct ListKeysParams {} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +struct InfoParams {} + +// ── Response types (used by resources) ──────────────────────────── + +#[allow(dead_code)] #[derive(Debug, Serialize, Deserialize)] struct User { id: String, @@ -61,92 +74,179 @@ struct User { role: String, } +#[allow(dead_code)] #[derive(Debug, Serialize, Deserialize)] struct Config { theme: String, language: String, } -#[derive(Debug, Serialize, Deserialize)] -struct DataInfo { - key: String, - exists: bool, - value_preview: Option, +// ── Server ──────────────────────────────────────────────────────── + +struct ResourcesDemo { + tool_router: ToolRouter, + state: Arc, + resources: Arc>, } -// 🎯 KEY PATTERN: Use #[mcp_tools] for BOTH tools AND resources -#[mcp_tools] impl ResourcesDemo { - // ==================== TOOLS ==================== - // Methods WITHOUT #[mcp_resource] attribute become tools + fn new() -> Self { + let state = State::new(); + + let mut router = ResourceRouter::::new(); + + // user://{user_id} — look up a user by ID + router.add_resource( + "/users/{user_id}", + "user://{user_id}", + "user", + "Get user data by ID", + Some("application/json"), + |state: &State, uri: &str, params: &matchit::Params| { + let user_id = params.get("user_id").unwrap_or("unknown"); + match state.data.get(user_id) { + Some(json) => ResourceContents::text(json.clone(), uri), + None => ResourceContents::text( + format!(r#"{{"error":"User not found: {user_id}"}}"#), + uri, + ), + } + }, + ); - /// List all available data keys - pub fn list_keys(&self) -> Vec { - self.data.keys().cloned().collect() - } + // config://{config_name} — look up a config section + router.add_resource( + "/config/{config_name}", + "config://{config_name}", + "config", + "Get configuration settings", + Some("application/json"), + |state: &State, uri: &str, params: &matchit::Params| { + let name = params.get("config_name").unwrap_or("unknown"); + match state.data.get(name) { + Some(json) => ResourceContents::text(json.clone(), uri), + None => ResourceContents::text( + format!(r#"{{"error":"Config not found: {name}"}}"#), + uri, + ), + } + }, + ); - /// Get information about how many items are stored - pub fn info(&self) -> String { - format!("Storing {} items", self.data.len()) - } + // data://{key} — generic key lookup with metadata + router.add_resource( + "/data/{key}", + "data://{key}", + "data", + "Get any data by key", + Some("application/json"), + |state: &State, uri: &str, params: &matchit::Params| { + let key = params.get("key").unwrap_or("unknown"); + let exists = state.data.contains_key(key); + let preview = state.data.get(key).map(|v| { + if v.len() > 50 { + format!("{}...", &v[..50]) + } else { + v.clone() + } + }); + let json = serde_json::json!({ + "key": key, + "exists": exists, + "value_preview": preview, + }); + ResourceContents::text(json.to_string(), uri) + }, + ); - // ==================== RESOURCES ==================== - // Methods WITH #[mcp_resource] attribute become resources with URI routing + Self { + tool_router: Self::tool_router(), + state: Arc::new(state), + resources: Arc::new(router), + } + } +} - /// Get user data by ID - #[mcp_resource(uri_template = "user://{user_id}")] - pub fn get_user(&self, user_id: String) -> Result { - let data = self - .data - .get(&user_id) - .ok_or_else(|| format!("User not found: {user_id}"))?; +// ── Tools ───────────────────────────────────────────────────────── - serde_json::from_str(data).map_err(|e| format!("Failed to parse user data: {e}")) +#[tool_router] +impl ResourcesDemo { + /// List all available data keys in the store + #[tool] + fn list_keys(&self, _params: Parameters) -> String { + let keys: Vec<&String> = self.state.data.keys().collect(); + serde_json::to_string_pretty(&keys).unwrap_or_default() } - /// Get configuration settings - #[mcp_resource(uri_template = "config://{config_name}")] - pub fn get_config(&self, config_name: String) -> Result { - let data = self - .data - .get(&config_name) - .ok_or_else(|| format!("Config not found: {config_name}"))?; + /// Get information about how many items are stored + #[tool] + fn info(&self, _params: Parameters) -> String { + format!("Storing {} items", self.state.data.len()) + } +} - serde_json::from_str(data).map_err(|e| format!("Failed to parse config: {e}")) +// ── ServerHandler (tools + resources) ───────────────────────────── + +#[tool_handler] +impl ServerHandler for ResourcesDemo { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(Implementation::new("resources-demo", "0.1.0")) } - /// Get any data by key - #[mcp_resource(uri_template = "data://{key}")] - pub fn get_data(&self, key: String) -> Result { - let exists = self.data.contains_key(&key); - let value_preview = self.data.get(&key).map(|v| { - if v.len() > 50 { - format!("{}...", &v[..50]) - } else { - v.clone() - } - }); - - Ok(DataInfo { - key, - exists, - value_preview, + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: self.resources.templates(), + next_cursor: None, + meta: None, }) } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let uri = &request.uri; + match self.resources.resolve(&self.state, uri) { + Some(contents) => Ok(ReadResourceResult::new(vec![contents])), + None => Err(ErrorData::resource_not_found( + format!("No resource matches URI: {uri}"), + None, + )), + } + } } +// ── Main ────────────────────────────────────────────────────────── + #[tokio::main] -async fn main() -> Result<(), Box> { +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + let server = ResourcesDemo::new(); - // Use HTTP transport for conformance testing - let port = std::env::var("PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(3001); + tracing::info!("Resources Demo: starting stdio MCP server"); + tracing::info!( + templates = server.resources.templates().len(), + "Registered resource templates" + ); - let mut mcp_server = server.serve_http(port).await?; - mcp_server.run().await?; + let transport = rmcp::transport::io::stdio(); + let service = server.serve(transport).await?; + service.waiting().await?; Ok(()) } diff --git a/examples/ui-enabled-server/Cargo.toml b/examples/ui-enabled-server/Cargo.toml index 2dd4732b..03d394ff 100644 --- a/examples/ui-enabled-server/Cargo.toml +++ b/examples/ui-enabled-server/Cargo.toml @@ -2,14 +2,19 @@ name = "ui-enabled-server" version = "0.1.0" edition = "2021" +publish = false [[bin]] name = "ui-enabled-server" path = "src/main.rs" [dependencies] -pulseengine-mcp-server = { path = "../../mcp-server" } -pulseengine-mcp-protocol = { path = "../../mcp-protocol" } -tokio = { version = "1.0", features = ["full"] } -async-trait = "0.1" -serde_json = "1.0" +rmcp = { version = "1.3", features = ["server", "macros", "transport-io"] } +pulseengine-mcp-apps = { path = "../../pulseengine-mcp-apps" } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" diff --git a/examples/ui-enabled-server/src/main.rs b/examples/ui-enabled-server/src/main.rs index 12fb4b96..a09802a5 100644 --- a/examples/ui-enabled-server/src/main.rs +++ b/examples/ui-enabled-server/src/main.rs @@ -1,232 +1,192 @@ -//! Example MCP server with UI resources (MCP Apps Extension) +//! # UI-Enabled MCP Server //! -//! This demonstrates how to create an MCP server that exposes interactive -//! HTML interfaces through the MCP Apps Extension (SEP-1865). +//! Demonstrates how to use `pulseengine-mcp-apps` with rmcp to build an MCP +//! server that exposes interactive HTML interfaces through the MCP Apps +//! Extension. //! -//! Run with: cargo run --bin ui-enabled-server - -use async_trait::async_trait; -use pulseengine_mcp_protocol::*; -use pulseengine_mcp_server::common_backend::CommonMcpError; -use pulseengine_mcp_server::{McpBackend, McpServer, ServerConfig, TransportConfig}; +//! ## Key Concepts +//! +//! 1. **MCP Apps capability** is declared via `mcp_apps_capabilities()` in the +//! `ServerCapabilities` extensions. +//! 2. **HTML tool results** are returned with `html_tool_result()`. +//! 3. **HTML resources** are served with `html_resource()` in `read_resource`. +//! 4. **App resource descriptors** for `list_resources` use `app_resource()`. +//! 5. Transport is stdio — connect with MCP Inspector or Claude Desktop. + +use pulseengine_mcp_apps::{app_resource, html_resource, html_tool_result, mcp_apps_capabilities}; +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + CallToolResult, Implementation, ListResourcesResult, PaginatedRequestParams, + ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, ServerInfo, +}; +use rmcp::service::RequestContext; +use rmcp::{ + schemars, tool, tool_handler, tool_router, ErrorData, RoleServer, ServerHandler, ServiceExt, +}; +use tracing_subscriber::EnvFilter; + +// ── Greeting HTML template ──────────────────────────────────────── + +const GREETING_HTML: &str = include_str!("../templates/greeting.html"); + +// ── Dashboard HTML ──────────────────────────────────────────────── + +const DASHBOARD_HTML: &str = r#" + + + MCP Dashboard + + + +

MCP Server Dashboard

+
+

Active Connections

+
42
+
+
+

Tools Called

+
1,337
+
+ +"#; + +// ── Tool params ─────────────────────────────────────────────────── + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct GreetParams { + /// Name to greet + name: Option, +} -#[derive(Clone)] -struct UiBackend; +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct SimpleGreetParams { + /// Name to greet + name: Option, +} -#[async_trait] -impl McpBackend for UiBackend { - type Error = CommonMcpError; - type Config = (); +// ── Server ──────────────────────────────────────────────────────── - async fn initialize(_config: Self::Config) -> std::result::Result { - Ok(Self) - } +struct UiServer { + tool_router: ToolRouter, +} - fn get_server_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::default(), - capabilities: ServerCapabilities::builder() - .enable_tools() - .enable_resources() - .enable_logging() - .build(), - server_info: Implementation::new("UI-Enabled Example Server", "1.0.0"), - instructions: Some( - "Example server demonstrating MCP Apps Extension with interactive UIs".to_string(), - ), +impl UiServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), } } +} - async fn health_check(&self) -> std::result::Result<(), Self::Error> { - Ok(()) - } +// ── Tools ───────────────────────────────────────────────────────── - async fn list_tools( +#[tool_router] +impl UiServer { + /// Greet someone with an interactive HTML UI + #[tool] + fn greet_with_ui( &self, - _params: PaginatedRequestParam, - ) -> std::result::Result { - Ok(ListToolsResult { - tools: vec![ - Tool { - name: "greet_with_ui".to_string(), - title: Some("Greet with Interactive UI".to_string()), - description: "Greet someone with an interactive button UI".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name to greet" - } - }, - "required": ["name"] - }), - output_schema: None, - annotations: None, - icons: None, - execution: None, - // KEY FEATURE: Link this tool to a UI resource - _meta: Some(ToolMeta::with_ui_resource("ui://greetings/interactive")), - }, - Tool { - name: "simple_greeting".to_string(), - title: None, - description: "Simple text-only greeting (no UI)".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Name to greet" - } - }, - "required": ["name"] - }), - output_schema: None, - annotations: None, - icons: None, - execution: None, - _meta: None, // No UI for this tool - }, - ], - next_cursor: None, - }) + Parameters(params): Parameters, + ) -> Result { + let name = params.name.as_deref().unwrap_or("World"); + let html = GREETING_HTML.replace( + "Click the button to greet someone!", + &format!("Hello, {name}!"), + ); + Ok(html_tool_result(html)) } - async fn call_tool( - &self, - request: CallToolRequestParam, - ) -> std::result::Result { - match request.name.as_str() { - "greet_with_ui" => { - let name = request - .arguments - .as_ref() - .and_then(|args| args.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("World"); - - // Use the self-contained template HTML (no external assets) - // TODO: Configure Vite to inline all assets for a true single-file React build - let html = include_str!("../templates/greeting.html"); - - // ✨ NEW: Use the convenient Content::ui_html() helper! - // This is much cleaner than manually constructing the resource JSON - Ok(CallToolResult { - content: vec![ - Content::text(format!("Hello, {name}!")), - Content::ui_html("ui://greetings/interactive", html), - ], - is_error: Some(false), - structured_content: None, - _meta: None, - }) - } - "simple_greeting" => { - let name = request - .arguments - .as_ref() - .and_then(|args| args.get("name")) - .and_then(|v| v.as_str()) - .unwrap_or("World"); - - Ok(CallToolResult { - content: vec![Content::text(format!("Hello, {name}!"))], - is_error: Some(false), - structured_content: None, - _meta: None, - }) - } - _ => Err(CommonMcpError::InvalidParams("Unknown tool".to_string())), - } + /// Simple text-only greeting (no UI) + #[tool] + fn simple_greeting(&self, Parameters(params): Parameters) -> String { + let name = params.name.as_deref().unwrap_or("World"); + format!("Hello, {name}!") + } +} + +// ── ServerHandler (tools + resources) ───────────────────────────── + +#[tool_handler] +impl ServerHandler for UiServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .enable_extensions_with(mcp_apps_capabilities()) + .build(), + ) + .with_server_info(Implementation::new("ui-enabled-server", "0.1.0")) } async fn list_resources( &self, - _params: PaginatedRequestParam, - ) -> std::result::Result { + _request: Option, + _context: RequestContext, + ) -> Result { Ok(ListResourcesResult { resources: vec![ - // 🎯 KEY FEATURE: UI resource with ui:// scheme - Resource::ui_resource( + app_resource( "ui://greetings/interactive", - "Interactive Greeting UI", - "Interactive HTML interface for greeting with a button", + "greeting-ui", + Some("Interactive Greeting UI"), + Some("Interactive HTML interface for greeting with a button"), + ), + app_resource( + "ui://dashboard", + "dashboard", + Some("Server Dashboard"), + Some("Interactive HTML dashboard with server metrics"), ), ], next_cursor: None, + meta: None, }) } async fn read_resource( &self, - params: ReadResourceRequestParam, - ) -> std::result::Result { - match params.uri.as_str() { - "ui://greetings/interactive" => { - // Use the self-contained template HTML (no external assets) - // TODO: Configure Vite to inline all assets for a true single-file React build - let html = include_str!("../templates/greeting.html"); - - // 🎯 KEY FEATURE: Serve HTML with text/html+mcp MIME type - Ok(ReadResourceResult { - contents: vec![ResourceContents::html_ui(params.uri, html)], - }) - } - _ => Err(CommonMcpError::InvalidParams( - "Resource not found".to_string(), + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let uri = &request.uri; + match uri.as_str() { + "ui://greetings/interactive" => Ok(ReadResourceResult::new(vec![html_resource( + uri, + GREETING_HTML, + )])), + "ui://dashboard" => Ok(ReadResourceResult::new(vec![html_resource( + uri, + DASHBOARD_HTML, + )])), + _ => Err(ErrorData::resource_not_found( + format!("Unknown resource: {uri}"), + None, )), } } +} - async fn list_prompts( - &self, - _params: PaginatedRequestParam, - ) -> std::result::Result { - Ok(ListPromptsResult { - prompts: vec![], - next_cursor: None, - }) - } +// ── Main ────────────────────────────────────────────────────────── - async fn get_prompt( - &self, - _params: GetPromptRequestParam, - ) -> std::result::Result { - Err(CommonMcpError::InvalidParams( - "No prompts available".to_string(), - )) - } +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); - // Note: set_level uses the default implementation from McpBackend which accepts - // any log level. Override this if you want to filter notifications/message logs. -} + tracing::info!("UI-Enabled MCP Server: starting on stdio"); + + let server = UiServer::new(); + let transport = rmcp::transport::io::stdio(); + let service = server.serve(transport).await?; + service.waiting().await?; -#[tokio::main] -async fn main() -> std::result::Result<(), Box> { - // NOTE: No println! allowed in stdio mode - MCP protocol uses stdout for JSON-RPC - // All informational messages should go to stderr or logs - - let backend = UiBackend::initialize(()).await?; - - // Create config with auth disabled and HTTP transport for UI testing - let mut config = ServerConfig::default(); - config.auth_config.enabled = false; - config.transport_config = TransportConfig::StreamableHttp { - port: 3001, - host: None, - }; - - let mut server = McpServer::new(backend, config).await?; - - eprintln!("🚀 UI-Enabled MCP Server running on http://localhost:3001"); - eprintln!("📋 Connect with UI Inspector:"); - eprintln!(" 1. Open http://localhost:6274"); - eprintln!(" 2. Select 'Streamable HTTP' transport"); - eprintln!(" 3. Enter URL: http://localhost:3001/mcp"); - eprintln!(" 4. Click Connect"); - eprintln!(); - - server.run().await?; Ok(()) } diff --git a/examples/ultra-simple/Cargo.toml b/examples/ultra-simple/Cargo.toml index c9181706..76cd7216 100644 --- a/examples/ultra-simple/Cargo.toml +++ b/examples/ultra-simple/Cargo.toml @@ -8,12 +8,9 @@ name = "ultra-simple" path = "src/main.rs" [dependencies] -pulseengine-mcp-macros = { path = "../../mcp-macros" } -pulseengine-mcp-server = { path = "../../mcp-server" } -pulseengine-mcp-protocol = { path = "../../mcp-protocol" } -async-trait = "0.1" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -schemars = { version = "1.0", features = ["derive"] } -tokio = { version = "1.0", features = ["full"] } -anyhow = "1.0" +rmcp = { version = "1.3", features = ["server", "macros", "transport-io"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +schemars = "1.0" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +anyhow = "1" diff --git a/examples/ultra-simple/src/main.rs b/examples/ultra-simple/src/main.rs index a250953c..07b6552c 100644 --- a/examples/ultra-simple/src/main.rs +++ b/examples/ultra-simple/src/main.rs @@ -1,59 +1,47 @@ -//! Ultra-Simple MCP Server - Just 8 Lines! 🚀 -//! This demonstrates the simplest possible MCP server with the current PulseEngine framework. -//! While we work on the mcp_app macro, this shows competitive simplicity vs official SDKs. - -use pulseengine_mcp_macros::{mcp_server, mcp_tools}; -use pulseengine_mcp_server::McpServerBuilder; +use rmcp::handler::server::tool::ToolRouter; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{Implementation, ServerCapabilities, ServerInfo}; +use rmcp::schemars; +use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt}; use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct SayHelloParams { - /// The name to greet - pub name: String, - /// Optional greeting to use (defaults to "Hello") - pub greeting: Option, -} +use serde::Deserialize; -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct AddParams { +#[derive(Debug, Deserialize, JsonSchema)] +struct AddParams { /// First number - pub a: i32, + a: i32, /// Second number - pub b: i32, + b: i32, } -#[mcp_server(name = "Ultra Simple")] -#[derive(Default, Clone)] -pub struct UltraSimple; +struct UltraSimple { + tool_router: ToolRouter, +} -#[mcp_tools] +#[tool_router] impl UltraSimple { - /// Say hello to someone with customizable greeting - pub async fn say_hello(&self, params: SayHelloParams) -> anyhow::Result { - let greeting = params.greeting.unwrap_or_else(|| "Hello".to_string()); - Ok(format!("{greeting}, {}! 👋", params.name)) - } - - /// Add two numbers together - pub fn add(&self, params: AddParams) -> i32 { - params.a + params.b + /// Add two numbers + #[tool] + fn add(&self, Parameters(params): Parameters) -> String { + format!("{}", params.a + params.b) } +} - /// Get the answer to the ultimate question - pub fn answer(&self) -> i32 { - 42 +#[tool_handler] +impl ServerHandler for UltraSimple { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("ultra-simple", "0.1.0")) } } #[tokio::main] -async fn main() -> Result<(), Box> { - UltraSimple::configure_stdio_logging(); - let mut server = UltraSimple::with_defaults().serve_stdio().await?; - server.run().await?; +async fn main() -> anyhow::Result<()> { + let server = UltraSimple { + tool_router: UltraSimple::tool_router(), + }; + let transport = rmcp::transport::io::stdio(); + let service = server.serve(transport).await?; + service.waiting().await?; Ok(()) } - -// 🎉 Complete MCP server in 8 meaningful lines! (struct + impl + main) -// Features: Auto JSON schema generation, type safety, enterprise capabilities -// Compare: TypeScript SDK ~10 lines, Official Rust SDK ~15-20 lines diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index b6367433..06b23dbf 100644 --- a/integration-tests/Cargo.toml +++ b/integration-tests/Cargo.toml @@ -30,7 +30,7 @@ rand = { workspace = true } # MCP framework crates pulseengine-mcp-protocol = { workspace = true } -pulseengine-mcp-auth = { workspace = true } +pulseengine-auth = { workspace = true } pulseengine-mcp-security = { workspace = true } pulseengine-mcp-transport = { workspace = true } pulseengine-mcp-server = { workspace = true } diff --git a/integration-tests/src/auth_server_integration.rs b/integration-tests/src/auth_server_integration.rs index 8823bcc3..47796bd2 100644 --- a/integration-tests/src/auth_server_integration.rs +++ b/integration-tests/src/auth_server_integration.rs @@ -2,7 +2,7 @@ use crate::test_utils::*; use async_trait::async_trait; -use pulseengine_mcp_auth::AuthenticationManager; +use pulseengine_auth::AuthenticationManager; use pulseengine_mcp_protocol::*; use pulseengine_mcp_server::{ backend::{BackendError, McpBackend}, diff --git a/integration-tests/src/end_to_end_scenarios.rs b/integration-tests/src/end_to_end_scenarios.rs index a34a5172..6a121a49 100644 --- a/integration-tests/src/end_to_end_scenarios.rs +++ b/integration-tests/src/end_to_end_scenarios.rs @@ -2,7 +2,7 @@ use crate::test_utils::*; use async_trait::async_trait; -use pulseengine_mcp_auth::AuthenticationManager; +use pulseengine_auth::AuthenticationManager; use pulseengine_mcp_protocol::*; use pulseengine_mcp_security::SecurityMiddleware; use pulseengine_mcp_server::observability::MetricsCollector; diff --git a/integration-tests/src/lib.rs b/integration-tests/src/lib.rs index 03f08cf7..ef2d8c6f 100644 --- a/integration-tests/src/lib.rs +++ b/integration-tests/src/lib.rs @@ -14,7 +14,7 @@ pub mod transport_server_integration; /// Common test utilities for integration tests pub mod test_utils { - use pulseengine_mcp_auth::{AuthConfig, config::StorageConfig}; + use pulseengine_auth::{AuthConfig, config::StorageConfig}; use pulseengine_mcp_security::SecurityConfig; use pulseengine_mcp_server::observability::MonitoringConfig; use std::time::Duration; diff --git a/integration-tests/src/monitoring_integration.rs b/integration-tests/src/monitoring_integration.rs index f9edd050..2eba1e5b 100644 --- a/integration-tests/src/monitoring_integration.rs +++ b/integration-tests/src/monitoring_integration.rs @@ -332,7 +332,7 @@ async fn test_handler_with_monitoring() { let mut auth_config = test_auth_config(); auth_config.enabled = false; let auth_manager = Arc::new( - pulseengine_mcp_auth::AuthenticationManager::new(auth_config) + pulseengine_auth::AuthenticationManager::new(auth_config) .await .unwrap(), ); @@ -376,7 +376,7 @@ async fn test_performance_monitoring() { let mut auth_config = test_auth_config(); auth_config.enabled = false; let auth_manager = Arc::new( - pulseengine_mcp_auth::AuthenticationManager::new(auth_config) + pulseengine_auth::AuthenticationManager::new(auth_config) .await .unwrap(), ); diff --git a/mcp-auth/src/permissions/mod.rs b/mcp-auth/src/permissions/mod.rs deleted file mode 100644 index a44b6266..00000000 --- a/mcp-auth/src/permissions/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Permission system for MCP tools and resources -//! -//! This module provides fine-grained permission control for MCP operations, -//! including tools, resources, and custom permission definitions. - -pub mod mcp_permissions; - -pub use mcp_permissions::{ - McpPermission, McpPermissionChecker, PermissionAction, PermissionConfig, PermissionError, - PermissionRule, ResourcePermissionConfig, ToolPermissionConfig, -}; diff --git a/mcp-external-validation/Cargo.toml b/mcp-external-validation/Cargo.toml index 6eb12f0b..f7f34bad 100644 --- a/mcp-external-validation/Cargo.toml +++ b/mcp-external-validation/Cargo.toml @@ -19,7 +19,7 @@ publish = false pulseengine-mcp-protocol = { workspace = true } pulseengine-mcp-server = { workspace = true } pulseengine-mcp-transport = { workspace = true } -pulseengine-mcp-auth = { workspace = true } +pulseengine-auth = { workspace = true } # Async runtime tokio = { workspace = true } diff --git a/mcp-external-validation/src/auth_integration.rs b/mcp-external-validation/src/auth_integration.rs index 8dc304a3..f3aee149 100644 --- a/mcp-external-validation/src/auth_integration.rs +++ b/mcp-external-validation/src/auth_integration.rs @@ -8,7 +8,7 @@ use crate::{ ValidationConfig, ValidationError, ValidationResult, report::{IssueSeverity, TestScore, ValidationIssue}, }; -use pulseengine_mcp_auth::{ +use pulseengine_auth::{ AuthenticationManager, RateLimitStats, Role, ValidationConfig as AuthValidationConfig, validation::permissions, }; @@ -148,7 +148,7 @@ impl AuthIntegrationTester { /// Initialize authentication manager for testing pub async fn initialize_auth_manager(&mut self) -> ValidationResult<()> { - use pulseengine_mcp_auth::{AuthConfig, config::StorageConfig}; + use pulseengine_auth::{AuthConfig, config::StorageConfig}; // Create temporary in-memory authentication configuration for testing let auth_config = AuthConfig { @@ -629,7 +629,7 @@ impl AuthIntegrationTester { ); // Test authentication header extraction - let extracted_token = pulseengine_mcp_auth::validation::extract_api_key(&headers, None); + let extracted_token = pulseengine_auth::validation::extract_api_key(&headers, None); if extracted_token == Some("test_token_123".to_string()) { info!("Authentication header extraction works correctly"); passed_tests += 1; @@ -643,7 +643,7 @@ impl AuthIntegrationTester { } // Test IP extraction - let extracted_ip = pulseengine_mcp_auth::validation::extract_client_ip(&headers); + let extracted_ip = pulseengine_auth::validation::extract_client_ip(&headers); if extracted_ip == "192.168.1.1" { info!("Client IP extraction works correctly"); passed_tests += 1; @@ -657,11 +657,11 @@ impl AuthIntegrationTester { } // Test input validation utilities - if pulseengine_mcp_auth::validation::is_valid_uuid("550e8400-e29b-41d4-a716-446655440000") { + if pulseengine_auth::validation::is_valid_uuid("550e8400-e29b-41d4-a716-446655440000") { passed_tests += 1; } - if pulseengine_mcp_auth::validation::is_valid_ip_address("192.168.1.1") { + if pulseengine_auth::validation::is_valid_ip_address("192.168.1.1") { passed_tests += 1; } @@ -679,7 +679,7 @@ impl AuthIntegrationTester { // Test input sanitization let dangerous_input = "test"; - let sanitized = pulseengine_mcp_auth::validation::sanitize_input(dangerous_input); + let sanitized = pulseengine_auth::validation::sanitize_input(dangerous_input); if !sanitized.contains(""; let sanitized = sanitizer.sanitize_string(suspicious_input); - // Should be sanitized (exact behavior depends on implementation) assert!(sanitized != suspicious_input || sanitized.is_empty()); - // Test very long input let long_input = "a".repeat(10000); let sanitized = sanitizer.sanitize_string(&long_input); - // Should be truncated or rejected assert!(sanitized.len() <= long_input.len()); } @@ -162,11 +143,9 @@ mod tests { let default = RequestSecurityConfig::default(); let strict = RequestSecurityConfig::strict(); - // Strict should have lower limits than default assert!(strict.limits.max_request_size <= default.limits.max_request_size); assert!(strict.limits.max_parameters <= default.limits.max_parameters); - // Permissive should have higher limits than default assert!(permissive.limits.max_request_size >= default.limits.max_request_size); assert!(permissive.limits.max_parameters >= default.limits.max_parameters); } @@ -199,32 +178,25 @@ mod tests { #[tokio::test] async fn test_security_integration() { - // Test that security components work together - let config = RequestSecurityConfig::strict(); let validator = RequestSecurityValidator::new(config); let sanitizer = InputSanitizer::new(); - // Create a potentially problematic request - let suspicious_request = Request { - jsonrpc: "2.0".to_string(), - method: "tools/call".to_string(), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(1)), - params: json!({ - "name": "test_tool", - "arguments": { - "input": "", - "data": "x".repeat(10000), // Very long string - } - }), - }; + let params = json!({ + "name": "test_tool", + "arguments": { + "input": "", + "data": "x".repeat(10000), + } + }); - // Validate the request - let validation_result = validator.validate_request(&suspicious_request, None).await; + let validation_result = validator + .validate_request_parts("tools/call", ¶ms, None) + .await; // If validation passes, sanitize the input - if let Ok(_) = validation_result { - if let Some(args) = suspicious_request.params.get("arguments") { + if validation_result.is_ok() { + if let Some(args) = params.get("arguments") { if let Some(input) = args.get("input").and_then(|v| v.as_str()) { let sanitized = sanitizer.sanitize_string(input); assert!(sanitized != input || sanitized.is_empty()); diff --git a/mcp-auth/src/security/request_security.rs b/pulseengine-auth/src/security/request_security.rs similarity index 93% rename from mcp-auth/src/security/request_security.rs rename to pulseengine-auth/src/security/request_security.rs index 9b03c8aa..fc8643a5 100644 --- a/mcp-auth/src/security/request_security.rs +++ b/pulseengine-auth/src/security/request_security.rs @@ -1,10 +1,9 @@ -//! MCP Request Security Validation and Sanitization +//! Request Security Validation and Sanitization //! -//! This module provides comprehensive security validation for MCP requests, +//! This module provides comprehensive security validation for requests, //! including parameter sanitization, size limits, and injection protection. use crate::AuthContext; -use pulseengine_mcp_protocol::Request; use regex::Regex; use serde_json::Value; use std::collections::{HashMap, HashSet}; @@ -359,32 +358,37 @@ impl RequestSecurityValidator { Self::new(RequestSecurityConfig::default()) } - /// Validate an MCP request for security issues - pub async fn validate_request( + /// Validate a request for security issues. + /// + /// Takes the method name and params (as `serde_json::Value`) instead of + /// a protocol-specific Request type. + pub async fn validate_request_parts( &self, - request: &Request, + method: &str, + params: &Value, auth_context: Option<&AuthContext>, ) -> Result<(), SecurityValidationError> { if !self.config.enabled { return Ok(()); } - debug!("Validating request security for method: {}", request.method); + debug!("Validating request security for method: {}", method); // Apply user-specific security rules based on authentication context if let Some(context) = auth_context { - self.validate_user_specific_rules(request, context)?; + self.validate_user_specific_rules(method, params, context)?; } // Validate method - self.validate_method(&request.method)?; + self.validate_method(method)?; - // Validate request size - let request_size = serde_json::to_string(request) - .map_err(|_| SecurityValidationError::MaliciousContent { - reason: "Request serialization failed".to_string(), - })? - .len(); + // Validate request size (method + params combined) + let request_size = method.len() + + serde_json::to_string(params) + .map_err(|_| SecurityValidationError::MaliciousContent { + reason: "Request serialization failed".to_string(), + })? + .len(); if request_size > self.config.limits.max_request_size { self.log_violation(SecurityViolation { @@ -406,26 +410,27 @@ impl RequestSecurityValidator { } // Validate parameters - self.validate_parameters(&request.params, "params")?; + self.validate_parameters(params, "params")?; // Check for injection attempts if self.config.enable_injection_detection { - self.detect_injection_attempts(&request.params, "params")?; + self.detect_injection_attempts(params, "params")?; } debug!("Request passed security validation"); Ok(()) } - /// Sanitize an MCP request - pub async fn sanitize_request(&self, mut request: Request) -> Request { + /// Sanitize request parameters. + /// + /// Returns sanitized params as a new `Value`. + pub fn sanitize_params(&self, params: &Value) -> Value { if !self.config.enabled || !self.config.enable_sanitization { - return request; + return params.clone(); } debug!("Sanitizing request parameters"); - request.params = self.sanitize_value(&request.params); - request + self.sanitize_value(params) } /// Validate method name @@ -630,18 +635,20 @@ impl RequestSecurityValidator { /// Validate user-specific security rules based on authentication context fn validate_user_specific_rules( &self, - request: &Request, + method: &str, + params: &Value, auth_context: &AuthContext, ) -> Result<(), SecurityValidationError> { // Apply stricter limits for lower-privilege users let user_limits = self.get_user_specific_limits(auth_context); // Validate request size against user-specific limits - let request_size = serde_json::to_string(request) - .map_err(|_| SecurityValidationError::MaliciousContent { - reason: "Request serialization failed for user validation".to_string(), - })? - .len(); + let request_size = method.len() + + serde_json::to_string(params) + .map_err(|_| SecurityValidationError::MaliciousContent { + reason: "Request serialization failed for user validation".to_string(), + })? + .len(); if request_size > user_limits.max_request_size { self.log_violation(SecurityViolation { @@ -666,22 +673,22 @@ impl RequestSecurityValidator { // Apply method-specific restrictions based on user role if let Some(restricted_methods) = self.get_restricted_methods_for_user(auth_context) { - if restricted_methods.contains(&request.method) { + if restricted_methods.iter().any(|m| m == method) { self.log_violation(SecurityViolation { violation_type: SecurityViolationType::UnauthorizedMethod, severity: SecuritySeverity::Critical, description: format!( "User {} attempted to access restricted method: {}", auth_context.user_id.as_deref().unwrap_or("unknown"), - request.method + method ), field: Some("method".to_string()), - value: Some(request.method.clone()), + value: Some(method.to_string()), timestamp: chrono::Utc::now(), }); return Err(SecurityValidationError::UnsupportedMethod { - method: request.method.clone(), + method: method.to_string(), }); } } @@ -689,7 +696,7 @@ impl RequestSecurityValidator { // Apply enhanced injection detection for anonymous users if auth_context.user_id.is_none() { // Anonymous users get stricter validation - self.validate_anonymous_user_request(request)?; + self.validate_anonymous_user_request(method, params)?; } Ok(()) @@ -794,10 +801,11 @@ impl RequestSecurityValidator { /// Apply enhanced validation for anonymous users fn validate_anonymous_user_request( &self, - request: &Request, + method: &str, + params: &Value, ) -> Result<(), SecurityValidationError> { // Check method parameters more strictly - self.detect_injection_attempts_strict(&request.params, "params")?; + self.detect_injection_attempts_strict(params, "params")?; // Anonymous users are limited to read-only operations let read_only_methods = [ @@ -809,21 +817,18 @@ impl RequestSecurityValidator { "completion/complete", ]; - if !read_only_methods.contains(&request.method.as_str()) { + if !read_only_methods.contains(&method) { self.log_violation(SecurityViolation { violation_type: SecurityViolationType::UnauthorizedMethod, severity: SecuritySeverity::High, - description: format!( - "Anonymous user attempted non-read-only method: {}", - request.method - ), + description: format!("Anonymous user attempted non-read-only method: {}", method), field: Some("method".to_string()), - value: Some(request.method.clone()), + value: Some(method.to_string()), timestamp: chrono::Utc::now(), }); return Err(SecurityValidationError::UnsupportedMethod { - method: request.method.clone(), + method: method.to_string(), }); } diff --git a/mcp-auth/src/session/mod.rs b/pulseengine-auth/src/session/mod.rs similarity index 100% rename from mcp-auth/src/session/mod.rs rename to pulseengine-auth/src/session/mod.rs diff --git a/mcp-auth/src/session/session_manager.rs b/pulseengine-auth/src/session/session_manager.rs similarity index 100% rename from mcp-auth/src/session/session_manager.rs rename to pulseengine-auth/src/session/session_manager.rs diff --git a/mcp-auth/src/storage.rs b/pulseengine-auth/src/storage.rs similarity index 100% rename from mcp-auth/src/storage.rs rename to pulseengine-auth/src/storage.rs diff --git a/mcp-auth/src/transport/auth_extractors.rs b/pulseengine-auth/src/transport/auth_extractors.rs similarity index 100% rename from mcp-auth/src/transport/auth_extractors.rs rename to pulseengine-auth/src/transport/auth_extractors.rs diff --git a/mcp-auth/src/transport/http_auth.rs b/pulseengine-auth/src/transport/http_auth.rs similarity index 100% rename from mcp-auth/src/transport/http_auth.rs rename to pulseengine-auth/src/transport/http_auth.rs diff --git a/mcp-auth/src/transport/mod.rs b/pulseengine-auth/src/transport/mod.rs similarity index 100% rename from mcp-auth/src/transport/mod.rs rename to pulseengine-auth/src/transport/mod.rs diff --git a/mcp-auth/src/transport/stdio_auth.rs b/pulseengine-auth/src/transport/stdio_auth.rs similarity index 100% rename from mcp-auth/src/transport/stdio_auth.rs rename to pulseengine-auth/src/transport/stdio_auth.rs diff --git a/mcp-auth/src/transport/websocket_auth.rs b/pulseengine-auth/src/transport/websocket_auth.rs similarity index 100% rename from mcp-auth/src/transport/websocket_auth.rs rename to pulseengine-auth/src/transport/websocket_auth.rs diff --git a/mcp-auth/src/validation.rs b/pulseengine-auth/src/validation.rs similarity index 100% rename from mcp-auth/src/validation.rs rename to pulseengine-auth/src/validation.rs diff --git a/mcp-auth/src/vault/infisical.rs b/pulseengine-auth/src/vault/infisical.rs similarity index 100% rename from mcp-auth/src/vault/infisical.rs rename to pulseengine-auth/src/vault/infisical.rs diff --git a/mcp-auth/src/vault/mod.rs b/pulseengine-auth/src/vault/mod.rs similarity index 100% rename from mcp-auth/src/vault/mod.rs rename to pulseengine-auth/src/vault/mod.rs diff --git a/mcp-auth/tests/oauth_basic_tests.rs b/pulseengine-auth/tests/oauth_basic_tests.rs similarity index 99% rename from mcp-auth/tests/oauth_basic_tests.rs rename to pulseengine-auth/tests/oauth_basic_tests.rs index 0324661c..2023534c 100644 --- a/mcp-auth/tests/oauth_basic_tests.rs +++ b/pulseengine-auth/tests/oauth_basic_tests.rs @@ -1,7 +1,7 @@ //! Basic unit tests for OAuth 2.1 components use chrono::{Duration, Utc}; -use pulseengine_mcp_auth::oauth::{ +use pulseengine_auth::oauth::{ models::{AuthorizationCode, OAuthClient, OAuthError, RefreshToken}, pkce::{validate_code_challenge, validate_code_verifier, verify_pkce}, storage::{InMemoryOAuthStorage, OAuthStorage, OAuthStorageError}, diff --git a/mcp-auth/tests/oauth_endpoints_tests.rs b/pulseengine-auth/tests/oauth_endpoints_tests.rs similarity index 99% rename from mcp-auth/tests/oauth_endpoints_tests.rs rename to pulseengine-auth/tests/oauth_endpoints_tests.rs index 575cea22..4ce8577c 100644 --- a/mcp-auth/tests/oauth_endpoints_tests.rs +++ b/pulseengine-auth/tests/oauth_endpoints_tests.rs @@ -7,7 +7,7 @@ use axum::{ body::Body, http::{Request, StatusCode}, }; -use pulseengine_mcp_auth::oauth::{OAuthState, oauth_router}; +use pulseengine_auth::oauth::{OAuthState, oauth_router}; use serde_json::json; use tower::util::ServiceExt; // for `oneshot` diff --git a/mcp-auth/tests/oauth_flow_tests.rs b/pulseengine-auth/tests/oauth_flow_tests.rs similarity index 96% rename from mcp-auth/tests/oauth_flow_tests.rs rename to pulseengine-auth/tests/oauth_flow_tests.rs index 222bf568..5763129a 100644 --- a/mcp-auth/tests/oauth_flow_tests.rs +++ b/pulseengine-auth/tests/oauth_flow_tests.rs @@ -8,7 +8,7 @@ use axum::{ http::{Request, StatusCode}, }; use chrono::{Duration, Utc}; -use pulseengine_mcp_auth::oauth::{ +use pulseengine_auth::oauth::{ OAuthState, models::{AuthorizationCode, RefreshToken}, oauth_router, @@ -130,7 +130,7 @@ async fn test_authorize_get_displays_consent_form() { let (app, state) = test_app(); // First register a client - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "secret".to_string(), client_name: "Test App".to_string(), @@ -172,7 +172,7 @@ async fn test_authorize_get_displays_consent_form() { async fn test_authorize_get_invalid_response_type() { let (app, state) = test_app(); - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "secret".to_string(), client_name: "Test App".to_string(), @@ -205,7 +205,7 @@ async fn test_authorize_get_invalid_response_type() { async fn test_authorize_get_invalid_code_challenge_method() { let (app, state) = test_app(); - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "secret".to_string(), client_name: "Test App".to_string(), @@ -232,7 +232,7 @@ async fn test_authorize_get_invalid_code_challenge_method() { async fn test_authorize_post_user_approval() { let (app, state) = test_app(); - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "secret".to_string(), client_name: "Test App".to_string(), @@ -267,7 +267,7 @@ async fn test_authorize_post_user_approval() { async fn test_authorize_post_user_denial() { let (app, state) = test_app(); - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "secret".to_string(), client_name: "Test App".to_string(), @@ -305,7 +305,7 @@ async fn test_full_authorization_code_flow() { let (_, state) = test_app(); // 1. Register client - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "test_secret".to_string(), client_name: "Test App".to_string(), @@ -362,7 +362,7 @@ async fn test_refresh_token_flow() { let (_, state) = test_app(); // 1. Register client - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "test_secret".to_string(), client_name: "Test App".to_string(), @@ -410,7 +410,7 @@ async fn test_refresh_token_flow() { async fn test_token_endpoint_wrong_code_verifier() { let (_, state) = test_app(); - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "test_secret".to_string(), client_name: "Test App".to_string(), @@ -470,7 +470,7 @@ async fn test_token_endpoint_wrong_code_verifier() { async fn test_token_endpoint_expired_code() { let (_, state) = test_app(); - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "test_secret".to_string(), client_name: "Test App".to_string(), @@ -529,7 +529,7 @@ async fn test_token_endpoint_expired_code() { async fn test_token_endpoint_wrong_redirect_uri() { let (_, state) = test_app(); - let client = pulseengine_mcp_auth::oauth::models::OAuthClient { + let client = pulseengine_auth::oauth::models::OAuthClient { client_id: "test_client".to_string(), client_secret: "test_secret".to_string(), client_name: "Test App".to_string(), diff --git a/mcp-auth/tests/simple_middleware_test.rs b/pulseengine-auth/tests/simple_middleware_test.rs similarity index 62% rename from mcp-auth/tests/simple_middleware_test.rs rename to pulseengine-auth/tests/simple_middleware_test.rs index 6d372520..e7de1eca 100644 --- a/mcp-auth/tests/simple_middleware_test.rs +++ b/pulseengine-auth/tests/simple_middleware_test.rs @@ -1,10 +1,9 @@ //! Simple middleware tests to verify basic functionality -use pulseengine_mcp_auth::{ +use pulseengine_auth::{ AuthConfig, AuthenticationManager, Role, middleware::mcp_auth::{McpAuthConfig, McpAuthMiddleware}, }; -use pulseengine_mcp_protocol::Request; use std::collections::HashMap; use std::sync::Arc; @@ -13,9 +12,6 @@ async fn test_basic_middleware_creation() { let auth_config = AuthConfig::memory(); let auth_manager = Arc::new(AuthenticationManager::new(auth_config).await.unwrap()); let _middleware = McpAuthMiddleware::with_default_config(auth_manager); - - // Test that middleware was created successfully - // We can't access private fields, but we can test functionality } #[tokio::test] @@ -24,17 +20,12 @@ async fn test_anonymous_method_processing() { let auth_manager = Arc::new(AuthenticationManager::new(auth_config).await.unwrap()); let middleware = McpAuthMiddleware::with_default_config(auth_manager); - let request = Request { - jsonrpc: "2.0".to_string(), - method: "initialize".to_string(), // This should be in anonymous methods - params: serde_json::json!({}), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(1)), - }; - - let result = middleware.process_request(request, None).await; + let result = middleware + .authenticate("initialize", Some("1".to_string()), None) + .await; assert!(result.is_ok()); - let (_, context) = result.unwrap(); + let context = result.unwrap(); assert!(context.auth.is_anonymous); assert!(context.auth.auth_context.is_none()); } @@ -58,17 +49,12 @@ async fn test_authenticated_request() { format!("Bearer {}", api_key.key), ); - let request = Request { - jsonrpc: "2.0".to_string(), - method: "tools/list".to_string(), - params: serde_json::json!({}), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(1)), - }; - - let result = middleware.process_request(request, Some(&headers)).await; + let result = middleware + .authenticate("tools/list", Some("1".to_string()), Some(&headers)) + .await; assert!(result.is_ok()); - let (_, context) = result.unwrap(); + let context = result.unwrap(); assert!(!context.auth.is_anonymous); assert!(context.auth.auth_context.is_some()); assert_eq!(context.auth.auth_method, Some("Bearer".to_string())); @@ -80,19 +66,14 @@ async fn test_missing_auth_required() { let auth_manager = Arc::new(AuthenticationManager::new(auth_config).await.unwrap()); let middleware = McpAuthMiddleware::with_default_config(auth_manager); - let request = Request { - jsonrpc: "2.0".to_string(), - method: "tools/list".to_string(), // This requires auth - params: serde_json::json!({}), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(1)), - }; - - let result = middleware.process_request(request, None).await; + let result = middleware + .authenticate("tools/list", Some("1".to_string()), None) + .await; assert!(result.is_err()); assert!( result .unwrap_err() - .message + .to_string() .contains("Authentication required") ); } @@ -109,19 +90,14 @@ async fn test_invalid_api_key() { "Bearer invalid_key".to_string(), ); - let request = Request { - jsonrpc: "2.0".to_string(), - method: "tools/list".to_string(), - params: serde_json::json!({}), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(1)), - }; - - let result = middleware.process_request(request, Some(&headers)).await; + let result = middleware + .authenticate("tools/list", Some("1".to_string()), Some(&headers)) + .await; assert!(result.is_err()); assert!( result .unwrap_err() - .message + .to_string() .contains("Authentication required") ); } @@ -138,17 +114,12 @@ async fn test_optional_auth_config() { let middleware = McpAuthMiddleware::new(auth_manager, config); - let request = Request { - jsonrpc: "2.0".to_string(), - method: "tools/list".to_string(), - params: serde_json::json!({}), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(1)), - }; - // Should succeed without auth when require_auth is false - let result = middleware.process_request(request, None).await; + let result = middleware + .authenticate("tools/list", Some("1".to_string()), None) + .await; assert!(result.is_ok()); - let (_, context) = result.unwrap(); + let context = result.unwrap(); assert!(context.auth.is_anonymous); } diff --git a/mcp-auth/tests/test_utils.rs b/pulseengine-auth/tests/test_utils.rs similarity index 99% rename from mcp-auth/tests/test_utils.rs rename to pulseengine-auth/tests/test_utils.rs index 68c07e78..d2bfa4cb 100644 --- a/mcp-auth/tests/test_utils.rs +++ b/pulseengine-auth/tests/test_utils.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use chrono::{Duration, Utc}; -use pulseengine_mcp_auth::{ +use pulseengine_auth::{ AuthenticationManager, config::{AuthConfig, StorageConfig}, models::{ApiKey, AuthContext, Role}, diff --git a/mcp-auth/tests/vault_integration_tests.rs b/pulseengine-auth/tests/vault_integration_tests.rs similarity index 98% rename from mcp-auth/tests/vault_integration_tests.rs rename to pulseengine-auth/tests/vault_integration_tests.rs index c1bc6398..486d1247 100644 --- a/mcp-auth/tests/vault_integration_tests.rs +++ b/pulseengine-auth/tests/vault_integration_tests.rs @@ -7,7 +7,7 @@ #![cfg(feature = "vault")] -use pulseengine_mcp_auth::vault::{VaultConfig, VaultType}; +use pulseengine_auth::vault::{VaultConfig, VaultType}; use std::env; #[cfg(test)] @@ -98,7 +98,7 @@ mod vault_tests { #[cfg(all(test, feature = "integration-tests"))] mod integration_tests { use super::*; - use pulseengine_mcp_auth::vault::{VaultIntegration, create_vault_client}; + use pulseengine_auth::vault::{VaultIntegration, create_vault_client}; // Helper to check if integration test environment is available fn integration_env_available() -> bool { diff --git a/mcp-logging/Cargo.toml b/pulseengine-logging/Cargo.toml similarity index 74% rename from mcp-logging/Cargo.toml rename to pulseengine-logging/Cargo.toml index d7253527..3928eb93 100644 --- a/mcp-logging/Cargo.toml +++ b/pulseengine-logging/Cargo.toml @@ -1,18 +1,22 @@ [package] -name = "pulseengine-mcp-logging" +name = "pulseengine-logging" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true -description = "Structured logging framework for MCP servers - PulseEngine MCP Framework" +description = "Structured logging with credential scrubbing, metrics, alerting, and correlation IDs" homepage.workspace = true repository.workspace = true -documentation = "https://docs.rs/pulseengine-mcp-logging" +documentation = "https://docs.rs/pulseengine-logging" readme = "README.md" -keywords = ["mcp", "logging", "structured", "metrics", "tracing"] +keywords = ["logging", "structured", "metrics", "tracing", "security"] categories = ["development-tools::debugging"] rust-version.workspace = true +[lib] +name = "pulseengine_logging" +path = "src/lib.rs" + [dependencies] # Logging tracing = { workspace = true } diff --git a/mcp-logging/README.md b/pulseengine-logging/README.md similarity index 100% rename from mcp-logging/README.md rename to pulseengine-logging/README.md diff --git a/mcp-logging/assets/dashboard-base.css b/pulseengine-logging/assets/dashboard-base.css similarity index 100% rename from mcp-logging/assets/dashboard-base.css rename to pulseengine-logging/assets/dashboard-base.css diff --git a/mcp-logging/assets/dashboard-contrast.css b/pulseengine-logging/assets/dashboard-contrast.css similarity index 100% rename from mcp-logging/assets/dashboard-contrast.css rename to pulseengine-logging/assets/dashboard-contrast.css diff --git a/mcp-logging/assets/dashboard-dark.css b/pulseengine-logging/assets/dashboard-dark.css similarity index 100% rename from mcp-logging/assets/dashboard-dark.css rename to pulseengine-logging/assets/dashboard-dark.css diff --git a/mcp-logging/assets/dashboard-light.css b/pulseengine-logging/assets/dashboard-light.css similarity index 100% rename from mcp-logging/assets/dashboard-light.css rename to pulseengine-logging/assets/dashboard-light.css diff --git a/mcp-logging/assets/dashboard.js b/pulseengine-logging/assets/dashboard.js similarity index 100% rename from mcp-logging/assets/dashboard.js rename to pulseengine-logging/assets/dashboard.js diff --git a/mcp-logging/src/aggregation.rs b/pulseengine-logging/src/aggregation.rs similarity index 100% rename from mcp-logging/src/aggregation.rs rename to pulseengine-logging/src/aggregation.rs diff --git a/mcp-logging/src/alerting.rs b/pulseengine-logging/src/alerting.rs similarity index 100% rename from mcp-logging/src/alerting.rs rename to pulseengine-logging/src/alerting.rs diff --git a/mcp-logging/src/correlation.rs b/pulseengine-logging/src/correlation.rs similarity index 100% rename from mcp-logging/src/correlation.rs rename to pulseengine-logging/src/correlation.rs diff --git a/mcp-logging/src/dashboard.rs b/pulseengine-logging/src/dashboard.rs similarity index 100% rename from mcp-logging/src/dashboard.rs rename to pulseengine-logging/src/dashboard.rs diff --git a/mcp-logging/src/lib.rs b/pulseengine-logging/src/lib.rs similarity index 94% rename from mcp-logging/src/lib.rs rename to pulseengine-logging/src/lib.rs index 87eac8ed..6dcd4f1b 100644 --- a/mcp-logging/src/lib.rs +++ b/pulseengine-logging/src/lib.rs @@ -1,6 +1,6 @@ -//! Structured logging framework for MCP servers +//! Structured logging framework with security-aware features //! -//! This crate provides comprehensive logging capabilities for MCP servers including: +//! This crate provides comprehensive logging capabilities including: //! - Structured logging with tracing //! - Metrics collection and reporting //! - Log sanitization for security @@ -9,7 +9,7 @@ //! # Example //! //! ```rust,ignore -//! use pulseengine_mcp_logging::{MetricsCollector, StructuredLogger}; +//! use pulseengine_logging::{MetricsCollector, StructuredLogger}; //! //! #[tokio::main] //! async fn main() { diff --git a/mcp-logging/src/lib_tests.rs b/pulseengine-logging/src/lib_tests.rs similarity index 100% rename from mcp-logging/src/lib_tests.rs rename to pulseengine-logging/src/lib_tests.rs diff --git a/mcp-logging/src/metrics.rs b/pulseengine-logging/src/metrics.rs similarity index 100% rename from mcp-logging/src/metrics.rs rename to pulseengine-logging/src/metrics.rs diff --git a/mcp-logging/src/metrics_tests.rs b/pulseengine-logging/src/metrics_tests.rs similarity index 100% rename from mcp-logging/src/metrics_tests.rs rename to pulseengine-logging/src/metrics_tests.rs diff --git a/mcp-logging/src/persistence.rs b/pulseengine-logging/src/persistence.rs similarity index 100% rename from mcp-logging/src/persistence.rs rename to pulseengine-logging/src/persistence.rs diff --git a/mcp-logging/src/profiling.rs b/pulseengine-logging/src/profiling.rs similarity index 100% rename from mcp-logging/src/profiling.rs rename to pulseengine-logging/src/profiling.rs diff --git a/mcp-logging/src/sanitization.rs b/pulseengine-logging/src/sanitization.rs similarity index 100% rename from mcp-logging/src/sanitization.rs rename to pulseengine-logging/src/sanitization.rs diff --git a/mcp-logging/src/sanitization_tests.rs b/pulseengine-logging/src/sanitization_tests.rs similarity index 100% rename from mcp-logging/src/sanitization_tests.rs rename to pulseengine-logging/src/sanitization_tests.rs diff --git a/mcp-logging/src/structured.rs b/pulseengine-logging/src/structured.rs similarity index 100% rename from mcp-logging/src/structured.rs rename to pulseengine-logging/src/structured.rs diff --git a/mcp-logging/src/structured_tests.rs b/pulseengine-logging/src/structured_tests.rs similarity index 100% rename from mcp-logging/src/structured_tests.rs rename to pulseengine-logging/src/structured_tests.rs diff --git a/mcp-logging/src/telemetry.rs b/pulseengine-logging/src/telemetry.rs similarity index 100% rename from mcp-logging/src/telemetry.rs rename to pulseengine-logging/src/telemetry.rs diff --git a/pulseengine-mcp-apps/Cargo.toml b/pulseengine-mcp-apps/Cargo.toml new file mode 100644 index 00000000..22190420 --- /dev/null +++ b/pulseengine-mcp-apps/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pulseengine-mcp-apps" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +description = "MCP Apps extension for rmcp — serve interactive HTML UIs via MCP" +keywords = ["mcp", "ui", "html", "rmcp", "apps"] +categories = ["web-programming"] +repository = "https://github.com/pulseengine/mcp" + +[dependencies] +rmcp = { version = "1.3", features = ["server"] } +serde_json = "1" diff --git a/pulseengine-mcp-apps/src/content.rs b/pulseengine-mcp-apps/src/content.rs new file mode 100644 index 00000000..e29c859d --- /dev/null +++ b/pulseengine-mcp-apps/src/content.rs @@ -0,0 +1,60 @@ +//! Content helpers for building HTML responses in MCP Apps. + +use rmcp::model::{Annotated, CallToolResult, Content, RawResource, ResourceContents}; + +use crate::MCP_APPS_MIME_TYPE; + +/// Create an HTML text [`Content`] block for tool responses. +/// +/// This is a thin wrapper around `Content::text()` for readability when +/// building MCP Apps tool handlers. +pub fn html_content(html: impl Into) -> Content { + Content::text(html) +} + +/// Create a [`CallToolResult`] containing HTML content. +/// +/// Returns a successful tool result with a single HTML text content block. +pub fn html_tool_result(html: impl Into) -> CallToolResult { + CallToolResult::success(vec![html_content(html)]) +} + +/// Create HTML [`ResourceContents`] for a resource response. +/// +/// Sets the MIME type to `text/html` so MCP clients know to render the +/// content as an interactive app. +pub fn html_resource(uri: impl Into, html: impl Into) -> ResourceContents { + ResourceContents::text(html, uri).with_mime_type(MCP_APPS_MIME_TYPE) +} + +/// Create an MCP Apps resource descriptor for `list_resources`. +/// +/// Builds a [`RawResource`] with `mime_type = "text/html"` and wraps it +/// in [`Annotated`] with no annotations. Use this to advertise HTML app +/// resources in your `list_resources` handler. +/// +/// # Arguments +/// +/// * `uri` - The resource URI (e.g. `"ui://dashboard"`) +/// * `name` - The resource name (e.g. `"dashboard"`) +/// * `title` - Optional human-readable title +/// * `description` - Optional description of the resource +pub fn app_resource( + uri: &str, + name: &str, + title: Option<&str>, + description: Option<&str>, +) -> Annotated { + use rmcp::model::AnnotateAble; + + let mut resource = RawResource::new(uri, name).with_mime_type(MCP_APPS_MIME_TYPE); + + if let Some(t) = title { + resource = resource.with_title(t); + } + if let Some(d) = description { + resource = resource.with_description(d); + } + + resource.no_annotation() +} diff --git a/pulseengine-mcp-apps/src/lib.rs b/pulseengine-mcp-apps/src/lib.rs new file mode 100644 index 00000000..b8a19a1f --- /dev/null +++ b/pulseengine-mcp-apps/src/lib.rs @@ -0,0 +1,91 @@ +//! # pulseengine-mcp-apps +//! +//! MCP Apps extension helpers for [rmcp](https://docs.rs/rmcp) — serve interactive +//! HTML UIs via the Model Context Protocol. +//! +//! This crate provides constants, capability builders, and content helpers that make +//! it easy to declare MCP Apps support and return HTML content from your rmcp server. +//! +//! # Quick start +//! +//! ```rust,no_run +//! use pulseengine_mcp_apps::{mcp_apps_capabilities, html_tool_result, html_resource, app_resource}; +//! use rmcp::model::ServerCapabilities; +//! +//! // Declare MCP Apps capability +//! let caps = ServerCapabilities::builder() +//! .enable_tools() +//! .enable_resources() +//! .enable_extensions_with(mcp_apps_capabilities()) +//! .build(); +//! +//! // Return HTML from a tool +//! let result = html_tool_result("

Hello

"); +//! +//! // Return HTML from a resource +//! let contents = html_resource("ui://dashboard", "

Dashboard

"); +//! +//! // Describe an app resource for list_resources +//! let resource = app_resource("ui://dashboard", "dashboard", Some("My Dashboard"), Some("An HTML dashboard")); +//! ``` + +mod content; + +pub use content::*; + +use serde_json::{Map, Value, json}; +use std::collections::BTreeMap; + +/// The MCP Apps extension key for `ServerCapabilities.extensions`. +pub const MCP_APPS_EXTENSION_KEY: &str = "io.modelcontextprotocol/apps"; + +/// The MIME type for MCP App HTML content. +pub const MCP_APPS_MIME_TYPE: &str = "text/html"; + +/// Create the MCP Apps extension capabilities map. +/// +/// Use with `ServerCapabilities::builder().enable_extensions_with(mcp_apps_capabilities())` +/// to declare MCP Apps support in your server. +/// +/// The returned map contains a single entry for `io.modelcontextprotocol/apps` with +/// `mimeTypes: ["text/html"]`. +pub fn mcp_apps_capabilities() -> BTreeMap> { + let mut map = BTreeMap::new(); + map.insert( + MCP_APPS_EXTENSION_KEY.to_string(), + json!({ "mimeTypes": [MCP_APPS_MIME_TYPE] }) + .as_object() + .unwrap() + .clone(), + ); + map +} + +/// Merge MCP Apps capabilities with existing extension capabilities. +/// +/// This is useful when your server declares other extensions and you want to add +/// MCP Apps support alongside them. +/// +/// # Example +/// +/// ```rust +/// use std::collections::BTreeMap; +/// use serde_json::{Map, Value, json}; +/// use pulseengine_mcp_apps::with_mcp_apps; +/// +/// let mut existing = BTreeMap::new(); +/// existing.insert( +/// "my.custom/extension".to_string(), +/// json!({ "enabled": true }).as_object().unwrap().clone(), +/// ); +/// +/// let combined = with_mcp_apps(existing); +/// assert!(combined.contains_key("io.modelcontextprotocol/apps")); +/// assert!(combined.contains_key("my.custom/extension")); +/// ``` +pub fn with_mcp_apps( + mut existing: BTreeMap>, +) -> BTreeMap> { + existing.extend(mcp_apps_capabilities()); + existing +} diff --git a/pulseengine-mcp-apps/tests/apps_tests.rs b/pulseengine-mcp-apps/tests/apps_tests.rs new file mode 100644 index 00000000..31f7a4a5 --- /dev/null +++ b/pulseengine-mcp-apps/tests/apps_tests.rs @@ -0,0 +1,170 @@ +use std::collections::BTreeMap; + +use pulseengine_mcp_apps::*; +use serde_json::{Map, Value, json}; + +// --------------------------------------------------------------------------- +// Capability helpers +// --------------------------------------------------------------------------- + +#[test] +fn mcp_apps_capabilities_has_correct_key() { + let caps = mcp_apps_capabilities(); + assert!( + caps.contains_key(MCP_APPS_EXTENSION_KEY), + "capabilities must contain the MCP Apps extension key" + ); + assert_eq!(caps.len(), 1, "should contain exactly one entry"); +} + +#[test] +fn mcp_apps_capabilities_has_correct_mime_types() { + let caps = mcp_apps_capabilities(); + let apps = &caps[MCP_APPS_EXTENSION_KEY]; + let mime_types = apps + .get("mimeTypes") + .expect("should have mimeTypes field") + .as_array() + .expect("mimeTypes should be an array"); + assert_eq!(mime_types.len(), 1); + assert_eq!(mime_types[0], MCP_APPS_MIME_TYPE); +} + +#[test] +fn with_mcp_apps_merges_without_overwriting() { + let mut existing: BTreeMap> = BTreeMap::new(); + existing.insert( + "my.custom/extension".to_string(), + json!({ "version": 2 }).as_object().unwrap().clone(), + ); + + let combined = with_mcp_apps(existing); + + // Both keys present + assert!(combined.contains_key(MCP_APPS_EXTENSION_KEY)); + assert!(combined.contains_key("my.custom/extension")); + + // Original extension unchanged + let custom = &combined["my.custom/extension"]; + assert_eq!(custom.get("version").unwrap(), &json!(2)); +} + +#[test] +fn with_mcp_apps_on_empty_map() { + let combined = with_mcp_apps(BTreeMap::new()); + assert_eq!(combined.len(), 1); + assert!(combined.contains_key(MCP_APPS_EXTENSION_KEY)); +} + +// --------------------------------------------------------------------------- +// Content helpers +// --------------------------------------------------------------------------- + +#[test] +fn html_content_creates_text_content() { + let content = html_content("

Hello

"); + // Content::text produces a TextContent variant — serialise and check + let json = serde_json::to_value(&content).unwrap(); + assert_eq!(json["type"], "text"); + assert_eq!(json["text"], "

Hello

"); +} + +#[test] +fn html_content_accepts_string_and_str() { + // &str + let _ = html_content("

static

"); + // String + let _ = html_content(String::from("

owned

")); +} + +#[test] +fn html_tool_result_is_successful() { + let result = html_tool_result("
chart
"); + let json = serde_json::to_value(&result).unwrap(); + + // isError should be absent or false + let is_error = json + .get("isError") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + assert!(!is_error, "tool result should not be an error"); + + // Should contain one content block + let content = json["content"] + .as_array() + .expect("content should be an array"); + assert_eq!(content.len(), 1); + assert_eq!(content[0]["text"], "
chart
"); +} + +#[test] +fn html_resource_has_html_mime_type() { + let resource = html_resource("ui://test", "

test

"); + let json = serde_json::to_value(&resource).unwrap(); + + assert_eq!(json["uri"], "ui://test"); + assert_eq!(json["mimeType"], MCP_APPS_MIME_TYPE); + assert_eq!(json["text"], "

test

"); +} + +#[test] +fn html_resource_preserves_full_html() { + let big_html = r#"

Dashboard

"#; + let resource = html_resource("ui://dashboard", big_html); + let json = serde_json::to_value(&resource).unwrap(); + assert_eq!(json["text"], big_html); +} + +// --------------------------------------------------------------------------- +// App resource descriptor +// --------------------------------------------------------------------------- + +#[test] +fn app_resource_with_all_fields() { + let resource = app_resource( + "ui://dashboard", + "dashboard", + Some("My Dashboard"), + Some("An interactive HTML dashboard"), + ); + let json = serde_json::to_value(&resource).unwrap(); + + assert_eq!(json["uri"], "ui://dashboard"); + assert_eq!(json["name"], "dashboard"); + assert_eq!(json["mimeType"], MCP_APPS_MIME_TYPE); + + // Title and description may be in annotations or directly on the resource + // depending on rmcp's Annotated serialization — check the flattened form + let title = json + .get("title") + .or_else(|| json.pointer("/annotations/title")); + assert_eq!(title.and_then(|v| v.as_str()), Some("My Dashboard"),); + + let desc = json + .get("description") + .or_else(|| json.pointer("/annotations/description")); + assert_eq!( + desc.and_then(|v| v.as_str()), + Some("An interactive HTML dashboard"), + ); +} + +#[test] +fn app_resource_without_optional_fields() { + let resource = app_resource("ui://simple", "simple", None, None); + let json = serde_json::to_value(&resource).unwrap(); + + assert_eq!(json["uri"], "ui://simple"); + assert_eq!(json["name"], "simple"); + assert_eq!(json["mimeType"], MCP_APPS_MIME_TYPE); +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +#[test] +fn constants_have_expected_values() { + assert_eq!(MCP_APPS_EXTENSION_KEY, "io.modelcontextprotocol/apps"); + assert_eq!(MCP_APPS_MIME_TYPE, "text/html"); +} diff --git a/pulseengine-mcp-resources/Cargo.toml b/pulseengine-mcp-resources/Cargo.toml new file mode 100644 index 00000000..db08ae9b --- /dev/null +++ b/pulseengine-mcp-resources/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pulseengine-mcp-resources" +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +description = "Resource URI template router for rmcp MCP servers" +keywords = ["mcp", "resources", "router", "rmcp"] +categories = ["web-programming"] +repository = "https://github.com/pulseengine/mcp" + +[dependencies] +rmcp = { version = "1.3", features = ["server"] } +matchit = "0.8" diff --git a/pulseengine-mcp-resources/src/lib.rs b/pulseengine-mcp-resources/src/lib.rs new file mode 100644 index 00000000..5a3885cf --- /dev/null +++ b/pulseengine-mcp-resources/src/lib.rs @@ -0,0 +1,33 @@ +//! Resource URI template router for rmcp MCP servers. +//! +//! rmcp (the official Rust MCP SDK) has no built-in resource routing. +//! This crate provides [`ResourceRouter`], a URI-template-based resource router +//! built on [`matchit`] that integrates with rmcp's `ServerHandler` trait. +//! +//! # Usage +//! +//! ```rust,no_run +//! use pulseengine_mcp_resources::{ResourceRouter, strip_uri_scheme}; +//! use rmcp::model::ResourceContents; +//! +//! let mut router = ResourceRouter::<()>::new(); +//! router.add_resource( +//! "/files/{path}", +//! "file:///{path}", +//! "file", +//! "Read a file by path", +//! Some("text/plain"), +//! |_state: &(), uri: &str, params: &matchit::Params| { +//! let path = params.get("path").unwrap_or("unknown"); +//! ResourceContents::text(format!("Contents of {path}"), uri) +//! }, +//! ); +//! +//! // Use strip_uri_scheme to convert MCP URIs to matchit-routable paths +//! let route_path = strip_uri_scheme("file:///README.md"); +//! assert_eq!(route_path, "/README.md"); +//! ``` + +pub mod router; + +pub use router::{ResourceHandler, ResourceRouter, strip_uri_scheme}; diff --git a/pulseengine-mcp-resources/src/router.rs b/pulseengine-mcp-resources/src/router.rs new file mode 100644 index 00000000..82dc6791 --- /dev/null +++ b/pulseengine-mcp-resources/src/router.rs @@ -0,0 +1,221 @@ +//! Core resource router implementation. + +use rmcp::model::{Annotated, RawResourceTemplate, ResourceContents, ResourceTemplate}; + +/// Handler trait for resource handlers. +/// +/// Implementors receive the shared state, the original URI, and the extracted +/// matchit params. The handler returns `ResourceContents` for the matched +/// resource. +/// +/// A blanket implementation is provided for closures with the signature +/// `Fn(&S, &str, &matchit::Params) -> ResourceContents + Send + Sync`. +pub trait ResourceHandler: Send + Sync { + /// Handle a matched resource request. + fn call(&self, state: &S, uri: &str, params: &matchit::Params) -> ResourceContents; +} + +impl ResourceHandler for F +where + F: Fn(&S, &str, &matchit::Params) -> ResourceContents + Send + Sync, +{ + fn call(&self, state: &S, uri: &str, params: &matchit::Params) -> ResourceContents { + (self)(state, uri, params) + } +} + +/// A registered resource route: template metadata, handler, and scheme-strip +/// function for URI-to-route conversion. +struct ResourceRoute { + template: ResourceTemplate, + handler: Box>, + /// The URI scheme prefix to strip when resolving concrete URIs. + /// For example, `"file:///"` for `file:///{path}` templates. + scheme_prefix: String, + /// The matchit route prefix that replaces the scheme. + /// For example, `"/files/"` maps from `file:///` scheme. + route_prefix: String, +} + +/// A URI-template-based resource router built on [`matchit`]. +/// +/// MCP URI templates use schemes like `file:///` or `config://` that +/// [`matchit`] cannot parse directly. The router maintains a mapping between +/// MCP URI templates and matchit route patterns, handling the conversion +/// transparently. +/// +/// The router is generic over state `S` so handlers can access shared server +/// state. +/// +/// # Note +/// +/// `matchit::Router` does not implement `Debug` or `Clone`, so this type +/// cannot derive those traits either. +pub struct ResourceRouter { + router: matchit::Router, + routes: Vec>, +} + +impl Default for ResourceRouter { + fn default() -> Self { + Self { + router: matchit::Router::new(), + routes: Vec::new(), + } + } +} + +impl ResourceRouter { + /// Create a new empty resource router. + pub fn new() -> Self { + Self::default() + } + + /// Register a resource template with a handler. + /// + /// # Arguments + /// + /// * `route_pattern` — the matchit route pattern (e.g. `"/files/{path}"`) + /// * `uri_template` — the MCP URI template (e.g. `"file:///{path}"`) + /// * `name` — human-readable resource name + /// * `description` — resource description + /// * `mime_type` — optional MIME type hint + /// * `handler` — the handler to call when a URI matches this template + /// + /// # Panics + /// + /// Panics if the route pattern conflicts with an existing route. + pub fn add_resource( + &mut self, + route_pattern: &str, + uri_template: &str, + name: &str, + description: &str, + mime_type: Option<&str>, + handler: impl ResourceHandler + 'static, + ) -> &mut Self { + let idx = self.routes.len(); + self.router.insert(route_pattern, idx).unwrap_or_else(|e| { + panic!("Failed to insert route '{route_pattern}' (from '{uri_template}'): {e}") + }); + + // Derive scheme prefix and route prefix from the uri_template and route_pattern. + // We find the scheme portion of the URI template by locating where the + // parameterized suffix begins — the part that matches the route_pattern suffix. + let (scheme_prefix, route_prefix) = derive_prefixes(uri_template, route_pattern); + + let mut raw = RawResourceTemplate::new(uri_template, name).with_description(description); + if let Some(mime) = mime_type { + raw = raw.with_mime_type(mime); + } + let template = Annotated::new(raw, None); + + self.routes.push(ResourceRoute { + template, + handler: Box::new(handler), + scheme_prefix, + route_prefix, + }); + + self + } + + /// Return all registered resource templates. + /// + /// Use this in your `ServerHandler::list_resource_templates` implementation. + pub fn templates(&self) -> Vec { + self.routes.iter().map(|r| r.template.clone()).collect() + } + + /// Match a concrete URI against registered templates and call the handler. + /// + /// Returns `None` if no route matches the URI. + /// + /// The router converts the URI to a matchit-routable path by applying the + /// scheme-to-route prefix mappings from registered templates. + pub fn resolve(&self, state: &S, uri: &str) -> Option { + // Try to convert the URI to a route path using registered scheme mappings + let route_path = self.uri_to_route_path(uri)?; + + let matched = self.router.at(&route_path).ok()?; + let route = &self.routes[*matched.value]; + Some(route.handler.call(state, uri, &matched.params)) + } + + /// Convert a concrete URI to a matchit route path using the registered + /// scheme prefix mappings. + fn uri_to_route_path(&self, uri: &str) -> Option { + for route in &self.routes { + if uri.starts_with(&route.scheme_prefix) { + let rest = &uri[route.scheme_prefix.len()..]; + return Some(format!("{}{rest}", route.route_prefix)); + } + } + None + } +} + +/// Derive the URI scheme prefix and the corresponding matchit route prefix +/// from a URI template and its route pattern. +/// +/// For example: +/// - `uri_template = "file:///{path}"`, `route_pattern = "/files/{path}"` +/// yields `("file:///", "/files/")` +/// - `uri_template = "config://{section}/{key}"`, `route_pattern = "/config/{section}/{key}"` +/// yields `("config://", "/config/")` +fn derive_prefixes(uri_template: &str, route_pattern: &str) -> (String, String) { + // Find where the first `{` appears in both strings — the prefix is everything before. + let uri_param_start = uri_template.find('{').unwrap_or(uri_template.len()); + let route_param_start = route_pattern.find('{').unwrap_or(route_pattern.len()); + + let scheme_prefix = uri_template[..uri_param_start].to_string(); + let route_prefix = route_pattern[..route_param_start].to_string(); + + (scheme_prefix, route_prefix) +} + +/// Strip the scheme from an MCP URI, returning the path portion. +/// +/// This is a convenience function for converting concrete URIs to a form +/// suitable for display or further processing. +/// +/// # Examples +/// +/// ``` +/// use pulseengine_mcp_resources::strip_uri_scheme; +/// +/// // file:/// URIs: the third slash is part of the path +/// assert_eq!(strip_uri_scheme("file:///README.md"), "/README.md"); +/// assert_eq!(strip_uri_scheme("config://database/host"), "database/host"); +/// assert_eq!(strip_uri_scheme("custom://some/path"), "some/path"); +/// assert_eq!(strip_uri_scheme("no-scheme"), "no-scheme"); +/// ``` +pub fn strip_uri_scheme(uri: &str) -> &str { + uri.find("://").map(|i| &uri[i + 3..]).unwrap_or(uri) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_uri_scheme() { + // file:/// URIs: the third slash is part of the absolute path + assert_eq!(strip_uri_scheme("file:///README.md"), "/README.md"); + assert_eq!(strip_uri_scheme("config://database/host"), "database/host"); + assert_eq!(strip_uri_scheme("custom://some/path"), "some/path"); + assert_eq!(strip_uri_scheme("no-scheme"), "no-scheme"); + } + + #[test] + fn test_derive_prefixes() { + let (scheme, route) = derive_prefixes("file:///{path}", "/files/{path}"); + assert_eq!(scheme, "file:///"); + assert_eq!(route, "/files/"); + + let (scheme, route) = + derive_prefixes("config://{section}/{key}", "/config/{section}/{key}"); + assert_eq!(scheme, "config://"); + assert_eq!(route, "/config/"); + } +} diff --git a/pulseengine-mcp-resources/tests/router_tests.rs b/pulseengine-mcp-resources/tests/router_tests.rs new file mode 100644 index 00000000..879c46e4 --- /dev/null +++ b/pulseengine-mcp-resources/tests/router_tests.rs @@ -0,0 +1,284 @@ +use pulseengine_mcp_resources::{ResourceRouter, strip_uri_scheme}; +use rmcp::model::ResourceContents; + +// --------------------------------------------------------------------------- +// Helper: extract text content from ResourceContents +// --------------------------------------------------------------------------- + +fn extract_text(contents: &ResourceContents) -> &str { + match contents { + ResourceContents::TextResourceContents { text, .. } => text, + ResourceContents::BlobResourceContents { .. } => { + panic!("Expected text contents, got blob") + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[test] +fn register_and_list_templates() { + let mut router = ResourceRouter::<()>::new(); + + router.add_resource( + "/files/{path}", + "file:///{path}", + "file", + "Read a file by path", + Some("text/plain"), + |_state: &(), uri: &str, params: &matchit::Params| { + let path = params.get("path").unwrap_or("unknown"); + ResourceContents::text(format!("Contents of {path}"), uri) + }, + ); + + router.add_resource( + "/config/{section}/{key}", + "config://{section}/{key}", + "config", + "Read a config value", + None, + |_state: &(), uri: &str, params: &matchit::Params| { + let section = params.get("section").unwrap_or("?"); + let key = params.get("key").unwrap_or("?"); + ResourceContents::text(format!("[{section}] {key} = value"), uri) + }, + ); + + let templates = router.templates(); + assert_eq!(templates.len(), 2); + assert_eq!(templates[0].raw.name, "file"); + assert_eq!(templates[0].raw.uri_template, "file:///{path}"); + assert_eq!(templates[1].raw.name, "config"); + assert_eq!(templates[1].raw.uri_template, "config://{section}/{key}"); +} + +#[test] +fn resolve_matching_uri() { + let mut router = ResourceRouter::<()>::new(); + router.add_resource( + "/files/{path}", + "file:///{path}", + "file", + "Read a file", + None, + |_state: &(), uri: &str, params: &matchit::Params| { + let path = params.get("path").unwrap_or("unknown"); + ResourceContents::text(format!("Mock contents of: {path}"), uri) + }, + ); + + let result = router.resolve(&(), "file:///README.md"); + assert!(result.is_some()); + + let contents = result.unwrap(); + let text = extract_text(&contents); + assert_eq!(text, "Mock contents of: README.md"); +} + +#[test] +fn resolve_non_matching_uri_returns_none() { + let mut router = ResourceRouter::<()>::new(); + router.add_resource( + "/files/{path}", + "file:///{path}", + "file", + "Read a file", + None, + |_state: &(), uri: &str, _params: &matchit::Params| { + ResourceContents::text("should not be called".to_string(), uri) + }, + ); + + let result = router.resolve(&(), "unknown://foo/bar"); + assert!(result.is_none()); +} + +#[test] +fn multiple_schemes() { + let mut router = ResourceRouter::<()>::new(); + + router.add_resource( + "/files/{path}", + "file:///{path}", + "file", + "Read a file", + Some("text/plain"), + |_state: &(), uri: &str, params: &matchit::Params| { + let path = params.get("path").unwrap_or("?"); + ResourceContents::text(format!("file:{path}"), uri) + }, + ); + + router.add_resource( + "/config/{section}/{key}", + "config://{section}/{key}", + "config", + "Read config", + None, + |_state: &(), uri: &str, params: &matchit::Params| { + let section = params.get("section").unwrap_or("?"); + let key = params.get("key").unwrap_or("?"); + ResourceContents::text(format!("config:{section}/{key}"), uri) + }, + ); + + // Resolve file URI + let file_result = router.resolve(&(), "file:///main.rs"); + assert!(file_result.is_some()); + assert_eq!(extract_text(&file_result.unwrap()), "file:main.rs"); + + // Resolve config URI + let config_result = router.resolve(&(), "config://database/host"); + assert!(config_result.is_some()); + assert_eq!( + extract_text(&config_result.unwrap()), + "config:database/host" + ); +} + +#[test] +fn template_with_multiple_params() { + let mut router = ResourceRouter::<()>::new(); + router.add_resource( + "/config/{section}/{key}", + "config://{section}/{key}", + "config", + "Read a config value by section and key", + None, + |_state: &(), uri: &str, params: &matchit::Params| { + let section = params.get("section").unwrap_or("?"); + let key = params.get("key").unwrap_or("?"); + ResourceContents::text(format!("Config [{section}] {key} = mock_value"), uri) + }, + ); + + let result = router.resolve(&(), "config://database/host"); + assert!(result.is_some()); + assert_eq!( + extract_text(&result.unwrap()), + "Config [database] host = mock_value" + ); +} + +#[test] +fn handler_receives_original_uri() { + let mut router = ResourceRouter::<()>::new(); + router.add_resource( + "/files/{path}", + "file:///{path}", + "file", + "Read a file", + None, + |_state: &(), uri: &str, _params: &matchit::Params| { + // Return the URI itself so we can verify it was passed correctly + ResourceContents::text(uri.to_string(), uri) + }, + ); + + let result = router.resolve(&(), "file:///path.txt"); + assert!(result.is_some()); + assert_eq!(extract_text(&result.unwrap()), "file:///path.txt"); +} + +#[test] +fn handler_with_state() { + struct AppState { + prefix: String, + } + + let mut router = ResourceRouter::::new(); + router.add_resource( + "/files/{path}", + "file:///{path}", + "file", + "Read a file", + None, + |state: &AppState, uri: &str, params: &matchit::Params| { + let path = params.get("path").unwrap_or("?"); + ResourceContents::text(format!("{}: {path}", state.prefix), uri) + }, + ); + + let state = AppState { + prefix: "STATE".to_string(), + }; + let result = router.resolve(&state, "file:///test.rs"); + assert!(result.is_some()); + assert_eq!(extract_text(&result.unwrap()), "STATE: test.rs"); +} + +#[test] +fn strip_uri_scheme_helper() { + // file:/// URIs: the third slash is part of the absolute path + assert_eq!(strip_uri_scheme("file:///README.md"), "/README.md"); + assert_eq!(strip_uri_scheme("config://database/host"), "database/host"); + assert_eq!(strip_uri_scheme("custom://some/path"), "some/path"); + assert_eq!(strip_uri_scheme("no-scheme"), "no-scheme"); +} + +#[test] +fn template_metadata() { + let mut router = ResourceRouter::<()>::new(); + router.add_resource( + "/files/{path}", + "file:///{path}", + "file", + "Read a file by path", + Some("text/plain"), + |_state: &(), uri: &str, _params: &matchit::Params| { + ResourceContents::text("".to_string(), uri) + }, + ); + + let templates = router.templates(); + assert_eq!(templates.len(), 1); + + let t = &templates[0]; + assert_eq!(t.raw.name, "file"); + assert_eq!(t.raw.uri_template, "file:///{path}"); + assert_eq!(t.raw.description.as_deref(), Some("Read a file by path")); + assert_eq!(t.raw.mime_type.as_deref(), Some("text/plain")); +} + +#[test] +fn chained_add_resource() { + let mut router = ResourceRouter::<()>::new(); + let handler = |_state: &(), uri: &str, _params: &matchit::Params| { + ResourceContents::text("ok".to_string(), uri) + }; + + // add_resource returns &mut Self, so we can chain + router + .add_resource("/a/{x}", "a:///{x}", "a", "A", None, handler) + .add_resource("/b/{y}", "b:///{y}", "b", "B", None, handler); + + assert_eq!(router.templates().len(), 2); +} + +#[test] +fn catch_all_route_for_deep_paths() { + // matchit's {*path} catch-all syntax supports multi-segment paths + let mut router = ResourceRouter::<()>::new(); + router.add_resource( + "/files/{*path}", + "file:///{*path}", + "file", + "Read a file by path (deep)", + None, + |_state: &(), uri: &str, params: &matchit::Params| { + let path = params.get("path").unwrap_or("?"); + ResourceContents::text(format!("deep:{path}"), uri) + }, + ); + + let result = router.resolve(&(), "file:///src/main.rs"); + assert!(result.is_some()); + assert_eq!(extract_text(&result.unwrap()), "deep:src/main.rs"); + + let result = router.resolve(&(), "file:///a/b/c/d.txt"); + assert!(result.is_some()); + assert_eq!(extract_text(&result.unwrap()), "deep:a/b/c/d.txt"); +} diff --git a/mcp-security-middleware/Cargo.toml b/pulseengine-security/Cargo.toml similarity index 77% rename from mcp-security-middleware/Cargo.toml rename to pulseengine-security/Cargo.toml index 0a839e36..20c201f8 100644 --- a/mcp-security-middleware/Cargo.toml +++ b/pulseengine-security/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "pulseengine-mcp-security-middleware" +name = "pulseengine-security" version.workspace = true rust-version.workspace = true edition.workspace = true @@ -7,10 +7,10 @@ license.workspace = true authors.workspace = true repository.workspace = true homepage.workspace = true -documentation = "https://docs.rs/pulseengine-mcp-security-middleware" -keywords = ["mcp", "security", "middleware", "authentication", "framework"] +documentation = "https://docs.rs/pulseengine-security" +keywords = ["security", "middleware", "authentication", "axum", "tower"] categories = ["api-bindings", "development-tools", "asynchronous", "web-programming"] -description = "Zero-configuration security middleware for MCP servers with Axum integration" +description = "Zero-configuration security middleware for Axum/Tower with API key, JWT, CORS, and rate limiting" [dependencies] # Core async and utilities @@ -54,9 +54,6 @@ dirs = { workspace = true } # Validation validator = { workspace = true } -# Framework internal dependencies -pulseengine-mcp-protocol = { workspace = true } - [dev-dependencies] tempfile = { workspace = true } assert_matches = { workspace = true } @@ -73,5 +70,5 @@ version = "3.5" optional = true [lib] -name = "pulseengine_mcp_security_middleware" +name = "pulseengine_security" path = "src/lib.rs" diff --git a/mcp-security-middleware/README.md b/pulseengine-security/README.md similarity index 100% rename from mcp-security-middleware/README.md rename to pulseengine-security/README.md diff --git a/mcp-security-middleware/src/auth.rs b/pulseengine-security/src/auth.rs similarity index 100% rename from mcp-security-middleware/src/auth.rs rename to pulseengine-security/src/auth.rs diff --git a/mcp-security-middleware/src/config.rs b/pulseengine-security/src/config.rs similarity index 100% rename from mcp-security-middleware/src/config.rs rename to pulseengine-security/src/config.rs diff --git a/mcp-security-middleware/src/error.rs b/pulseengine-security/src/error.rs similarity index 100% rename from mcp-security-middleware/src/error.rs rename to pulseengine-security/src/error.rs diff --git a/mcp-security-middleware/src/lib.rs b/pulseengine-security/src/lib.rs similarity index 94% rename from mcp-security-middleware/src/lib.rs rename to pulseengine-security/src/lib.rs index 380b6359..87992bc1 100644 --- a/mcp-security-middleware/src/lib.rs +++ b/pulseengine-security/src/lib.rs @@ -1,6 +1,6 @@ -//! # PulseEngine MCP Security Middleware +//! # PulseEngine Security Middleware //! -//! Zero-configuration security middleware for MCP servers with Axum integration. +//! Zero-configuration security middleware for Axum/Tower services. //! //! This crate provides a simple, secure-by-default authentication and authorization //! middleware system that can be integrated into MCP servers with minimal configuration. @@ -12,12 +12,12 @@ //! - **Environment-Based Config**: Configure via environment variables without CLI tools //! - **Auto-Generation**: Automatically generates API keys and JWT secrets securely //! - **Axum Integration**: Built on `middleware::from_fn` for seamless integration -//! - **MCP Compliance**: Follows 2025 MCP security best practices +//! - **Standards Compliant**: Follows OWASP security best practices //! //! ## Quick Start //! //! ```rust,no_run -//! use pulseengine_mcp_security_middleware::*; +//! use pulseengine_security::*; //! use axum::{Router, routing::get}; //! use axum::middleware::from_fn; //! @@ -43,7 +43,7 @@ //! //! ### Development Profile //! ```rust -//! use pulseengine_mcp_security_middleware::SecurityConfig; +//! use pulseengine_security::SecurityConfig; //! //! let config = SecurityConfig::development(); //! // - Permissive settings for local development @@ -54,7 +54,7 @@ //! //! ### Production Profile //! ```rust -//! use pulseengine_mcp_security_middleware::SecurityConfig; +//! use pulseengine_security::SecurityConfig; //! let config = SecurityConfig::production(); //! // - Strict security settings //! // - JWT authentication with secure secrets @@ -107,7 +107,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// /// # Example /// ```rust -/// use pulseengine_mcp_security_middleware::dev_security; +/// use pulseengine_security::dev_security; /// /// let config = dev_security(); /// // Ready to use with permissive development settings @@ -122,7 +122,7 @@ pub fn dev_security() -> SecurityConfig { /// /// # Example /// ```rust -/// use pulseengine_mcp_security_middleware::prod_security; +/// use pulseengine_security::prod_security; /// /// let config = prod_security(); /// // Ready to use with strict production security @@ -138,7 +138,7 @@ pub fn prod_security() -> SecurityConfig { /// /// # Example /// ```rust -/// use pulseengine_mcp_security_middleware::env_security; +/// use pulseengine_security::env_security; /// /// // Reads MCP_SECURITY_PROFILE=production from environment /// let config = env_security().unwrap(); diff --git a/mcp-security-middleware/src/middleware.rs b/pulseengine-security/src/middleware.rs similarity index 99% rename from mcp-security-middleware/src/middleware.rs rename to pulseengine-security/src/middleware.rs index 08671d24..cc3f043d 100644 --- a/mcp-security-middleware/src/middleware.rs +++ b/pulseengine-security/src/middleware.rs @@ -407,7 +407,7 @@ impl RateLimiter { /// # Example /// ```rust,no_run /// use axum::{Router, routing::get, middleware::from_fn}; -/// use pulseengine_mcp_security_middleware::*; +/// use pulseengine_security::*; /// /// #[tokio::main] /// async fn main() { diff --git a/mcp-security-middleware/src/profiles.rs b/pulseengine-security/src/profiles.rs similarity index 100% rename from mcp-security-middleware/src/profiles.rs rename to pulseengine-security/src/profiles.rs diff --git a/mcp-security-middleware/src/utils.rs b/pulseengine-security/src/utils.rs similarity index 98% rename from mcp-security-middleware/src/utils.rs rename to pulseengine-security/src/utils.rs index ca0888a0..a50fdde9 100644 --- a/mcp-security-middleware/src/utils.rs +++ b/pulseengine-security/src/utils.rs @@ -45,7 +45,7 @@ impl SecureRandom { /// /// # Example /// ```rust -/// use pulseengine_mcp_security_middleware::generate_api_key; +/// use pulseengine_security::generate_api_key; /// /// let api_key = generate_api_key(); /// assert!(api_key.starts_with("mcp_")); @@ -63,7 +63,7 @@ pub fn generate_api_key() -> String { /// /// # Example /// ```rust -/// use pulseengine_mcp_security_middleware::generate_jwt_secret; +/// use pulseengine_security::generate_jwt_secret; /// /// let secret = generate_jwt_secret(); /// assert!(secret.len() >= 64); // At least 64 characters for security diff --git a/rivet.yaml b/rivet.yaml new file mode 100644 index 00000000..ece8fbf8 --- /dev/null +++ b/rivet.yaml @@ -0,0 +1,11 @@ +project: + name: mcp + version: "0.1.0" + schemas: + - common + - dev + - stpa + +sources: + - path: artifacts + format: generic-yaml