From b4ffb853e6ae13b048a660e9bc15cd8f0c2478eb Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 09:03:05 +0100 Subject: [PATCH 01/19] docs: add STPA analysis, migration plan, and implementation spec Rivet project initialized with STPA schema for rmcp migration safety analysis. 58 artifacts covering losses, hazards, constraints, UCAs, and loss scenarios for migrating from full MCP stack to rmcp extensions. Phase 0+1 design spec and implementation plan ready for execution. --- AGENTS.md | 92 ++ CLAUDE.md | 7 + artifacts/migration-plan.yaml | 311 +++++ artifacts/requirements.yaml | 28 + artifacts/stpa-migration.yaml | 708 ++++++++++ docs/getting-started.md | 17 + ...2026-03-28-rmcp-migration-phase0-phase1.md | 1182 +++++++++++++++++ ...-28-rmcp-migration-phase0-phase1-design.md | 218 +++ rivet.yaml | 11 + 9 files changed, 2574 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 artifacts/migration-plan.yaml create mode 100644 artifacts/requirements.yaml create mode 100644 artifacts/stpa-migration.yaml create mode 100644 docs/getting-started.md create mode 100644 docs/superpowers/plans/2026-03-28-rmcp-migration-phase0-phase1.md create mode 100644 docs/superpowers/specs/2026-03-28-rmcp-migration-phase0-phase1-design.md create mode 100644 rivet.yaml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..093708d --- /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 0000000..d915a05 --- /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/artifacts/migration-plan.yaml b/artifacts/migration-plan.yaml new file mode 100644 index 0000000..27dfe99 --- /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 0000000..f85e58c --- /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 0000000..90690bd --- /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/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..334a334 --- /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 0000000..e410c28 --- /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 0000000..3d92105 --- /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/rivet.yaml b/rivet.yaml new file mode 100644 index 0000000..ece8fbf --- /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 From 993ed1307fe590f2176c2898a534a8862f05cca6 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 09:21:54 +0100 Subject: [PATCH 02/19] feat: add poc workspace for rmcp migration validation --- poc/Cargo.lock | 1504 +++++++++++++++++++++++++++++++ poc/Cargo.toml | 7 + poc/mcp-apps/Cargo.toml | 15 + poc/mcp-apps/src/main.rs | 3 + poc/resource-router/Cargo.toml | 16 + poc/resource-router/src/main.rs | 3 + poc/tower-auth/Cargo.toml | 20 + poc/tower-auth/src/main.rs | 3 + 8 files changed, 1571 insertions(+) create mode 100644 poc/Cargo.lock create mode 100644 poc/Cargo.toml create mode 100644 poc/mcp-apps/Cargo.toml create mode 100644 poc/mcp-apps/src/main.rs create mode 100644 poc/resource-router/Cargo.toml create mode 100644 poc/resource-router/src/main.rs create mode 100644 poc/tower-auth/Cargo.toml create mode 100644 poc/tower-auth/src/main.rs diff --git a/poc/Cargo.lock b/poc/Cargo.lock new file mode 100644 index 0000000..66b8897 --- /dev/null +++ b/poc/Cargo.lock @@ -0,0 +1,1504 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[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", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +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.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "matchit" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f926ade0c4e170215ae43342bf13b9310a437609c81f29f86c5df6657582ef9" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "poc-mcp-apps" +version = "0.1.0" +dependencies = [ + "anyhow", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "poc-resource-router" +version = "0.1.0" +dependencies = [ + "anyhow", + "matchit 0.8.6", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "poc-tower-auth" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "http", + "rmcp", + "schemars", + "serde", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rmcp" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2231b2c085b371c01bc90c0e6c1cab8834711b6394533375bdbf870b0166d419" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "futures", + "http", + "http-body", + "http-body-util", + "pastey", + "pin-project-lite", + "rand", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror", + "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", + "proc-macro2", + "quote", + "serde_json", + "syn", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[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", + "http-body-util", + "pin-project-lite", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +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 = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/poc/Cargo.toml b/poc/Cargo.toml new file mode 100644 index 0000000..58c9910 --- /dev/null +++ b/poc/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +members = [ + "tower-auth", + "resource-router", + "mcp-apps", +] +resolver = "2" diff --git a/poc/mcp-apps/Cargo.toml b/poc/mcp-apps/Cargo.toml new file mode 100644 index 0000000..604953a --- /dev/null +++ b/poc/mcp-apps/Cargo.toml @@ -0,0 +1,15 @@ +[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" diff --git a/poc/mcp-apps/src/main.rs b/poc/mcp-apps/src/main.rs new file mode 100644 index 0000000..895e714 --- /dev/null +++ b/poc/mcp-apps/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("placeholder"); +} diff --git a/poc/resource-router/Cargo.toml b/poc/resource-router/Cargo.toml new file mode 100644 index 0000000..1c4978c --- /dev/null +++ b/poc/resource-router/Cargo.toml @@ -0,0 +1,16 @@ +[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" diff --git a/poc/resource-router/src/main.rs b/poc/resource-router/src/main.rs new file mode 100644 index 0000000..895e714 --- /dev/null +++ b/poc/resource-router/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("placeholder"); +} diff --git a/poc/tower-auth/Cargo.toml b/poc/tower-auth/Cargo.toml new file mode 100644 index 0000000..cf6c0a2 --- /dev/null +++ b/poc/tower-auth/Cargo.toml @@ -0,0 +1,20 @@ +[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" diff --git a/poc/tower-auth/src/main.rs b/poc/tower-auth/src/main.rs new file mode 100644 index 0000000..895e714 --- /dev/null +++ b/poc/tower-auth/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("placeholder"); +} From 68531a137cfb9e146c4f39e5389582758eef75a8 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 09:49:03 +0100 Subject: [PATCH 03/19] feat(poc): validate Tower auth middleware with rmcp Working PoC proving Tower middleware can intercept rmcp HTTP requests, enforce Bearer auth, and propagate AuthContext into MCP tool handlers via http::request::Parts extensions. --- poc/Cargo.lock | 39 +++++++ poc/tower-auth/Cargo.toml | 2 +- poc/tower-auth/src/main.rs | 213 ++++++++++++++++++++++++++++++++++++- 3 files changed, 251 insertions(+), 3 deletions(-) diff --git a/poc/Cargo.lock b/poc/Cargo.lock index 66b8897..ea916fa 100644 --- a/poc/Cargo.lock +++ b/poc/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -553,6 +562,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.7.3" @@ -787,6 +805,23 @@ dependencies = [ "syn", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "rmcp" version = "1.3.0" @@ -1201,10 +1236,14 @@ version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex-automata", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/poc/tower-auth/Cargo.toml b/poc/tower-auth/Cargo.toml index cf6c0a2..2749d95 100644 --- a/poc/tower-auth/Cargo.toml +++ b/poc/tower-auth/Cargo.toml @@ -16,5 +16,5 @@ tokio-util = { version = "0.7", features = ["rt"] } serde = { version = "1", features = ["derive"] } schemars = "1.0" tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" diff --git a/poc/tower-auth/src/main.rs b/poc/tower-auth/src/main.rs index 895e714..62d3fed 100644 --- a/poc/tower-auth/src/main.rs +++ b/poc/tower-auth/src/main.rs @@ -1,3 +1,212 @@ -fn main() { - println!("placeholder"); +// PoC 1: Tower Auth Middleware with rmcp 1.3 +// +// ADJUSTMENTS from the original starting code: +// +// 1. No `#[tool_handler]` on the `impl ServerHandler` block — it was missing in the +// initial scaffold. Without it, `call_tool`/`list_tools`/`get_tool` get default +// implementations that return "method not found" or empty lists. +// +// 2. `AuthContext` must derive `Clone` **and** be `Send + Sync + 'static` so it can +// be stored in both `http::Extensions` and rmcp's `Extensions` type map. +// +// 3. The whoami tool uses `Extension` as a parameter extractor +// (documented in rmcp's `StreamableHttpService` docs) rather than manually reading +// from `RequestContext.extensions`. The HTTP transport injects `Parts` automatically. +// Inside `Parts.extensions` we find our `AuthContext` that the Tower middleware inserted. +// +// 4. The `AuthService` response type must be generic over the inner service's `Response`, +// not hardcoded to `axum::body::Body`. When wrapping an axum `Router`, the inner +// service returns `Response`, so we produce 401 responses with the +// same body type via `axum::body::Body::from(...)`. +// +// 5. Import paths: `rmcp::handler::server::tool::Extension` is the extractor type for +// pulling values from `RequestContext.extensions`. +// +// 6. `ServerInfo::new(caps)` and `ServerCapabilities::builder().enable_tools().build()` +// are correct as-is. +// +// 7. `CallToolResult::success(vec![Content::text(...)])` is correct as-is. + +use std::{ + future::Future, + pin::Pin, + task::{Context, Poll}, +}; + +use axum::Router; +use http::{Request, Response, StatusCode}; +use rmcp::{ + handler::server::{router::tool::ToolRouter, tool::Extension, wrapper::Parameters}, + model::{CallToolResult, Content, ServerCapabilities, ServerInfo}, + schemars, tool, tool_handler, tool_router, + transport::streamable_http_server::{ + StreamableHttpServerConfig, StreamableHttpService, + session::local::LocalSessionManager, + }, + ServerHandler, +}; +use tower::{Layer, Service}; +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, + S::Error: 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()) + }); + } + + // Inject AuthContext into http::Extensions so it propagates through + // rmcp's StreamableHttpService into the tool handler via Parts.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 tool handler --- + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct WhoamiParams {} + +#[derive(Debug, Clone)] +struct AuthDemo { + tool_router: ToolRouter, +} + +impl AuthDemo { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl AuthDemo { + /// Returns the authenticated user and role from the Tower auth middleware. + #[tool(description = "Returns the authenticated user and role")] + fn whoami( + &self, + _params: Parameters, + Extension(parts): Extension, + ) -> Result { + let auth = parts.extensions.get::().cloned(); + match auth { + Some(ctx) => Ok(CallToolResult::success(vec![Content::text(format!( + "user={}, role={}", + ctx.user, ctx.role + ))])), + None => Ok(CallToolResult::success(vec![Content::text( + "no auth context available", + )])), + } + } +} + +#[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()), + ); + + 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"); + + axum::serve(listener, app) + .with_graceful_shutdown(async move { + tokio::signal::ctrl_c().await.ok(); + ct.cancel(); + }) + .await?; + + Ok(()) } From 914c269db7b831b9227d46263d0dbe72c11ea013 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 10:27:52 +0100 Subject: [PATCH 04/19] feat(poc): validate MCP Apps UI resources with rmcp Implements PoC 3 proving rmcp 1.3 supports the MCP Apps extension pattern (SEP-1865) for serving interactive HTML via resources and tools. --- poc/mcp-apps/Cargo.toml | 2 +- poc/mcp-apps/src/main.rs | 332 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 331 insertions(+), 3 deletions(-) diff --git a/poc/mcp-apps/Cargo.toml b/poc/mcp-apps/Cargo.toml index 604953a..d264689 100644 --- a/poc/mcp-apps/Cargo.toml +++ b/poc/mcp-apps/Cargo.toml @@ -11,5 +11,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.0" tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" diff --git a/poc/mcp-apps/src/main.rs b/poc/mcp-apps/src/main.rs index 895e714..606a9b3 100644 --- a/poc/mcp-apps/src/main.rs +++ b/poc/mcp-apps/src/main.rs @@ -1,3 +1,331 @@ -fn main() { - println!("placeholder"); +// PoC 3: MCP Apps / UI Resources with rmcp 1.3 +// +// GOAL: Validate that rmcp's type system supports the MCP Apps extension pattern +// (SEP-1865) — serving interactive HTML via resources and tools. +// +// ADJUSTMENTS from the original starting code: +// +// 1. `ServerCapabilities::builder()` supports `.enable_extensions_with(map)` directly, +// so there is no need to mutate `caps.extensions` after building. The builder macro +// generates `enable_extensions_with` for every field in the struct. +// +// 2. `ExtensionCapabilities` is `BTreeMap` (not a serde_json::Map). +// `JsonObject` is also a BTreeMap-based type. To insert, use +// `serde_json::from_value::(json!({...})).unwrap()`. +// +// 3. `Resource` is `Annotated`. Use `RawResource::new(uri, name)` builder +// chain then `.no_annotation()` (from `AnnotateAble` trait) to wrap. +// +// 4. `ListResourcesResult::with_all_items(vec)` is the cleanest constructor. +// +// 5. `ReadResourceResult::new(vec![...])` takes a `Vec`. +// +// 6. `ResourceContents::TextResourceContents { uri, mime_type, text, meta }` is the +// enum variant — all fields must be provided. Using `with_mime_type()` helper on +// `ResourceContents::text()` is more ergonomic. +// +// 7. `ErrorData::resource_not_found(message, data)` exists and returns the right code. +// +// 8. Tool parameters struct needs `serde::Deserialize` + `schemars::JsonSchema`. +// +// 9. The `#[tool_handler]` attribute on `impl ServerHandler` is required for the +// tool router to wire up `call_tool` / `list_tools` / `get_tool`. Without it, +// tools silently return "method not found". +// +// 10. For resource methods (`list_resources`, `read_resource`), we override them +// manually in the `impl ServerHandler` block — there is no resource_router macro. +// +// 11. `Parameters` is a newtype tuple struct — access inner value via `.0`, +// not `.into_inner()`. +// +// 12. `AnnotateAble` trait must be imported from `rmcp::model::AnnotateAble` (the +// `annotated` submodule is private). Without this import, `.no_annotation()` +// is not available on `RawResource`. +// +// 13. `ServiceExt` must be imported for `.serve(transport)` on the handler. +// +// 14. `tracing-subscriber` needs the `env-filter` feature for `EnvFilter`. +// +// 15. Tracing must use `.with_writer(std::io::stderr)` to avoid corrupting the +// stdio JSON-RPC transport on stdout. + +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ + AnnotateAble, CallToolResult, Content, ExtensionCapabilities, ListResourcesResult, + PaginatedRequestParams, RawResource, ReadResourceRequestParams, ReadResourceResult, + ResourceContents, ServerCapabilities, ServerInfo, + }, + schemars, tool, tool_handler, tool_router, + transport::io::stdio, + RoleServer, ServerHandler, ServiceExt, +}; +use rmcp::service::RequestContext; +use serde_json::json; +use tracing_subscriber::EnvFilter; + +// --------------------------------------------------------------------------- +// Dashboard HTML — self-contained, inline CSS, no external deps +// --------------------------------------------------------------------------- + +const DASHBOARD_HTML: &str = r#" + + + +MCP Apps Dashboard + + + +
+

PulseEngine Dashboard

+

MCP Apps PoC — served via ui://dashboard resource

+
+
+
+

Requests / sec

+
1,247
+
+12% from last hour
+
+
+

P95 Latency

+
142 ms
+
Target: < 200 ms
+
+
+

Error Rate

+
0.03%
+
Below 0.1% threshold
+
+
+

Traffic (last 8h)

+
+
+
+
+
+
+
+
+
+
+
+
+

Service Health

+
    +
  • API Gateway OK
  • +
  • Auth Service OK
  • +
  • Database OK
  • +
  • Cache SLOW
  • +
+
+
+ +"#; + +// --------------------------------------------------------------------------- +// Chart HTML template — returned by the render_chart tool +// --------------------------------------------------------------------------- + +fn chart_html(title: &str, values: &[u32]) -> String { + let max = values.iter().copied().max().unwrap_or(1) as f64; + let bars: String = values + .iter() + .enumerate() + .map(|(i, v)| { + let pct = (*v as f64 / max * 100.0).round(); + format!( + r#"
"#, + ) + }) + .collect(); + + format!( + r#" + + + +{title} + + + +

{title}

+
{bars}
+ +"# + ) +} + +// --------------------------------------------------------------------------- +// MCP server handler +// --------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct RenderChartParams { + /// Title for the chart + #[serde(default = "default_chart_title")] + title: String, + /// Comma-separated numeric values (e.g. "10,20,30,40") + #[serde(default = "default_chart_values")] + values: String, +} + +fn default_chart_title() -> String { + "Chart".to_string() +} + +fn default_chart_values() -> String { + "25,50,75,100,60,40,80,55".to_string() +} + +#[derive(Debug, Clone)] +struct McpAppsDemo { + tool_router: ToolRouter, +} + +impl McpAppsDemo { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[tool_router] +impl McpAppsDemo { + /// Render an interactive HTML chart with the given title and data values. + #[tool(description = "Render an interactive HTML chart. Returns self-contained HTML.")] + fn render_chart( + &self, + params: Parameters, + ) -> Result { + let p = params.0; + let values: Vec = p + .values + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + let html = chart_html(&p.title, &values); + Ok(CallToolResult::success(vec![Content::text(html)])) + } +} + +#[tool_handler] +impl ServerHandler for McpAppsDemo { + fn get_info(&self) -> ServerInfo { + // Build extension capabilities for MCP Apps + let mut extensions = ExtensionCapabilities::new(); + extensions.insert( + "io.modelcontextprotocol/apps".to_string(), + serde_json::from_value(json!({ + "mimeTypes": ["text/html"] + })) + .unwrap(), + ); + + let caps = ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .enable_extensions_with(extensions) + .build(); + + ServerInfo::new(caps) + } + + async fn list_resources( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + let resource = RawResource::new("ui://dashboard", "dashboard") + .with_title("PulseEngine Dashboard") + .with_description("Interactive HTML dashboard served via MCP Apps") + .with_mime_type("text/html") + .no_annotation(); + + Ok(ListResourcesResult::with_all_items(vec![resource])) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + if request.uri == "ui://dashboard" { + let contents = ResourceContents::text(DASHBOARD_HTML, "ui://dashboard") + .with_mime_type("text/html"); + Ok(ReadResourceResult::new(vec![contents])) + } else { + Err(rmcp::ErrorData::resource_not_found( + format!("Unknown resource: {}", request.uri), + None, + )) + } + } +} + +// --------------------------------------------------------------------------- +// main — validate types then start stdio server +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Send tracing output to stderr so it doesn't interfere with stdio JSON-RPC + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + + // Quick type validation before starting the server + let demo = McpAppsDemo::new(); + let info = demo.get_info(); + + tracing::info!("PoC 3: MCP Apps / UI Resources"); + tracing::info!("Server info: {:?}", info.server_info); + tracing::info!("Capabilities: {}", serde_json::to_string_pretty(&info.capabilities)?); + + // Verify extensions are present + if let Some(ref ext) = info.capabilities.extensions { + tracing::info!("MCP Apps extensions declared: {:?}", ext.keys().collect::>()); + } else { + tracing::warn!("No extensions found in capabilities — MCP Apps not declared!"); + } + + // Start the stdio MCP server + tracing::info!("Starting stdio MCP server..."); + let server = demo.serve(stdio()).await.inspect_err(|e| { + tracing::error!("Server failed to start: {e}"); + })?; + + tracing::info!("Server running. Waiting for shutdown..."); + server.waiting().await?; + + Ok(()) } From 5389aa30c3750079be7b9f9cc2d3f4c21a54a159 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 10:29:11 +0100 Subject: [PATCH 05/19] feat(poc): validate resource router with rmcp ServerHandler Implements a matchit-based ResourceRouter that maps MCP URI templates (file:///, config://) to handler functions, overriding list_resource_templates and read_resource on ServerHandler alongside #[tool_handler] tools. --- poc/resource-router/Cargo.toml | 2 +- poc/resource-router/src/main.rs | 346 +++++++++++++++++++++++++++++++- 2 files changed, 345 insertions(+), 3 deletions(-) diff --git a/poc/resource-router/Cargo.toml b/poc/resource-router/Cargo.toml index 1c4978c..baf82cf 100644 --- a/poc/resource-router/Cargo.toml +++ b/poc/resource-router/Cargo.toml @@ -12,5 +12,5 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = "1.0" tracing = "0.1" -tracing-subscriber = "0.3" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } anyhow = "1" diff --git a/poc/resource-router/src/main.rs b/poc/resource-router/src/main.rs index 895e714..6df7cf5 100644 --- a/poc/resource-router/src/main.rs +++ b/poc/resource-router/src/main.rs @@ -1,3 +1,345 @@ -fn main() { - println!("placeholder"); +// PoC 2: Resource Router with rmcp 1.3 +// +// GOAL: Validate that we can build a resource URI template router on top of +// rmcp's `ServerHandler` trait. rmcp has no built-in resource routing — its +// `handler/server/resource.rs` is literally empty. We override +// `list_resource_templates` and `read_resource` on `ServerHandler` to prove +// the approach works. +// +// ADJUSTMENTS from the original plan: +// +// 1. `ResourceContents::text(text, uri)` is a convenience constructor on the +// enum — no need to construct the `TextResourceContents` variant by hand. +// +// 2. `Annotated::new(raw, None)` works. There's also `raw.no_annotation()` +// via the `AnnotateAble` trait. +// +// 3. `ErrorData::resource_not_found(msg, data)` exists and uses error code -32002. +// +// 4. `ListResourceTemplatesResult` has fields: `meta`, `next_cursor`, +// `resource_templates`. Created via `paginated_result!` macro. +// +// 5. `ReadResourceResult::new(contents)` is the constructor. +// +// 6. `RawResourceTemplate::new(uri_template, name)` builder with `.with_description()`. +// +// 7. `matchit::Params::get(name)` returns `Option<&str>` for named params. +// +// 8. `ServerCapabilities::builder().enable_tools().enable_resources().build()` +// enables both tool and resource capabilities. +// +// 9. `#[tool_handler]` only injects `call_tool`, `list_tools`, `get_tool` — so +// we can freely add `list_resource_templates` and `read_resource` overrides +// in the same `impl ServerHandler` block. +// +// 10. The `uri_to_matchit_path` function strips the URI scheme (e.g., `file:///` +// or `config://`) and returns the path portion for matchit routing. +// +// 11. `Parameters` is a newtype wrapper — access inner fields via `.0` +// (e.g., `params.0.message`), not directly. +// +// 12. `matchit::Router` does not implement `Debug` or `Clone`, so the server +// struct wrapping it cannot derive those traits. Manual impls needed. +// +// 13. `tracing-subscriber` requires the `env-filter` feature for `EnvFilter` +// and `with_env_filter()`. + +use std::sync::Arc; + +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{ + Annotated, CallToolResult, Content, ListResourceTemplatesResult, PaginatedRequestParams, + RawResourceTemplate, ReadResourceRequestParams, ReadResourceResult, ResourceContents, + ResourceTemplate, ServerCapabilities, ServerInfo, + }, + schemars, tool, tool_handler, tool_router, + service::RequestContext, + ErrorData, RoleServer, ServerHandler, ServiceExt, +}; + +// --------------------------------------------------------------------------- +// Resource Router +// --------------------------------------------------------------------------- + +/// Handler function signature: given extracted params, return resource contents. +type ResourceHandler = Arc ResourceContents + Send + Sync>; + +/// A registered resource route: its template metadata plus the handler. +struct ResourceRoute { + template: ResourceTemplate, + handler: ResourceHandler, +} + +/// A URI-template-based resource router built on `matchit`. +/// +/// MCP URI templates use the form `scheme://host/{param}` but matchit routes on +/// plain paths like `/host/{param}`. We convert URIs to matchit paths via +/// `uri_to_matchit_path()` before inserting and matching. +/// +/// Note: `matchit::Router` does not implement `Debug`, so we implement it manually. +struct ResourceRouter { + router: matchit::Router, + routes: Vec, +} + +impl ResourceRouter { + fn new() -> Self { + Self { + router: matchit::Router::new(), + routes: Vec::new(), + } + } + + /// Register a resource template with its handler. + fn add( + &mut self, + uri_template: &str, + name: &str, + description: &str, + handler: ResourceHandler, + ) { + let idx = self.routes.len(); + let matchit_path = uri_template_to_matchit_path(uri_template); + self.router.insert(&matchit_path, idx).unwrap_or_else(|e| { + panic!("Failed to insert route '{matchit_path}' (from '{uri_template}'): {e}") + }); + + let raw = RawResourceTemplate::new(uri_template, name) + .with_description(description); + let template = Annotated::new(raw, None); + + self.routes.push(ResourceRoute { template, handler }); + } + + /// Return all registered resource templates (for `list_resource_templates`). + fn templates(&self) -> Vec { + self.routes.iter().map(|r| r.template.clone()).collect() + } + + /// Match a concrete URI against registered templates, call the handler. + fn resolve(&self, uri: &str) -> Result { + let path = uri_to_matchit_path(uri); + match self.router.at(&path) { + Ok(matched) => { + let route = &self.routes[*matched.value]; + Ok((route.handler)(&matched.params)) + } + Err(_) => Err(ErrorData::resource_not_found( + format!("No resource matches URI: {uri}"), + None, + )), + } + } +} + +// --------------------------------------------------------------------------- +// URI conversion helpers +// --------------------------------------------------------------------------- + +/// Convert an MCP URI template to a matchit-compatible route path. +/// +/// Examples: +/// `file:///{path}` -> `/files/{path}` +/// `config://{section}/{key}` -> `/config/{section}/{key}` +fn uri_template_to_matchit_path(uri_template: &str) -> String { + if let Some(rest) = uri_template.strip_prefix("file:///") { + format!("/files/{rest}") + } else if let Some(rest) = uri_template.strip_prefix("config://") { + format!("/config/{rest}") + } else { + // Fallback: strip scheme and use as-is + let after_scheme = uri_template + .find("://") + .map(|i| &uri_template[i + 3..]) + .unwrap_or(uri_template); + format!("/{after_scheme}") + } +} + +/// Convert a concrete MCP URI to a matchit-routable path. +/// +/// Examples: +/// `file:///README.md` -> `/files/README.md` +/// `config://database/host` -> `/config/database/host` +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 { + let after_scheme = uri + .find("://") + .map(|i| &uri[i + 3..]) + .unwrap_or(uri); + format!("/{after_scheme}") + } +} + +// --------------------------------------------------------------------------- +// MCP Server +// --------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct EchoParams { + message: String, +} + +struct ResourceDemo { + tool_router: ToolRouter, + resource_router: Arc, +} + +impl std::fmt::Debug for ResourceDemo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResourceDemo") + .field("tool_router", &self.tool_router) + .field("resource_router", &"") + .finish() + } +} + +impl Clone for ResourceDemo { + fn clone(&self) -> Self { + Self { + tool_router: Self::tool_router(), + resource_router: Arc::clone(&self.resource_router), + } + } +} + +impl ResourceDemo { + fn new() -> Self { + let mut rr = ResourceRouter::new(); + + // Register: file:///{path} — returns mock file contents + rr.add( + "file:///{path}", + "file", + "Read a file by path", + Arc::new(|params: &matchit::Params| { + let path = params.get("path").unwrap_or("unknown"); + ResourceContents::text( + format!("Mock file contents of: {path}"), + format!("file:///{path}"), + ) + }), + ); + + // Register: config://{section}/{key} — returns mock config values + rr.add( + "config://{section}/{key}", + "config", + "Read a config value by section and key", + Arc::new(|params: &matchit::Params| { + let section = params.get("section").unwrap_or("unknown"); + let key = params.get("key").unwrap_or("unknown"); + ResourceContents::text( + format!("Config [{section}] {key} = mock_value"), + format!("config://{section}/{key}"), + ) + }), + ); + + Self { + tool_router: Self::tool_router(), + resource_router: Arc::new(rr), + } + } +} + +#[tool_router] +impl ResourceDemo { + /// Echo a message (tool included for comparison with resources). + #[tool(description = "Echo a message back")] + fn echo( + &self, + params: Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text( + format!("Echo: {}", params.0.message), + )])) + } +} + +#[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 { + meta: None, + next_cursor: None, + resource_templates: self.resource_router.templates(), + }) + } + + async fn read_resource( + &self, + request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + let contents = self.resource_router.resolve(&request.uri)?; + Ok(ReadResourceResult::new(vec![contents])) + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let server = ResourceDemo::new(); + + // --- Direct router tests (no MCP transport needed) --- + tracing::info!("=== Direct Resource Router Tests ==="); + + // Test 1: file:///README.md + match server.resource_router.resolve("file:///README.md") { + Ok(contents) => tracing::info!("file:///README.md -> {contents:?}"), + Err(e) => tracing::error!("file:///README.md -> ERROR: {e}"), + } + + // Test 2: config://database/host + match server.resource_router.resolve("config://database/host") { + Ok(contents) => tracing::info!("config://database/host -> {contents:?}"), + Err(e) => tracing::error!("config://database/host -> ERROR: {e}"), + } + + // Test 3: unknown URI + match server.resource_router.resolve("unknown://foo/bar") { + Ok(contents) => tracing::error!("unknown://foo/bar -> UNEXPECTED: {contents:?}"), + Err(e) => tracing::info!("unknown://foo/bar -> expected error: {e}"), + } + + // Test 4: list templates + let templates = server.resource_router.templates(); + tracing::info!("Registered templates: {}", templates.len()); + for t in &templates { + tracing::info!(" - {} ({})", t.raw.name, t.raw.uri_template); + } + + tracing::info!("=== Starting stdio MCP server ==="); + + // Start the MCP server over stdio + let service = server.serve(rmcp::transport::io::stdio()).await?; + service.waiting().await?; + + Ok(()) } From 726eaac777973444bba34f71ed1cf4e23da00e7e Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 10:34:40 +0100 Subject: [PATCH 06/19] =?UTF-8?q?docs(poc):=20record=20rmcp=20migration=20?= =?UTF-8?q?PoC=20results=20=E2=80=94=20all=203=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- poc/RESULTS.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 poc/RESULTS.md diff --git a/poc/RESULTS.md b/poc/RESULTS.md new file mode 100644 index 0000000..f06f6ab --- /dev/null +++ b/poc/RESULTS.md @@ -0,0 +1,36 @@ +# PoC Results — rmcp Migration Validation + +## PoC 1: Tower Auth Middleware +- [x] Tower layer intercepts HTTP requests before rmcp +- [x] 401 returned for unauthenticated requests +- [x] AuthContext accessible in tool handler via RequestContext.extensions +- Notes: `http::request::Parts` is injected into rmcp `Extensions` by `StreamableHttpService`. Custom state lives inside `Parts.extensions`. `Extension` extractor works. + +## PoC 2: Resource Router +- [x] matchit routes MCP URIs after scheme normalization +- [x] list_resource_templates returns registered templates via ServerHandler override +- [x] read_resource dispatches to correct handler with extracted params +- [x] Unknown URIs return proper error (ErrorData::resource_not_found, code -32002) +- Notes: `#[tool_handler]` only injects tool methods — resource methods must be manually implemented. `matchit::Router` doesn't impl Debug/Clone, needs wrapper. `Annotated::new(raw, None)` works. `ResourceContents::text(text, uri)` convenience constructor available. + +## PoC 3: MCP Apps +- [x] HTML content served via ResourceContents with text/html mime type +- [x] HTML content returned from tool via Content::text() +- [x] MCP Apps extension declared in ServerCapabilities via enable_extensions_with() +- Notes: `ServerCapabilities::builder()` has `enable_extensions_with()` — no post-build mutation needed. `ExtensionCapabilities = BTreeMap`. `AnnotateAble` trait needs explicit import. + +## Key Adjustments Discovered + +| Assumption | Reality | +|---|---| +| `Annotated::from(raw)` | `Annotated::new(raw, None)` or import `AnnotateAble` trait | +| `Parameters(inner)` destructure | `params.0.field` — newtype access | +| `caps.extensions = Some(map)` | `builder.enable_extensions_with(btreemap)` | +| `tower = "0.5"` | Works, but `tower-layer` and `tower-service` also needed | +| `tracing-subscriber` | Needs `features = ["env-filter"]` for `EnvFilter` | + +## Gate Decision +- [x] All 3 PoCs pass — proceed to Phase 1 +- [ ] Blockers found — document and reassess + +**All integration points validated. rmcp 1.3 supports all three extension patterns. Proceeding to Phase 1.** From d3773cfb9a9e8ea87cb098886caa7c46f58c8d48 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 10:54:23 +0100 Subject: [PATCH 07/19] refactor: rename mcp-logging to pulseengine-logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic structured logging crate — not MCP-specific. Provides credential scrubbing, metrics, alerting, correlation IDs. --- .claude/settings.local.json | 24 ++++++++++++++++- Cargo.lock | 40 ++++++++++++++-------------- Cargo.toml | 4 +-- mcp-logging/Cargo.toml | 12 ++++++--- mcp-logging/src/lib.rs | 6 ++--- mcp-protocol/Cargo.toml | 4 +-- mcp-protocol/src/error.rs | 14 +++++----- mcp-server/Cargo.toml | 2 +- mcp-server/src/alerting_endpoint.rs | 4 +-- mcp-server/src/dashboard_endpoint.rs | 6 ++--- mcp-server/src/handler.rs | 6 ++--- mcp-server/src/metrics_endpoint.rs | 2 +- mcp-server/src/server.rs | 8 +++--- mcp-server/src/server_tests.rs | 4 +-- 14 files changed, 81 insertions(+), 55 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a97051f..45740bd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -61,7 +61,29 @@ "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\")" ], "deny": [] } diff --git a/Cargo.lock b/Cargo.lock index c0cbac2..c3a6b50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2105,6 +2105,24 @@ dependencies = [ "thiserror 1.0.69", ] +[[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-auth" version = "0.17.0" @@ -2223,24 +2241,6 @@ 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" @@ -2274,7 +2274,7 @@ dependencies = [ "async-trait", "chrono", "jsonschema", - "pulseengine-mcp-logging", + "pulseengine-logging", "schemars 0.8.22", "serde", "serde_json", @@ -2350,8 +2350,8 @@ dependencies = [ "chrono", "futures", "prometheus", + "pulseengine-logging", "pulseengine-mcp-auth", - "pulseengine-mcp-logging", "pulseengine-mcp-protocol", "pulseengine-mcp-security", "pulseengine-mcp-transport", diff --git a/Cargo.toml b/Cargo.toml index 1e5e850..ed17436 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,7 +103,7 @@ 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-logging = { version = "0.17.0", path = "mcp-logging" } pulseengine-mcp-auth = { version = "0.17.0", path = "mcp-auth" } pulseengine-mcp-security = { version = "0.17.0", path = "mcp-security" } pulseengine-mcp-security-middleware = { version = "0.17.0", path = "mcp-security-middleware" } @@ -146,7 +146,7 @@ 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-logging = { path = "mcp-logging" } pulseengine-mcp-auth = { path = "mcp-auth" } pulseengine-mcp-security = { path = "mcp-security" } pulseengine-mcp-security-middleware = { path = "mcp-security-middleware" } diff --git a/mcp-logging/Cargo.toml b/mcp-logging/Cargo.toml index d725352..3928eb9 100644 --- a/mcp-logging/Cargo.toml +++ b/mcp-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/src/lib.rs b/mcp-logging/src/lib.rs index 87eac8e..6dcd4f1 100644 --- a/mcp-logging/src/lib.rs +++ b/mcp-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-protocol/Cargo.toml b/mcp-protocol/Cargo.toml index 4a465e6..1db635e 100644 --- a/mcp-protocol/Cargo.toml +++ b/mcp-protocol/Cargo.toml @@ -24,14 +24,14 @@ async-trait = { workspace = true } jsonschema = { workspace = true } # Optional dependency for error classification -pulseengine-mcp-logging = { workspace = true, optional = true } +pulseengine-logging = { workspace = true, optional = true } # Optional dependency for automatic JSON Schema generation schemars = { version = "0.8", optional = true } [features] default = [] -logging = ["pulseengine-mcp-logging"] +logging = ["pulseengine-logging"] schemars = ["dep:schemars"] [dev-dependencies] diff --git a/mcp-protocol/src/error.rs b/mcp-protocol/src/error.rs index 20a298b..132d55a 100644 --- a/mcp-protocol/src/error.rs +++ b/mcp-protocol/src/error.rs @@ -241,19 +241,19 @@ impl From for Error { } #[cfg(feature = "logging")] -impl From for Error { - fn from(err: pulseengine_mcp_logging::LoggingError) -> Self { +impl From for Error { + fn from(err: pulseengine_logging::LoggingError) -> Self { match err { - pulseengine_mcp_logging::LoggingError::Config(msg) => { + pulseengine_logging::LoggingError::Config(msg) => { Error::invalid_request(format!("Logging config: {msg}")) } - pulseengine_mcp_logging::LoggingError::Io(io_err) => { + pulseengine_logging::LoggingError::Io(io_err) => { Error::internal_error(format!("Logging I/O: {io_err}")) } - pulseengine_mcp_logging::LoggingError::Serialization(serde_err) => { + pulseengine_logging::LoggingError::Serialization(serde_err) => { Error::internal_error(format!("Logging serialization: {serde_err}")) } - pulseengine_mcp_logging::LoggingError::Tracing(msg) => { + pulseengine_logging::LoggingError::Tracing(msg) => { Error::internal_error(format!("Tracing: {msg}")) } } @@ -262,7 +262,7 @@ impl From for Error { // Optional ErrorClassification implementation when logging feature is enabled #[cfg(feature = "logging")] -impl pulseengine_mcp_logging::ErrorClassification for Error { +impl pulseengine_logging::ErrorClassification for Error { fn error_type(&self) -> &str { match self.code { ErrorCode::ParseError => "parse_error", diff --git a/mcp-server/Cargo.toml b/mcp-server/Cargo.toml index a90a769..08994a5 100644 --- a/mcp-server/Cargo.toml +++ b/mcp-server/Cargo.toml @@ -18,7 +18,7 @@ pulseengine-mcp-protocol = { workspace = true, features = ["logging"] } pulseengine-mcp-auth = { workspace = true } pulseengine-mcp-transport = { workspace = true } pulseengine-mcp-security = { workspace = true } -pulseengine-mcp-logging = { workspace = true } +pulseengine-logging = { workspace = true } # System info for metrics collection (from merged mcp-monitoring) sysinfo = "0.32" diff --git a/mcp-server/src/alerting_endpoint.rs b/mcp-server/src/alerting_endpoint.rs index 10ad9a1..077d28c 100644 --- a/mcp-server/src/alerting_endpoint.rs +++ b/mcp-server/src/alerting_endpoint.rs @@ -7,7 +7,7 @@ use axum::{ response::{IntoResponse, Json}, routing::{get, post}, }; -use pulseengine_mcp_logging::{AlertManager, AlertSeverity, AlertState}; +use pulseengine_logging::{AlertManager, AlertSeverity, AlertState}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -174,7 +174,7 @@ mod tests { use super::*; use axum::http::StatusCode; use axum_test::TestServer; - use pulseengine_mcp_logging::{Alert, AlertConfig}; + use pulseengine_logging::{Alert, AlertConfig}; #[tokio::test] async fn test_alert_summary_endpoint() { diff --git a/mcp-server/src/dashboard_endpoint.rs b/mcp-server/src/dashboard_endpoint.rs index 242e0ad..68d502a 100644 --- a/mcp-server/src/dashboard_endpoint.rs +++ b/mcp-server/src/dashboard_endpoint.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse, Json}, routing::get, }; -use pulseengine_mcp_logging::DashboardManager; +use pulseengine_logging::DashboardManager; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -19,7 +19,7 @@ pub struct DashboardState { /// Dashboard data response #[derive(Debug, Serialize, Deserialize)] pub struct DashboardDataResponse { - pub charts: std::collections::HashMap, + pub charts: std::collections::HashMap, pub last_updated: chrono::DateTime, } @@ -128,7 +128,7 @@ mod tests { use super::*; use axum::http::StatusCode; use axum_test::TestServer; - use pulseengine_mcp_logging::DashboardConfig; + use pulseengine_logging::DashboardConfig; #[tokio::test] async fn test_dashboard_config_endpoint() { diff --git a/mcp-server/src/handler.rs b/mcp-server/src/handler.rs index 696ba4c..4f91851 100644 --- a/mcp-server/src/handler.rs +++ b/mcp-server/src/handler.rs @@ -3,7 +3,7 @@ use crate::tool_context::{NoOpToolContext, ToolContext, create_tool_context, with_context}; use crate::{backend::McpBackend, context::RequestContext, middleware::MiddlewareStack}; use pulseengine_mcp_auth::AuthenticationManager; -use pulseengine_mcp_logging::{get_metrics, spans}; +use pulseengine_logging::{get_metrics, spans}; use pulseengine_mcp_protocol::*; use pulseengine_mcp_transport::{Transport, try_current_session_id}; @@ -31,7 +31,7 @@ pub enum HandlerError { } // Implement ErrorClassification for HandlerError -impl pulseengine_mcp_logging::ErrorClassification for HandlerError { +impl pulseengine_logging::ErrorClassification for HandlerError { fn error_type(&self) -> &str { match self { HandlerError::Authentication(_) => "authentication", @@ -592,7 +592,7 @@ mod tests { use async_trait::async_trait; use pulseengine_mcp_auth::AuthenticationManager; use pulseengine_mcp_auth::config::AuthConfig; - use pulseengine_mcp_logging::ErrorClassification; + use pulseengine_logging::ErrorClassification; use pulseengine_mcp_protocol::{ CallToolRequestParam, CallToolResult, CompleteRequestParam, CompleteResult, Content, Error, GetPromptRequestParam, GetPromptResult, Implementation, InitializeResult, diff --git a/mcp-server/src/metrics_endpoint.rs b/mcp-server/src/metrics_endpoint.rs index 0ce88b1..61e7763 100644 --- a/mcp-server/src/metrics_endpoint.rs +++ b/mcp-server/src/metrics_endpoint.rs @@ -3,7 +3,7 @@ use crate::observability::MetricsCollector; use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get}; use prometheus::{Counter, Encoder, Gauge, Histogram, Registry, TextEncoder}; -use pulseengine_mcp_logging::get_metrics as get_logging_metrics; +use pulseengine_logging::get_metrics as get_logging_metrics; use std::sync::Arc; /// Prometheus metrics registry diff --git a/mcp-server/src/server.rs b/mcp-server/src/server.rs index 397029b..bcf1420 100644 --- a/mcp-server/src/server.rs +++ b/mcp-server/src/server.rs @@ -4,7 +4,7 @@ use crate::observability::{MetricsCollector, MonitoringConfig}; use crate::{backend::McpBackend, handler::GenericServerHandler, middleware::MiddlewareStack}; use async_trait::async_trait; use pulseengine_mcp_auth::{AuthConfig, AuthenticationManager}; -use pulseengine_mcp_logging::{ +use pulseengine_logging::{ AlertConfig, AlertManager, DashboardConfig, DashboardManager, PerformanceProfiler, PersistenceConfig, ProfilingConfig, SanitizationConfig, StructuredLogger, }; @@ -195,7 +195,7 @@ pub struct McpServer { middleware_stack: MiddlewareStack, monitoring_metrics: Arc, #[allow(dead_code)] - logging_metrics: Arc, + logging_metrics: Arc, #[allow(dead_code)] logger: StructuredLogger, alert_manager: Arc, @@ -238,7 +238,7 @@ impl McpServer { let monitoring_metrics = Arc::new(MetricsCollector::new(config.monitoring_config.clone())); // Initialize logging metrics with optional persistence - let logging_metrics = Arc::new(pulseengine_mcp_logging::MetricsCollector::new()); + let logging_metrics = Arc::new(pulseengine_logging::MetricsCollector::new()); if let Some(persistence_config) = config.persistence_config.clone() { logging_metrics .enable_persistence(persistence_config.clone()) @@ -332,7 +332,7 @@ impl McpServer { profiler .start_session( format!("server_session_{}", chrono::Utc::now().timestamp()), - pulseengine_mcp_logging::ProfilingSessionType::Continuous, + pulseengine_logging::ProfilingSessionType::Continuous, ) .await .map_err(|e| { diff --git a/mcp-server/src/server_tests.rs b/mcp-server/src/server_tests.rs index 193014d..39f1f61 100644 --- a/mcp-server/src/server_tests.rs +++ b/mcp-server/src/server_tests.rs @@ -765,7 +765,7 @@ async fn test_server_with_streamable_http_transport() { #[tokio::test] async fn test_server_with_profiling_enabled() { - use pulseengine_mcp_logging::ProfilingConfig; + use pulseengine_logging::ProfilingConfig; let backend = MockServerBackend::initialize((false, false, false, "Profiling Server".to_string())) @@ -798,7 +798,7 @@ async fn test_server_with_profiling_enabled() { #[tokio::test] async fn test_server_with_persistence_config() { - use pulseengine_mcp_logging::PersistenceConfig; + use pulseengine_logging::PersistenceConfig; let backend = MockServerBackend::initialize((false, false, false, "Persistence Server".to_string())) From 3a4ffee5d00d4c91a36158fe17b72f9376ed394d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 11:19:54 +0100 Subject: [PATCH 08/19] refactor: rename mcp-security-middleware to pulseengine-security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic Axum/Tower security middleware — not MCP-specific. Remove unused mcp-protocol dependency. --- Cargo.lock | 63 +++++++++++----------- Cargo.toml | 4 +- examples/hello-world-with-auth/Cargo.toml | 2 +- examples/hello-world-with-auth/src/main.rs | 4 +- mcp-security-middleware/Cargo.toml | 13 ++--- mcp-security-middleware/src/lib.rs | 18 +++---- mcp-security-middleware/src/middleware.rs | 2 +- mcp-security-middleware/src/utils.rs | 4 +- 8 files changed, 53 insertions(+), 57 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c3a6b50..886242c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1058,8 +1058,8 @@ dependencies = [ "axum", "pulseengine-mcp-macros", "pulseengine-mcp-protocol", - "pulseengine-mcp-security-middleware", "pulseengine-mcp-server", + "pulseengine-security", "schemars 0.8.22", "serde", "serde_json", @@ -2307,92 +2307,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-logging", + "pulseengine-mcp-auth", "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-logging", - "pulseengine-mcp-auth", + "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]] diff --git a/Cargo.toml b/Cargo.toml index ed17436..a468001 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,7 +106,7 @@ pulseengine-mcp-protocol = { version = "0.17.0", path = "mcp-protocol" } pulseengine-logging = { version = "0.17.0", path = "mcp-logging" } pulseengine-mcp-auth = { version = "0.17.0", path = "mcp-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 = "mcp-security-middleware" } 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" } @@ -149,7 +149,7 @@ pulseengine-mcp-protocol = { path = "mcp-protocol" } pulseengine-logging = { path = "mcp-logging" } pulseengine-mcp-auth = { path = "mcp-auth" } pulseengine-mcp-security = { path = "mcp-security" } -pulseengine-mcp-security-middleware = { path = "mcp-security-middleware" } +pulseengine-security = { path = "mcp-security-middleware" } pulseengine-mcp-transport = { path = "mcp-transport" } pulseengine-mcp-server = { path = "mcp-server" } pulseengine-mcp-macros = { path = "mcp-macros" } diff --git a/examples/hello-world-with-auth/Cargo.toml b/examples/hello-world-with-auth/Cargo.toml index 3877e61..a6fad73 100644 --- a/examples/hello-world-with-auth/Cargo.toml +++ b/examples/hello-world-with-auth/Cargo.toml @@ -29,5 +29,5 @@ tower = { workspace = true } # Framework dependencies pulseengine-mcp-macros = { workspace = true } pulseengine-mcp-server = { workspace = true } -pulseengine-mcp-security-middleware = { workspace = true } +pulseengine-security = { workspace = true } pulseengine-mcp-protocol = { workspace = true } diff --git a/examples/hello-world-with-auth/src/main.rs b/examples/hello-world-with-auth/src/main.rs index 06b379f..b986738 100644 --- a/examples/hello-world-with-auth/src/main.rs +++ b/examples/hello-world-with-auth/src/main.rs @@ -28,7 +28,7 @@ //! ``` use pulseengine_mcp_macros::{mcp_server, mcp_tools}; -use pulseengine_mcp_security_middleware::*; +use pulseengine_security::*; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; @@ -85,7 +85,7 @@ impl HelloWorldAuth { async fn main() -> Result<(), Box> { // Initialize logging tracing_subscriber::fmt() - .with_env_filter("hello_world_with_auth=info,pulseengine_mcp_security_middleware=info") + .with_env_filter("hello_world_with_auth=info,pulseengine_security=info") .init(); info!("Starting Hello World MCP Server with Authentication"); diff --git a/mcp-security-middleware/Cargo.toml b/mcp-security-middleware/Cargo.toml index 0a839e3..20c201f 100644 --- a/mcp-security-middleware/Cargo.toml +++ b/mcp-security-middleware/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/src/lib.rs b/mcp-security-middleware/src/lib.rs index 380b635..87992bc 100644 --- a/mcp-security-middleware/src/lib.rs +++ b/mcp-security-middleware/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/mcp-security-middleware/src/middleware.rs index 08671d2..cc3f043 100644 --- a/mcp-security-middleware/src/middleware.rs +++ b/mcp-security-middleware/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/utils.rs b/mcp-security-middleware/src/utils.rs index ca0888a..a50fdde 100644 --- a/mcp-security-middleware/src/utils.rs +++ b/mcp-security-middleware/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 From 41805f609161c9b5a07ddc99ca39829f97d226c1 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 15:50:12 +0100 Subject: [PATCH 09/19] =?UTF-8?q?feat:=20add=20pulseengine-mcp-resources?= =?UTF-8?q?=20crate=20=E2=80=94=20resource=20router=20for=20rmcp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URI-template-based resource router built on matchit for rmcp MCP servers. Provides ResourceRouter with scheme-aware URI matching, handler dispatch, and template listing for ServerHandler integration. --- Cargo.lock | 97 +++++- Cargo.toml | 1 + pulseengine-mcp-resources/Cargo.toml | 13 + pulseengine-mcp-resources/src/lib.rs | 33 ++ pulseengine-mcp-resources/src/router.rs | 223 +++++++++++++ .../tests/router_tests.rs | 296 ++++++++++++++++++ 6 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 pulseengine-mcp-resources/Cargo.toml create mode 100644 pulseengine-mcp-resources/src/lib.rs create mode 100644 pulseengine-mcp-resources/src/router.rs create mode 100644 pulseengine-mcp-resources/tests/router_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 886242c..29e808e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -636,8 +636,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 +664,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", ] @@ -1898,6 +1932,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" @@ -2247,7 +2287,7 @@ version = "0.17.0" dependencies = [ "anyhow", "async-trait", - "darling", + "darling 0.20.11", "matchit 0.8.4", "proc-macro2", "pulseengine-mcp-auth", @@ -2284,6 +2324,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" @@ -2700,6 +2748,41 @@ 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", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars 1.0.4", + "serde", + "serde_json", + "thiserror 2.0.12", + "tokio", + "tokio-util", + "tracing", +] + +[[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" @@ -3866,7 +3949,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", diff --git a/Cargo.toml b/Cargo.toml index a468001..6c48b19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ members = [ "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", ] resolver = "2" diff --git a/pulseengine-mcp-resources/Cargo.toml b/pulseengine-mcp-resources/Cargo.toml new file mode 100644 index 0000000..db08ae9 --- /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 0000000..5a3885c --- /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 0000000..19c6cc4 --- /dev/null +++ b/pulseengine-mcp-resources/src/router.rs @@ -0,0 +1,223 @@ +//! 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 ResourceRouter { + /// Create a new empty resource router. + pub fn new() -> Self { + Self { + router: matchit::Router::new(), + routes: Vec::new(), + } + } + + /// 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 0000000..9fdaca5 --- /dev/null +++ b/pulseengine-mcp-resources/tests/router_tests.rs @@ -0,0 +1,296 @@ +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"); +} From da46ea4391a4bda0c3ad70a15d9d42783566f6af Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 16:15:03 +0100 Subject: [PATCH 10/19] =?UTF-8?q?feat:=20add=20pulseengine-mcp-apps=20crat?= =?UTF-8?q?e=20=E2=80=94=20MCP=20Apps=20extension=20for=20rmcp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 8 ++ Cargo.toml | 1 + pulseengine-mcp-apps/Cargo.toml | 13 ++ pulseengine-mcp-apps/src/content.rs | 62 +++++++++ pulseengine-mcp-apps/src/lib.rs | 91 ++++++++++++ pulseengine-mcp-apps/tests/apps_tests.rs | 168 +++++++++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 pulseengine-mcp-apps/Cargo.toml create mode 100644 pulseengine-mcp-apps/src/content.rs create mode 100644 pulseengine-mcp-apps/src/lib.rs create mode 100644 pulseengine-mcp-apps/tests/apps_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 29e808e..7992677 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2163,6 +2163,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "pulseengine-mcp-apps" +version = "0.1.0" +dependencies = [ + "rmcp", + "serde_json", +] + [[package]] name = "pulseengine-mcp-auth" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index 6c48b19..a6ef96a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "examples/resources-demo", # Resource handling patterns "examples/conformance-server", # MCP conformance test server "pulseengine-mcp-resources", + "pulseengine-mcp-apps", ] resolver = "2" diff --git a/pulseengine-mcp-apps/Cargo.toml b/pulseengine-mcp-apps/Cargo.toml new file mode 100644 index 0000000..2219042 --- /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 0000000..a9f6da8 --- /dev/null +++ b/pulseengine-mcp-apps/src/content.rs @@ -0,0 +1,62 @@ +//! 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 0000000..9c2a480 --- /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 std::collections::BTreeMap; +use serde_json::{Map, Value, json}; + +/// 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 0000000..67ad135 --- /dev/null +++ b/pulseengine-mcp-apps/tests/apps_tests.rs @@ -0,0 +1,168 @@ +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"); +} From f01c03a9abece3d9ee32b8e5a4472681db2f34aa Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 16:35:10 +0100 Subject: [PATCH 11/19] docs: add migration guide for rmcp-based crate structure --- docs/MIGRATION.md | 395 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 docs/MIGRATION.md diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..c53a75e --- /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. From 6e8db45c2725bafb71e82dcaf3ce9856aa32c8b6 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sat, 28 Mar 2026 17:12:53 +0100 Subject: [PATCH 12/19] refactor: rename mcp-auth to pulseengine-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic auth/RBAC/session crate — not MCP-specific. Remove mcp-protocol dependency, rename MCP-prefixed types (McpPermission → Permission, McpPermissionChecker → PermissionChecker). Middleware now operates on generic (method, params) instead of MCP Request. --- Cargo.lock | 63 ++++----- Cargo.toml | 4 +- integration-tests/Cargo.toml | 2 +- .../src/auth_server_integration.rs | 2 +- integration-tests/src/end_to_end_scenarios.rs | 2 +- integration-tests/src/lib.rs | 2 +- .../src/monitoring_integration.rs | 4 +- mcp-auth/Cargo.toml | 12 +- mcp-auth/src/crypto/encryption.rs | 2 +- mcp-auth/src/jwt.rs | 2 +- mcp-auth/src/lib.rs | 16 +-- mcp-auth/src/manager.rs | 22 --- mcp-auth/src/middleware/mcp_auth.rs | 132 +++++------------- mcp-auth/src/middleware/mod.rs | 6 +- mcp-auth/src/middleware/session_middleware.rs | 106 +++++--------- mcp-auth/src/oauth/mod.rs | 6 +- mcp-auth/src/permissions/mcp_permissions.rs | 44 +++--- mcp-auth/src/permissions/mod.rs | 6 +- mcp-auth/src/security/mod.rs | 80 ++++------- mcp-auth/src/security/request_security.rs | 86 ++++++------ mcp-auth/tests/oauth_basic_tests.rs | 2 +- mcp-auth/tests/oauth_endpoints_tests.rs | 2 +- mcp-auth/tests/oauth_flow_tests.rs | 22 +-- mcp-auth/tests/simple_middleware_test.rs | 81 +++-------- mcp-auth/tests/test_utils.rs | 2 +- mcp-auth/tests/vault_integration_tests.rs | 4 +- mcp-external-validation/Cargo.toml | 2 +- .../src/auth_integration.rs | 18 +-- mcp-external-validation/src/security.rs | 6 +- mcp-macros/Cargo.toml | 4 +- mcp-server/Cargo.toml | 2 +- mcp-server/src/handler.rs | 6 +- mcp-server/src/handler_tests.rs | 2 +- mcp-server/src/lib.rs | 6 +- mcp-server/src/lib_tests.rs | 2 +- mcp-server/src/middleware.rs | 46 +----- mcp-server/src/middleware_tests.rs | 2 +- mcp-server/src/server.rs | 4 +- mcp-server/src/server_tests.rs | 2 +- 39 files changed, 301 insertions(+), 513 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7992677..b76328e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2146,33 +2146,7 @@ dependencies = [ ] [[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-auth" +name = "pulseengine-auth" version = "0.17.0" dependencies = [ "aes-gcm", @@ -2190,7 +2164,6 @@ dependencies = [ "keyring", "libc", "pbkdf2", - "pulseengine-mcp-protocol", "rand 0.8.5", "regex", "reqwest 0.11.27", @@ -2211,6 +2184,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" @@ -2241,7 +2240,7 @@ dependencies = [ "jsonschema", "proptest", "proptest-derive", - "pulseengine-mcp-auth", + "pulseengine-auth", "pulseengine-mcp-protocol", "pulseengine-mcp-server", "pulseengine-mcp-transport", @@ -2271,7 +2270,7 @@ dependencies = [ "assert_matches", "async-trait", "futures", - "pulseengine-mcp-auth", + "pulseengine-auth", "pulseengine-mcp-protocol", "pulseengine-mcp-security", "pulseengine-mcp-server", @@ -2298,7 +2297,7 @@ dependencies = [ "darling 0.20.11", "matchit 0.8.4", "proc-macro2", - "pulseengine-mcp-auth", + "pulseengine-auth", "pulseengine-mcp-protocol", "pulseengine-mcp-server", "pulseengine-mcp-transport", @@ -2373,8 +2372,8 @@ dependencies = [ "chrono", "futures", "prometheus", + "pulseengine-auth", "pulseengine-logging", - "pulseengine-mcp-auth", "pulseengine-mcp-protocol", "pulseengine-mcp-security", "pulseengine-mcp-transport", diff --git a/Cargo.toml b/Cargo.toml index a6ef96a..9884485 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,7 +106,7 @@ serde_yaml = "0.9" # Framework internal dependencies (published versions) pulseengine-mcp-protocol = { version = "0.17.0", path = "mcp-protocol" } pulseengine-logging = { version = "0.17.0", path = "mcp-logging" } -pulseengine-mcp-auth = { version = "0.17.0", path = "mcp-auth" } +pulseengine-auth = { version = "0.17.0", path = "mcp-auth" } pulseengine-mcp-security = { version = "0.17.0", path = "mcp-security" } pulseengine-security = { version = "0.17.0", path = "mcp-security-middleware" } pulseengine-mcp-transport = { version = "0.17.0", path = "mcp-transport" } @@ -149,7 +149,7 @@ lto = false # Patch published crates to use local versions for development pulseengine-mcp-protocol = { path = "mcp-protocol" } pulseengine-logging = { path = "mcp-logging" } -pulseengine-mcp-auth = { path = "mcp-auth" } +pulseengine-auth = { path = "mcp-auth" } pulseengine-mcp-security = { path = "mcp-security" } pulseengine-security = { path = "mcp-security-middleware" } pulseengine-mcp-transport = { path = "mcp-transport" } diff --git a/integration-tests/Cargo.toml b/integration-tests/Cargo.toml index b636743..06b23db 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 8823bcc..47796bd 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 a34a517..6a121a4 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 03f08cf..ef2d8c6 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 f9edd05..2eba1e5 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/Cargo.toml b/mcp-auth/Cargo.toml index 9626f36..277f5e9 100644 --- a/mcp-auth/Cargo.toml +++ b/mcp-auth/Cargo.toml @@ -1,20 +1,22 @@ [package] -name = "pulseengine-mcp-auth" +name = "pulseengine-auth" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true -description = "Authentication and authorization framework for MCP servers - PulseEngine MCP Framework" +description = "Authentication, authorization, and session management middleware for Axum/Tower services" homepage.workspace = true repository.workspace = true -documentation = "https://docs.rs/pulseengine-mcp-auth" +documentation = "https://docs.rs/pulseengine-auth" readme = "README.md" -keywords = ["mcp", "authentication", "authorization", "security", "auth"] +keywords = ["auth", "middleware", "rbac", "jwt", "tower"] categories = ["authentication", "web-programming"] rust-version.workspace = true +[lib] +name = "pulseengine_auth" + [dependencies] -pulseengine-mcp-protocol = { workspace = true } tokio = { workspace = true } serde = { workspace = true } diff --git a/mcp-auth/src/crypto/encryption.rs b/mcp-auth/src/crypto/encryption.rs index 5ecb39e..04f46d7 100644 --- a/mcp-auth/src/crypto/encryption.rs +++ b/mcp-auth/src/crypto/encryption.rs @@ -88,7 +88,7 @@ pub fn derive_encryption_key(master_key: &[u8], context: &str) -> [u8; 32] { let hkdf = Hkdf::::new(None, master_key); let mut okm = [0u8; 32]; - let info = format!("pulseengine-mcp-auth-{context}"); + let info = format!("pulseengine-auth-{context}"); hkdf.expand(info.as_bytes(), &mut okm) .expect("32 bytes is a valid length for HKDF-SHA256"); diff --git a/mcp-auth/src/jwt.rs b/mcp-auth/src/jwt.rs index 79116d1..32ffb68 100644 --- a/mcp-auth/src/jwt.rs +++ b/mcp-auth/src/jwt.rs @@ -119,7 +119,7 @@ pub struct JwtConfig { impl Default for JwtConfig { fn default() -> Self { Self { - issuer: "pulseengine-mcp-auth".to_string(), + issuer: "pulseengine-auth".to_string(), audience: vec!["mcp-server".to_string()], algorithm: Algorithm::HS256, signing_secret: b"default-secret-change-in-production".to_vec(), diff --git a/mcp-auth/src/lib.rs b/mcp-auth/src/lib.rs index 5c3ead7..bf1df05 100644 --- a/mcp-auth/src/lib.rs +++ b/mcp-auth/src/lib.rs @@ -1,6 +1,6 @@ -//! # MCP Authentication and Authorization Framework +//! # Authentication and Authorization Framework //! -//! A comprehensive, drop-in security framework for Model Context Protocol (MCP) servers +//! A comprehensive, drop-in security framework for Axum/Tower services //! providing enterprise-grade authentication, authorization, session management, and security monitoring. //! #![allow(clippy::uninlined_format_args)] @@ -19,7 +19,7 @@ //! ### Creating an Authentication Manager //! //! ```rust,ignore -//! use pulseengine_mcp_auth::{AuthenticationManager, AuthConfig}; +//! use pulseengine_auth::{AuthenticationManager, AuthConfig}; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { @@ -38,7 +38,7 @@ //! ### Creating and Validating API Keys //! //! ```rust,ignore -//! use pulseengine_mcp_auth::{AuthenticationManager, Role}; +//! use pulseengine_auth::{AuthenticationManager, Role}; //! //! // Create an API key //! let api_key = auth_manager.create_api_key( @@ -91,7 +91,7 @@ //! ## Session Management //! //! ```rust,ignore -//! use pulseengine_mcp_auth::{SessionManager, SessionConfig}; +//! use pulseengine_auth::{SessionManager, SessionConfig}; //! //! // Create a session manager //! let session_manager = SessionManager::new(SessionConfig::default()).await?; @@ -117,7 +117,7 @@ //! Enable features in Cargo.toml: //! ```toml //! [dependencies] -//! pulseengine-mcp-auth = { version = "*", features = ["monitoring", "vault"] } +//! pulseengine-auth = { version = "*", features = ["monitoring", "vault"] } //! ``` pub mod audit; @@ -159,7 +159,7 @@ pub use manager::{ #[cfg(feature = "vault")] pub use manager_vault::{VaultAuthManagerError, VaultAuthenticationManager, VaultStatus}; pub use middleware::{ - AuthExtractionError, McpAuthConfig, McpAuthMiddleware, SessionMiddleware, + AuthExtractionError, AuthMiddlewareError, McpAuthConfig, McpAuthMiddleware, SessionMiddleware, SessionMiddlewareConfig, SessionMiddlewareError, SessionRequestContext, }; pub use models::{ @@ -173,7 +173,7 @@ pub use monitoring::{ SystemHealth, create_default_alert_rules, }; pub use permissions::{ - McpPermission, McpPermissionChecker, PermissionAction, PermissionConfig, PermissionError, + Permission, PermissionAction, PermissionChecker, PermissionConfig, PermissionError, PermissionRule, ResourcePermissionConfig, ToolPermissionConfig, }; pub use security::{ diff --git a/mcp-auth/src/manager.rs b/mcp-auth/src/manager.rs index 7c4170e..e9b6925 100644 --- a/mcp-auth/src/manager.rs +++ b/mcp-auth/src/manager.rs @@ -8,7 +8,6 @@ use crate::{ storage::{StorageBackend, create_storage_backend}, }; use chrono::{DateTime, Utc}; -use pulseengine_mcp_protocol::{Request, Response}; use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; @@ -1187,27 +1186,6 @@ impl AuthenticationManager { Ok(()) } - pub async fn process_request( - &self, - request: Request, - _context: &RequestContext, - ) -> Result { - if !self.config.enabled { - return Ok(request); - } - - // For now, just pass through - implement authentication logic later - Ok(request) - } - - pub async fn process_response( - &self, - response: Response, - _context: &RequestContext, - ) -> Result { - Ok(response) - } - // JWT Token-based Authentication Methods /// Generate a JWT token pair for an API key diff --git a/mcp-auth/src/middleware/mcp_auth.rs b/mcp-auth/src/middleware/mcp_auth.rs index a9b1421..dd591ca 100644 --- a/mcp-auth/src/middleware/mcp_auth.rs +++ b/mcp-auth/src/middleware/mcp_auth.rs @@ -1,17 +1,31 @@ -//! MCP Authentication Middleware +//! Authentication Middleware //! //! This middleware provides comprehensive authentication and authorization -//! for MCP requests, integrating with the AuthenticationManager and +//! for incoming requests, integrating with the AuthenticationManager and //! permission system. use crate::{AuthContext, AuthenticationManager, models::Role, security::RequestSecurityValidator}; -use async_trait::async_trait; -use pulseengine_mcp_protocol::{Error as McpError, Request, Response}; use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; use tracing::{debug, error, warn}; +/// Errors returned by the auth middleware +#[derive(Debug, Error)] +pub enum AuthMiddlewareError { + #[error("Authentication required: {0}")] + AuthRequired(String), + + #[error("Access denied: {0}")] + AccessDenied(String), + + #[error("Security validation failed: {0}")] + SecurityValidation(String), + + #[error("Auth extraction failed: {0}")] + Extraction(#[from] AuthExtractionError), +} + /// Errors that can occur during authentication extraction #[derive(Debug, Error)] pub enum AuthExtractionError { @@ -172,33 +186,18 @@ impl McpAuthMiddleware { &self.security_validator } - /// Process an incoming MCP request - pub async fn process_request( + /// Authenticate and authorize a request. + /// + /// Takes the method name, an optional request ID, and optional HTTP headers. + /// Returns the auth context on success, or an `AuthMiddlewareError` on failure. + pub async fn authenticate( &self, - request: Request, + method: &str, + request_id: Option, headers: Option<&HashMap>, - ) -> Result<(Request, McpRequestContext), McpError> { - // Step 1: Validate request security first - if let Err(security_error) = self - .security_validator - .validate_request(&request, None) - .await - { - error!("Request security validation failed: {}", security_error); - return Err(McpError::invalid_request(&format!( - "Security validation failed: {}", - security_error - ))); - } - - // Step 2: Sanitize request if needed - let sanitized_request = self.security_validator.sanitize_request(request).await; - - let request_id = match &sanitized_request.id { - Some(id) => id.to_string(), - None => uuid::Uuid::new_v4().to_string(), - }; - let mut context = McpRequestContext::new(request_id); + ) -> Result { + let id = request_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut context = McpRequestContext::new(id); // Extract client IP if available if let Some(headers) = headers { @@ -210,12 +209,9 @@ impl McpAuthMiddleware { } // Check if authentication is required for this method - if self.should_skip_auth(&sanitized_request.method) { - debug!( - "Skipping authentication for method: {}", - sanitized_request.method - ); - return Ok((sanitized_request, context)); + if self.should_skip_auth(method) { + debug!("Skipping authentication for method: {}", method); + return Ok(context); } // Extract authentication from headers @@ -231,43 +227,26 @@ impl McpAuthMiddleware { context = context.with_auth(auth_context, auth_method); // Check method-specific role requirements - if let Err(e) = self - .check_method_permissions(&sanitized_request.method, &context) - .await - { + if let Err(e) = self.check_method_permissions(method, &context).await { error!("Method permission check failed: {}", e); - return Err(McpError::invalid_request(&format!("Access denied: {}", e))); + return Err(AuthMiddlewareError::AccessDenied(e)); } debug!("Request authenticated successfully"); - Ok((sanitized_request, context)) + Ok(context) } Err(e) => { if self.config.require_auth { warn!("Authentication failed: {}", e); - Err(McpError::invalid_request(&format!( - "Authentication required: {}", - e - ))) + Err(AuthMiddlewareError::AuthRequired(e.to_string())) } else { debug!("Authentication failed but not required: {}", e); - Ok((sanitized_request, context)) + Ok(context) } } } } - /// Process an outgoing MCP response - pub async fn process_response( - &self, - response: Response, - _context: &McpRequestContext, - ) -> Result { - // Add security headers or process response as needed - // For now, just pass through - Ok(response) - } - /// Extract authentication from request headers async fn extract_authentication( &self, @@ -366,45 +345,6 @@ impl McpAuthMiddleware { } } -/// Trait for middleware that can process MCP requests and responses -#[async_trait] -pub trait McpMiddleware: Send + Sync { - /// Process an incoming request - async fn process_request( - &self, - request: Request, - context: &McpRequestContext, - ) -> Result; - - /// Process an outgoing response - async fn process_response( - &self, - response: Response, - context: &McpRequestContext, - ) -> Result; -} - -#[async_trait] -impl McpMiddleware for McpAuthMiddleware { - async fn process_request( - &self, - request: Request, - _context: &McpRequestContext, - ) -> Result { - // This implementation assumes context has already been created - // by the initial process_request call - Ok(request) - } - - async fn process_response( - &self, - response: Response, - context: &McpRequestContext, - ) -> Result { - self.process_response(response, context).await - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/mcp-auth/src/middleware/mod.rs b/mcp-auth/src/middleware/mod.rs index 5eddd65..06c47bc 100644 --- a/mcp-auth/src/middleware/mod.rs +++ b/mcp-auth/src/middleware/mod.rs @@ -1,12 +1,12 @@ -//! Middleware components for MCP request/response processing +//! Middleware components for request/response processing //! //! This module provides middleware components that integrate authentication, -//! authorization, and security features into the MCP request pipeline. +//! authorization, and security features into the request pipeline. pub mod mcp_auth; pub mod session_middleware; -pub use mcp_auth::{AuthExtractionError, McpAuthConfig, McpAuthMiddleware}; +pub use mcp_auth::{AuthExtractionError, AuthMiddlewareError, McpAuthConfig, McpAuthMiddleware}; pub use session_middleware::{ SessionMiddleware, SessionMiddlewareConfig, SessionMiddlewareError, SessionRequestContext, }; diff --git a/mcp-auth/src/middleware/session_middleware.rs b/mcp-auth/src/middleware/session_middleware.rs index 2efc096..f9c6ac0 100644 --- a/mcp-auth/src/middleware/session_middleware.rs +++ b/mcp-auth/src/middleware/session_middleware.rs @@ -1,16 +1,15 @@ -//! Session-Aware MCP Authentication Middleware +//! Session-Aware Authentication Middleware //! -//! This middleware extends the basic MCP authentication to include session management, +//! This middleware extends the basic authentication to include session management, //! JWT token validation, and enhanced security features. use crate::{ AuthContext, AuthenticationManager, jwt::JwtError, - middleware::mcp_auth::{AuthExtractionError, McpAuthConfig, McpRequestContext}, + middleware::mcp_auth::{AuthExtractionError, AuthMiddlewareError, McpAuthConfig, McpRequestContext}, security::RequestSecurityValidator, session::{Session, SessionError, SessionManager}, }; -use pulseengine_mcp_protocol::{Error as McpError, Request, Response}; use std::collections::HashMap; use std::sync::Arc; use thiserror::Error; @@ -184,37 +183,22 @@ impl SessionMiddleware { ) } - /// Process an incoming MCP request with session awareness - pub async fn process_request( + /// Authenticate a request with session awareness. + /// + /// Takes the method name, an optional request ID, and optional HTTP headers. + /// Returns the session request context on success. + pub async fn authenticate( &self, - request: Request, + method: &str, + request_id: Option, headers: Option<&HashMap>, - ) -> Result<(Request, SessionRequestContext), McpError> { - // Step 1: Security validation (same as before) - if let Err(security_error) = self - .security_validator - .validate_request(&request, None) - .await - { - error!("Request security validation failed: {}", security_error); - return Err(McpError::invalid_request(&format!( - "Security validation failed: {}", - security_error - ))); - } - - let sanitized_request = self.security_validator.sanitize_request(request).await; - - // Step 2: Extract request ID and create base context - let request_id = match &sanitized_request.id { - Some(id) => id.to_string(), - None => uuid::Uuid::new_v4().to_string(), - }; + ) -> Result { + let id = request_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let mut base_context = McpRequestContext::new(request_id); + let mut base_context = McpRequestContext::new(id); let mut session_context = SessionRequestContext::new(base_context.clone()); - // Step 3: Extract client IP + // Extract client IP if let Some(headers) = headers { if let Some(ip_header) = &self.config.auth_config.client_ip_header { if let Some(client_ip) = headers.get(ip_header) { @@ -223,17 +207,14 @@ impl SessionMiddleware { } } - // Step 4: Check if this method requires authentication/sessions - if self.should_skip_auth(&sanitized_request.method) { - debug!( - "Skipping authentication for method: {}", - sanitized_request.method - ); + // Check if this method requires authentication/sessions + if self.should_skip_auth(method) { + debug!("Skipping authentication for method: {}", method); session_context.base_context = base_context; - return Ok((sanitized_request, session_context)); + return Ok(session_context); } - // Step 5: Try different authentication methods + // Try different authentication methods let auth_result = self.authenticate_request(headers).await; match auth_result { @@ -264,29 +245,23 @@ impl SessionMiddleware { } // Check method permissions - if let Err(e) = self - .check_method_permissions(&sanitized_request.method, &base_context) - .await - { + if let Err(e) = self.check_method_permissions(method, &base_context).await { error!("Method permission check failed: {}", e); - return Err(McpError::invalid_request(&format!("Access denied: {}", e))); + return Err(AuthMiddlewareError::AccessDenied(e)); } session_context.base_context = base_context; debug!("Request authenticated successfully"); - Ok((sanitized_request, session_context)) + Ok(session_context) } Err(e) => { if self.config.auth_config.require_auth { warn!("Authentication failed: {}", e); - Err(McpError::invalid_request(&format!( - "Authentication required: {}", - e - ))) + Err(AuthMiddlewareError::AuthRequired(e.to_string())) } else { debug!("Authentication failed but not required: {}", e); session_context.base_context = base_context; - Ok((sanitized_request, session_context)) + Ok(session_context) } } } @@ -514,27 +489,25 @@ impl SessionMiddleware { Ok(()) } - /// Process response (add session headers if needed) - pub async fn process_response( - &self, - response: Response, - context: &SessionRequestContext, - ) -> Result<(Response, HashMap), McpError> { - let mut response_headers = HashMap::new(); + /// Get response headers for a given session context. + /// + /// Returns headers that should be added to the HTTP response + /// (e.g., session ID, session-created indicator). + pub fn response_headers(&self, context: &SessionRequestContext) -> HashMap { + let mut headers = HashMap::new(); - // Add session ID to response headers if session exists if let Some(session) = &context.session { - response_headers.insert( + headers.insert( self.config.session_header_name.clone(), session.session_id.clone(), ); if context.auto_created_session { - response_headers.insert("X-Session-Created".to_string(), "true".to_string()); + headers.insert("X-Session-Created".to_string(), "true".to_string()); } } - Ok((response, response_headers)) + headers } /// Get session manager for external access @@ -582,17 +555,12 @@ mod tests { async fn test_anonymous_request_processing() { let middleware = create_test_middleware().await; - let request = Request { - jsonrpc: "2.0".to_string(), - method: "initialize".to_string(), // Anonymous method - 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.session.is_none()); assert!(context.base_context.auth.is_anonymous); } diff --git a/mcp-auth/src/oauth/mod.rs b/mcp-auth/src/oauth/mod.rs index 2732e01..1417cb0 100644 --- a/mcp-auth/src/oauth/mod.rs +++ b/mcp-auth/src/oauth/mod.rs @@ -64,7 +64,7 @@ impl OAuthState { /// /// # Example /// ```no_run - /// use pulseengine_mcp_auth::oauth::{OAuthState, oauth_router}; + /// use pulseengine_auth::oauth::{OAuthState, oauth_router}; /// /// let state = OAuthState::new_in_memory(); /// let app: axum::Router = oauth_router().with_state(state); @@ -79,7 +79,7 @@ impl OAuthState { /// /// # Example /// ```no_run - /// use pulseengine_mcp_auth::oauth::{OAuthState, OAuthStorage, oauth_router}; + /// use pulseengine_auth::oauth::{OAuthState, OAuthStorage, oauth_router}; /// use std::sync::Arc; /// /// // Bring your own storage implementation @@ -96,7 +96,7 @@ impl OAuthState { /// /// # Easy Setup (Python-like simplicity) /// ```no_run -/// use pulseengine_mcp_auth::oauth::{OAuthState, oauth_router}; +/// use pulseengine_auth::oauth::{OAuthState, oauth_router}; /// /// // That's it! One line to create OAuth state, one line to create the router /// let state = OAuthState::new_in_memory(); diff --git a/mcp-auth/src/permissions/mcp_permissions.rs b/mcp-auth/src/permissions/mcp_permissions.rs index cf3f940..3f9e42f 100644 --- a/mcp-auth/src/permissions/mcp_permissions.rs +++ b/mcp-auth/src/permissions/mcp_permissions.rs @@ -1,6 +1,6 @@ -//! MCP Permission System +//! Permission System //! -//! This module provides comprehensive permission management for MCP tools, +//! This module provides comprehensive permission management for tools, //! resources, and custom operations with role-based access control. use crate::{AuthContext, models::Role}; @@ -25,9 +25,9 @@ pub enum PermissionError { RoleConfig(String), } -/// MCP-specific permission types +/// Permission types for tools, resources, and operations #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum McpPermission { +pub enum Permission { /// Permission to use a specific tool UseTool(String), @@ -59,7 +59,7 @@ pub enum McpPermission { Custom(String), } -impl McpPermission { +impl Permission { /// Create a tool permission from a tool name pub fn tool(name: &str) -> Self { Self::UseTool(name.to_string()) @@ -135,7 +135,7 @@ impl Default for PermissionAction { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermissionRule { /// The permission this rule applies to - pub permission: McpPermission, + pub permission: Permission, /// Roles this rule applies to pub roles: Vec, @@ -149,7 +149,7 @@ pub struct PermissionRule { impl PermissionRule { /// Create a new allow rule - pub fn allow(permission: McpPermission, roles: Vec) -> Self { + pub fn allow(permission: Permission, roles: Vec) -> Self { Self { permission, roles, @@ -159,7 +159,7 @@ impl PermissionRule { } /// Create a new deny rule - pub fn deny(permission: McpPermission, roles: Vec) -> Self { + pub fn deny(permission: Permission, roles: Vec) -> Self { Self { permission, roles, @@ -311,18 +311,18 @@ impl PermissionConfig { /// Builder pattern for denying resource access pub fn deny_role_resource(mut self, role: Role, resource: &str) -> Self { let permission_rule = - PermissionRule::deny(McpPermission::UseResource(resource.to_string()), vec![role]); + PermissionRule::deny(Permission::UseResource(resource.to_string()), vec![role]); self.custom_rules.push(permission_rule); self } } -/// MCP Permission Checker -pub struct McpPermissionChecker { +/// Permission Checker +pub struct PermissionChecker { config: PermissionConfig, } -impl McpPermissionChecker { +impl PermissionChecker { /// Create a new permission checker pub fn new(config: PermissionConfig) -> Self { Self { config } @@ -337,7 +337,7 @@ impl McpPermissionChecker { // Check custom rules first for rule in &self.config.custom_rules { - if let McpPermission::UseTool(rule_tool) = &rule.permission { + if let Permission::UseTool(rule_tool) = &rule.permission { if rule_tool == tool_name { for role in &auth_context.roles { if rule.applies_to_role(role) { @@ -398,7 +398,7 @@ impl McpPermissionChecker { // Check custom rules first for rule in &self.config.custom_rules { - if let McpPermission::UseResource(rule_resource) = &rule.permission { + if let Permission::UseResource(rule_resource) = &rule.permission { if self.matches_resource_pattern(rule_resource, resource_uri) { for role in &auth_context.roles { if rule.applies_to_role(role) { @@ -474,7 +474,7 @@ impl McpPermissionChecker { // Check for subscription-specific rules for rule in &self.config.custom_rules { - if let McpPermission::Subscribe(rule_resource) = &rule.permission { + if let Permission::Subscribe(rule_resource) = &rule.permission { if self.matches_resource_pattern(rule_resource, resource_uri) { for role in &auth_context.roles { if rule.applies_to_role(role) { @@ -513,7 +513,7 @@ impl McpPermissionChecker { "completion/complete" => { // Custom rules for completion for rule in &self.config.custom_rules { - if matches!(rule.permission, McpPermission::Complete) { + if matches!(rule.permission, Permission::Complete) { for role in &auth_context.roles { if rule.applies_to_role(role) { return matches!(rule.action, PermissionAction::Allow); @@ -610,17 +610,17 @@ mod tests { #[test] fn test_permission_string_conversion() { - let perm = McpPermission::tool("control_device"); + let perm = Permission::tool("control_device"); assert_eq!(perm.to_string(), "tool:control_device"); - let parsed = McpPermission::from_string("tool:control_device").unwrap(); + let parsed = Permission::from_string("tool:control_device").unwrap(); assert_eq!(perm, parsed); } #[test] fn test_permission_rule_creation() { let rule = PermissionRule::allow( - McpPermission::tool("test_tool"), + Permission::tool("test_tool"), vec![Role::Admin, Role::Operator], ); @@ -632,7 +632,7 @@ mod tests { #[test] fn test_tool_category_extraction() { - let checker = McpPermissionChecker::new(PermissionConfig::default()); + let checker = PermissionChecker::new(PermissionConfig::default()); assert_eq!( checker.extract_tool_category("control_lights"), @@ -654,7 +654,7 @@ mod tests { #[test] fn test_resource_category_extraction() { - let checker = McpPermissionChecker::new(PermissionConfig::default()); + let checker = PermissionChecker::new(PermissionConfig::default()); assert_eq!( checker.extract_resource_category("loxone://devices/all"), @@ -668,7 +668,7 @@ mod tests { #[test] fn test_resource_pattern_matching() { - let checker = McpPermissionChecker::new(PermissionConfig::default()); + let checker = PermissionChecker::new(PermissionConfig::default()); assert!(checker.matches_resource_pattern("loxone://admin/*", "loxone://admin/keys")); assert!(checker.matches_resource_pattern("system://status", "system://status")); diff --git a/mcp-auth/src/permissions/mod.rs b/mcp-auth/src/permissions/mod.rs index a44b626..fc6451a 100644 --- a/mcp-auth/src/permissions/mod.rs +++ b/mcp-auth/src/permissions/mod.rs @@ -1,11 +1,11 @@ -//! Permission system for MCP tools and resources +//! Permission system for tools and resources //! -//! This module provides fine-grained permission control for MCP operations, +//! This module provides fine-grained permission control for operations, //! including tools, resources, and custom permission definitions. pub mod mcp_permissions; pub use mcp_permissions::{ - McpPermission, McpPermissionChecker, PermissionAction, PermissionConfig, PermissionError, + Permission, PermissionAction, PermissionChecker, PermissionConfig, PermissionError, PermissionRule, ResourcePermissionConfig, ToolPermissionConfig, }; diff --git a/mcp-auth/src/security/mod.rs b/mcp-auth/src/security/mod.rs index 930ac1c..440318b 100644 --- a/mcp-auth/src/security/mod.rs +++ b/mcp-auth/src/security/mod.rs @@ -1,7 +1,7 @@ -//! Security features for MCP request/response processing +//! Security features for request/response processing //! //! This module provides comprehensive security validation, sanitization, -//! and protection features for MCP protocol messages. +//! and protection features for protocol messages. pub mod request_security; @@ -13,19 +13,15 @@ pub use request_security::{ #[cfg(test)] mod tests { use super::*; - use pulseengine_mcp_protocol::Request; use serde_json::json; #[test] fn test_security_module_exports() { - // Test that all security types are accessible - let config = RequestSecurityConfig::default(); assert!(config.limits.max_request_size > 0); assert!(config.limits.max_parameters > 0); let _sanitizer = InputSanitizer::new(); - // InputSanitizer should be creatable let violation = SecurityViolation { violation_type: SecurityViolationType::SizeLimit, @@ -42,11 +38,9 @@ mod tests { #[test] fn test_security_severity_ordering() { - // Test that severity levels are properly ordered assert!(SecuritySeverity::Critical > SecuritySeverity::High); assert!(SecuritySeverity::High > SecuritySeverity::Medium); assert!(SecuritySeverity::Medium > SecuritySeverity::Low); - assert!(SecuritySeverity::Medium > SecuritySeverity::Low); } #[test] @@ -82,29 +76,21 @@ mod tests { let validator = RequestSecurityValidator::new(config); // Test valid request - let valid_request = Request { - jsonrpc: "2.0".to_string(), - method: "tools/list".to_string(), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(1)), - params: json!({}), - }; - - let result = validator.validate_request(&valid_request, None).await; + let result = validator + .validate_request_parts("tools/list", &json!({}), None) + .await; assert!(result.is_ok()); // Test request with too many parameters - let large_params = (0..1000) - .map(|i| (format!("param_{}", i), json!(i))) - .collect::>(); - let large_request = Request { - jsonrpc: "2.0".to_string(), - method: "tools/call".to_string(), - id: Some(pulseengine_mcp_protocol::NumberOrString::Number(2)), - params: json!(large_params), - }; - - let result = validator.validate_request(&large_request, None).await; - // Should detect too many parameters (depending on limits) + let large_params: serde_json::Value = json!( + (0..1000) + .map(|i| (format!("param_{}", i), json!(i))) + .collect::>() + ); + + let result = validator + .validate_request_parts("tools/call", &large_params, None) + .await; if result.is_err() { match result.unwrap_err() { SecurityValidationError::TooManyParameters { current, limit } => { @@ -119,21 +105,16 @@ mod tests { fn test_input_sanitizer() { let sanitizer = InputSanitizer::new(); - // Test normal input let normal_input = "hello world"; let sanitized = sanitizer.sanitize_string(normal_input); assert_eq!(sanitized, normal_input); - // Test input with potential issues let suspicious_input = ""; 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/mcp-auth/src/security/request_security.rs index 9b03c8a..4d9b40e 100644 --- a/mcp-auth/src/security/request_security.rs +++ b/mcp-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.contains(&method.to_string()) { 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,21 @@ 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 + 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/tests/oauth_basic_tests.rs b/mcp-auth/tests/oauth_basic_tests.rs index 0324661..2023534 100644 --- a/mcp-auth/tests/oauth_basic_tests.rs +++ b/mcp-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/mcp-auth/tests/oauth_endpoints_tests.rs index 575cea2..4ce8577 100644 --- a/mcp-auth/tests/oauth_endpoints_tests.rs +++ b/mcp-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/mcp-auth/tests/oauth_flow_tests.rs index 222bf56..5763129 100644 --- a/mcp-auth/tests/oauth_flow_tests.rs +++ b/mcp-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/mcp-auth/tests/simple_middleware_test.rs index 6d37252..f2b1d4e 100644 --- a/mcp-auth/tests/simple_middleware_test.rs +++ b/mcp-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,21 +66,11 @@ 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 - .contains("Authentication required") - ); + assert!(result.unwrap_err().to_string().contains("Authentication required")); } #[tokio::test] @@ -109,21 +85,11 @@ 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 - .contains("Authentication required") - ); + assert!(result.unwrap_err().to_string().contains("Authentication required")); } #[tokio::test] @@ -138,17 +104,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/mcp-auth/tests/test_utils.rs index 68c07e7..d2bfa4c 100644 --- a/mcp-auth/tests/test_utils.rs +++ b/mcp-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/mcp-auth/tests/vault_integration_tests.rs index c1bc639..486d124 100644 --- a/mcp-auth/tests/vault_integration_tests.rs +++ b/mcp-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-external-validation/Cargo.toml b/mcp-external-validation/Cargo.toml index 6eb12f0..f7f34ba 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 8dc304a..a88ac94 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("