feat: mcp sdk conformance (#687)
* 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>
This commit is contained in:
parent
b967c132ae
commit
a7e4ae3203
12 changed files with 2392 additions and 10 deletions
4
.github/codeql/codeql-config.yml
vendored
Normal file
4
.github/codeql/codeql-config.yml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
name: "CodeQL config"
|
||||||
|
|
||||||
|
paths-ignore:
|
||||||
|
- conformance
|
||||||
36
.github/workflows/codeql.yml
vendored
Normal file
36
.github/workflows/codeql.yml
vendored
Normal file
|
|
@ -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
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[workspace]
|
||||||
members = ["crates/rmcp", "crates/rmcp-macros", "examples/*"]
|
members = ["crates/rmcp", "crates/rmcp-macros", "examples/*", "conformance"]
|
||||||
default-members = ["crates/rmcp", "crates/rmcp-macros"]
|
default-members = ["crates/rmcp", "crates/rmcp-macros"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
|
|
|
||||||
35
conformance/Cargo.toml
Normal file
35
conformance/Cargo.toml
Normal file
|
|
@ -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"
|
||||||
292
conformance/results/2026-02-25-rust-sdk-assessment.md
Normal file
292
conformance/results/2026-02-25-rust-sdk-assessment.md
Normal file
|
|
@ -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 |
|
||||||
43
conformance/results/2026-02-25-rust-sdk-remediation.md
Normal file
43
conformance/results/2026-02-25-rust-sdk-remediation.md
Normal file
|
|
@ -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.
|
||||||
987
conformance/src/bin/client.rs
Normal file
987
conformance/src/bin/client.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
// pre-registration / client-credentials-basic
|
||||||
|
#[serde(default)]
|
||||||
|
client_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
client_secret: Option<String>,
|
||||||
|
// client-credentials-jwt
|
||||||
|
#[serde(default)]
|
||||||
|
private_key_pem: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
signing_algorithm: Option<String>,
|
||||||
|
// cross-app-access
|
||||||
|
#[serde(default)]
|
||||||
|
idp_client_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
idp_id_token: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
idp_issuer: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
idp_token_endpoint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RoleClient>,
|
||||||
|
) -> impl Future<Output = Result<CreateElicitationResult, ErrorData>> + 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<RoleClient>,
|
||||||
|
) -> impl Future<Output = Result<CreateMessageResult, ErrorData>> + 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<RoleClient>,
|
||||||
|
) -> impl Future<Output = Result<CreateElicitationResult, ErrorData>> + 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<AuthClient<reqwest::Client>> {
|
||||||
|
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<AuthClient<reqwest::Client>> {
|
||||||
|
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<String> {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
// Decode PEM → DER
|
||||||
|
let pem_body = pem
|
||||||
|
.lines()
|
||||||
|
.filter(|l| !l.starts_with("-----"))
|
||||||
|
.collect::<String>();
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
// 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<serde_json::Map<String, Value>> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.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(())
|
||||||
|
}
|
||||||
959
conformance/src/bin/server.rs
Normal file
959
conformance/src/bin/server.rs
Normal file
|
|
@ -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<Mutex<HashSet<String>>>,
|
||||||
|
log_level: Arc<Mutex<LoggingLevel>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<InitializeResult, ErrorData>> + 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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<(), ErrorData>> + Send + '_ {
|
||||||
|
async { Ok(()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_tools(
|
||||||
|
&self,
|
||||||
|
_request: Option<PaginatedRequestParams>,
|
||||||
|
_cx: RequestContext<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<ListToolsResult, ErrorData>> + 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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<CallToolResult, ErrorData>> + 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<PaginatedRequestParams>,
|
||||||
|
_cx: RequestContext<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<ListResourcesResult, ErrorData>> + 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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<ReadResourceResult, ErrorData>> + 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<PaginatedRequestParams>,
|
||||||
|
_cx: RequestContext<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<ListResourceTemplatesResult, ErrorData>> + 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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<(), ErrorData>> + Send + '_ {
|
||||||
|
async move {
|
||||||
|
let mut subs = self.subscriptions.lock().await;
|
||||||
|
subs.insert(request.uri.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unsubscribe(
|
||||||
|
&self,
|
||||||
|
request: UnsubscribeRequestParams,
|
||||||
|
_cx: RequestContext<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<(), ErrorData>> + Send + '_ {
|
||||||
|
async move {
|
||||||
|
let mut subs = self.subscriptions.lock().await;
|
||||||
|
subs.remove(request.uri.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_prompts(
|
||||||
|
&self,
|
||||||
|
_request: Option<PaginatedRequestParams>,
|
||||||
|
_cx: RequestContext<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<ListPromptsResult, ErrorData>> + 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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<GetPromptResult, ErrorData>> + 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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<CompleteResult, ErrorData>> + 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<RoleServer>,
|
||||||
|
) -> impl Future<Output = Result<(), ErrorData>> + 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(())
|
||||||
|
}
|
||||||
|
|
@ -153,8 +153,7 @@ impl ProtocolVersion {
|
||||||
pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18"));
|
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_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26"));
|
||||||
pub const V_2024_11_05: Self = Self(Cow::Borrowed("2024-11-05"));
|
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_06_18;
|
||||||
pub const LATEST: Self = Self::V_2025_03_26;
|
|
||||||
|
|
||||||
/// All protocol versions known to this SDK.
|
/// All protocol versions known to this SDK.
|
||||||
pub const KNOWN_VERSIONS: &[Self] =
|
pub const KNOWN_VERSIONS: &[Self] =
|
||||||
|
|
|
||||||
|
|
@ -255,8 +255,23 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
tracing::debug!("sse stream terminated");
|
// Per SEP-1699, a graceful stream close is
|
||||||
return Poll::Ready(None);
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -197,8 +197,18 @@ impl StreamableHttpClient for reqwest::Client {
|
||||||
Ok(StreamableHttpPostResponse::Sse(event_stream, session_id))
|
Ok(StreamableHttpPostResponse::Sse(event_stream, session_id))
|
||||||
}
|
}
|
||||||
Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => {
|
Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => {
|
||||||
let message: ServerJsonRpcMessage = response.json().await?;
|
// Try to parse as a valid JSON-RPC message. If the body is
|
||||||
Ok(StreamableHttpPostResponse::Json(message, session_id))
|
// malformed (e.g. a 200 response to a notification that lacks
|
||||||
|
// an `id` field), treat it as accepted rather than failing.
|
||||||
|
match response.json::<ServerJsonRpcMessage>().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
|
// unexpected content type
|
||||||
|
|
|
||||||
|
|
@ -154,14 +154,16 @@ impl StreamableHttpPostResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn expect_accepted<E>(self) -> Result<(), StreamableHttpError<E>>
|
pub fn expect_accepted_or_json<E>(self) -> Result<(), StreamableHttpError<E>>
|
||||||
where
|
where
|
||||||
E: std::error::Error + Send + Sync + 'static,
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
match self {
|
match self {
|
||||||
Self::Accepted => Ok(()),
|
Self::Accepted => Ok(()),
|
||||||
|
// Tolerate servers that return 200 with JSON for notifications
|
||||||
|
Self::Json(..) => Ok(()),
|
||||||
got => Err(StreamableHttpError::UnexpectedServerResponse(
|
got => Err(StreamableHttpError::UnexpectedServerResponse(
|
||||||
format!("expect accepted, got {got:?}").into(),
|
format!("expect accepted or json, got {got:?}").into(),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -410,7 +412,7 @@ impl<C: StreamableHttpClient> Worker for StreamableHttpClientWorker<C> {
|
||||||
.map_err(WorkerQuitReason::fatal_context(
|
.map_err(WorkerQuitReason::fatal_context(
|
||||||
"send initialized notification",
|
"send initialized notification",
|
||||||
))?
|
))?
|
||||||
.expect_accepted::<C::Error>()
|
.expect_accepted_or_json::<C::Error>()
|
||||||
.map_err(WorkerQuitReason::fatal_context(
|
.map_err(WorkerQuitReason::fatal_context(
|
||||||
"process initialized notification response",
|
"process initialized notification response",
|
||||||
))?;
|
))?;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue