From 13e7b764ad724aa541e21178471a44bbc7916340 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Tue, 24 Feb 2026 16:01:01 -0500 Subject: [PATCH] feat: mcp sdk conformance * adds conformance server and client * adds results from initial run of https://github.com/modelcontextprotocol/conformance/tree/main/.claude/skills/mcp-sdk-tier-audit skill * various small changes applied during the testing loop Co-authored-by: Dale Seo <5466341+DaleSeo@users.noreply.github.com> --- .github/codeql/codeql-config.yml | 4 + .github/workflows/codeql.yml | 36 + Cargo.toml | 2 +- conformance/Cargo.toml | 35 + .../results/2026-02-25-rust-sdk-assessment.md | 292 ++++++ .../2026-02-25-rust-sdk-remediation.md | 43 + conformance/src/bin/client.rs | 987 ++++++++++++++++++ conformance/src/bin/server.rs | 959 +++++++++++++++++ crates/rmcp/src/model.rs | 3 +- .../src/transport/common/client_side_sse.rs | 19 +- .../common/reqwest/streamable_http_client.rs | 14 +- .../src/transport/streamable_http_client.rs | 8 +- 12 files changed, 2392 insertions(+), 10 deletions(-) create mode 100644 .github/codeql/codeql-config.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 conformance/Cargo.toml create mode 100644 conformance/results/2026-02-25-rust-sdk-assessment.md create mode 100644 conformance/results/2026-02-25-rust-sdk-remediation.md create mode 100644 conformance/src/bin/client.rs create mode 100644 conformance/src/bin/server.rs diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 00000000..b2abf50c --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,4 @@ +name: "CodeQL config" + +paths-ignore: + - conformance diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..1cb1d2de --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,36 @@ +name: "CodeQL" + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + - cron: '0 0 * * 1' # Weekly on Monday + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + strategy: + fail-fast: false + matrix: + language: [rust, javascript-typescript, python, actions] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + config-file: ./.github/codeql/codeql-config.yml + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 diff --git a/Cargo.toml b/Cargo.toml index ddbff958..551ac7be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/rmcp", "crates/rmcp-macros", "examples/*"] +members = ["crates/rmcp", "crates/rmcp-macros", "examples/*", "conformance"] default-members = ["crates/rmcp", "crates/rmcp-macros"] resolver = "2" diff --git a/conformance/Cargo.toml b/conformance/Cargo.toml new file mode 100644 index 00000000..a38e9e62 --- /dev/null +++ b/conformance/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "mcp-conformance" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "conformance-server" +path = "src/bin/server.rs" + +[[bin]] +name = "conformance-client" +path = "src/bin/client.rs" + +[dependencies] +rmcp = { path = "../crates/rmcp", features = [ + "server", + "client", + "elicitation", + "auth", + "transport-streamable-http-server", + "transport-streamable-http-client-reqwest", +] } +tokio = { version = "1", features = ["full"] } +tokio-util = { version = "0.7" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +axum = { version = "0.8", features = ["macros"] } +anyhow = "1" +reqwest = { version = "0.13", features = ["json"] } +urlencoding = "2" +url = "2" +p256 = { version = "0.13", features = ["ecdsa"] } +base64 = "0.22" diff --git a/conformance/results/2026-02-25-rust-sdk-assessment.md b/conformance/results/2026-02-25-rust-sdk-assessment.md new file mode 100644 index 00000000..d9175c03 --- /dev/null +++ b/conformance/results/2026-02-25-rust-sdk-assessment.md @@ -0,0 +1,292 @@ +# MCP SDK Tier Audit: modelcontextprotocol/rust-sdk + +**Date**: 2026-02-25 +**Branch**: alexhancock/conformance +**Auditor**: mcp-sdk-tier-audit skill (automated + subagent evaluation) + +## Tier Assessment: Tier 3 + +The Rust SDK (rmcp) is currently at Tier 3. While server and client conformance pass rates exceed the 80% Tier 2 threshold, several critical Tier 2 requirements are not met: issue triage compliance is very low (14.1%), required labels are largely missing (3/12), no stable release ≥1.0.0 exists, and no roadmap is published. + +### Requirements Summary + +| # | Requirement | Tier 1 Standard | Tier 2 Standard | Current Value | T1? | T2? | Gap | +|---|-------------|----------------|-----------------|---------------|-----|-----|-----| +| 1a | Server Conformance | 100% pass rate | >= 80% pass rate | 83.3% (25/30) | FAIL | PASS | 5 failing scenarios (prompts-get-with-args, prompts-get-embedded-resource, elicitation-sep1330-enums, elicitation-sep1034-defaults, dns-rebinding-protection) | +| 1b | Client Conformance | 100% pass rate | >= 80% pass rate | 85.0% (17/20) | FAIL | PASS | 3 failing date-versioned scenarios (scope-step-up, metadata-var3, 2025-03-26-oauth-endpoint-fallback) | +| 2 | Issue Triage | >= 90% within 2 biz days | >= 80% within 1 month | 14.1% (9/64) | FAIL | FAIL | 54 issues exceeding SLA; median 4341h | +| 2b | Labels | 12 required labels | 12 required labels | 3/12 | FAIL | FAIL | Missing: bug, enhancement, needs confirmation, needs repro, ready for work, P0, P1, P2, P3 | +| 3 | Critical Bug Resolution | All P0s within 7 days | All P0s within 2 weeks | 0 open | PASS | PASS | None | +| 4 | Stable Release | Required + clear versioning | At least one stable release | rmcp-v0.16.0 | FAIL | FAIL | No release >= 1.0.0 | +| 4b | Spec Tracking | Timeline agreed per release | Within 6 months | 6d gap (PASS) | PASS | PASS | None | +| 5 | Documentation | Comprehensive w/ examples | Basic docs for core features | ~8/48 features | FAIL | FAIL | Most features lack prose documentation | +| 6 | Dependency Policy | Published update policy | Published update policy | dependabot.yml configured | PASS | PASS | None | +| 7 | Roadmap | Published roadmap | Plan toward Tier 1 | Not found | FAIL | FAIL | No ROADMAP.md or docs/roadmap.md | +| 8 | Versioning Policy | Documented breaking change policy | N/A | Not found | FAIL | N/A | No VERSIONING.md or BREAKING_CHANGES.md | + +### Tier Determination + +- Tier 1: FAIL — 3/11 requirements met (failing: server_conformance, client_conformance, triage, labels, stable_release, documentation, roadmap, versioning) +- Tier 2: FAIL — 4/9 requirements met (failing: triage, labels, stable_release, documentation, roadmap) +- **Final Tier: 3** + +--- + +## Server Conformance Details + +Pass rate: 83.3% (25/30) + +| Scenario | Status | Checks | Spec Versions | +|----------|--------|--------|---------------| +| server-tools-list | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-with-progress | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-with-logging | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-simple-text | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-sampling | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-mixed-content | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-image | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-error | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-embedded-resource | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-elicitation | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-tools-call-audio | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-server-sse-multiple-streams | PASS | 2/2 | 2025-11-25 | +| server-server-initialize | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-unsubscribe | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-templates-read | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-subscribe | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-read-text | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-read-binary | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-resources-list | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-list | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-with-image | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-with-args | FAIL | 0/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-simple | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-prompts-get-embedded-resource | FAIL | 0/1 | 2025-06-18, 2025-11-25 | +| server-ping | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-logging-set-level | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| server-elicitation-sep1330-enums | FAIL | 4/5 | 2025-11-25 | +| server-elicitation-sep1034-defaults | FAIL | 2/5 | 2025-11-25 | +| server-dns-rebinding-protection | FAIL | 1/2 | 2025-11-25 | +| server-completion-complete | PASS | 1/1 | 2025-06-18, 2025-11-25 | + +--- + +## Client Conformance Details + +Full suite pass rate: 85.0% (17/20 date-versioned) + +> **Suite breakdown**: Core: 4/4 (100%), Auth (date-versioned): 13/16 (81.3%) + +### Core Scenarios + +| Scenario | Status | Checks | Spec Versions | +|----------|--------|--------|---------------| +| tools_call | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| sse-retry | PASS | 3/3 | 2025-11-25 | +| initialize | PASS | 1/1 | 2025-06-18, 2025-11-25 | +| elicitation-sep1034-client-defaults | PASS | 5/5 | 2025-11-25 | + +### Auth Scenarios (Date-Versioned) + +| Scenario | Status | Checks | Spec Versions | Notes | +|----------|--------|--------|---------------|-------| +| auth/token-endpoint-auth-post | PASS | 19/19 | 2025-06-18, 2025-11-25 | | +| auth/token-endpoint-auth-none | PASS | 19/19 | 2025-06-18, 2025-11-25 | | +| auth/token-endpoint-auth-basic | PASS | 19/19 | 2025-06-18, 2025-11-25 | | +| auth/scope-step-up | FAIL | 13/14 | 2025-11-25 | | +| auth/scope-retry-limit | PASS | 11/11 | 2025-11-25 | | +| auth/scope-omitted-when-undefined | PASS | 15/15 | 2025-11-25 | | +| auth/scope-from-www-authenticate | PASS | 11/11 | 2025-11-25 | | +| auth/scope-from-scopes-supported | PASS | 15/15 | 2025-11-25 | | +| auth/pre-registration | PASS | 14/14 | 2025-11-25 | | +| auth/metadata-var3 | FAIL | 0/4 | 2025-11-25 | | +| auth/metadata-var2 | PASS | 14/14 | 2025-11-25 | | +| auth/metadata-var1 | PASS | 14/14 | 2025-11-25 | | +| auth/metadata-default | PASS | 14/14 | 2025-11-25 | | +| auth/basic-cimd | PASS | 14/14 | 2025-11-25 | | +| auth/2025-03-26-oauth-metadata-backcompat | PASS | 12/12 | 2025-03-26 | | +| auth/2025-03-26-oauth-endpoint-fallback | FAIL | 0/3 | 2025-03-26 | | + +### Auth Scenarios (Informational — not scored) + +| Scenario | Status | Checks | Spec Versions | +|----------|--------|--------|---------------| +| auth/resource-mismatch | FAIL | 14/15 | draft | +| auth/cross-app-access-complete-flow | FAIL | 10/12 | extension | +| auth/client-credentials-jwt | FAIL | 4/5 | extension | +| auth/client-credentials-basic | PASS | 9/9 | extension | + +--- + +## Issue Triage Details + +Analysis period: Last 64 issues +Labels present: question, good first issue, help wanted (3/12) +Uses issue types: No + +| Metric | Value | T1 Req | T2 Req | Verdict | +|--------|-------|--------|--------|---------| +| Compliance rate | 14.1% | >= 90% | >= 80% | FAIL | +| Triaged within SLA | 9 | — | — | — | +| Exceeding SLA | 54 | — | — | — | +| Median triage time | 4341.3h | — | — | — | +| P95 triage time | 8095.1h | — | — | — | +| Open P0s | 0 | 0 | 0 | PASS | + +--- + +## Documentation Coverage + +### Documentation Coverage Assessment + +**SDK path**: ~/Development/rust-sdk +**Documentation locations found**: + +- README.md: Top-level overview, basic client/server setup +- crates/rmcp/README.md: Core library docs with quick start, transport options, feature flags, structured output, tasks +- examples/README.md: Quick start with Claude Desktop +- examples/servers/README.md: Server example descriptions +- examples/clients/README.md: Client example descriptions +- docs/OAUTH_SUPPORT.md: OAuth 2.1 authorization documentation +- crates/rmcp-macros/README.md: Macro crate documentation + +#### Feature Documentation Table + +| # | Feature | Documented? | Where | Has Examples? | Verdict | +|---|---------|-------------|-------|---------------|---------| +| 1 | Tools - listing | Yes | crates/rmcp/README.md:21-90 | Yes (1 example) | PASS | +| 2 | Tools - calling | Yes | crates/rmcp/README.md:21-90, examples/clients/README.md | Yes (2 examples) | PASS | +| 3 | Tools - text results | Yes | crates/rmcp/README.md:50-60 | Yes (1 example) | PASS | +| 4 | Tools - image results | No | — | No | FAIL | +| 5 | Tools - audio results | No | — | No | FAIL | +| 6 | Tools - embedded resources | No | — | No | FAIL | +| 7 | Tools - error handling | No | — | No | FAIL | +| 8 | Tools - change notifications | No | — | No | FAIL | +| 9 | Resources - listing | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 10 | Resources - reading text | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 11 | Resources - reading binary | No | — | No | FAIL | +| 12 | Resources - templates | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 13 | Resources - template reading | No | — | No | FAIL | +| 14 | Resources - subscribing | No | — | No | FAIL | +| 15 | Resources - unsubscribing | No | — | No | FAIL | +| 16 | Resources - change notifications | No | — | No | FAIL | +| 17 | Prompts - listing | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 18 | Prompts - getting simple | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 19 | Prompts - getting with arguments | No | — | Yes (example in everything_stdio.rs) | PARTIAL | +| 20 | Prompts - embedded resources | No | — | No | FAIL | +| 21 | Prompts - image content | No | — | No | FAIL | +| 22 | Prompts - change notifications | No | — | No | FAIL | +| 23 | Sampling - creating messages | No | — | Yes (servers/sampling_stdio.rs, clients/sampling_stdio.rs) | PARTIAL | +| 24 | Elicitation - form mode | Yes | examples/servers/README.md:38-53 | Yes (elicitation_stdio.rs) | PASS | +| 25 | Elicitation - URL mode | No | — | No | FAIL | +| 26 | Elicitation - schema validation | No | — | No | FAIL | +| 27 | Elicitation - default values | No | — | No | FAIL | +| 28 | Elicitation - enum values | No | — | Yes (elicitation_enum_inference.rs) | PARTIAL | +| 29 | Elicitation - complete notification | No | — | No | FAIL | +| 30 | Roots - listing | No | — | No | FAIL | +| 31 | Roots - change notifications | No | — | No | FAIL | +| 32 | Logging - sending log messages | No | — | No | FAIL | +| 33 | Logging - setting level | No | — | No | FAIL | +| 34 | Completions - resource argument | No | — | Yes (completion_stdio.rs) | PARTIAL | +| 35 | Completions - prompt argument | No | — | Yes (completion_stdio.rs) | PARTIAL | +| 36 | Ping | No | — | No | FAIL | +| 37 | Streamable HTTP transport (client) | Yes | crates/rmcp/README.md:175-195 | Yes (clients/streamable_http.rs) | PASS | +| 38 | Streamable HTTP transport (server) | Yes | crates/rmcp/README.md:175-195 | Yes (servers/counter_streamhttp.rs) | PASS | +| 39 | SSE transport - legacy (client) | No | — | No | FAIL | +| 40 | SSE transport - legacy (server) | No | — | No | FAIL | +| 41 | stdio transport (client) | Yes | crates/rmcp/README.md:140-165 | Yes (clients/git_stdio.rs) | PASS | +| 42 | stdio transport (server) | Yes | crates/rmcp/README.md:21-90 | Yes (servers/counter_stdio.rs) | PASS | +| 43 | Progress notifications | No | — | Yes (servers/progress_demo.rs, clients/progress_client.rs) | PARTIAL | +| 44 | Cancellation | No | — | No | FAIL | +| 45 | Pagination | No | — | No | FAIL | +| 46 | Capability negotiation | No | — | No | FAIL | +| 47 | Protocol version negotiation | No | — | No | FAIL | +| 48 | JSON Schema 2020-12 support | Yes | README.md:32-33, crates/rmcp/README.md:92-120 | Yes (structured output example) | PASS | +| — | Tasks - get (experimental) | Yes | crates/rmcp/README.md (Tasks section) | No | INFO | +| — | Tasks - result (experimental) | Yes | crates/rmcp/README.md (Tasks section) | No | INFO | +| — | Tasks - cancel (experimental) | Yes | crates/rmcp/README.md (Tasks section) | No | INFO | +| — | Tasks - list (experimental) | No | — | No | INFO | +| — | Tasks - status notifications (experimental) | No | — | No | INFO | + +#### Summary + +**Total non-experimental features**: 48 +**PASS (documented with examples)**: 9/48 +**PARTIAL (documented or examples only)**: 11/48 +**FAIL (not documented)**: 28/48 + +**Core features documented**: ~6/36 (16.7%) +**All features documented with examples**: 9/48 (18.8%) + +#### Tier Verdicts + +**Tier 1** (all non-experimental features documented with examples): **FAIL** + +- 39 features missing full documentation with examples + +**Tier 2** (basic docs covering core features): **FAIL** + +- Most core features (resources, prompts, sampling, roots, logging, completions, notifications, subscriptions) lack prose documentation +- Only tools (basic), transports (stdio, streamable HTTP), elicitation (form mode), and JSON Schema have adequate prose docs + +--- + +## Policy Evaluation + +### Policy Evaluation Assessment + +**SDK path**: ~/Development/rust-sdk +**Repository**: modelcontextprotocol/rust-sdk + +--- + +#### 1. Dependency Update Policy: PASS + +| File | Exists (CLI) | Content Verdict | +|------|-------------|----------------| +| DEPENDENCY_POLICY.md | No | N/A | +| docs/dependency-policy.md | No | N/A | +| .github/dependabot.yml | Yes | Configured — weekly Cargo updates, daily GitHub Actions updates, with PR limits and labeling | +| .github/renovate.json | No | N/A | + +**Verdict**: **PASS** — Dependabot is properly configured with weekly Cargo dependency updates and daily GitHub Actions updates. + +--- + +#### 2. Roadmap: FAIL + +| File | Exists (CLI) | Content Verdict | +|------|-------------|----------------| +| ROADMAP.md | No | N/A | +| docs/roadmap.md | No | N/A | + +**Verdict**: + +- **Tier 1**: **FAIL** — No roadmap file exists. +- **Tier 2**: **FAIL** — No roadmap or plan-toward-Tier-1 file exists. + +--- + +#### 3. Versioning Policy: FAIL + +| File | Exists (CLI) | Content Verdict | +|------|-------------|----------------| +| VERSIONING.md | No | N/A | +| docs/versioning.md | No | N/A | +| BREAKING_CHANGES.md | No | N/A | +| CONTRIBUTING.md (versioning section) | No | N/A | + +**Verdict**: + +- **Tier 1**: **FAIL** — No versioning or breaking change documentation exists. +- **Tier 2**: **N/A** — only requires stable release. + +--- + +#### Overall Policy Summary + +| Policy Area | Tier 1 | Tier 2 | +|-------------|--------|--------| +| Dependency Update Policy | PASS | PASS | +| Roadmap | FAIL | FAIL | +| Versioning Policy | FAIL | N/A | diff --git a/conformance/results/2026-02-25-rust-sdk-remediation.md b/conformance/results/2026-02-25-rust-sdk-remediation.md new file mode 100644 index 00000000..17d4e973 --- /dev/null +++ b/conformance/results/2026-02-25-rust-sdk-remediation.md @@ -0,0 +1,43 @@ +# Remediation Guide: modelcontextprotocol/rust-sdk + +**Date**: 2026-02-25 +**Current Tier**: 3 + +## Path to Tier 2 + +The following requirements must be met to advance from Tier 3 to Tier 2: + +| # | Action | Requirement | Effort | Where | +|---|--------|-------------|--------|-------| +| 1 | Create 9 missing issue labels (bug, enhancement, needs confirmation, needs repro, ready for work, P0, P1, P2, P3) and triage existing issues | Labels (3/12 → 12/12) + Triage (14.1% → ≥80%) | Medium | GitHub repo settings, open issues | +| 2 | Publish stable release ≥ 1.0.0 | Stable Release | Medium | Cargo.toml, release process | +| 3 | Add prose documentation for core features: resources, prompts, sampling, roots, logging, completions, notifications, subscriptions | Documentation (basic docs for core features) | Large | README.md, docs/, crates/rmcp/README.md | +| 4 | Create ROADMAP.md with plan toward Tier 1 | Roadmap | Small | ROADMAP.md | + +## Path to Tier 1 + +The following requirements must be met to advance to Tier 1 (includes all Tier 2 gaps): + +| # | Action | Requirement | Effort | Where | +|---|--------|-------------|--------|-------| +| 1 | Fix 5 failing server conformance scenarios: prompts-get-with-args, prompts-get-embedded-resource, elicitation-sep1330-enums, elicitation-sep1034-defaults, dns-rebinding-protection | Server Conformance (83.3% → 100%) | Medium | Conformance server implementation | +| 2 | Fix 3 failing client conformance scenarios: auth/scope-step-up, auth/metadata-var3, auth/2025-03-26-oauth-endpoint-fallback | Client Conformance (85.0% → 100%) | Medium | OAuth client implementation | +| 3 | Create 9 missing issue labels and triage all open issues within 2 business days going forward | Labels + Triage (14.1% → ≥90%) | Medium | GitHub repo settings, issue triage process | +| 4 | Publish stable release ≥ 1.0.0 with clear versioning | Stable Release | Medium | Cargo.toml, release process | +| 5 | Document ALL 48 non-experimental features with prose and code examples | Documentation (9/48 → 48/48) | Large | README.md, docs/, crates/rmcp/README.md, examples/ | +| 6 | Create ROADMAP.md with concrete steps tracking MCP spec components | Roadmap | Small | ROADMAP.md | +| 7 | Create VERSIONING.md documenting breaking change policy and versioning scheme | Versioning Policy | Small | VERSIONING.md | + +## Recommended Next Steps + +1. **Set up issue labels and begin triage process** (Small effort, unblocks Tier 2 triage requirement). Create the 9 missing labels (bug, enhancement, needs confirmation, needs repro, ready for work, P0-P3) and begin labeling all new issues within 2 business days. Retroactively triage the 54 unlabeled issues. + +2. **Create ROADMAP.md and VERSIONING.md** (Small effort, unblocks Tier 2 roadmap and Tier 1 versioning). Write a roadmap outlining the path to 1.0.0 and Tier 1, and document the versioning/breaking-change policy. + +3. **Write prose documentation for core features** (Large effort, unblocks Tier 2 documentation). Priority features to document: resources (listing, reading, templates, subscriptions), prompts (listing, getting, arguments, embedded resources), sampling, roots, logging, completions, notifications, and change notifications. The SDK already has good examples in `examples/` — these need accompanying prose in `docs/` or `crates/rmcp/README.md`. + +4. **Fix server conformance failures** (Medium effort, advances toward Tier 1). The 5 failures are in prompts-get-with-args, prompts-get-embedded-resource, elicitation-sep1330-enums, elicitation-sep1034-defaults, and dns-rebinding-protection. The elicitation failures appear to be in default value handling and enum validation; the prompts failures may be response format issues. + +5. **Fix client auth conformance failures** (Medium effort, advances toward Tier 1). The 3 date-versioned failures are auth/scope-step-up (1 check failing), auth/metadata-var3 (all 4 checks failing — likely a metadata discovery edge case), and auth/2025-03-26-oauth-endpoint-fallback (all 3 checks failing — legacy endpoint fallback). + +6. **Plan and execute 1.0.0 release** (Medium effort, unblocks Tier 2 stable release). The current version is 0.16.0. A 1.0.0 release signals production readiness and is required for both Tier 1 and Tier 2. diff --git a/conformance/src/bin/client.rs b/conformance/src/bin/client.rs new file mode 100644 index 00000000..53a44d9e --- /dev/null +++ b/conformance/src/bin/client.rs @@ -0,0 +1,987 @@ +use std::future::Future; + +use rmcp::{ + ClientHandler, ErrorData, RoleClient, ServiceExt, + model::*, + service::RequestContext, + transport::{ + AuthClient, AuthorizationManager, StreamableHttpClientTransport, + auth::{OAuthClientConfig, OAuthState}, + streamable_http_client::StreamableHttpClientTransportConfig, + }, +}; +use serde_json::{Value, json}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +// ─── Context parsed from MCP_CONFORMANCE_CONTEXT ──────────────────────────── + +#[derive(Debug, Default, serde::Deserialize)] +struct ConformanceContext { + #[serde(default)] + name: Option, + // pre-registration / client-credentials-basic + #[serde(default)] + client_id: Option, + #[serde(default)] + client_secret: Option, + // client-credentials-jwt + #[serde(default)] + private_key_pem: Option, + #[serde(default)] + signing_algorithm: Option, + // cross-app-access + #[serde(default)] + idp_client_id: Option, + #[serde(default)] + idp_id_token: Option, + #[serde(default)] + idp_issuer: Option, + #[serde(default)] + idp_token_endpoint: Option, +} + +fn load_context() -> ConformanceContext { + std::env::var("MCP_CONFORMANCE_CONTEXT") + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +// ─── Client handlers ──────────────────────────────────────────────────────── + +/// A basic client handler that does nothing special +struct BasicClientHandler; +impl ClientHandler for BasicClientHandler {} + +/// A client handler that handles elicitation requests by applying schema defaults. +struct ElicitationDefaultsClientHandler; + +impl ClientHandler for ElicitationDefaultsClientHandler { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, + }); + info + } + + fn create_elicitation( + &self, + request: CreateElicitationRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let content = match &request { + CreateElicitationRequestParams::FormElicitationParams { + requested_schema, .. + } => { + let mut defaults = serde_json::Map::new(); + for (name, prop) in &requested_schema.properties { + match prop { + PrimitiveSchema::String(s) => { + if let Some(d) = &s.default { + defaults.insert(name.clone(), Value::String(d.clone())); + } + } + PrimitiveSchema::Number(n) => { + if let Some(d) = n.default { + defaults.insert(name.clone(), json!(d)); + } + } + PrimitiveSchema::Integer(i) => { + if let Some(d) = i.default { + defaults.insert(name.clone(), json!(d)); + } + } + PrimitiveSchema::Boolean(b) => { + if let Some(d) = b.default { + defaults.insert(name.clone(), Value::Bool(d)); + } + } + PrimitiveSchema::Enum(e) => { + let val = match e { + EnumSchema::Single(SingleSelectEnumSchema::Untitled(u)) => { + u.default.as_ref().map(|d| Value::String(d.clone())) + } + EnumSchema::Single(SingleSelectEnumSchema::Titled(t)) => { + t.default.as_ref().map(|d| Value::String(d.clone())) + } + EnumSchema::Multi(MultiSelectEnumSchema::Untitled(u)) => { + u.default.as_ref().map(|d| { + Value::Array( + d.iter() + .map(|s| Value::String(s.clone())) + .collect(), + ) + }) + } + EnumSchema::Multi(MultiSelectEnumSchema::Titled(t)) => { + t.default.as_ref().map(|d| { + Value::Array( + d.iter() + .map(|s| Value::String(s.clone())) + .collect(), + ) + }) + } + EnumSchema::Legacy(_) => None, + }; + if let Some(v) = val { + defaults.insert(name.clone(), v); + } + } + } + } + Some(Value::Object(defaults)) + } + _ => Some(json!({})), + }; + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content, + }) + } + } +} + +/// A client handler that handles both sampling and elicitation +struct FullClientHandler; + +impl ClientHandler for FullClientHandler { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.capabilities.elicitation = Some(ElicitationCapability { + form: Some(FormElicitationCapability { + schema_validation: Some(true), + }), + url: None, + }); + info + } + + fn create_message( + &self, + params: CreateMessageRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let prompt_text = params + .messages + .first() + .and_then(|m| m.content.first()) + .and_then(|c| c.as_text()) + .map(|t| t.text.clone()) + .unwrap_or_default(); + Ok(CreateMessageResult { + message: SamplingMessage::new( + Role::Assistant, + SamplingMessageContent::text(format!( + "This is a mock LLM response to: {}", + prompt_text + )), + ), + model: "mock-model".into(), + stop_reason: Some("endTurn".into()), + }) + } + } + + fn create_elicitation( + &self, + _request: CreateElicitationRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + Ok(CreateElicitationResult { + action: ElicitationAction::Accept, + content: Some(json!({"username": "testuser", "email": "test@example.com"})), + }) + } + } +} + +// ─── OAuth helpers ────────────────────────────────────────────────────────── + +const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json"; +const REDIRECT_URI: &str = "http://localhost:3000/callback"; + +/// Perform the headless OAuth authorization-code flow. +/// +/// 1. Discover metadata, register (or use CIMD), get auth URL +/// 2. Fetch the auth URL with redirect:manual → extract code from Location header +/// 3. Exchange code for token +/// 4. Return an `AuthClient` wrapping `reqwest::Client` +async fn perform_oauth_flow( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result> { + let mut oauth = OAuthState::new(server_url, None).await?; + + // Discover + register + get auth URL + oauth + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + + let auth_url = oauth.get_authorization_url().await?; + tracing::debug!("Authorization URL: {}", auth_url); + + // Headless: fetch the auth URL without following redirects + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let resp = http.get(&auth_url).send().await?; + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow::anyhow!("No Location header in auth redirect"))?; + + let redirect_url = url::Url::parse(location)?; + let code = redirect_url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No code in redirect URL"))?; + let state = redirect_url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No state in redirect URL"))?; + + tracing::debug!("Got auth code, exchanging for token..."); + oauth.handle_callback(&code, &state).await?; + + let am = oauth + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; + + Ok(AuthClient::new(reqwest::Client::default(), am)) +} + +/// Like `perform_oauth_flow` but uses pre-registered client credentials. +async fn perform_oauth_flow_preregistered( + server_url: &str, + client_id: &str, + client_secret: &str, +) -> anyhow::Result> { + let mut manager = AuthorizationManager::new(server_url).await?; + let metadata = manager.discover_metadata().await?; + manager.set_metadata(metadata); + + // Configure with pre-registered credentials + let config = rmcp::transport::auth::OAuthClientConfig { + client_id: client_id.to_string(), + client_secret: Some(client_secret.to_string()), + scopes: vec![], + redirect_uri: REDIRECT_URI.to_string(), + }; + manager.configure_client(config)?; + + let scopes = manager.select_scopes(None, &[]); + let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect(); + let auth_url = manager.get_authorization_url(&scope_refs).await?; + + // Headless redirect + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let resp = http.get(&auth_url).send().await?; + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow::anyhow!("No Location header"))?; + let redirect_url = url::Url::parse(location)?; + let code = redirect_url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No code"))?; + let state = redirect_url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No state"))?; + + manager.exchange_code_for_token(&code, &state).await?; + + Ok(AuthClient::new(reqwest::Client::default(), manager)) +} + +/// Run the standard auth flow, then connect and exercise the server. +async fn run_auth_client(server_url: &str, ctx: &ConformanceContext) -> anyhow::Result<()> { + let auth_client = perform_oauth_flow(server_url, ctx).await?; + + let transport = StreamableHttpClientTransport::with_client( + auth_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + tracing::debug!("Connected (authenticated)"); + + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + + // Call each tool + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + + client.cancel().await?; + Ok(()) +} + +/// Auth flow with scope step-up: connect, list tools (ok with basic scope), +/// then call tool which triggers 403 → re-auth with expanded scopes → retry. +async fn run_auth_scope_step_up_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + // First auth + let mut oauth = OAuthState::new(server_url, None).await?; + oauth + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + + let auth_url = oauth.get_authorization_url().await?; + let (code, state) = headless_authorize(&auth_url).await?; + oauth.handle_callback(&code, &state).await?; + + let am = oauth + .into_authorization_manager() + .ok_or_else(|| anyhow::anyhow!("No AM"))?; + let auth_client = AuthClient::new(reqwest::Client::default(), am); + + let transport = StreamableHttpClientTransport::with_client( + auth_client.clone(), + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + + // Try calling tool – may get 403 insufficient_scope + for tool in &tools.tools { + let args = build_tool_arguments(tool); + match client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args.clone(), + task: None, + }) + .await + { + Ok(_) => { + tracing::debug!("Tool call succeeded on first try"); + } + Err(_) => { + tracing::debug!("Tool call failed (likely 403), attempting scope upgrade..."); + // Drop old client, re-auth with upgraded scopes + client.cancel().await.ok(); + + // Re-do the full flow; the server will give us the right scopes + // on the second authorization request. + let mut oauth2 = OAuthState::new(server_url, None).await?; + // Pass the escalated scope hint + oauth2 + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + let auth_url2 = oauth2.get_authorization_url().await?; + let (code2, state2) = headless_authorize(&auth_url2).await?; + oauth2.handle_callback(&code2, &state2).await?; + + let am2 = oauth2.into_authorization_manager().unwrap(); + let auth_client2 = AuthClient::new(reqwest::Client::default(), am2); + let transport2 = StreamableHttpClientTransport::with_client( + auth_client2, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + let client2 = BasicClientHandler.serve(transport2).await?; + let _ = client2 + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + client2.cancel().await.ok(); + return Ok(()); + } + } + } + + client.cancel().await?; + Ok(()) +} + +/// Auth flow for scope-retry-limit: keep re-authing on 403 until we hit a limit. +async fn run_auth_scope_retry_limit_client( + server_url: &str, + _ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let max_retries = 3u32; + let mut attempt = 0u32; + + loop { + let mut oauth = OAuthState::new(server_url, None).await?; + oauth + .start_authorization_with_metadata_url( + &[], + REDIRECT_URI, + Some("conformance-client"), + Some(CIMD_CLIENT_METADATA_URL), + ) + .await?; + let auth_url = oauth.get_authorization_url().await?; + let (code, state) = headless_authorize(&auth_url).await?; + oauth.handle_callback(&code, &state).await?; + + let am = oauth.into_authorization_manager().unwrap(); + let auth_client = AuthClient::new(reqwest::Client::default(), am); + let transport = StreamableHttpClientTransport::with_client( + auth_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + + let mut got_403 = false; + for tool in &tools.tools { + let args = build_tool_arguments(tool); + match client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await + { + Ok(_) => {} + Err(_) => { + got_403 = true; + break; + } + } + } + client.cancel().await.ok(); + + if !got_403 { + break; + } + attempt += 1; + if attempt >= max_retries { + tracing::info!("Reached retry limit ({max_retries}), giving up"); + return Err(anyhow::anyhow!("Scope retry limit reached")); + } + } + Ok(()) +} + +/// Auth flow with pre-registered credentials (from context). +async fn run_auth_preregistered_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let client_id = ctx + .client_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing client_id in context"))?; + let client_secret = ctx + .client_secret + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing client_secret in context"))?; + + let auth_client = + perform_oauth_flow_preregistered(server_url, client_id, client_secret).await?; + + let transport = StreamableHttpClientTransport::with_client( + auth_client, + StreamableHttpClientTransportConfig::with_uri(server_url), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + client.cancel().await?; + Ok(()) +} + +/// Client-credentials flow with client_secret_basic. +async fn run_client_credentials_basic( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let client_id = ctx + .client_id + .as_deref() + .unwrap_or("conformance-test-client"); + let client_secret = ctx + .client_secret + .as_deref() + .unwrap_or("conformance-test-secret"); + + let mut manager = AuthorizationManager::new(server_url).await?; + let metadata = manager.discover_metadata().await?; + let token_endpoint = metadata.token_endpoint.clone(); + manager.set_metadata(metadata); + + let http = reqwest::Client::new(); + let resp = http + .post(&token_endpoint) + .basic_auth(client_id, Some(client_secret)) + .header("content-type", "application/x-www-form-urlencoded") + .body("grant_type=client_credentials") + .send() + .await?; + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No access_token in response"))?; + + // Use static token + let transport = StreamableHttpClientTransport::with_client( + reqwest::Client::default(), + StreamableHttpClientTransportConfig::with_uri(server_url) + .auth_header(access_token.to_string()), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + client.cancel().await?; + Ok(()) +} + +/// Client-credentials flow with private_key_jwt (JWT assertion). +async fn run_client_credentials_jwt( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + let client_id = ctx + .client_id + .as_deref() + .unwrap_or("conformance-test-client"); + let _pem = ctx + .private_key_pem + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?; + let _alg = ctx + .signing_algorithm + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?; + + // Discover metadata to get token endpoint + let mut manager = AuthorizationManager::new(server_url).await?; + let metadata = manager.discover_metadata().await?; + let token_endpoint = metadata.token_endpoint.clone(); + manager.set_metadata(metadata); + + // Build JWT assertion + // Parse the PEM private key + let key = openssl_free_ec_sign(_pem, client_id, &token_endpoint)?; + + let http = reqwest::Client::new(); + let form_body = format!( + "grant_type=client_credentials&client_assertion_type={}&client_assertion={}", + urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"), + urlencoding::encode(&key), + ); + let resp = http + .post(&token_endpoint) + .header("content-type", "application/x-www-form-urlencoded") + .body(form_body) + .send() + .await?; + + let token_resp: serde_json::Value = resp.json().await?; + let access_token = token_resp["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No access_token: {}", token_resp))?; + + let transport = StreamableHttpClientTransport::with_client( + reqwest::Client::default(), + StreamableHttpClientTransportConfig::with_uri(server_url) + .auth_header(access_token.to_string()), + ); + + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await; + } + client.cancel().await?; + Ok(()) +} + +/// Minimal ES256 JWT signing without heavy deps. +/// We use ring or pure-Rust approach. For simplicity, use the p256 + base64 crates +/// that are already transitive deps of oauth2. +fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::Result { + use std::time::{SystemTime, UNIX_EPOCH}; + + // Decode PEM → DER + let pem_body = pem + .lines() + .filter(|l| !l.starts_with("-----")) + .collect::(); + let der = base64_decode(&pem_body)?; + + // Parse PKCS#8 DER to get the raw EC private key bytes + // PKCS#8 for EC P-256: the raw 32-byte key is at the end of the structure + let raw_key = extract_ec_private_key(&der)?; + + let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); + let header = base64url_encode(br#"{"alg":"ES256","typ":"JWT"}"#); + let payload_json = serde_json::json!({ + "iss": client_id, + "sub": client_id, + "aud": audience, + "iat": now, + "exp": now + 300, + "jti": format!("jti-{}", now), + }); + let payload = base64url_encode(payload_json.to_string().as_bytes()); + let signing_input = format!("{}.{}", header, payload); + + // Sign with p256 + let secret_key = p256::ecdsa::SigningKey::from_bytes(raw_key.as_slice().into()) + .map_err(|e| anyhow::anyhow!("Invalid EC key: {}", e))?; + use p256::ecdsa::signature::Signer; + let sig: p256::ecdsa::Signature = secret_key.sign(signing_input.as_bytes()); + let sig_bytes = sig.to_bytes(); + let sig_b64 = base64url_encode(&sig_bytes); + + Ok(format!("{}.{}", signing_input, sig_b64)) +} + +fn base64url_encode(data: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(data) +} + +fn base64_decode(s: &str) -> anyhow::Result> { + use base64::Engine; + Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?) +} + +/// Extract the raw 32-byte EC private key from a PKCS#8 DER blob. +fn extract_ec_private_key(der: &[u8]) -> anyhow::Result> { + // PKCS#8 wraps an ECPrivateKey. We look for the octet string containing + // the 32-byte private key. A simple heuristic: find 0x04 0x20 (OCTET STRING, len 32) + // followed by exactly 32 bytes that form the key. + // More robust: parse ASN.1. But for conformance testing this suffices. + for i in 0..der.len().saturating_sub(33) { + if der[i] == 0x04 && der[i + 1] == 0x20 && i + 34 <= der.len() { + return Ok(der[i + 2..i + 34].to_vec()); + } + } + Err(anyhow::anyhow!( + "Could not extract 32-byte EC private key from PKCS#8 DER" + )) +} + +/// Cross-app access flow (SEP-1046 extension). +async fn run_cross_app_access_client( + server_url: &str, + ctx: &ConformanceContext, +) -> anyhow::Result<()> { + // For now, fall back to standard auth flow + // The cross-app-access test is an extension scenario + run_auth_client(server_url, ctx).await +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +/// Fetch an authorization URL headlessly, returning (code, state). +async fn headless_authorize(auth_url: &str) -> anyhow::Result<(String, String)> { + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let resp = http.get(auth_url).send().await?; + let location = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| anyhow::anyhow!("No Location header in auth redirect"))?; + let redirect_url = url::Url::parse(location)?; + let code = redirect_url + .query_pairs() + .find(|(k, _)| k == "code") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No code in redirect URL"))?; + let state = redirect_url + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("No state in redirect URL"))?; + Ok((code, state)) +} + +/// Build arguments for a tool based on its input schema. +fn build_tool_arguments(tool: &Tool) -> Option> { + let schema = &tool.input_schema; + let properties = schema.get("properties").and_then(|p| p.as_object()); + let required = schema + .get("required") + .and_then(|r| r.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + + let Some(properties) = properties else { + return None; + }; + if properties.is_empty() && required.is_empty() { + return None; + } + + let mut args = serde_json::Map::new(); + for (name, prop_schema) in properties { + if !required.contains(name) { + continue; + } + let type_str = prop_schema.get("type").and_then(|t| t.as_str()); + let value = match type_str { + Some("number") => json!(1.0), + Some("integer") => json!(1), + Some("string") => json!("test"), + Some("boolean") => json!(true), + _ => json!(null), + }; + args.insert(name.clone(), value); + } + Some(args) +} + +// ─── Non-auth scenarios ───────────────────────────────────────────────────── + +async fn run_basic_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + tracing::debug!("Listed {} tools", tools.tools.len()); + client.cancel().await?; + Ok(()) +} + +async fn run_tools_call_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = FullClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + for tool in &tools.tools { + let args = build_tool_arguments(tool); + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: args, + task: None, + }) + .await?; + } + client.cancel().await?; + Ok(()) +} + +async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = ElicitationDefaultsClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + let test_tool = tools.tools.iter().find(|t| { + let n = t.name.as_ref(); + n == "test_client_elicitation_defaults" || n == "test_elicitation_sep1034_defaults" + }); + if let Some(tool) = test_tool { + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: None, + task: None, + }) + .await?; + } + client.cancel().await?; + Ok(()) +} + +async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> { + let transport = StreamableHttpClientTransport::from_uri(server_url); + let client = BasicClientHandler.serve(transport).await?; + let tools = client.list_tools(Default::default()).await?; + if let Some(tool) = tools + .tools + .iter() + .find(|t| t.name.as_ref() == "test_reconnection") + { + let _ = client + .call_tool(CallToolRequestParams { + meta: None, + name: tool.name.clone(), + arguments: None, + task: None, + }) + .await?; + } + client.cancel().await?; + Ok(()) +} + +// ─── Main ─────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let scenario = + std::env::var("MCP_CONFORMANCE_SCENARIO").unwrap_or_else(|_| "initialize".to_string()); + let server_url = std::env::args() + .nth(1) + .unwrap_or_else(|| "http://127.0.0.1:8001/mcp".to_string()); + let ctx = load_context(); + + tracing::info!("Running scenario '{}' against {}", scenario, server_url); + + match scenario.as_str() { + // Non-auth scenarios + "initialize" => run_basic_client(&server_url).await?, + "tools_call" => run_tools_call_client(&server_url).await?, + "elicitation-sep1034-client-defaults" => { + run_elicitation_defaults_client(&server_url).await? + } + "sse-retry" => run_sse_retry_client(&server_url).await?, + + // Auth scenarios - standard OAuth flow + "auth/metadata-default" + | "auth/metadata-var1" + | "auth/metadata-var2" + | "auth/metadata-var3" + | "auth/basic-cimd" + | "auth/scope-from-www-authenticate" + | "auth/scope-from-scopes-supported" + | "auth/scope-omitted-when-undefined" + | "auth/token-endpoint-auth-basic" + | "auth/token-endpoint-auth-post" + | "auth/token-endpoint-auth-none" + | "auth/2025-03-26-oauth-metadata-backcompat" + | "auth/2025-03-26-oauth-endpoint-fallback" => run_auth_client(&server_url, &ctx).await?, + + // Auth - scope step-up + "auth/scope-step-up" => run_auth_scope_step_up_client(&server_url, &ctx).await?, + + // Auth - scope retry limit + "auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?, + + // Auth - pre-registration + "auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?, + + // Auth - resource mismatch (should fail to auth → pass) + "auth/resource-mismatch" => { + // Try to auth; it should fail because PRM resource doesn't match + match run_auth_client(&server_url, &ctx).await { + Ok(_) => { + tracing::warn!("Auth succeeded despite resource mismatch!"); + } + Err(e) => { + tracing::info!("Auth correctly failed: {}", e); + } + } + } + + // Auth - client credentials + "auth/client-credentials-basic" => run_client_credentials_basic(&server_url, &ctx).await?, + "auth/client-credentials-jwt" => run_client_credentials_jwt(&server_url, &ctx).await?, + + // Auth - cross-app access + "auth/cross-app-access-complete-flow" => { + run_cross_app_access_client(&server_url, &ctx).await? + } + + _ => { + tracing::warn!("Unknown scenario '{}', trying auth flow", scenario); + match run_auth_client(&server_url, &ctx).await { + Ok(_) => {} + Err(e) => { + tracing::debug!("Auth flow failed for unknown scenario: {e}"); + run_basic_client(&server_url).await? + } + } + } + } + + Ok(()) +} diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs new file mode 100644 index 00000000..97bfbcdc --- /dev/null +++ b/conformance/src/bin/server.rs @@ -0,0 +1,959 @@ +use std::{collections::HashSet, future::Future, sync::Arc}; + +use rmcp::{ + ErrorData, RoleServer, ServerHandler, + model::*, + service::RequestContext, + transport::{ + StreamableHttpServerConfig, StreamableHttpService, + streamable_http_server::session::local::LocalSessionManager, + }, +}; +use serde_json::{Value, json}; +use tokio::sync::Mutex; +use tracing_subscriber::EnvFilter; + +// Small base64-encoded 1x1 red PNG +const TEST_IMAGE_DATA: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="; +// Small base64-encoded WAV (silence) +const TEST_AUDIO_DATA: &str = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA="; + +/// Helper to convert a serde_json::Value (must be an object) into a JsonObject +fn json_object(v: Value) -> JsonObject { + match v { + Value::Object(map) => map, + _ => panic!("Expected JSON object"), + } +} + +#[derive(Clone)] +struct ConformanceServer { + subscriptions: Arc>>, + log_level: Arc>, +} + +impl ConformanceServer { + fn new() -> Self { + Self { + subscriptions: Arc::new(Mutex::new(HashSet::new())), + log_level: Arc::new(Mutex::new(LoggingLevel::Debug)), + } + } +} + +impl ServerHandler for ConformanceServer { + fn initialize( + &self, + _request: InitializeRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(InitializeResult { + server_info: Implementation { + name: "rust-conformance-server".into(), + title: None, + version: "0.1.0".into(), + description: None, + icons: None, + website_url: None, + }, + capabilities: ServerCapabilities::builder() + .enable_prompts() + .enable_resources() + .enable_tools() + .enable_logging() + .build(), + instructions: Some("Rust MCP conformance test server".into()), + ..Default::default() + }) + } + } + + fn ping( + &self, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { Ok(()) } + } + + fn list_tools( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + let tools = vec![ + Tool::new( + "test_simple_text", + "Returns simple text content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_image_content", + "Returns image content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_audio_content", + "Returns audio content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_embedded_resource", + "Returns embedded resource content", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_multiple_content_types", + "Returns multiple content types", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_tool_with_logging", + "Sends logging notifications during execution", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_error_handling", + "Always returns an error", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_tool_with_progress", + "Reports progress notifications", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_sampling", + "Requests LLM sampling from client", + json_object(json!({ + "type": "object", + "properties": { + "prompt": { "type": "string", "description": "The prompt to send" } + }, + "required": ["prompt"] + })), + ), + Tool::new( + "test_elicitation", + "Requests user input from client", + json_object(json!({ + "type": "object", + "properties": { + "message": { "type": "string", "description": "The message to show" } + }, + "required": ["message"] + })), + ), + Tool::new( + "test_elicitation_sep1034_defaults", + "Tests elicitation with default values (SEP-1034)", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "test_elicitation_sep1330_enums", + "Tests enum schema improvements (SEP-1330)", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + Tool::new( + "json_schema_2020_12_tool", + "Tool with JSON Schema 2020-12 features", + json_object(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "type": "object", + "properties": { + "street": { "type": "string" }, + "city": { "type": "string" } + } + } + }, + "properties": { + "name": { "type": "string" }, + "address": { "$ref": "#/$defs/address" } + }, + "additionalProperties": false + })), + ), + Tool::new( + "test_reconnection", + "Tests SSE reconnection behavior", + json_object(json!({ + "type": "object", + "properties": {} + })), + ), + ]; + Ok(ListToolsResult { + meta: None, + tools, + next_cursor: None, + }) + } + } + + fn call_tool( + &self, + request: CallToolRequestParams, + cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let args = request.arguments.unwrap_or_default(); + match request.name.as_ref() { + "test_simple_text" => Ok(CallToolResult { + content: vec![Content::text("This is a simple text response for testing.")], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_image_content" => Ok(CallToolResult { + content: vec![Content::image(TEST_IMAGE_DATA, "image/png")], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_audio_content" => { + // No Content::audio() helper, construct manually + let audio = RawContent::Audio(RawAudioContent { + data: TEST_AUDIO_DATA.into(), + mime_type: "audio/wav".into(), + }) + .no_annotation(); + Ok(CallToolResult { + content: vec![audio], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_embedded_resource" => Ok(CallToolResult { + content: vec![Content::resource(ResourceContents::TextResourceContents { + uri: "test://embedded-resource".into(), + mime_type: Some("text/plain".into()), + text: "This is an embedded resource content.".into(), + meta: None, + })], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_multiple_content_types" => Ok(CallToolResult { + content: vec![ + Content::text("Multiple content types test:"), + Content::image(TEST_IMAGE_DATA, "image/png"), + Content::resource(ResourceContents::TextResourceContents { + uri: "test://mixed-content-resource".into(), + mime_type: Some("application/json".into()), + text: r#"{"test":"data","value":123}"#.into(), + meta: None, + }), + ], + structured_content: None, + is_error: None, + meta: None, + }), + + "test_tool_with_logging" => { + for msg in [ + "Tool execution started", + "Tool processing data", + "Tool execution completed", + ] { + let _ = cx + .peer + .notify_logging_message(LoggingMessageNotificationParam { + level: LoggingLevel::Info, + logger: Some("conformance-server".into()), + data: json!(msg), + }) + .await; + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + Ok(CallToolResult { + content: vec![Content::text("Logging test completed")], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_error_handling" => Ok(CallToolResult { + content: vec![Content::text( + "This tool intentionally returns an error for testing", + )], + structured_content: None, + is_error: Some(true), + meta: None, + }), + + "test_tool_with_progress" => { + let progress_token = cx.meta.get_progress_token(); + + for (progress, message) in + [(0.0, "Starting"), (50.0, "Halfway"), (100.0, "Complete")] + { + if let Some(token) = &progress_token { + let _ = cx + .peer + .notify_progress(ProgressNotificationParam { + progress_token: token.clone(), + progress, + total: Some(100.0), + message: Some(message.into()), + }) + .await; + } + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + Ok(CallToolResult { + content: vec![Content::text("Progress test completed")], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_sampling" => { + let prompt = args + .get("prompt") + .and_then(|v| v.as_str()) + .unwrap_or("Hello"); + + match cx + .peer + .create_message(CreateMessageRequestParams { + meta: None, + task: None, + messages: vec![SamplingMessage::user_text(prompt)], + max_tokens: 100, + model_preferences: None, + system_prompt: None, + include_context: None, + temperature: None, + stop_sequences: None, + metadata: None, + tools: None, + tool_choice: None, + }) + .await + { + Ok(result) => { + let text = result + .message + .content + .first() + .and_then(|c| c.as_text()) + .map(|t| t.text.clone()) + .unwrap_or_else(|| "No text response".into()); + Ok(CallToolResult { + content: vec![Content::text(format!("LLM response: {}", text))], + structured_content: None, + is_error: None, + meta: None, + }) + } + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Sampling error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "test_elicitation" => { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Please provide your information"); + + let schema_json = json!({ + "type": "object", + "properties": { + "username": { + "type": "string", + "description": "User's response" + }, + "email": { + "type": "string", + "description": "User's email address" + } + }, + "required": ["username", "email"] + }); + + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: message.into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult { + content: vec![Content::text(format!( + "User response: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))], + structured_content: None, + is_error: None, + meta: None, + }), + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Elicitation error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "test_elicitation_sep1034_defaults" => { + let schema_json = json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "User's name", + "default": "John Doe" + }, + "age": { + "type": "integer", + "description": "User's age", + "default": 30 + }, + "score": { + "type": "number", + "description": "User's score", + "default": 95.5 + }, + "status": { + "type": "string", + "description": "User's status", + "enum": ["active", "inactive", "pending"], + "default": "active" + }, + "verified": { + "type": "boolean", + "description": "Whether user is verified", + "default": true + } + } + }); + + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Please provide values (all have defaults)".into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult { + content: vec![Content::text(format!( + "Elicitation completed: action={}, content={:?}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + }, + result.content + ))], + structured_content: None, + is_error: None, + meta: None, + }), + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Elicitation error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "test_elicitation_sep1330_enums" => { + let schema_json = json!({ + "type": "object", + "properties": { + "untitledSingle": { + "type": "string", + "enum": ["option1", "option2", "option3"] + }, + "titledSingle": { + "type": "string", + "oneOf": [ + { "const": "value1", "title": "First Option" }, + { "const": "value2", "title": "Second Option" }, + { "const": "value3", "title": "Third Option" } + ] + }, + "legacyEnum": { + "type": "string", + "enum": ["opt1", "opt2", "opt3"], + "enumNames": ["Option One", "Option Two", "Option Three"] + }, + "untitledMulti": { + "type": "array", + "items": { + "type": "string", + "enum": ["option1", "option2", "option3"] + } + }, + "titledMulti": { + "type": "array", + "items": { + "anyOf": [ + { "const": "value1", "title": "First Choice" }, + { "const": "value2", "title": "Second Choice" }, + { "const": "value3", "title": "Third Choice" } + ] + } + } + } + }); + + let schema: ElicitationSchema = serde_json::from_value(schema_json).unwrap(); + + match cx + .peer + .create_elicitation(CreateElicitationRequestParams::FormElicitationParams { + meta: None, + message: "Test enum schema improvements".into(), + requested_schema: schema, + }) + .await + { + Ok(result) => Ok(CallToolResult { + content: vec![Content::text(format!( + "Enum elicitation completed: action={}", + match result.action { + ElicitationAction::Accept => "accept", + ElicitationAction::Decline => "decline", + ElicitationAction::Cancel => "cancel", + } + ))], + structured_content: None, + is_error: None, + meta: None, + }), + Err(e) => Ok(CallToolResult { + content: vec![Content::text(format!("Elicitation error: {}", e))], + structured_content: None, + is_error: Some(true), + meta: None, + }), + } + } + + "json_schema_2020_12_tool" => { + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world"); + Ok(CallToolResult { + content: vec![Content::text(format!("Hello, {}!", name))], + structured_content: None, + is_error: None, + meta: None, + }) + } + + "test_reconnection" => { + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + Ok(CallToolResult { + content: vec![Content::text("Reconnection test completed")], + structured_content: None, + is_error: None, + meta: None, + }) + } + + _ => Err(ErrorData::invalid_params( + format!("Unknown tool: {}", request.name), + None, + )), + } + } + } + + fn list_resources( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(ListResourcesResult { + meta: None, + resources: vec![ + RawResource { + uri: "test://static-text".into(), + name: "Static Text Resource".into(), + title: None, + description: Some("A static text resource for testing".into()), + mime_type: Some("text/plain".into()), + size: None, + icons: None, + meta: None, + } + .no_annotation(), + RawResource { + uri: "test://static-binary".into(), + name: "Static Binary Resource".into(), + title: None, + description: Some("A static binary/blob resource for testing".into()), + mime_type: Some("image/png".into()), + size: None, + icons: None, + meta: None, + } + .no_annotation(), + ], + next_cursor: None, + }) + } + } + + fn read_resource( + &self, + request: ReadResourceRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let uri = request.uri.as_str(); + match uri { + "test://static-text" => Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("text/plain".into()), + text: "This is the content of the static text resource.".into(), + meta: None, + }], + }), + "test://static-binary" => Ok(ReadResourceResult { + contents: vec![ResourceContents::BlobResourceContents { + uri: uri.into(), + mime_type: Some("image/png".into()), + blob: TEST_IMAGE_DATA.into(), + meta: None, + }], + }), + _ => { + // Check if it matches template: test://template/{id}/data + if uri.starts_with("test://template/") && uri.ends_with("/data") { + let id = uri + .strip_prefix("test://template/") + .and_then(|s| s.strip_suffix("/data")) + .unwrap_or("unknown"); + Ok(ReadResourceResult { + contents: vec![ResourceContents::TextResourceContents { + uri: uri.into(), + mime_type: Some("application/json".into()), + text: format!( + r#"{{"id":"{}","templateTest":true,"data":"Data for ID: {}"}}"#, + id, id + ), + meta: None, + }], + }) + } else { + Err(ErrorData::resource_not_found( + format!("Resource not found: {}", uri), + None, + )) + } + } + } + } + } + + fn list_resource_templates( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(ListResourceTemplatesResult { + meta: None, + resource_templates: vec![ + RawResourceTemplate { + uri_template: "test://template/{id}/data".into(), + name: "Dynamic Resource".into(), + title: None, + description: Some("A dynamic resource with parameter substitution".into()), + mime_type: Some("application/json".into()), + icons: None, + } + .no_annotation(), + ], + next_cursor: None, + }) + } + } + + fn subscribe( + &self, + request: SubscribeRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let mut subs = self.subscriptions.lock().await; + subs.insert(request.uri.to_string()); + Ok(()) + } + } + + fn unsubscribe( + &self, + request: UnsubscribeRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let mut subs = self.subscriptions.lock().await; + subs.remove(request.uri.as_str()); + Ok(()) + } + } + + fn list_prompts( + &self, + _request: Option, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async { + Ok(ListPromptsResult { + meta: None, + prompts: vec![ + Prompt::new( + "test_simple_prompt", + Some("A simple test prompt with no arguments"), + None, + ), + Prompt::new( + "test_prompt_with_arguments", + Some("A test prompt that accepts arguments"), + Some(vec![ + PromptArgument { + name: "name".into(), + title: None, + description: Some("The name to greet".into()), + required: Some(true), + }, + PromptArgument { + name: "style".into(), + title: None, + description: Some("The greeting style".into()), + required: Some(false), + }, + ]), + ), + Prompt::new( + "test_prompt_with_embedded_resource", + Some("A test prompt that includes an embedded resource"), + None, + ), + Prompt::new( + "test_prompt_with_image", + Some("A test prompt that includes an image"), + None, + ), + ], + next_cursor: None, + }) + } + } + + fn get_prompt( + &self, + request: GetPromptRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + match request.name.as_str() { + "test_simple_prompt" => Ok(GetPromptResult { + description: Some("A simple test prompt".into()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + "This is a simple test prompt.", + )], + }), + "test_prompt_with_arguments" => { + let args = request.arguments.unwrap_or_default(); + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("World"); + let style = args + .get("style") + .and_then(|v| v.as_str()) + .unwrap_or("friendly"); + Ok(GetPromptResult { + description: Some("A prompt with arguments".into()), + messages: vec![PromptMessage::new_text( + PromptMessageRole::User, + format!("Please greet {} in a {} style.", name, style), + )], + }) + } + "test_prompt_with_embedded_resource" => Ok(GetPromptResult { + description: Some("A prompt with an embedded resource".into()), + messages: vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"), + PromptMessage::new_resource( + PromptMessageRole::User, + "test://static-text".into(), + Some("text/plain".into()), + Some("Resource content for prompt".into()), + None, + None, + None, + ), + ], + }), + "test_prompt_with_image" => { + let image_content = RawImageContent { + data: TEST_IMAGE_DATA.into(), + mime_type: "image/png".into(), + meta: None, + }; + Ok(GetPromptResult { + description: Some("A prompt with an image".into()), + messages: vec![ + PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"), + PromptMessage { + role: PromptMessageRole::User, + content: PromptMessageContent::Image { + image: image_content.no_annotation(), + }, + }, + ], + }) + } + _ => Err(ErrorData::invalid_params( + format!("Unknown prompt: {}", request.name), + None, + )), + } + } + } + + fn complete( + &self, + request: CompleteRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let values = match &request.r#ref { + Reference::Resource(_) => { + if request.argument.name == "id" { + vec!["1".into(), "2".into(), "3".into()] + } else { + vec![] + } + } + Reference::Prompt(prompt_ref) => { + if request.argument.name == "name" { + vec!["Alice".into(), "Bob".into(), "Charlie".into()] + } else if request.argument.name == "style" { + vec!["friendly".into(), "formal".into(), "casual".into()] + } else { + vec![prompt_ref.name.clone()] + } + } + }; + Ok(CompleteResult { + completion: CompletionInfo::new(values) + .map_err(|e| ErrorData::internal_error(e, None))?, + }) + } + } + + fn set_level( + &self, + request: SetLevelRequestParams, + _cx: RequestContext, + ) -> impl Future> + Send + '_ { + async move { + let mut level = self.log_level.lock().await; + *level = request.level; + Ok(()) + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into())) + .init(); + + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8001); + + let bind_addr = format!("127.0.0.1:{}", port); + tracing::info!("Starting conformance server on {}", bind_addr); + + let server = ConformanceServer::new(); + let config = StreamableHttpServerConfig { + stateful_mode: true, + ..Default::default() + }; + let service = StreamableHttpService::new( + move || Ok(server.clone()), + LocalSessionManager::default().into(), + config, + ); + + let router = axum::Router::new().nest_service("/mcp", service); + + let listener = tokio::net::TcpListener::bind(&bind_addr).await?; + tracing::info!("Conformance server listening on http://{}/mcp", bind_addr); + axum::serve(listener, router).await?; + + Ok(()) +} diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index a72301b0..b358f523 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -153,8 +153,7 @@ impl ProtocolVersion { pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05")); - // Keep LATEST at 2025-03-26 until full 2025-06-18 compliance and automated testing are in place. - pub const LATEST: Self = Self::V_2025_03_26; + pub const LATEST: Self = Self::V_2025_06_18; /// All protocol versions known to this SDK. pub const KNOWN_VERSIONS: &[Self] = diff --git a/crates/rmcp/src/transport/common/client_side_sse.rs b/crates/rmcp/src/transport/common/client_side_sse.rs index 4e01994f..b826b12d 100644 --- a/crates/rmcp/src/transport/common/client_side_sse.rs +++ b/crates/rmcp/src/transport/common/client_side_sse.rs @@ -255,8 +255,23 @@ where } } None => { - tracing::debug!("sse stream terminated"); - return Poll::Ready(None); + // Per SEP-1699, a graceful stream close is + // reconnectable. If the server sent a `retry` field + // we MUST wait that long before reconnecting. + let interval = this + .server_retry_interval + .take() + .or_else(|| this.retry_policy.retry(0)); + if let Some(interval) = interval { + tracing::debug!(?interval, "sse stream ended gracefully, reconnecting"); + SseAutoReconnectStreamState::WaitingNextRetry { + sleep: tokio::time::sleep(interval), + retry_times: 0, + } + } else { + tracing::debug!("sse stream terminated, no reconnect policy"); + return Poll::Ready(None); + } } } } diff --git a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs index 5a39b4a4..ae70f72f 100644 --- a/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs +++ b/crates/rmcp/src/transport/common/reqwest/streamable_http_client.rs @@ -197,8 +197,18 @@ impl StreamableHttpClient for reqwest::Client { Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) } Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { - let message: ServerJsonRpcMessage = response.json().await?; - Ok(StreamableHttpPostResponse::Json(message, session_id)) + // Try to parse as a valid JSON-RPC message. If the body is + // malformed (e.g. a 200 response to a notification that lacks + // an `id` field), treat it as accepted rather than failing. + match response.json::().await { + Ok(message) => Ok(StreamableHttpPostResponse::Json(message, session_id)), + Err(e) => { + tracing::warn!( + "could not parse JSON response as ServerJsonRpcMessage, treating as accepted: {e}" + ); + Ok(StreamableHttpPostResponse::Accepted) + } + } } _ => { // unexpected content type diff --git a/crates/rmcp/src/transport/streamable_http_client.rs b/crates/rmcp/src/transport/streamable_http_client.rs index 1c388e50..779dfe1c 100644 --- a/crates/rmcp/src/transport/streamable_http_client.rs +++ b/crates/rmcp/src/transport/streamable_http_client.rs @@ -154,14 +154,16 @@ impl StreamableHttpPostResponse { } } - pub fn expect_accepted(self) -> Result<(), StreamableHttpError> + pub fn expect_accepted_or_json(self) -> Result<(), StreamableHttpError> where E: std::error::Error + Send + Sync + 'static, { match self { Self::Accepted => Ok(()), + // Tolerate servers that return 200 with JSON for notifications + Self::Json(..) => Ok(()), got => Err(StreamableHttpError::UnexpectedServerResponse( - format!("expect accepted, got {got:?}").into(), + format!("expect accepted or json, got {got:?}").into(), )), } } @@ -410,7 +412,7 @@ impl Worker for StreamableHttpClientWorker { .map_err(WorkerQuitReason::fatal_context( "send initialized notification", ))? - .expect_accepted::() + .expect_accepted_or_json::() .map_err(WorkerQuitReason::fatal_context( "process initialized notification response", ))?;