Compare commits

..
186 changed files with 8164 additions and 41907 deletions

View file

@ -16,15 +16,11 @@ updates:
- package-ecosystem: "github-actions" - package-ecosystem: "github-actions"
directory: "/" directory: "/"
schedule: schedule:
interval: "weekly" interval: "daily"
labels: labels:
# Mark PRs as CI related change. # Mark PRs as CI related change.
- T-CI - T-CI
open-pull-requests-limit: 3 open-pull-requests-limit: 3
groups:
codeql-action:
patterns:
- "github/codeql-action*"
commit-message: commit-message:
prefix: "chore" prefix: "chore"
include: "scope" include: "scope"

View file

@ -3,9 +3,6 @@ on:
# Runs workflow when activity on a PR in the workflow's repository occurs. # Runs workflow when activity on a PR in the workflow's repository occurs.
pull_request_target: pull_request_target:
permissions:
contents: read
jobs: jobs:
auto-label: auto-label:
permissions: permissions:
@ -23,7 +20,7 @@ jobs:
PR_URL: ${{ github.event.pull_request.html_url }} PR_URL: ${{ github.event.pull_request.html_url }}
steps: steps:
- uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7 - uses: actions/labeler@v6
with: with:
# Auto-include paths starting with dot (e.g. .github) # Auto-include paths starting with dot (e.g. .github)
dot: true dot: true

View file

@ -12,35 +12,34 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
ARTIFACT_DIR: release-artifacts ARTIFACT_DIR: release-artifacts
permissions:
contents: read
jobs: jobs:
commit-lint: commit-lint:
name: Lint Commit Messages name: Lint Commit Messages
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 uses: actions/setup-node@v6
with: with:
node-version: '22' node-version: '22'
- name: Install commitlint - name: Install commitlint
run: npm install --global @commitlint/cli@20.4.3 @commitlint/config-conventional@20.4.3 run: |
npm install --save-dev @commitlint/cli @commitlint/config-conventional
echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js
- name: Lint commit messages - name: Lint commit messages
run: commitlint --extends @commitlint/config-conventional --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
fmt: fmt:
name: Code Formatting name: Code Formatting
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Install Rust fmt - name: Install Rust fmt
run: rustup toolchain install nightly --component rustfmt run: rustup toolchain install nightly --component rustfmt
@ -52,40 +51,32 @@ jobs:
name: Lint with Clippy name: Lint with Clippy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Run clippy (all features) - name: Run clippy
run: cargo clippy --all-targets --all-features -- -D warnings run: cargo clippy --all-targets --all-features -- -D warnings
- name: Run clippy (all features except local)
run: |
FEATURES=$(cargo metadata --no-deps --format-version 1 \
| jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[]
| select(startswith("__") | not)
| select(. != "local")] | join(",")')
cargo clippy --package rmcp --all-targets --no-default-features --features "$FEATURES" -- -D warnings
semver: semver:
name: SemVer Check name: SemVer Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Install cargo-semver-checks - name: Install cargo-semver-checks
uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 uses: taiki-e/install-action@v2
with: with:
tool: cargo-semver-checks tool: cargo-semver-checks
@ -126,7 +117,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
@ -134,12 +125,12 @@ jobs:
# to be installed (it does not need to be the default; the tool invokes it # to be installed (it does not need to be the default; the tool invokes it
# via `cargo +nightly`). # via `cargo +nightly`).
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@7c8d7d138f5c09cef361f8214cf96882cd029cdb # nightly uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Install cargo-public-api - name: Install cargo-public-api
uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 uses: taiki-e/install-action@v2
with: with:
tool: cargo-public-api tool: cargo-public-api
@ -179,24 +170,10 @@ jobs:
- name: Check rmcp (all features except local) - name: Check rmcp (all features except local)
run: | run: |
BASE_SHA=${{ github.event.pull_request.base.sha }} FEATURES=$(cargo metadata --no-deps --format-version 1 \
# `cargo public-api diff` builds both revisions with the same feature | jq -r '[.packages[] | select(.name == "rmcp") | .features | keys[]
# set, so a feature that exists on only one side (e.g. a feature added | select(startswith("__") | not)
# or removed by this PR) would fail to build the other revision. Diff | select(. != "local")] | join(",")')
# only the features present in BOTH the base and the head; features
# unique to one side are necessarily pure additions/removals, which the
# release-type deny flags already govern.
list_features() {
cargo metadata --no-deps --format-version 1 --manifest-path "$1/Cargo.toml" \
| jq -r '.packages[] | select(.name == "rmcp") | .features | keys[]
| select(startswith("__") | not)
| select(. != "local")'
}
list_features "." | sort -u > "$RUNNER_TEMP/head_features"
git worktree add --detach "$RUNNER_TEMP/rmcp-base" "$BASE_SHA"
list_features "$RUNNER_TEMP/rmcp-base" | sort -u > "$RUNNER_TEMP/base_features"
git worktree remove --force "$RUNNER_TEMP/rmcp-base"
FEATURES=$(comm -12 "$RUNNER_TEMP/head_features" "$RUNNER_TEMP/base_features" | paste -sd, -)
cargo public-api \ cargo public-api \
--package rmcp \ --package rmcp \
--features "$FEATURES" \ --features "$FEATURES" \
@ -204,57 +181,33 @@ jobs:
diff \ diff \
$DENY \ $DENY \
--force \ --force \
"$BASE_SHA"..${{ github.sha }} ${{ github.event.pull_request.base.sha }}..${{ github.sha }}
spelling: spelling:
name: spell check with typos name: spell check with typos
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b # master uses: crate-ci/typos@master
msrv:
name: Check MSRV
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Read MSRV from Cargo.toml
run: |
MSRV=$(cargo metadata --no-deps --format-version 1 \
| jq -r '.packages[] | select(.name == "rmcp") | .rust_version')
echo "MSRV=$MSRV" >> "$GITHUB_ENV"
- name: Install MSRV Rust
uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
with:
toolchain: ${{ env.MSRV }}
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- name: Check default workspace members with MSRV
run: cargo +${{ env.MSRV }} check --all-targets --all-features
test: test:
name: Run Tests name: Run Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
# install nodejs # install nodejs
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 uses: actions/setup-node@v6
with: with:
node-version: '22' node-version: '22'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
- name: Set up Python - name: Set up Python
run: uv python install run: uv python install
@ -262,7 +215,7 @@ jobs:
- name: Create venv for python - name: Create venv for python
run: uv venv run: uv venv
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Run tests - name: Run tests
run: cargo test --all-features run: cargo test --all-features
@ -271,19 +224,19 @@ jobs:
name: Run Tests (no local feature) name: Run Tests (no local feature)
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
# install nodejs # install nodejs
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 uses: actions/setup-node@v6
with: with:
node-version: '22' node-version: '22'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
- name: Set up Python - name: Set up Python
run: uv python install run: uv python install
@ -291,7 +244,7 @@ jobs:
- name: Create venv for python - name: Create venv for python
run: uv venv run: uv venv
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Run tests without local feature - name: Run tests without local feature
run: | run: |
@ -305,21 +258,21 @@ jobs:
name: Code Coverage name: Code Coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: write
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
# install nodejs # install nodejs
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 uses: actions/setup-node@v6
with: with:
node-version: '22' node-version: '22'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
- name: Set up Python - name: Set up Python
run: uv python install run: uv python install
@ -327,7 +280,7 @@ jobs:
- name: Create venv for python - name: Create venv for python
run: uv venv run: uv venv
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Install cargo-llvm-cov - name: Install cargo-llvm-cov
run: cargo install cargo-llvm-cov run: cargo install cargo-llvm-cov
@ -342,19 +295,19 @@ jobs:
name: Example test name: Example test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
# install nodejs # install nodejs
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 uses: actions/setup-node@v6
with: with:
node-version: '22' node-version: '22'
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0 uses: astral-sh/setup-uv@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
- name: Set up Python - name: Set up Python
run: uv python install run: uv python install
@ -362,7 +315,7 @@ jobs:
- name: Create venv for python - name: Create venv for python
run: uv venv run: uv venv
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Add target WASI preview 2 - name: Add target WASI preview 2
run: | run: |
@ -398,12 +351,12 @@ jobs:
name: Security Audit name: Security Audit
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Install cargo-audit - name: Install cargo-audit
run: cargo install cargo-audit run: cargo install cargo-audit
@ -415,12 +368,12 @@ jobs:
name: Generate Documentation name: Generate Documentation
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@7c8d7d138f5c09cef361f8214cf96882cd029cdb # nightly uses: dtolnay/rust-toolchain@nightly
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - uses: Swatinem/rust-cache@v2
- name: Generate documentation - name: Generate documentation
run: | run: |
@ -441,7 +394,7 @@ jobs:
# This happened recently in the attack on `tj-actions/changed-files`, but # This happened recently in the attack on `tj-actions/changed-files`, but
# has happened many times before as well. # has happened many times before as well.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Update Rust - name: Update Rust
run: | run: |

View file

@ -8,9 +8,6 @@ on:
schedule: schedule:
- cron: '0 0 * * 1' # Weekly on Monday - cron: '0 0 * * 1' # Weekly on Monday
permissions:
contents: read
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze
@ -24,16 +21,16 @@ jobs:
language: [rust, javascript-typescript, python, actions] language: [rust, javascript-typescript, python, actions]
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v7
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/init@v4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml config-file: ./.github/codeql/codeql-config.yml
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/analyze@v4

View file

@ -1,149 +0,0 @@
name: Conformance
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: conformance-${{ github.ref }}
cancel-in-progress: true
env:
CONFORMANCE_VERSION: "0.2.0-alpha.10"
permissions:
contents: read
jobs:
server:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- name: Build conformance binaries
run: cargo build -p mcp-conformance
- name: Test conformance server
run: cargo test -p mcp-conformance --bin conformance-server
- name: Start conformance server
run: |
PORT=8001 ./target/debug/conformance-server &
echo $! > server.pid
for _ in $(seq 1 30); do
if curl -s -o /dev/null http://127.0.0.1:8001/mcp; then
exit 0
fi
sleep 1
done
echo "conformance server did not become ready" >&2
exit 1
- name: Run 2025-11-25 server suite
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
--url http://127.0.0.1:8001/mcp \
--suite all \
--spec-version 2025-11-25 \
-o conformance-results/2025-11-25
- name: Run 2026-07-28 server suite
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
--url http://127.0.0.1:8001/mcp \
--suite all \
--spec-version 2026-07-28 \
-o conformance-results/2026-07-28
- name: Run extension server scenarios (informational)
run: |
scenarios=(
tasks-lifecycle
tasks-capability-negotiation
tasks-wire-fields
tasks-request-state-removal
tasks-mrtr-input
tasks-request-headers
tasks-dispatch-and-envelope
tasks-status-notifications
tasks-required-task-error
tasks-mrtr-composition
)
for scenario in "${scenarios[@]}"; do
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
--url http://127.0.0.1:8001/mcp \
--scenario "$scenario" \
--expected-failures conformance/expected-failures-extensions.yaml \
-o conformance-extension-results
done
- name: Stop conformance server
if: always()
run: kill "$(cat server.pid)" 2>/dev/null || true
- name: Upload results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: conformance-server-results
path: |
conformance-results
conformance-extension-results
client:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
- name: Build conformance binaries
run: cargo build -p mcp-conformance
- name: Run 2025-11-25 client suite
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
--command "$(pwd)/target/debug/conformance-client" \
--suite all \
--spec-version 2025-11-25 \
-o conformance-client-results/2025-11-25
- name: Run 2026-07-28 client suite
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
--command "$(pwd)/target/debug/conformance-client" \
--suite all \
--spec-version 2026-07-28 \
-o conformance-client-results/2026-07-28
- name: Run extension client suite (informational)
run: |
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" client \
--command "$(pwd)/target/debug/conformance-client" \
--suite extensions \
--expected-failures conformance/expected-failures-extensions.yaml \
-o conformance-client-results/extensions
- name: Upload results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: conformance-client-results
path: conformance-client-results

View file

@ -1,6 +1,8 @@
name: Release-plz name: Release-plz
permissions: {} permissions:
pull-requests: write
contents: write
on: on:
push: push:
@ -16,20 +18,19 @@ jobs:
if: ${{ github.repository_owner == 'modelcontextprotocol' }} if: ${{ github.repository_owner == 'modelcontextprotocol' }}
permissions: permissions:
contents: write contents: write
pull-requests: read
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: "1.92" toolchain: "1.92"
# Using fork until semver_check_features support is released upstream. # Using fork until semver_check_features support is released upstream.
# See: https://github.com/release-plz/release-plz/pull/2757 # See: https://github.com/release-plz/release-plz/pull/2757
- name: Install release-plz from fork - name: Install release-plz from fork
run: cargo install --locked --git https://github.com/DaleSeo/release-plz --rev c2ea743bacf9bbd56e344df68219e60b0ad35d04 release-plz run: cargo install --locked --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz
- name: Run release-plz release - name: Run release-plz release
run: release-plz release run: release-plz release
env: env:
@ -50,17 +51,17 @@ jobs:
cancel-in-progress: false cancel-in-progress: false
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install Rust toolchain - name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable uses: dtolnay/rust-toolchain@stable
with: with:
toolchain: "1.92" toolchain: "1.92"
# Using fork until semver_check_features support is released upstream. # Using fork until semver_check_features support is released upstream.
# See: https://github.com/release-plz/release-plz/pull/2757 # See: https://github.com/release-plz/release-plz/pull/2757
- name: Install release-plz from fork - name: Install release-plz from fork
run: cargo install --locked --git https://github.com/DaleSeo/release-plz --rev c2ea743bacf9bbd56e344df68219e60b0ad35d04 release-plz run: cargo install --locked --git https://github.com/DaleSeo/release-plz --branch feat/semver-check-features release-plz
- name: Run release-plz release-pr - name: Run release-plz release-pr
run: release-plz release-pr run: release-plz release-pr
env: env:

View file

@ -1,48 +0,0 @@
# OpenSSF Scorecard supply-chain security analysis.
name: Scorecard supply-chain security
on:
branch_protection_rule:
schedule:
- cron: '5 23 * * 5'
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions: read-all
jobs:
analysis:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Run analysis
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
# Publishing only succeeds on the default branch. PR and manual runs
# still validate the analysis and produce SARIF without publishing.
publish_results: ${{ github.event_name != 'pull_request' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
- name: Upload SARIF artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: scorecard-results
path: results.sarif
retention-days: 5
- name: Upload to code scanning
uses: github/codeql-action/upload-sarif@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
with:
sarif_file: results.sarif

View file

@ -1,38 +0,0 @@
name: Clean Up Stale PRs and Issues
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
permissions:
contents: read
jobs:
stale:
name: Clean Up
runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11
with:
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: keep,security,pinned
exempt-pr-labels: keep,security,pinned
exempt-draft-pr: true
stale-issue-message: >
This issue has had no activity for 60 days and is now marked as stale.
It will be closed in 14 days if there is no further activity. Add the
`keep` label to keep it open.
close-issue-message: >
Closing this issue after 14 days of inactivity since it was marked stale.
Feel free to reopen if it is still relevant.
stale-pr-message: >
This pull request has had no activity for 60 days and is now marked as stale.
It will be closed in 14 days if there is no further activity. Add the
`keep` label to keep it open.
close-pr-message: >
Closing this pull request after 14 days of inactivity since it was marked stale.
Feel free to reopen if you plan to continue the work.

View file

@ -39,7 +39,7 @@ jobs:
TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL || 'gpt-4o-mini' }} TRIAGE_MODEL: ${{ vars.TRIAGE_MODEL || 'gpt-4o-mini' }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@v7
- name: Install jq - name: Install jq
run: sudo apt-get install -y jq run: sudo apt-get install -y jq

View file

@ -4,13 +4,12 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
rmcp = { version = "3.4.0", path = "./crates/rmcp" } rmcp = { version = "2.1.0", path = "./crates/rmcp" }
rmcp-macros = { version = "3.4.0", path = "./crates/rmcp-macros" } rmcp-macros = { version = "2.1.0", path = "./crates/rmcp-macros" }
[workspace.package] [workspace.package]
edition = "2024" edition = "2024"
rust-version = "1.88" version = "2.1.0"
version = "3.4.0"
authors = ["4t145 <u4t145@163.com>"] authors = ["4t145 <u4t145@163.com>"]
license = "Apache-2.0" license = "Apache-2.0"
repository = "https://github.com/modelcontextprotocol/rust-sdk/" repository = "https://github.com/modelcontextprotocol/rust-sdk/"

View file

@ -1,65 +0,0 @@
# Dependency Policy
This document describes how the Rust MCP SDK (`rmcp`, `rmcp-macros`) selects,
updates, and maintains its dependencies. It applies to all crates in the workspace.
## Goals
- Keep dependencies current and secure without churn that destabilizes the public API.
- Minimize the dependency footprint we impose on downstream users.
- Make dependency updates reviewable and traceable.
## Choosing dependencies
Before adding a new dependency we consider:
- **Necessity** — whether the functionality is worth an added dependency, or is small
enough to implement directly.
- **Maintenance & trust** — active maintenance, a healthy release history, and a
permissive, compatible license (the workspace is `Apache-2.0`).
- **Footprint** — transitive dependency count and compile-time cost.
- **Optionality** — anything not needed by every user should sit behind a Cargo
feature so downstream builds stay lean. The SDK is heavily feature-gated for this
reason (transports, `auth`, `schemars`, `reqwest`, etc.).
Version requirements are specified with caret (`^`, the Cargo default) constraints so
compatible upgrades are picked up automatically.
## Automated updates
Dependency updates are automated with [Dependabot](https://docs.github.com/en/code-security/dependabot),
configured in [`.github/dependabot.yml`](.github/dependabot.yml):
| Ecosystem | Cadence | PR label |
| -------------- | ------- | ----------------- |
| Cargo crates | Weekly | `T-dependencies` |
| GitHub Actions | Daily | `T-CI` |
Dependabot opens a limited number of PRs at a time (currently 3 per ecosystem) to
keep the review queue manageable. Update PRs run the full CI suite (build, format,
clippy, tests, and conformance) and must be green before merge.
## Review and merge
- Patch and minor updates that pass CI are reviewed and merged promptly by maintainers.
- Major (breaking) updates are evaluated individually; they may require code changes
and are scheduled deliberately rather than merged automatically.
- Any dependency change that affects the **public API** (for example a type re-exported
from a dependency) is treated as a potential breaking change and follows
[`VERSIONING.md`](VERSIONING.md).
## Security updates
- Security fixes take priority over routine updates and are expedited.
- Vulnerabilities in the SDK itself are handled per [`SECURITY.md`](SECURITY.md) via
GitHub's private Security Advisory process.
- Advisories affecting our dependencies (e.g. via the [RustSec Advisory Database](https://rustsec.org/))
are addressed as soon as a fixed version is available; if a released version of the
SDK is impacted, a patched release is published and, where warranted, the affected
versions are yanked from crates.io.
## Minimum Supported Rust Version
Dependency upgrades must not raise the SDK's declared MSRV (`rust-version` in
`Cargo.toml`) without an explicit, documented MSRV bump — which is itself treated as a
breaking change per [`VERSIONING.md`](VERSIONING.md).

1044
README.md

File diff suppressed because it is too large Load diff

View file

@ -1,112 +1,71 @@
# RMCP Roadmap # RMCP Roadmap
This roadmap tracks the path to [SEP-1730](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1730) Tier 1 for the Rust MCP SDK. This roadmap tracks the path to SEP-1730 Tier 1 for the Rust MCP SDK.
**Status: all SEP-1730 Tier 1 requirements are met.** Conformance is Server conformance: 87.5% (28/32) · Client conformance: 80.0% (16/20)
100% across every date-versioned suite, a stable ≥1.0.0 release has shipped
([Releases](https://github.com/modelcontextprotocol/rust-sdk/releases)),
issue triage and critical-bug (P0) resolution are within the Tier 1 SLAs, the
governance documents (`VERSIONING.md`, `DEPENDENCY_POLICY.md`, this `ROADMAP.md`) are
published, and all 48 non-experimental features are documented with examples. This
document now serves as the ongoing tracker for spec conformance and SDK health.
| SEP-1730 Tier 1 requirement | Status | Evidence |
| --------------------------------------------------- | ------ | -------- |
| Server conformance 100% (date-versioned) | ✅ | 30/30 — see below |
| Client conformance 100% (date-versioned) | ✅ | 39/39 scored — see below |
| Issue triage ≥90% within 2 business days | ✅ | 95.2% (20/21) |
| All P0 bugs resolved within 7 days | ✅ | 0 open; last P0 (#741) resolved in 3 days |
| Stable release ≥1.0.0 (no pre-release suffix) | ✅ | [latest release](https://github.com/modelcontextprotocol/rust-sdk/releases/latest) (see tooling note below) |
| Clear versioning + breaking-change policy | ✅ | [`VERSIONING.md`](VERSIONING.md) |
| All non-experimental features documented w/ examples| ✅ | 48/48 in [`README.md`](README.md) |
| Published dependency update policy | ✅ | [`DEPENDENCY_POLICY.md`](DEPENDENCY_POLICY.md) + [`.github/dependabot.yml`](.github/dependabot.yml) |
| Published roadmap tracking spec components | ✅ | this document |
| Suite (date-versioned) | Server | Client |
| ---------------------- | ------------- | ------------- |
| 2025-11-25 | 100% (30/30) | 100% |
| 2026-07-28 | 100% (30/30) | 100% |
Only date-versioned scenarios count toward SDK tiering. `draft` (2026-07-28 draft)
and `extension` scenarios are informational and reported separately below.
> **Tooling note — `stable_release`:** the SEP-1730 `tier-check` CLI may report
> `stable_release` as failing because it does not parse the workspace tag prefix
> `rmcp-v` (as in `rmcp-vX.Y.Z`). The published releases are genuine stable,
> non-pre-release versions ≥1.0.0
> ([Releases](https://github.com/modelcontextprotocol/rust-sdk/releases)); the
> flag is a tooling artifact, not an unmet requirement.
--- ---
## Conformance ## Tier 2 → Tier 1
Per-scenario status for the current spec is tracked in the epic issue ### Conformance
[#977 — Tracking: 2026-07-28 spec conformance](https://github.com/modelcontextprotocol/rust-sdk/issues/977),
under the [`2026-07-28 spec` milestone](https://github.com/modelcontextprotocol/rust-sdk/milestone/3).
CI (`.github/workflows/conformance.yml`) runs the full `2025-11-25` and `2026-07-28` #### Server (87.5% → 100%)
server and client suites on every push and PR. Both suites are fully green.
### Informational (not scored for tiering) - [ ] Fix `prompts-get-with-args` — prompt argument handling returns incorrect result (arg1/arg2 not substituted)
- [ ] Fix `prompts-get-embedded-resource` — embedded resource content in prompt responses (invalid content union)
- [ ] Fix `elicitation-sep1330-enums` — enum inference handling per SEP-1330 (missing enumNames for legacy titled enum)
- [ ] Fix `dns-rebinding-protection` — validate `Host` / `Origin` headers on Streamable HTTP transport (accepts invalid headers with 200)
Extension-tagged scenarios are excluded by `--spec-version` filters, so CI runs them #### Client (80.0% → 100%)
in separate steps against `conformance/expected-failures-extensions.yaml`:
| Scenario | Tag | Status | - [ ] Fix `auth/metadata-var3` — AS metadata discovery variant 3 (no authorization support detected)
| --------------------------------------- | ---------- | ------ | - [ ] Fix `auth/scope-from-www-authenticate` — use scope parameter from WWW-Authenticate header on 403 insufficient_scope
| `auth/client-credentials-basic` | extension | ✅ Pass | - [ ] Fix `auth/scope-step-up` — handle 403 `insufficient_scope` and re-authorize with upgraded scopes
| `auth/client-credentials-jwt` | extension | ✅ Pass | - [ ] Fix `auth/2025-03-26-oauth-endpoint-fallback` — legacy OAuth endpoint fallback for pre-2025-06-18 servers (no authorization support detected)
| `auth/enterprise-managed-authorization` | extension | ❌ Expected failure (not implemented by the conformance client) |
| `auth/wif-jwt-bearer` | 2026-07-28 draft | ❌ Expected failure (WIF / SEP-1933, draft) |
| `tasks-*` (SEP-2663) | extension | ❌ 9 expected failures · ⏭️ 1 upstream-skipped |
### Spec features without conformance scenarios ### Governance & Policy
Conformance does not cover the entire spec surface. Remaining feature work tracked via - [ ] Create `VERSIONING.md` — document semver scheme, what constitutes a breaking change, and how breaking changes are communicated
the milestone:
- SEP-2567 sessionless MCP via explicit state handles (#870) ### Documentation (26/48 → 48/48 features with prose + examples)
- SEP-2260 server requests must associate with a client request (#873)
- SEP-2549 follow-up: client-side TTL-honoring cache (#974) #### Undocumented features (14)
- [ ] Tools — image results
- [ ] Tools — audio results
- [ ] Tools — embedded resources
- [ ] Prompts — embedded resources
- [ ] Prompts — image content
- [ ] Elicitation — URL mode
- [ ] Elicitation — default values
- [ ] Elicitation — complete notification
- [ ] Ping
- [ ] SSE transport — legacy (client)
- [ ] SSE transport — legacy (server)
- [ ] Pagination
- [ ] Protocol version negotiation
- [ ] JSON Schema 2020-12 support *(upgrade from partial)*
#### Partially documented features (7)
- [ ] Tools — error handling *(add dedicated prose + example)*
- [ ] Resources — reading binary *(add dedicated example)*
- [ ] Elicitation — form mode *(add prose docs, not just example README)*
- [ ] Elicitation — schema validation *(add prose docs)*
- [ ] Elicitation — enum values *(add prose docs)*
- [ ] Capability negotiation *(add dedicated prose explaining the builder API)*
- [ ] Protocol version negotiation *(document version negotiation behavior)*
--- ---
## Completed ## Informational (not scored for tiering)
### Tier 1 requirements These draft/extension scenarios are tracked but do not count toward tier advancement:
- [x] **v3.0.0 stable released** (2026-07-28) — MRTR, SEP-2549 cache hints, SEP-2243 | Scenario | Tag | Status |
standard headers, SEP-2575 stateless MCP, and SEP-2106 relaxations |---|---|---|
- [x] 2025-11-25 server conformance 100% (30/30) | `auth/resource-mismatch` | draft | ❌ Failed |
- [x] 2025-11-25 client conformance 100% | `auth/client-credentials-jwt` | extension | ❌ Failed — JWT `aud` claim verification error |
- [x] 2026-07-28 server conformance 100% (30/30 dated) | `auth/client-credentials-basic` | extension | ✅ Passed |
- [x] 2026-07-28 client conformance 100% (dated) | `auth/cross-app-access-complete-flow` | extension | ❌ Failed — sends `authorization_code` grant instead of `jwt-bearer` |
- [x] Issue triage ≥90% within 2 business days (95.2%, 20/21) with the full SEP-1730
label taxonomy (bug, enhancement, question, needs confirmation, needs repro,
ready for work, good first issue, help wanted, P0–P3)
- [x] All P0 bugs resolved within 7 days (0 open; #741 resolved in 3 days). #815 was
reclassified from `P0` to `T-security` — it was a CVE/advisory-coordination task
for an already-shipped fix (PR #764, released in v1.4.0), not a critical-bug fix
- [x] `VERSIONING.md` — semver scheme, breaking-change definition, and communication policy
- [x] `DEPENDENCY_POLICY.md` — published dependency update policy (with `.github/dependabot.yml`)
- [x] `SECURITY.md` and Dependabot configuration
- [x] All 48 non-experimental features documented with examples in the README
(closed the last 6 gaps on 2026-07-30: audio results, resource-template reading,
resource-argument completion, ping, and the legacy HTTP+SSE non-goal writeup)
### Spec implementation
- [x] SEP-2322 MRTR (server scenarios + `sep-2322-client-request-state`)
- [x] SEP-2575 Make MCP Stateless (`server-stateless`)
- [x] SEP-2164 resource not found
- [x] SEP-2549 cache hints (`caching`)
- [x] SEP-2243 HTTP standardization (`http-header-validation`, standard headers)
- [x] DNS rebinding protection
---
## Nice-to-have (scorecard hygiene, not required for Tier 1)
- [ ] Add a top-level `CHANGELOG.md` (release notes are currently managed by release-plz).
- [ ] Add a top-level `CONTRIBUTING.md` (contributor docs currently live at `docs/CONTRIBUTE.MD`).

View file

@ -1,66 +0,0 @@
# Versioning Policy
`rmcp` and `rmcp-macros` follow [Semantic Versioning 2.0.0](https://semver.org/),
the standard versioning scheme for the Rust/Cargo ecosystem. Given a version
`MAJOR.MINOR.PATCH`:
- **MAJOR** — incremented for breaking changes to the public API.
- **MINOR** — incremented for new, backwards-compatible functionality.
- **PATCH** — incremented for backwards-compatible bug fixes.
The `rmcp` and `rmcp-macros` crates are versioned together and released with the
same version number.
## What counts as a breaking change
We treat a change as breaking if it would cause code that compiled against the
previous release to fail to compile, or to change behavior in a way callers could
not reasonably anticipate. This follows the Cargo SemVer guidelines
([The Cargo Book — SemVer Compatibility](https://doc.rust-lang.org/cargo/reference/semver.html)).
Examples include:
- Removing or renaming a public item (function, type, trait, module, variant, field).
- Changing a public function signature, trait method, or trait bound.
- Adding a required method to a public trait, or a field to a struct that callers
construct directly. (Public structs and enums are marked `#[non_exhaustive]` where
practical so that additive changes remain non-breaking.)
- Raising the Minimum Supported Rust Version (MSRV) — see below.
Additive changes — new functions, new types, new enum variants on
`#[non_exhaustive]` enums, new optional Cargo features — are **not** breaking and
ship in MINOR releases.
### Cargo features
The public API is feature-gated. SemVer compatibility is evaluated against the
crate's **default features** and all features except `local`.
## Pre-1.0 crates in the workspace
The core crates (`rmcp`, `rmcp-macros`) are `>= 1.0.0` and follow the rules above.
Any workspace crate still in `0.x` follows Cargo's pre-1.0 convention, where the
`MINOR` version acts as the breaking-change signal (`0.MINOR.PATCH`).
## Minimum Supported Rust Version (MSRV)
The MSRV is declared via `rust-version` in `Cargo.toml`. Raising the MSRV is
considered a breaking change and is only done in a MINOR or MAJOR release with a
note in the release notes.
## How breaking changes are communicated
- Release automation is handled by [release-plz](https://release-plz.dev/), which
derives version bumps from [Conventional Commits](https://www.conventionalcommits.org/)
(`feat!:` / `fix!:` and `BREAKING CHANGE:` footers trigger a MAJOR bump) and runs
`cargo-semver-checks` to catch unintended API breaks.
- Every release is published as a tagged [GitHub Release](https://github.com/modelcontextprotocol/rust-sdk/releases)
with generated notes.
- Significant migrations (for example the 2.x → 3.x upgrade) are accompanied by a
migration guide linked from the release notes and the README.
## Yanking
A published version that is later found to be broken or to contain a security issue
may be [yanked](https://doc.rust-lang.org/cargo/commands/cargo-yank.html) from
crates.io. Yanking prevents new dependents from selecting the version but does not
delete it; a fixed PATCH release is published alongside.

View file

@ -1,2 +1,3 @@
msrv = "1.85"
too-many-arguments-threshold = 10 too-many-arguments-threshold = 10
check-private-items = false check-private-items = false

View file

@ -18,9 +18,6 @@ rmcp = { path = "../crates/rmcp", features = [
"client", "client",
"elicitation", "elicitation",
"auth", "auth",
"auth-client-credentials-jwt",
"auth-enterprise-managed",
"request-state",
"transport-streamable-http-server", "transport-streamable-http-server",
"transport-streamable-http-client-reqwest", "transport-streamable-http-client-reqwest",
] } ] }
@ -32,7 +29,8 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
axum = { version = "0.8", features = ["macros"] } axum = { version = "0.8", features = ["macros"] }
anyhow = "1" anyhow = "1"
oauth2 = { version = "5.0", default-features = false }
reqwest = { version = "0.13", features = ["json"] } reqwest = { version = "0.13", features = ["json"] }
urlencoding = "2" urlencoding = "2"
url = "2" url = "2"
p256 = { version = "0.13", features = ["ecdsa"] }
base64 = "0.22"

View file

@ -1,26 +0,0 @@
# Known failures for informational extension scenarios in
# @modelcontextprotocol/conformance CONFORMANCE_VERSION.
#
# Extensions are not selected by a `--spec-version` run and do not count toward
# SDK tiering. CI runs them separately so a green versioned-spec suite does not
# hide extension gaps.
#
# This is a strict baseline:
# - an unlisted failure fails the build
# - a listed scenario that starts passing fails the build as a stale entry
#
# When bumping CONFORMANCE_VERSION, review the available extension and
# pending scenarios and update this file deliberately.
# The SEP-2663 Tasks Extension server scenarios (tracked in #868) all pass and
# were removed from this baseline. `tasks-status-notifications` remains
# upstream-skipped pending the subscriptions/listen rewrite; CI will fail if it
# becomes active but does not pass.
server: []
client:
# Informational OAuth extension scenarios.
- auth/enterprise-managed-authorization
- auth/dpop
- auth/dpop-nonce
- auth/wif-jwt-bearer

View file

@ -1,21 +1,10 @@
#![expect(
deprecated,
reason = "The conformance suite still exercises deprecated sampling scenarios"
)]
use anyhow::Context;
use oauth2::{ClientSecret, RefreshToken};
use rmcp::{ use rmcp::{
ClientHandler, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt, ClientHandler, ErrorData, RoleClient, ServiceExt,
model::*, model::*,
service::RequestContext, service::RequestContext,
transport::{ transport::{
AuthClient, AuthorizationManager, StreamableHttpClientTransport, AuthClient, AuthorizationManager, StreamableHttpClientTransport,
auth::{ auth::{AuthorizationCallback, OAuthState},
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState, default_oauth_http_client,
enterprise::{EmaAuthorizationServer, EmaClientAuthentication, EmaExchangeRequest},
},
streamable_http_client::StreamableHttpClientTransportConfig, streamable_http_client::StreamableHttpClientTransportConfig,
}, },
}; };
@ -24,17 +13,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
// ─── Context parsed from MCP_CONFORMANCE_CONTEXT ──────────────────────────── // ─── Context parsed from MCP_CONFORMANCE_CONTEXT ────────────────────────────
#[derive(Debug, Default, serde::Deserialize)]
struct ConformanceToolCall {
name: String,
#[serde(default)]
arguments: Option<serde_json::Map<String, Value>>,
}
#[derive(Debug, Default, serde::Deserialize)] #[derive(Debug, Default, serde::Deserialize)]
struct ConformanceContext { struct ConformanceContext {
#[serde(default, alias = "toolCalls")]
tool_calls: Vec<ConformanceToolCall>,
#[serde(default)] #[serde(default)]
client_id: Option<String>, client_id: Option<String>,
#[serde(default)] #[serde(default)]
@ -44,17 +24,6 @@ struct ConformanceContext {
private_key_pem: Option<String>, private_key_pem: Option<String>,
#[serde(default)] #[serde(default)]
signing_algorithm: Option<String>, signing_algorithm: Option<String>,
// enterprise-managed-authorization-refresh-token
#[serde(default)]
idp_client_id: Option<String>,
#[serde(default)]
idp_client_secret: Option<String>,
#[serde(default)]
idp_refresh_token: Option<String>,
#[serde(default)]
idp_issuer: Option<String>,
#[serde(default)]
idp_token_endpoint: Option<String>,
} }
fn load_context() -> ConformanceContext { fn load_context() -> ConformanceContext {
@ -74,8 +43,8 @@ impl ClientHandler for BasicClientHandler {}
struct ElicitationDefaultsClientHandler; struct ElicitationDefaultsClientHandler;
impl ClientHandler for ElicitationDefaultsClientHandler { impl ClientHandler for ElicitationDefaultsClientHandler {
fn get_info(&self) -> ClientConfig { fn get_info(&self) -> ClientInfo {
let mut info = ClientConfig::default(); let mut info = ClientInfo::default();
info.capabilities.elicitation = Some( info.capabilities.elicitation = Some(
ElicitationCapability::new() ElicitationCapability::new()
.with_form(FormElicitationCapability::new().with_schema_validation(true)), .with_form(FormElicitationCapability::new().with_schema_validation(true)),
@ -163,8 +132,8 @@ impl ClientHandler for ElicitationDefaultsClientHandler {
struct FullClientHandler; struct FullClientHandler;
impl ClientHandler for FullClientHandler { impl ClientHandler for FullClientHandler {
fn get_info(&self) -> ClientConfig { fn get_info(&self) -> ClientInfo {
let mut info = ClientConfig::default(); let mut info = ClientInfo::default();
info.capabilities.elicitation = Some( info.capabilities.elicitation = Some(
ElicitationCapability::new() ElicitationCapability::new()
.with_form(FormElicitationCapability::new().with_schema_validation(true)), .with_form(FormElicitationCapability::new().with_schema_validation(true)),
@ -211,76 +180,27 @@ impl ClientHandler for FullClientHandler {
const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json"; const CIMD_CLIENT_METADATA_URL: &str = "https://conformance-test.local/client-metadata.json";
const REDIRECT_URI: &str = "http://localhost:3000/callback"; const REDIRECT_URI: &str = "http://localhost:3000/callback";
const SCOPE_STEP_UP_INITIAL_SCOPES: &[&str] = &["mcp:basic"];
const SCOPE_STEP_UP_ESCALATED_SCOPES: &[&str] = &["mcp:basic", "mcp:write"];
/// Attempt the real connection unauthenticated and return the server's /// Perform the headless OAuth authorization-code flow.
/// `WWW-Authenticate` challenge from the 401 — the reactive discovery
/// trigger.
/// ///
/// `None` (server accepted the unauthenticated connection, which is then /// 1. Discover metadata, register (or use CIMD), get auth URL
/// closed cleanly) is a legitimate outcome, not an error: the scope-step-up /// 2. Fetch the auth URL with redirect:manual → extract code from Location header
/// and scope-retry-limit mocks allow unauthenticated `initialize` and only /// 3. Exchange code for token
/// enforce authorization on tool calls. /// 4. Return an `AuthClient` wrapping `reqwest::Client`
async fn initialize_challenge(
server_url: &str,
lifecycle: ClientLifecycleMode,
) -> anyhow::Result<Option<String>> {
let transport = StreamableHttpClientTransport::from_uri(server_url);
match BasicClientHandler
.serve_with_lifecycle(transport, lifecycle)
.await
{
Ok(client) => {
client.cancel().await.ok();
Ok(None)
}
Err(error) => match error.auth_challenge() {
Some(challenge) => Ok(Some(challenge.to_string())),
None => Err(error.into()),
},
}
}
fn with_optional_challenge(
request: AuthorizationRequest,
challenge: Option<String>,
) -> AuthorizationRequest {
match challenge {
Some(challenge) => request.with_challenge(challenge),
None => request,
}
}
/// Perform the headless OAuth authorization-code flow, reactively:
///
/// 1. Attempt the real connection; take the 401's WWW-Authenticate challenge
/// 2. Discover from the challenge, register (or use CIMD), get auth URL
/// 3. Fetch the auth URL with redirect:manual → extract code from Location header
/// 4. Exchange code for token
/// 5. Return an `AuthClient` wrapping `reqwest::Client`
async fn perform_oauth_flow( async fn perform_oauth_flow(
server_url: &str, server_url: &str,
_ctx: &ConformanceContext, _ctx: &ConformanceContext,
) -> anyhow::Result<AuthClient<reqwest::Client>> { ) -> anyhow::Result<AuthClient<reqwest::Client>> {
// Always the discover lifecycle here (not `conformance_lifecycle()`):
// this flow serves `run_auth_client`, whose 2026-07-28 auth mocks require
// the per-request MCP-Protocol-Version negotiation.
let challenge = initialize_challenge(
server_url,
ClientLifecycleMode::Discover {
preferred_versions: preferred_protocol_versions(),
},
)
.await?;
let mut oauth = OAuthState::new(server_url, None).await?; let mut oauth = OAuthState::new(server_url, None).await?;
// Discover + register + get auth URL // Discover + register + get auth URL
let request = AuthorizationRequest::new(REDIRECT_URI)
.with_client_name("conformance-client")
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
oauth oauth
.start_authorization(with_optional_challenge(request, challenge)) .start_authorization_with_metadata_url(
&[],
REDIRECT_URI,
Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL),
)
.await?; .await?;
let auth_url = oauth.get_authorization_url().await?; let auth_url = oauth.get_authorization_url().await?;
@ -315,38 +235,50 @@ async fn perform_oauth_flow(
Ok(AuthClient::new(reqwest::Client::default(), am)) Ok(AuthClient::new(reqwest::Client::default(), am))
} }
/// Like `perform_oauth_flow` but uses pre-registered client credentials, /// Like `perform_oauth_flow` but uses pre-registered client credentials.
/// exercising the SDK's high-level `OAuthState` path (no DCR).
async fn perform_oauth_flow_preregistered( async fn perform_oauth_flow_preregistered(
server_url: &str, server_url: &str,
client_id: &str, client_id: &str,
client_secret: &str, client_secret: &str,
) -> anyhow::Result<AuthClient<reqwest::Client>> { ) -> anyhow::Result<AuthClient<reqwest::Client>> {
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; let mut manager = AuthorizationManager::new(server_url).await?;
let mut oauth = OAuthState::new(server_url, None).await?; let metadata = manager.discover_metadata().await?;
manager.set_metadata(metadata);
let request = AuthorizationRequest::new(REDIRECT_URI) // Configure with pre-registered credentials
.with_preregistered_client(client_id) let config = rmcp::transport::auth::OAuthClientConfig::new(client_id, REDIRECT_URI)
.with_client_secret(client_secret); .with_client_secret(client_secret);
oauth manager.configure_client(config)?;
.start_authorization(with_optional_challenge(request, challenge))
.await?;
let auth_url = oauth.get_authorization_url().await?; let scopes = manager.select_scopes(None, &[]);
let callback = headless_authorize(&auth_url).await?; let scope_refs: Vec<&str> = scopes.iter().map(|s| s.as_str()).collect();
oauth let auth_url = manager.get_authorization_url(&scope_refs).await?;
.handle_callback_with_issuer(
&callback.code,
&callback.csrf_token,
callback.issuer.as_deref(),
)
.await?;
let am = oauth // Headless redirect
.into_authorization_manager() let http = reqwest::Client::builder()
.ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; .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"))?;
Ok(AuthClient::new(reqwest::Client::default(), am)) 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. /// Run the standard auth flow, then connect and exercise the server.
@ -358,17 +290,7 @@ async fn run_auth_client(server_url: &str, ctx: &ConformanceContext) -> anyhow::
StreamableHttpClientTransportConfig::with_uri(server_url), StreamableHttpClientTransportConfig::with_uri(server_url),
); );
// The 2026-07-28 auth mocks require the modern per-request lifecycle let client = BasicClientHandler.serve(transport).await?;
// (MCP-Protocol-Version header on every request), so negotiate via the
// discover lifecycle rather than the legacy initialize handshake.
let client = BasicClientHandler
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover {
preferred_versions: preferred_protocol_versions(),
},
)
.await?;
tracing::debug!("Connected (authenticated)"); tracing::debug!("Connected (authenticated)");
let tools = client.list_tools(Default::default()).await?; let tools = client.list_tools(Default::default()).await?;
@ -392,14 +314,15 @@ async fn run_auth_scope_step_up_client(
server_url: &str, server_url: &str,
_ctx: &ConformanceContext, _ctx: &ConformanceContext,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; // First auth
let mut oauth = OAuthState::new(server_url, None).await?; let mut oauth = OAuthState::new(server_url, None).await?;
let request = AuthorizationRequest::new(REDIRECT_URI)
.with_scopes(SCOPE_STEP_UP_INITIAL_SCOPES.iter().copied())
.with_client_name("conformance-client")
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
oauth oauth
.start_authorization(with_optional_challenge(request, challenge)) .start_authorization_with_metadata_url(
&[],
REDIRECT_URI,
Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL),
)
.await?; .await?;
let auth_url = oauth.get_authorization_url().await?; let auth_url = oauth.get_authorization_url().await?;
@ -422,9 +345,7 @@ async fn run_auth_scope_step_up_client(
StreamableHttpClientTransportConfig::with_uri(server_url), StreamableHttpClientTransportConfig::with_uri(server_url),
); );
let client = BasicClientHandler let client = BasicClientHandler.serve(transport).await?;
.serve_with_lifecycle(transport, conformance_lifecycle())
.await?;
let tools = client.list_tools(Default::default()).await?; let tools = client.list_tools(Default::default()).await?;
tracing::debug!("Listed {} tools", tools.tools.len()); tracing::debug!("Listed {} tools", tools.tools.len());
@ -444,13 +365,16 @@ async fn run_auth_scope_step_up_client(
// Drop old client, re-auth with upgraded scopes // Drop old client, re-auth with upgraded scopes
client.cancel().await.ok(); 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?; let mut oauth2 = OAuthState::new(server_url, None).await?;
// Pass the escalated scope hint
oauth2 oauth2
.start_authorization( .start_authorization_with_metadata_url(
AuthorizationRequest::new(REDIRECT_URI) &[],
.with_scopes(SCOPE_STEP_UP_ESCALATED_SCOPES.iter().copied()) REDIRECT_URI,
.with_client_name("conformance-client") Some("conformance-client"),
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL), Some(CIMD_CLIENT_METADATA_URL),
) )
.await?; .await?;
let auth_url2 = oauth2.get_authorization_url().await?; let auth_url2 = oauth2.get_authorization_url().await?;
@ -463,20 +387,16 @@ async fn run_auth_scope_step_up_client(
) )
.await?; .await?;
let am2 = oauth2.into_authorization_manager().ok_or_else(|| { let am2 = oauth2.into_authorization_manager().unwrap();
anyhow::anyhow!("Missing authorization manager after step-up")
})?;
let auth_client2 = AuthClient::new(reqwest::Client::default(), am2); let auth_client2 = AuthClient::new(reqwest::Client::default(), am2);
let transport2 = StreamableHttpClientTransport::with_client( let transport2 = StreamableHttpClientTransport::with_client(
auth_client2, auth_client2,
StreamableHttpClientTransportConfig::with_uri(server_url), StreamableHttpClientTransportConfig::with_uri(server_url),
); );
let client2 = BasicClientHandler let client2 = BasicClientHandler.serve(transport2).await?;
.serve_with_lifecycle(transport2, conformance_lifecycle()) let _ = client2
.await?;
client2
.call_tool(call_tool_params(tool.name.clone(), args)) .call_tool(call_tool_params(tool.name.clone(), args))
.await?; .await;
client2.cancel().await.ok(); client2.cancel().await.ok();
return Ok(()); return Ok(());
} }
@ -494,15 +414,16 @@ async fn run_auth_scope_retry_limit_client(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let max_retries = 3u32; let max_retries = 3u32;
let mut attempt = 0u32; let mut attempt = 0u32;
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
loop { loop {
let mut oauth = OAuthState::new(server_url, None).await?; let mut oauth = OAuthState::new(server_url, None).await?;
let request = AuthorizationRequest::new(REDIRECT_URI)
.with_client_name("conformance-client")
.with_client_metadata_url(CIMD_CLIENT_METADATA_URL);
oauth oauth
.start_authorization(with_optional_challenge(request, challenge.clone())) .start_authorization_with_metadata_url(
&[],
REDIRECT_URI,
Some("conformance-client"),
Some(CIMD_CLIENT_METADATA_URL),
)
.await?; .await?;
let auth_url = oauth.get_authorization_url().await?; let auth_url = oauth.get_authorization_url().await?;
let callback = headless_authorize(&auth_url).await?; let callback = headless_authorize(&auth_url).await?;
@ -514,9 +435,7 @@ async fn run_auth_scope_retry_limit_client(
) )
.await?; .await?;
let am = oauth let am = oauth.into_authorization_manager().unwrap();
.into_authorization_manager()
.ok_or_else(|| anyhow::anyhow!("Missing authorization manager"))?;
let auth_client = AuthClient::new(reqwest::Client::default(), am); let auth_client = AuthClient::new(reqwest::Client::default(), am);
let transport = StreamableHttpClientTransport::with_client( let transport = StreamableHttpClientTransport::with_client(
auth_client, auth_client,
@ -524,18 +443,7 @@ async fn run_auth_scope_retry_limit_client(
); );
let client = BasicClientHandler.serve(transport).await?; let client = BasicClientHandler.serve(transport).await?;
let tools = match client.list_tools(Default::default()).await { let tools = client.list_tools(Default::default()).await?;
Ok(tools) => tools,
Err(err) => {
tracing::info!(
"Scope retry limit scenario stopped after authorization attempt {}: {}",
attempt + 1,
err
);
client.cancel().await.ok();
return Ok(());
}
};
let mut got_403 = false; let mut got_403 = false;
for tool in &tools.tools { for tool in &tools.tools {
@ -559,75 +467,12 @@ async fn run_auth_scope_retry_limit_client(
attempt += 1; attempt += 1;
if attempt >= max_retries { if attempt >= max_retries {
tracing::info!("Reached retry limit ({max_retries}), giving up"); tracing::info!("Reached retry limit ({max_retries}), giving up");
return Ok(()); return Err(anyhow::anyhow!("Scope retry limit reached"));
} }
} }
Ok(()) Ok(())
} }
async fn migration_token(
server_url: &str,
store: &InMemoryCredentialStore,
) -> anyhow::Result<String> {
let mut manager = AuthorizationManager::new(server_url).await?;
manager.set_credential_store(store.clone());
if manager.initialize_from_store().await? {
return Ok(manager.get_access_token().await?);
}
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?;
let resolution = manager
.resolve_metadata_from_challenge(challenge.as_deref())
.await?;
manager.set_metadata(resolution.metadata);
manager
.register_client("conformance-client", REDIRECT_URI, &[])
.await?;
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?;
let callback = headless_authorize(&auth_url).await?;
manager
.exchange_code_for_token_with_issuer(
&callback.code,
&callback.csrf_token,
callback.issuer.as_deref(),
)
.await?;
Ok(manager.get_access_token().await?)
}
async fn run_auth_server_migration_client(
server_url: &str,
_ctx: &ConformanceContext,
) -> anyhow::Result<()> {
let store = InMemoryCredentialStore::new();
let http = reqwest::Client::new();
let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}});
let mut token = migration_token(server_url, &store).await?;
for _ in 0..3 {
let resp = http
.post(server_url)
.header(
"MCP-Protocol-Version",
conformance_protocol_version().as_str(),
)
.bearer_auth(&token)
.json(&body)
.send()
.await?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
token = migration_token(server_url, &store).await?;
}
}
Ok(())
}
/// Auth flow with pre-registered credentials (from context). /// Auth flow with pre-registered credentials (from context).
async fn run_auth_preregistered_client( async fn run_auth_preregistered_client(
server_url: &str, server_url: &str,
@ -679,12 +524,9 @@ async fn run_client_credentials_basic(
.unwrap_or("conformance-test-secret"); .unwrap_or("conformance-test-secret");
let mut manager = AuthorizationManager::new(server_url).await?; let mut manager = AuthorizationManager::new(server_url).await?;
let challenge = initialize_challenge(server_url, conformance_lifecycle()).await?; let metadata = manager.discover_metadata().await?;
let resolution = manager let token_endpoint = metadata.token_endpoint.clone();
.resolve_metadata_from_challenge(challenge.as_deref()) manager.set_metadata(metadata);
.await?;
let token_endpoint = resolution.metadata.token_endpoint.clone();
manager.set_metadata(resolution.metadata);
let http = reqwest::Client::new(); let http = reqwest::Client::new();
let resp = http let resp = http
@ -727,43 +569,49 @@ async fn run_client_credentials_jwt(
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let client_id = ctx let client_id = ctx
.client_id .client_id
.clone() .as_deref()
.unwrap_or_else(|| "conformance-test-client".to_string()); .unwrap_or("conformance-test-client");
let signing_key = ctx let _pem = ctx
.private_key_pem .private_key_pem
.as_ref() .as_deref()
.ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))? .ok_or_else(|| anyhow::anyhow!("Missing private_key_pem"))?;
.as_bytes() let _alg = ctx
.to_vec();
let signing_algorithm = match ctx
.signing_algorithm .signing_algorithm
.as_deref() .as_deref()
.ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))? .ok_or_else(|| anyhow::anyhow!("Missing signing_algorithm"))?;
{
"RS256" => JwtSigningAlgorithm::RS256, // Discover metadata to get token endpoint
"RS384" => JwtSigningAlgorithm::RS384, let mut manager = AuthorizationManager::new(server_url).await?;
"RS512" => JwtSigningAlgorithm::RS512, let metadata = manager.discover_metadata().await?;
"ES256" => JwtSigningAlgorithm::ES256, let token_endpoint = metadata.token_endpoint.clone();
"ES384" => JwtSigningAlgorithm::ES384, manager.set_metadata(metadata);
algorithm => anyhow::bail!("Unsupported signing_algorithm: {algorithm}"),
}; // Build JWT assertion
let config = ClientCredentialsConfig::PrivateKeyJwt { // Parse the PEM private key
client_id, let key = openssl_free_ec_sign(_pem, client_id, &token_endpoint)?;
signing_key,
signing_algorithm, let http = reqwest::Client::new();
token_endpoint_audience: None, let form_body = format!(
scopes: vec![], "grant_type=client_credentials&client_assertion_type={}&client_assertion={}",
resource: Some(server_url.to_string()), urlencoding::encode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"),
}; urlencoding::encode(&key),
let mut oauth_state = OAuthState::new(server_url, None).await?; );
oauth_state.authenticate_client_credentials(config).await?; let resp = http
let manager = oauth_state .post(&token_endpoint)
.into_authorization_manager() .header("content-type", "application/x-www-form-urlencoded")
.ok_or_else(|| anyhow::anyhow!("Client credentials flow did not authorize"))?; .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( let transport = StreamableHttpClientTransport::with_client(
AuthClient::new(reqwest::Client::default(), manager), reqwest::Client::default(),
StreamableHttpClientTransportConfig::with_uri(server_url), StreamableHttpClientTransportConfig::with_uri(server_url)
.auth_header(access_token.to_string()),
); );
let client = BasicClientHandler.serve(transport).await?; let client = BasicClientHandler.serve(transport).await?;
@ -779,64 +627,71 @@ async fn run_client_credentials_jwt(
Ok(()) Ok(())
} }
/// Exchange the fixture's IdP refresh token, then exercise authenticated MCP access. /// Minimal ES256 JWT signing without heavy deps.
async fn run_ema_refresh_token_client( /// We use ring or pure-Rust approach. For simplicity, use the p256 + base64 crates
server_url: &str, /// that are already transitive deps of oauth2.
ctx: &ConformanceContext, fn openssl_free_ec_sign(pem: &str, client_id: &str, audience: &str) -> anyhow::Result<String> {
) -> anyhow::Result<()> { use std::time::{SystemTime, UNIX_EPOCH};
let manager = AuthorizationManager::new(server_url).await?;
let metadata = manager.resolve_metadata().await?.metadata;
let idp = EmaAuthorizationServer::new(
ctx.idp_issuer.as_deref().context("Missing idp_issuer")?,
ctx.idp_token_endpoint
.as_deref()
.context("Missing idp_token_endpoint")?,
ctx.idp_client_id
.as_deref()
.context("Missing idp_client_id")?,
)
.with_client_authentication(EmaClientAuthentication::ClientSecretBasic(
ClientSecret::new(
ctx.idp_client_secret
.clone()
.context("Missing idp_client_secret")?,
),
));
let resource_as = EmaAuthorizationServer::new(
metadata
.issuer
.context("Missing authorization server issuer")?,
metadata.token_endpoint,
ctx.client_id.as_deref().context("Missing client_id")?,
)
.with_client_authentication(EmaClientAuthentication::ClientSecretBasic(
ClientSecret::new(ctx.client_secret.clone().context("Missing client_secret")?),
));
let refresh_token = RefreshToken::new(
ctx.idp_refresh_token
.clone()
.context("Missing idp_refresh_token")?,
);
let http = default_oauth_http_client()?;
let token = EmaExchangeRequest::new(idp, resource_as, server_url, &refresh_token)
.with_scopes(manager.select_scopes(None, &[]))
.exchange(&http, &http)
.await?;
let transport = StreamableHttpClientTransport::from_config( // Decode PEM → DER
StreamableHttpClientTransportConfig::with_uri(server_url) let pem_body = pem
.auth_header(token.access_token.secret()), .lines()
); .filter(|l| !l.starts_with("-----"))
let client = BasicClientHandler .collect::<String>();
.serve_with_lifecycle(transport, conformance_lifecycle()) let der = base64_decode(&pem_body)?;
.await?;
let tools = client.list_tools(Default::default()).await?; // Parse PKCS#8 DER to get the raw EC private key bytes
for tool in tools.tools { // PKCS#8 for EC P-256: the raw 32-byte key is at the end of the structure
let args = build_tool_arguments(&tool); let raw_key = extract_ec_private_key(&der)?;
client.call_tool(call_tool_params(tool.name, args)).await?;
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());
}
} }
client.cancel().await?; Err(anyhow::anyhow!(
Ok(()) "Could not extract 32-byte EC private key from PKCS#8 DER"
))
} }
/// Cross-app access flow (SEP-1046 extension). /// Cross-app access flow (SEP-1046 extension).
@ -925,53 +780,16 @@ async fn run_basic_client(server_url: &str) -> anyhow::Result<()> {
Ok(()) Ok(())
} }
async fn run_tools_call_client(server_url: &str, ctx: &ConformanceContext) -> anyhow::Result<()> { async fn run_tools_call_client(server_url: &str) -> anyhow::Result<()> {
run_tools_call_client_with_lifecycle(server_url, ctx, conformance_lifecycle()).await
}
async fn run_discover_tools_call_client(
server_url: &str,
ctx: &ConformanceContext,
) -> anyhow::Result<()> {
run_tools_call_client_with_lifecycle(
server_url,
ctx,
ClientLifecycleMode::Discover {
preferred_versions: preferred_protocol_versions(),
},
)
.await
}
async fn run_tools_call_client_with_lifecycle(
server_url: &str,
ctx: &ConformanceContext,
lifecycle: ClientLifecycleMode,
) -> anyhow::Result<()> {
let transport = StreamableHttpClientTransport::from_uri(server_url); let transport = StreamableHttpClientTransport::from_uri(server_url);
let client = FullClientHandler let client = FullClientHandler.serve(transport).await?;
.serve_with_lifecycle(transport, lifecycle)
.await?;
let tools = client.list_tools(Default::default()).await?; let tools = client.list_tools(Default::default()).await?;
for tool in &tools.tools {
if ctx.tool_calls.is_empty() { let args = build_tool_arguments(tool);
for tool in &tools.tools { let _ = client
let args = build_tool_arguments(tool); .call_tool(call_tool_params(tool.name.clone(), args))
client .await?;
.call_tool(call_tool_params(tool.name.clone(), args))
.await?;
}
} else {
for tool_call in &ctx.tool_calls {
client
.call_tool(call_tool_params(
tool_call.name.clone().into(),
tool_call.arguments.clone(),
))
.await?;
}
} }
client.cancel().await?; client.cancel().await?;
Ok(()) Ok(())
} }
@ -993,58 +811,6 @@ async fn run_elicitation_defaults_client(server_url: &str) -> anyhow::Result<()>
Ok(()) Ok(())
} }
fn conformance_protocol_version() -> ProtocolVersion {
std::env::var("MCP_CONFORMANCE_PROTOCOL_VERSION")
.ok()
.and_then(|version| serde_json::from_value(Value::String(version)).ok())
.unwrap_or(ProtocolVersion::V_2025_11_25)
}
fn conformance_lifecycle() -> ClientLifecycleMode {
if conformance_protocol_version().as_str() >= ProtocolVersion::V_2026_07_28.as_str() {
ClientLifecycleMode::Discover {
preferred_versions: preferred_protocol_versions(),
}
} else {
ClientLifecycleMode::Initialize
}
}
/// Preferred protocol versions for discover-lifecycle negotiation: the
/// runner-provided version first, then all other known versions newest-first.
fn preferred_protocol_versions() -> Vec<ProtocolVersion> {
let mut preferred_versions = vec![conformance_protocol_version()];
for version in ProtocolVersion::KNOWN_VERSIONS.iter().rev() {
if !preferred_versions.contains(version) {
preferred_versions.push(version.clone());
}
}
preferred_versions
}
/// Runs scenarios through the discover lifecycle and Streamable HTTP transport.
async fn run_discover_client(server_url: &str) -> anyhow::Result<()> {
let preferred_versions = preferred_protocol_versions();
let transport = StreamableHttpClientTransport::from_uri(server_url);
let client = FullClientHandler
.serve_with_lifecycle(
transport,
ClientLifecycleMode::Discover { preferred_versions },
)
.await?;
let tools = client.list_tools(Default::default()).await?;
tracing::debug!("Listed {} tools", tools.tools.len());
for tool in &tools.tools {
let result = client
.call_tool(CallToolRequestParams::new(tool.name.clone()))
.await;
tracing::debug!("Called {}: {:?}", tool.name, result.is_ok());
}
client.cancel().await?;
Ok(())
}
async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> { async fn run_sse_retry_client(server_url: &str) -> anyhow::Result<()> {
let transport = StreamableHttpClientTransport::from_uri(server_url); let transport = StreamableHttpClientTransport::from_uri(server_url);
let client = BasicClientHandler.serve(transport).await?; let client = BasicClientHandler.serve(transport).await?;
@ -1082,50 +848,14 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("Running scenario '{}' against {}", scenario, server_url); tracing::info!("Running scenario '{}' against {}", scenario, server_url);
// Safety net: some harness servers intentionally misbehave (e.g. reply match scenario.as_str() {
// with an id-less error instead of answering a request), which would
// leave the client waiting forever. Exit on our own before the harness's
// 30s client timeout so it never has to kill us (which has been observed
// to wedge the harness process in CI).
let timeout_secs: u64 = std::env::var("MCP_CONFORMANCE_TIMEOUT_SECS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(25);
tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
run_scenario(&scenario, &server_url, &ctx),
)
.await
.map_err(|_| anyhow::anyhow!("Scenario '{scenario}' timed out after {timeout_secs}s"))??;
Ok(())
}
async fn run_scenario(
scenario: &str,
server_url: &str,
ctx: &ConformanceContext,
) -> anyhow::Result<()> {
match scenario {
// Non-auth scenarios // Non-auth scenarios
"initialize" => run_basic_client(server_url).await?, "initialize" => run_basic_client(&server_url).await?,
// SEP-2106: the scenario serves a tool whose schema carries a network "tools_call" => run_tools_call_client(&server_url).await?,
// `$ref`; the check passes when the client lists tools without
// dereferencing (fetching) that URL. A plain connect → list_tools →
// close is sufficient; the scenario's mock server does not implement
// the discover lifecycle, so `run_discover_client` hangs against it.
"json-schema-ref-no-deref" => run_basic_client(server_url).await?,
"tools_call" => run_tools_call_client(server_url, ctx).await?,
"elicitation-sep1034-client-defaults" => { "elicitation-sep1034-client-defaults" => {
run_elicitation_defaults_client(server_url).await? run_elicitation_defaults_client(&server_url).await?
}
"sse-retry" => run_sse_retry_client(server_url).await?,
"request-metadata" | "sep-2322-client-request-state" => {
run_discover_client(server_url).await?
}
"http-standard-headers" | "http-custom-headers" | "http-invalid-tool-headers" => {
run_discover_tools_call_client(server_url, ctx).await?
} }
"sse-retry" => run_sse_retry_client(&server_url).await?,
// Auth scenarios - standard OAuth flow // Auth scenarios - standard OAuth flow
"auth/metadata-default" "auth/metadata-default"
@ -1140,42 +870,21 @@ async fn run_scenario(
| "auth/token-endpoint-auth-post" | "auth/token-endpoint-auth-post"
| "auth/token-endpoint-auth-none" | "auth/token-endpoint-auth-none"
| "auth/2025-03-26-oauth-metadata-backcompat" | "auth/2025-03-26-oauth-metadata-backcompat"
| "auth/2025-03-26-oauth-endpoint-fallback" | "auth/2025-03-26-oauth-endpoint-fallback" => run_auth_client(&server_url, &ctx).await?,
// Offline access scope handling: positive/negative variants both run
// the well-behaved flow; the referee inspects the requested scopes.
| "auth/offline-access-scope"
| "auth/offline-access-not-supported"
// SEP-2468 (RFC 9207 iss / RFC 8414 §3.3 issuer-echo). The client
// captures `iss` from the authorization redirect and passes it to the
// callback handler; the SDK validates internally. Positive scenarios
// proceed to the token endpoint; negative scenarios error out (the
// referee sets `allowClientError`).
| "auth/iss-supported"
| "auth/iss-not-advertised"
| "auth/iss-supported-missing"
| "auth/iss-wrong-issuer"
| "auth/iss-unexpected"
| "auth/iss-normalized"
| "auth/metadata-issuer-mismatch" => run_auth_client(server_url, ctx).await?,
// Auth - scope step-up // Auth - scope step-up
"auth/scope-step-up" => run_auth_scope_step_up_client(server_url, ctx).await?, "auth/scope-step-up" => run_auth_scope_step_up_client(&server_url, &ctx).await?,
// Auth - scope retry limit // Auth - scope retry limit
"auth/scope-retry-limit" => run_auth_scope_retry_limit_client(server_url, ctx).await?, "auth/scope-retry-limit" => run_auth_scope_retry_limit_client(&server_url, &ctx).await?,
// Auth - authorization server migration (SEP-2352)
"auth/authorization-server-migration" => {
run_auth_server_migration_client(server_url, ctx).await?
}
// Auth - pre-registration // Auth - pre-registration
"auth/pre-registration" => run_auth_preregistered_client(server_url, ctx).await?, "auth/pre-registration" => run_auth_preregistered_client(&server_url, &ctx).await?,
// Auth - resource mismatch (should fail to auth → pass) // Auth - resource mismatch (should fail to auth → pass)
"auth/resource-mismatch" => { "auth/resource-mismatch" => {
// Try to auth; it should fail because PRM resource doesn't match // Try to auth; it should fail because PRM resource doesn't match
match run_auth_client(server_url, ctx).await { match run_auth_client(&server_url, &ctx).await {
Ok(_) => { Ok(_) => {
tracing::warn!("Auth succeeded despite resource mismatch!"); tracing::warn!("Auth succeeded despite resource mismatch!");
} }
@ -1186,48 +895,25 @@ async fn run_scenario(
} }
// Auth - client credentials // Auth - client credentials
"auth/client-credentials-basic" => run_client_credentials_basic(server_url, ctx).await?, "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/client-credentials-jwt" => run_client_credentials_jwt(&server_url, &ctx).await?,
// Auth - enterprise-managed authorization with a refresh-token subject
"auth/enterprise-managed-authorization-refresh-token" => {
run_ema_refresh_token_client(server_url, ctx).await?
}
// Auth - cross-app access // Auth - cross-app access
"auth/cross-app-access-complete-flow" => { "auth/cross-app-access-complete-flow" => {
run_cross_app_access_client(server_url, ctx).await? run_cross_app_access_client(&server_url, &ctx).await?
} }
unknown => anyhow::bail!("Unsupported conformance scenario: {unknown}"), _ => {
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(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conformance_context_accepts_camel_case_tool_calls() {
let context: ConformanceContext = serde_json::from_value(json!({
"toolCalls": [{
"name": "test_custom_headers",
"arguments": { "region": "us-west1" }
}]
}))
.expect("valid conformance context");
assert_eq!(
context.tool_calls.first().map(|tool_call| (
tool_call.name.as_str(),
tool_call
.arguments
.as_ref()
.and_then(|arguments| arguments.get("region")),
)),
Some(("test_custom_headers", Some(&json!("us-west1"))))
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -7,99 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [3.4.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.3.0...rmcp-macros-v3.4.0) - 2026-09-15
### Added
- *(model)* add ServerConfig and ClientConfig ([#1266](https://github.com/modelcontextprotocol/rust-sdk/pull/1266))
### Fixed
- *(model)* deprecate ServerInfo and ClientInfo aliases ([#1156](https://github.com/modelcontextprotocol/rust-sdk/pull/1156))
## [3.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.2.0...rmcp-macros-v3.3.0) - 2026-09-10
### Added
- *(macros)* reject empty tool_router ([#1233](https://github.com/modelcontextprotocol/rust-sdk/pull/1233))
## [3.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.1.4...rmcp-macros-v3.2.0) - 2026-08-31
### Added
- add request-state key rotation ([#1128](https://github.com/modelcontextprotocol/rust-sdk/pull/1128))
### Fixed
- allow concurrent streamable http requests ([#1186](https://github.com/modelcontextprotocol/rust-sdk/pull/1186))
## [3.1.3](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.1.2...rmcp-macros-v3.1.3) - 2026-08-17
### Fixed
- time out auto discovery probe ([#1149](https://github.com/modelcontextprotocol/rust-sdk/pull/1149))
## [3.1.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.1.0...rmcp-macros-v3.1.1) - 2026-08-05
### Fixed
- emit cache hints from handler macros ([#1120](https://github.com/modelcontextprotocol/rust-sdk/pull/1120))
### Other
- upgrade darling and syn ([#1138](https://github.com/modelcontextprotocol/rust-sdk/pull/1138))
## [3.1.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.1...rmcp-macros-v3.1.0) - 2026-07-31
### Added
- add strict stateless protocol metadata validation ([#1091](https://github.com/modelcontextprotocol/rust-sdk/pull/1091))
### Other
- document the ping utility with examples ([#1106](https://github.com/modelcontextprotocol/rust-sdk/pull/1106))
- complete Tier 1 feature docs and finalize roadmap ([#1101](https://github.com/modelcontextprotocol/rust-sdk/pull/1101))
- *(conformance)* meeting requirements for tier 1 ([#1087](https://github.com/modelcontextprotocol/rust-sdk/pull/1087))
## [3.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.5...rmcp-macros-v3.0.0) - 2026-07-28
### Other
- release stable 3.0.0
## [3.0.0-beta.5](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.4...rmcp-macros-v3.0.0-beta.5) - 2026-07-28
### Other
- prepare for stable 3.0 release ([#1073](https://github.com/modelcontextprotocol/rust-sdk/pull/1073))
## [3.0.0-beta.2](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.0.0-beta.1...rmcp-macros-v3.0.0-beta.2) - 2026-07-24
### Fixed
- [**breaking**] omit resultType for legacy protocol sessions ([#1038](https://github.com/modelcontextprotocol/rust-sdk/pull/1038))
### Other
- declare and check MSRV ([#1034](https://github.com/modelcontextprotocol/rust-sdk/pull/1034))
## [3.0.0-beta.1](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v2.2.0...rmcp-macros-v3.0.0-beta.1) - 2026-07-23
### Added
- add client-side TTL-honoring response cache (SEP-2549) ([#1025](https://github.com/modelcontextprotocol/rust-sdk/pull/1025))
- [**breaking**] Implement SEP-2663 Tasks Extension ([#1020](https://github.com/modelcontextprotocol/rust-sdk/pull/1020))
- add subscription listen streams (SEP-2575) ([#1000](https://github.com/modelcontextprotocol/rust-sdk/pull/1000))
- add modern client lifecycle modes (SEP-2575) ([#995](https://github.com/modelcontextprotocol/rust-sdk/pull/995))
- [**breaking**] implement SEP-2549 cache hints ([#889](https://github.com/modelcontextprotocol/rust-sdk/pull/889))
- [**breaking**] add MRTR behavior support (SEP-2322) ([#929](https://github.com/modelcontextprotocol/rust-sdk/pull/929))
- relax outputSchema to accept non-object JSON Schema types (SEP-2106) ([#895](https://github.com/modelcontextprotocol/rust-sdk/pull/895))
- [**breaking**] add MRTR model types (SEP-2322) ([#915](https://github.com/modelcontextprotocol/rust-sdk/pull/915))
### Other
- update for 2026-07-28 version ([#1032](https://github.com/modelcontextprotocol/rust-sdk/pull/1032))
## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.8.0...rmcp-macros-v2.0.0) - 2026-06-27 ## [2.0.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v1.8.0...rmcp-macros-v2.0.0) - 2026-06-27
### Added ### Added

Some files were not shown because too many files have changed in this diff Show more