Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd2382755 |
||
|
|
9f7ace357a |
||
|
|
011f281ced |
||
|
|
0cfbe7f453 |
||
|
|
fd7811fdaa |
||
|
|
0550e13c1b |
||
|
|
b037c0fa88 |
||
|
|
d481cb0eec |
||
|
|
47fcdf4b57 |
||
|
|
165ff92df1 |
||
|
|
fbfc5cc96e |
||
|
|
3075dc9152 |
||
|
|
a5d1169e20 |
||
|
|
4fa84a5130 |
||
|
|
b86995aebd |
||
|
|
e27c5d1c24 |
||
|
|
3e636cab26 |
||
|
|
b2c90b1edc |
||
|
|
46db531df9 |
||
|
|
744b9f904c |
||
|
|
e8a0d7099a |
||
|
|
6fa40536de |
||
|
|
f9a68bfa5c |
||
|
|
7d14f3a8a2 |
||
|
|
04c2f3fac9 |
||
|
|
add9cbeab5 |
||
|
|
302319861a |
||
|
|
3b5ca4dd3f |
||
|
|
42cf8d9fa3 |
94 changed files with 6149 additions and 860 deletions
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
|
|
@ -21,6 +21,10 @@ updates:
|
||||||
# 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"
|
||||||
|
|
|
||||||
32
.github/workflows/ci.yml
vendored
32
.github/workflows/ci.yml
vendored
|
|
@ -31,12 +31,10 @@ jobs:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install commitlint
|
- name: Install commitlint
|
||||||
run: |
|
run: npm install --global @commitlint/cli@20.4.3 @commitlint/config-conventional@20.4.3
|
||||||
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: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
run: commitlint --extends @commitlint/config-conventional --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
name: Code Formatting
|
name: Code Formatting
|
||||||
|
|
@ -61,9 +59,17 @@ jobs:
|
||||||
|
|
||||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||||
|
|
||||||
- name: Run clippy
|
- name: Run clippy (all features)
|
||||||
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
|
||||||
|
|
@ -79,7 +85,7 @@ jobs:
|
||||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||||
|
|
||||||
- name: Install cargo-semver-checks
|
- name: Install cargo-semver-checks
|
||||||
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
|
uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12
|
||||||
with:
|
with:
|
||||||
tool: cargo-semver-checks
|
tool: cargo-semver-checks
|
||||||
|
|
||||||
|
|
@ -133,7 +139,7 @@ jobs:
|
||||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
|
||||||
|
|
||||||
- name: Install cargo-public-api
|
- name: Install cargo-public-api
|
||||||
uses: taiki-e/install-action@b6ff580856c41316412a0b9b60540fbc6f8c82cc # v2.86.7
|
uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12
|
||||||
with:
|
with:
|
||||||
tool: cargo-public-api
|
tool: cargo-public-api
|
||||||
|
|
||||||
|
|
@ -206,7 +212,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@1a51d4b5a03bb97576af186c813af67e9137ba7c # master
|
uses: crate-ci/typos@d43b6c087ac471e2ea7b8af622ff15f05c0c365b # master
|
||||||
|
|
||||||
msrv:
|
msrv:
|
||||||
name: Check MSRV
|
name: Check MSRV
|
||||||
|
|
@ -245,7 +251,7 @@ jobs:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||||
|
|
@ -274,7 +280,7 @@ jobs:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||||
|
|
@ -310,7 +316,7 @@ jobs:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||||
|
|
@ -345,7 +351,7 @@ jobs:
|
||||||
node-version: '22'
|
node-version: '22'
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
|
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
|
||||||
|
|
||||||
- name: Install Rust
|
- name: Install Rust
|
||||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||||
|
|
|
||||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
|
|
@ -27,13 +27,13 @@ jobs:
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
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@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
|
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||||
|
|
|
||||||
48
.github/workflows/scorecard.yml
vendored
Normal file
48
.github/workflows/scorecard.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
# 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
|
||||||
|
|
@ -4,13 +4,13 @@ default-members = ["crates/rmcp", "crates/rmcp-macros"]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
rmcp = { version = "3.2.0", path = "./crates/rmcp" }
|
rmcp = { version = "3.4.0", path = "./crates/rmcp" }
|
||||||
rmcp-macros = { version = "3.2.0", path = "./crates/rmcp-macros" }
|
rmcp-macros = { version = "3.4.0", path = "./crates/rmcp-macros" }
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
version = "3.2.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/"
|
||||||
|
|
|
||||||
36
README.md
36
README.md
|
|
@ -99,10 +99,10 @@ Use [`ClientServiceExt::serve_with_lifecycle`](crates/rmcp/src/service/client.rs
|
||||||
select another lifecycle explicitly:
|
select another lifecycle explicitly:
|
||||||
|
|
||||||
```rust, ignore
|
```rust, ignore
|
||||||
use rmcp::{ClientInfo, ClientLifecycleMode, ClientServiceExt, ProtocolVersion};
|
use rmcp::{ClientLifecycleMode, ClientServiceExt, ProtocolVersion, model::ClientConfig};
|
||||||
|
|
||||||
// Start directly with server/discover and include client metadata on every request.
|
// Start directly with server/discover and include client metadata on every request.
|
||||||
let client = ClientInfo::default()
|
let client = ClientConfig::default()
|
||||||
.serve_with_lifecycle(
|
.serve_with_lifecycle(
|
||||||
transport,
|
transport,
|
||||||
ClientLifecycleMode::Discover {
|
ClientLifecycleMode::Discover {
|
||||||
|
|
@ -113,7 +113,7 @@ let client = ClientInfo::default()
|
||||||
|
|
||||||
// Or probe the discover lifecycle and fall back when a legacy server reports
|
// Or probe the discover lifecycle and fall back when a legacy server reports
|
||||||
// that server/discover is not implemented or does not respond within 10 seconds.
|
// that server/discover is not implemented or does not respond within 10 seconds.
|
||||||
let client = ClientInfo::default()
|
let client = ClientConfig::default()
|
||||||
.serve_with_lifecycle(
|
.serve_with_lifecycle(
|
||||||
transport,
|
transport,
|
||||||
ClientLifecycleMode::Auto {
|
ClientLifecycleMode::Auto {
|
||||||
|
|
@ -369,8 +369,8 @@ use serde_json::json;
|
||||||
struct MyServer;
|
struct MyServer;
|
||||||
|
|
||||||
impl ServerHandler for MyServer {
|
impl ServerHandler for MyServer {
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::new(
|
ServerConfig::new(
|
||||||
ServerCapabilities::builder()
|
ServerCapabilities::builder()
|
||||||
.enable_resources()
|
.enable_resources()
|
||||||
.build(),
|
.build(),
|
||||||
|
|
@ -582,8 +582,8 @@ impl MyServer {
|
||||||
|
|
||||||
#[prompt_handler]
|
#[prompt_handler]
|
||||||
impl ServerHandler for MyServer {
|
impl ServerHandler for MyServer {
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::new(ServerCapabilities::builder().enable_prompts().build())
|
ServerConfig::new(ServerCapabilities::builder().enable_prompts().build())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
@ -934,8 +934,8 @@ Enable the logging capability, handle level changes from the client, and send lo
|
||||||
use rmcp::{ServerHandler, model::*, service::RequestContext};
|
use rmcp::{ServerHandler, model::*, service::RequestContext};
|
||||||
|
|
||||||
impl ServerHandler for MyServer {
|
impl ServerHandler for MyServer {
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::new(
|
ServerConfig::new(
|
||||||
ServerCapabilities::builder()
|
ServerCapabilities::builder()
|
||||||
.enable_logging()
|
.enable_logging()
|
||||||
.build(),
|
.build(),
|
||||||
|
|
@ -1007,8 +1007,8 @@ Enable the completions capability and implement the `complete()` handler. Use `r
|
||||||
use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer};
|
use rmcp::{ErrorData as McpError, ServerHandler, model::*, service::RequestContext, RoleServer};
|
||||||
|
|
||||||
impl ServerHandler for MyServer {
|
impl ServerHandler for MyServer {
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::new(
|
ServerConfig::new(
|
||||||
ServerCapabilities::builder()
|
ServerCapabilities::builder()
|
||||||
.enable_completions()
|
.enable_completions()
|
||||||
.enable_prompts()
|
.enable_prompts()
|
||||||
|
|
@ -1253,8 +1253,8 @@ use rmcp::{
|
||||||
};
|
};
|
||||||
|
|
||||||
impl ServerHandler for MyServer {
|
impl ServerHandler for MyServer {
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::new(
|
ServerConfig::new(
|
||||||
ServerCapabilities::builder()
|
ServerCapabilities::builder()
|
||||||
.enable_tools()
|
.enable_tools()
|
||||||
.enable_tool_list_changed()
|
.enable_tool_list_changed()
|
||||||
|
|
@ -1584,7 +1584,7 @@ use rmcp::transport::StreamableHttpClientTransport;
|
||||||
|
|
||||||
// Defaults are stateless-friendly.
|
// Defaults are stateless-friendly.
|
||||||
let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp");
|
let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp");
|
||||||
let client = ClientInfo::default().serve(transport).await?;
|
let client = ClientConfig::default().serve(transport).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example:** [`examples/servers/src/counter_streamhttp.rs`](examples/servers/src/counter_streamhttp.rs) (server), [`examples/clients/src/streamable_http.rs`](examples/clients/src/streamable_http.rs) (client)
|
**Example:** [`examples/servers/src/counter_streamhttp.rs`](examples/servers/src/counter_streamhttp.rs) (server), [`examples/clients/src/streamable_http.rs`](examples/clients/src/streamable_http.rs) (client)
|
||||||
|
|
@ -1638,7 +1638,7 @@ server example). The client transport connects with a single URI:
|
||||||
use rmcp::transport::StreamableHttpClientTransport;
|
use rmcp::transport::StreamableHttpClientTransport;
|
||||||
|
|
||||||
let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp");
|
let transport = StreamableHttpClientTransport::from_uri("http://localhost:8000/mcp");
|
||||||
let client = ClientInfo::default().serve(transport).await?;
|
let client = ClientConfig::default().serve(transport).await?;
|
||||||
```
|
```
|
||||||
|
|
||||||
The client allows up to 16 ordinary http POSTs at once. Configure this with
|
The client allows up to 16 ordinary http POSTs at once. Configure this with
|
||||||
|
|
@ -1742,10 +1742,10 @@ knows what the other supports. Declare yours with the `ServerCapabilities`
|
||||||
builder in `get_info()`:
|
builder in `get_info()`:
|
||||||
|
|
||||||
```rust,ignore
|
```rust,ignore
|
||||||
use rmcp::model::{ServerCapabilities, ServerInfo};
|
use rmcp::model::{ServerCapabilities, ServerConfig};
|
||||||
|
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::new(
|
ServerConfig::new(
|
||||||
ServerCapabilities::builder()
|
ServerCapabilities::builder()
|
||||||
.enable_tools()
|
.enable_tools()
|
||||||
.enable_prompts()
|
.enable_prompts()
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ rmcp = { path = "../crates/rmcp", features = [
|
||||||
"elicitation",
|
"elicitation",
|
||||||
"auth",
|
"auth",
|
||||||
"auth-client-credentials-jwt",
|
"auth-client-credentials-jwt",
|
||||||
|
"auth-enterprise-managed",
|
||||||
"request-state",
|
"request-state",
|
||||||
"transport-streamable-http-server",
|
"transport-streamable-http-server",
|
||||||
"transport-streamable-http-client-reqwest",
|
"transport-streamable-http-client-reqwest",
|
||||||
|
|
@ -31,6 +32,7 @@ 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"
|
||||||
|
|
|
||||||
|
|
@ -1,3 +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, ClientLifecycleMode, ClientServiceExt, ErrorData, RoleClient, ServiceExt,
|
||||||
model::*,
|
model::*,
|
||||||
|
|
@ -6,7 +13,8 @@ use rmcp::{
|
||||||
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
|
AuthClient, AuthorizationManager, StreamableHttpClientTransport,
|
||||||
auth::{
|
auth::{
|
||||||
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
|
AuthorizationCallback, AuthorizationRequest, ClientCredentialsConfig,
|
||||||
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState,
|
InMemoryCredentialStore, JwtSigningAlgorithm, OAuthState, default_oauth_http_client,
|
||||||
|
enterprise::{EmaAuthorizationServer, EmaClientAuthentication, EmaExchangeRequest},
|
||||||
},
|
},
|
||||||
streamable_http_client::StreamableHttpClientTransportConfig,
|
streamable_http_client::StreamableHttpClientTransportConfig,
|
||||||
},
|
},
|
||||||
|
|
@ -36,6 +44,17 @@ 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 {
|
||||||
|
|
@ -55,8 +74,8 @@ impl ClientHandler for BasicClientHandler {}
|
||||||
struct ElicitationDefaultsClientHandler;
|
struct ElicitationDefaultsClientHandler;
|
||||||
|
|
||||||
impl ClientHandler for ElicitationDefaultsClientHandler {
|
impl ClientHandler for ElicitationDefaultsClientHandler {
|
||||||
fn get_info(&self) -> ClientInfo {
|
fn get_info(&self) -> ClientConfig {
|
||||||
let mut info = ClientInfo::default();
|
let mut info = ClientConfig::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)),
|
||||||
|
|
@ -144,8 +163,8 @@ impl ClientHandler for ElicitationDefaultsClientHandler {
|
||||||
struct FullClientHandler;
|
struct FullClientHandler;
|
||||||
|
|
||||||
impl ClientHandler for FullClientHandler {
|
impl ClientHandler for FullClientHandler {
|
||||||
fn get_info(&self) -> ClientInfo {
|
fn get_info(&self) -> ClientConfig {
|
||||||
let mut info = ClientInfo::default();
|
let mut info = ClientConfig::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)),
|
||||||
|
|
@ -760,6 +779,66 @@ async fn run_client_credentials_jwt(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exchange the fixture's IdP refresh token, then exercise authenticated MCP access.
|
||||||
|
async fn run_ema_refresh_token_client(
|
||||||
|
server_url: &str,
|
||||||
|
ctx: &ConformanceContext,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
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(
|
||||||
|
StreamableHttpClientTransportConfig::with_uri(server_url)
|
||||||
|
.auth_header(token.access_token.secret()),
|
||||||
|
);
|
||||||
|
let client = BasicClientHandler
|
||||||
|
.serve_with_lifecycle(transport, conformance_lifecycle())
|
||||||
|
.await?;
|
||||||
|
let tools = client.list_tools(Default::default()).await?;
|
||||||
|
for tool in tools.tools {
|
||||||
|
let args = build_tool_arguments(&tool);
|
||||||
|
client.call_tool(call_tool_params(tool.name, args)).await?;
|
||||||
|
}
|
||||||
|
client.cancel().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Cross-app access flow (SEP-1046 extension).
|
/// Cross-app access flow (SEP-1046 extension).
|
||||||
async fn run_cross_app_access_client(
|
async fn run_cross_app_access_client(
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
|
|
@ -1110,6 +1189,11 @@ async fn run_scenario(
|
||||||
"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?
|
||||||
|
|
|
||||||
|
|
@ -704,8 +704,8 @@ impl ServerHandler for ConformanceServer {
|
||||||
(name == "test_custom_header").then(custom_header_tool)
|
(name == "test_custom_header").then(custom_header_tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::new(
|
ServerConfig::new(
|
||||||
ServerCapabilities::builder()
|
ServerCapabilities::builder()
|
||||||
.enable_prompts()
|
.enable_prompts()
|
||||||
.enable_prompts_list_changed()
|
.enable_prompts_list_changed()
|
||||||
|
|
@ -1744,7 +1744,9 @@ async fn main() -> anyhow::Result<()> {
|
||||||
tracing::info!("Starting conformance server on {}", bind_addr);
|
tracing::info!("Starting conformance server on {}", bind_addr);
|
||||||
|
|
||||||
let server = ConformanceServer::new();
|
let server = ConformanceServer::new();
|
||||||
let config = StreamableHttpServerConfig::default();
|
let config = StreamableHttpServerConfig::default()
|
||||||
|
.with_allowed_origins([format!("http://{bind_addr}")])
|
||||||
|
.enforce_origin_validation();
|
||||||
let service = StreamableHttpService::new(
|
let service = StreamableHttpService::new(
|
||||||
move || Ok(server.clone()),
|
move || Ok(server.clone()),
|
||||||
LocalSessionManager::default().into(),
|
LocalSessionManager::default().into(),
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,22 @@ 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
|
## [3.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-macros-v3.1.4...rmcp-macros-v3.2.0) - 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
/// | `router` | `Ident` | The name of the router function to be generated. Defaults to `tool_router`. |
|
/// | `router` | `Ident` | The name of the router function to be generated. Defaults to `tool_router`. |
|
||||||
/// | `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. |
|
/// | `vis` | `Visibility` | The visibility of the generated router function. Defaults to empty. |
|
||||||
/// | `server_handler` | `flag` | When set, also emits `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so you can omit a separate `#[tool_handler]` block. |
|
/// | `server_handler` | `flag` | When set, also emits `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so you can omit a separate `#[tool_handler]` block. |
|
||||||
|
/// | `allow_empty` | `flag` | When set, accepts an impl block with no `#[tool]` fn. Without it, an empty router is a compile error. |
|
||||||
///
|
///
|
||||||
/// ## Example
|
/// ## Example
|
||||||
///
|
///
|
||||||
|
|
@ -122,6 +123,37 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
///
|
||||||
|
/// ### Empty routers
|
||||||
|
///
|
||||||
|
/// Collecting tools is this attribute's whole purpose, so an impl block with no `#[tool]` fn is a
|
||||||
|
/// compile error rather than a router that silently serves nothing. Pass `allow_empty` when that
|
||||||
|
/// is what you want:
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// #[tool_router(allow_empty)]
|
||||||
|
/// impl MyToolHandler {}
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// The usual way to hit this by accident is a `macro_rules!` helper *inside* the impl block. An
|
||||||
|
/// attribute macro receives the unexpanded item, so `#[tool]` fns produced by such a helper are
|
||||||
|
/// invisible to `#[tool_router]`. Let the `macro_rules!` emit the whole annotated impl instead:
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// macro_rules! define_tools {
|
||||||
|
/// ($($name:ident => $description:literal),* $(,)?) => {
|
||||||
|
/// #[tool_router]
|
||||||
|
/// impl MyToolHandler {
|
||||||
|
/// $(
|
||||||
|
/// #[tool(description = $description)]
|
||||||
|
/// async fn $name(&self) -> String { stringify!($name).to_owned() }
|
||||||
|
/// )*
|
||||||
|
/// }
|
||||||
|
/// };
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// define_tools!(my_tool => "what my tool does");
|
||||||
|
/// ```
|
||||||
#[proc_macro_attribute]
|
#[proc_macro_attribute]
|
||||||
pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream {
|
pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
tool_router::tool_router(attr.into(), input.into())
|
tool_router::tool_router(attr.into(), input.into())
|
||||||
|
|
@ -185,8 +217,8 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
/// ```rust,ignore
|
/// ```rust,ignore
|
||||||
/// #[tool_handler]
|
/// #[tool_handler]
|
||||||
/// impl ServerHandler for MyToolHandler {
|
/// impl ServerHandler for MyToolHandler {
|
||||||
/// fn get_info(&self) -> ServerInfo {
|
/// fn get_info(&self) -> ServerConfig {
|
||||||
/// ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
/// ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ pub(crate) enum CallerCapability {
|
||||||
Prompts,
|
Prompts,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `get_info()` method that returns `ServerInfo` with the appropriate capabilities.
|
/// Build a `get_info()` method that returns `ServerConfig` with the appropriate capabilities.
|
||||||
///
|
///
|
||||||
/// The caller declares its own capability via `caller`. Sibling handler attributes
|
/// The caller declares its own capability via `caller`. Sibling handler attributes
|
||||||
/// (`prompt_handler`, `tool_handler`) are detected automatically
|
/// (`prompt_handler`, `tool_handler`) are detected automatically
|
||||||
|
|
@ -157,8 +157,8 @@ pub(crate) fn build_get_info(
|
||||||
}
|
}
|
||||||
|
|
||||||
syn::parse2::<ImplItem>(quote! {
|
syn::parse2::<ImplItem>(quote! {
|
||||||
fn get_info(&self) -> rmcp::model::ServerInfo {
|
fn get_info(&self) -> rmcp::model::ServerConfig {
|
||||||
rmcp::model::ServerInfo::new(
|
rmcp::model::ServerConfig::new(
|
||||||
rmcp::model::ServerCapabilities::builder()
|
rmcp::model::ServerCapabilities::builder()
|
||||||
#(#capability_calls)*
|
#(#capability_calls)*
|
||||||
.build()
|
.build()
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ pub struct ToolRouterAttribute {
|
||||||
/// When set, also emit `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so callers
|
/// When set, also emit `#[::rmcp::tool_handler]` on `impl ServerHandler for Self` so callers
|
||||||
/// can skip a separate `#[tool_handler]` block (expanded in a later macro pass).
|
/// can skip a separate `#[tool_handler]` block (expanded in a later macro pass).
|
||||||
pub server_handler: bool,
|
pub server_handler: bool,
|
||||||
|
/// When set, accept an impl block with no `#[tool]` fn instead of reporting an error.
|
||||||
|
pub allow_empty: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ToolRouterAttribute {
|
impl Default for ToolRouterAttribute {
|
||||||
|
|
@ -25,6 +27,7 @@ impl Default for ToolRouterAttribute {
|
||||||
router: format_ident!("tool_router"),
|
router: format_ident!("tool_router"),
|
||||||
vis: None,
|
vis: None,
|
||||||
server_handler: false,
|
server_handler: false,
|
||||||
|
allow_empty: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -35,6 +38,7 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result<TokenSt
|
||||||
router,
|
router,
|
||||||
vis,
|
vis,
|
||||||
server_handler,
|
server_handler,
|
||||||
|
allow_empty,
|
||||||
} = ToolRouterAttribute::from_list(&attr_args)?;
|
} = ToolRouterAttribute::from_list(&attr_args)?;
|
||||||
let mut item_impl = syn::parse2::<ItemImpl>(input)?;
|
let mut item_impl = syn::parse2::<ItemImpl>(input)?;
|
||||||
// find all function marked with `#[rmcp::tool]`
|
// find all function marked with `#[rmcp::tool]`
|
||||||
|
|
@ -58,6 +62,19 @@ pub fn tool_router(attr: TokenStream, input: TokenStream) -> syn::Result<TokenSt
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
if tool_attr_fns.is_empty() && !allow_empty {
|
||||||
|
return Err(syn::Error::new_spanned(
|
||||||
|
&item_impl.self_ty,
|
||||||
|
format!(
|
||||||
|
"`#[tool_router]` found no `#[tool]` fn in this impl block, so `Self::{router}()` \
|
||||||
|
would serve no tools\n\
|
||||||
|
note: a `macro_rules!` invocation inside the impl block is not expanded before \
|
||||||
|
this attribute runs, so any `#[tool]` fn it generates is invisible here; let the \
|
||||||
|
`macro_rules!` emit the whole `#[tool_router] impl` instead\n\
|
||||||
|
note: use `#[tool_router(allow_empty)]` if an empty router is intended"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut routers = Vec::with_capacity(tool_attr_fns.len());
|
let mut routers = Vec::with_capacity(tool_attr_fns.len());
|
||||||
for handler in tool_attr_fns {
|
for handler in tool_attr_fns {
|
||||||
let tool_attr_fn_ident = format_ident!("{handler}_tool_attr");
|
let tool_attr_fn_ident = format_ident!("{handler}_tool_attr");
|
||||||
|
|
@ -111,6 +128,7 @@ mod test {
|
||||||
router,
|
router,
|
||||||
vis,
|
vis,
|
||||||
server_handler,
|
server_handler,
|
||||||
|
allow_empty,
|
||||||
} = ToolRouterAttribute::from_list(&attr_args)?;
|
} = ToolRouterAttribute::from_list(&attr_args)?;
|
||||||
assert_eq!(router.to_string(), "test_router");
|
assert_eq!(router.to_string(), "test_router");
|
||||||
assert!(vis.is_some(), "vis = \"pub(crate)\" should parse");
|
assert!(vis.is_some(), "vis = \"pub(crate)\" should parse");
|
||||||
|
|
@ -118,6 +136,10 @@ mod test {
|
||||||
!server_handler,
|
!server_handler,
|
||||||
"server_handler should default to false when omitted"
|
"server_handler should default to false when omitted"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
!allow_empty,
|
||||||
|
"allow_empty should default to false when omitted"
|
||||||
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,4 +159,54 @@ mod test {
|
||||||
assert!(server_handler);
|
assert!(server_handler);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_router_rejects_an_impl_without_directly_visible_tool_fns() {
|
||||||
|
let input = quote! {
|
||||||
|
impl Probe {
|
||||||
|
a_capability!(broadcast = "what a capability would own");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let message = tool_router(TokenStream::new(), input)
|
||||||
|
.expect_err("an impl with no `#[tool]` fn should not compile")
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains("`Self::tool_router()` would serve no tools"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
assert!(message.contains("macro_rules!"), "{message}");
|
||||||
|
assert!(message.contains("allow_empty"), "{message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_router_names_the_custom_router_fn_when_it_is_empty() {
|
||||||
|
let message = tool_router(quote! { router = custom_router }, quote! { impl Probe {} })
|
||||||
|
.expect_err("an impl with no `#[tool]` fn should not compile")
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains("`Self::custom_router()` would serve no tools"),
|
||||||
|
"{message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_router_accepts_an_impl_with_a_tool_fn() -> syn::Result<()> {
|
||||||
|
let input = quote! {
|
||||||
|
impl Probe {
|
||||||
|
#[tool(description = "probe")]
|
||||||
|
async fn probe(&self) -> String { "probed".to_owned() }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let generated = tool_router(TokenStream::new(), input)?.to_string();
|
||||||
|
assert!(generated.contains("with_route"), "{generated}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_router_allow_empty_generates_a_router_without_routes() -> syn::Result<()> {
|
||||||
|
let generated = tool_router(quote! { allow_empty }, quote! { impl Probe {} })?.to_string();
|
||||||
|
assert!(generated.contains("fn tool_router"), "{generated}");
|
||||||
|
assert!(!generated.contains("with_route"), "{generated}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,42 @@ 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-v3.3.0...rmcp-v3.4.0) - 2026-09-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- *(model)* add ServerConfig and ClientConfig ([#1266](https://github.com/modelcontextprotocol/rust-sdk/pull/1266))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- *(rmcp)* use ServerConfig in cancellation test ([#1273](https://github.com/modelcontextprotocol/rust-sdk/pull/1273))
|
||||||
|
- *(rmcp)* route peer cancellation by lifecycle ([#1262](https://github.com/modelcontextprotocol/rust-sdk/pull/1262))
|
||||||
|
- run first pre-init request in service loop ([#1263](https://github.com/modelcontextprotocol/rust-sdk/pull/1263))
|
||||||
|
- *(auth)* let url origin decide prm discovery ([#1264](https://github.com/modelcontextprotocol/rust-sdk/pull/1264))
|
||||||
|
- *(model)* deprecate ServerInfo and ClientInfo aliases ([#1156](https://github.com/modelcontextprotocol/rust-sdk/pull/1156))
|
||||||
|
- *(auth)* ignore non-metadata JSON when probing for protected resource metadata ([#1204](https://github.com/modelcontextprotocol/rust-sdk/pull/1204))
|
||||||
|
- do not treat malformed JSON 200 as Accepted for requests ([#1208](https://github.com/modelcontextprotocol/rust-sdk/pull/1208))
|
||||||
|
- *(streamable-http-server)* map handler-generated HeaderMismatch to HTTP 400 ([#1259](https://github.com/modelcontextprotocol/rust-sdk/pull/1259))
|
||||||
|
- *(http)* enforce Origin validation semantics ([#1192](https://github.com/modelcontextprotocol/rust-sdk/pull/1192))
|
||||||
|
|
||||||
|
## [3.3.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.2.0...rmcp-v3.3.0) - 2026-09-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- add ServerHandler::negotiate_initialize ([#1247](https://github.com/modelcontextprotocol/rust-sdk/pull/1247))
|
||||||
|
- *(macros)* reject empty tool_router ([#1233](https://github.com/modelcontextprotocol/rust-sdk/pull/1233))
|
||||||
|
- *(auth)* add enterprise refresh-token and ID-JAG exchanges ([#1234](https://github.com/modelcontextprotocol/rust-sdk/pull/1234))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- *(sse)* saturate exponential reconnect backoff to avoid overflow panic ([#1231](https://github.com/modelcontextprotocol/rust-sdk/pull/1231))
|
||||||
|
- resolve clippy warnings across workspace ([#1195](https://github.com/modelcontextprotocol/rust-sdk/pull/1195))
|
||||||
|
- *(auth)* unify refresh checks and error handling ([#1236](https://github.com/modelcontextprotocol/rust-sdk/pull/1236))
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- *(deps)* update process-wrap requirement from 9.0 to 10.0 ([#1229](https://github.com/modelcontextprotocol/rust-sdk/pull/1229))
|
||||||
|
|
||||||
## [3.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.1.4...rmcp-v3.2.0) - 2026-08-31
|
## [3.2.0](https://github.com/modelcontextprotocol/rust-sdk/compare/rmcp-v3.1.4...rmcp-v3.2.0) - 2026-08-31
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ exhaustive_enums = "warn"
|
||||||
features = [
|
features = [
|
||||||
"auth",
|
"auth",
|
||||||
"auth-client-credentials-jwt",
|
"auth-client-credentials-jwt",
|
||||||
|
"auth-enterprise-managed",
|
||||||
"base64",
|
"base64",
|
||||||
"client",
|
"client",
|
||||||
"client-side-sse",
|
"client-side-sse",
|
||||||
|
|
@ -87,7 +88,7 @@ url = { version = "2.4", optional = true }
|
||||||
tower-service = { version = "0.3", optional = true }
|
tower-service = { version = "0.3", optional = true }
|
||||||
|
|
||||||
# for child process transport
|
# for child process transport
|
||||||
process-wrap = { version = "9.0", features = ["tokio1"], optional = true }
|
process-wrap = { version = "10.0", features = ["tokio1"], optional = true }
|
||||||
|
|
||||||
# for cross-platform executable path resolution
|
# for cross-platform executable path resolution
|
||||||
which = { version = "8", optional = true }
|
which = { version = "8", optional = true }
|
||||||
|
|
@ -196,10 +197,11 @@ transport-streamable-http-server-session = [
|
||||||
tower = ["dep:tower-service"]
|
tower = ["dep:tower-service"]
|
||||||
auth = ["dep:async-trait", "dep:oauth2", "__reqwest", "dep:url"]
|
auth = ["dep:async-trait", "dep:oauth2", "__reqwest", "dep:url"]
|
||||||
auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"]
|
auth-client-credentials-jwt = ["auth", "dep:jsonwebtoken", "uuid"]
|
||||||
|
auth-enterprise-managed = ["auth", "base64"]
|
||||||
schemars = ["dep:schemars"]
|
schemars = ["dep:schemars"]
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full", "test-util"] }
|
||||||
schemars = { version = "1.1.0", features = ["chrono04"] }
|
schemars = { version = "1.1.0", features = ["chrono04"] }
|
||||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
|
axum = { version = "0.8", default-features = false, features = ["http1", "tokio"] }
|
||||||
hyper = { version = "1", features = ["server", "http1"] }
|
hyper = { version = "1", features = ["server", "http1"] }
|
||||||
|
|
@ -213,7 +215,7 @@ tracing-subscriber = { version = "0.3", features = [
|
||||||
"fmt",
|
"fmt",
|
||||||
] }
|
] }
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
rstest = "0.26.1"
|
rstest = "0.27.0"
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "test_tool_macros"
|
name = "test_tool_macros"
|
||||||
required-features = ["server", "client"]
|
required-features = ["server", "client"]
|
||||||
|
|
@ -346,6 +348,15 @@ name = "test_streamable_http_4xx_error_body"
|
||||||
required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"]
|
required-features = ["transport-streamable-http-client", "transport-streamable-http-client-reqwest"]
|
||||||
path = "tests/test_streamable_http_4xx_error_body.rs"
|
path = "tests/test_streamable_http_4xx_error_body.rs"
|
||||||
|
|
||||||
|
[[test]]
|
||||||
|
name = "test_streamable_http_malformed_json_request"
|
||||||
|
required-features = [
|
||||||
|
"client",
|
||||||
|
"transport-streamable-http-client",
|
||||||
|
"transport-streamable-http-client-reqwest",
|
||||||
|
]
|
||||||
|
path = "tests/test_streamable_http_malformed_json_request.rs"
|
||||||
|
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
name = "test_custom_request"
|
name = "test_custom_request"
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ For **getting started**, **usage guides**, and **full MCP feature documentation*
|
||||||
| `macros` | `#[tool]` / `#[prompt]` macros (re-exports [`rmcp-macros`](../rmcp-macros)) | ✅ |
|
| `macros` | `#[tool]` / `#[prompt]` macros (re-exports [`rmcp-macros`](../rmcp-macros)) | ✅ |
|
||||||
| `schemars` | JSON Schema generation for tool definitions | |
|
| `schemars` | JSON Schema generation for tool definitions | |
|
||||||
| `auth` | OAuth 2.0 authentication support | |
|
| `auth` | OAuth 2.0 authentication support | |
|
||||||
|
| `auth-enterprise-managed` | EMA/XAA refresh-token and ID-JAG exchanges for registered public and confidential clients (includes `auth`) | |
|
||||||
| `elicitation` | Elicitation support | |
|
| `elicitation` | Elicitation support | |
|
||||||
|
|
||||||
### Transport features
|
### Transport features
|
||||||
|
|
@ -45,6 +46,10 @@ For **getting started**, **usage guides**, and **full MCP feature documentation*
|
||||||
| `reqwest-native-tls` | Uses platform-native TLS (OpenSSL / Secure Transport / SChannel) |
|
| `reqwest-native-tls` | Uses platform-native TLS (OpenSSL / Secure Transport / SChannel) |
|
||||||
| `reqwest-tls-no-provider` | Uses rustls without a default crypto provider (bring your own) |
|
| `reqwest-tls-no-provider` | Uses rustls without a default crypto provider (bring your own) |
|
||||||
|
|
||||||
|
For enterprise-managed authorization, enable `auth-enterprise-managed` and a TLS
|
||||||
|
backend such as `reqwest`. See the [EMA/XAA guide](../../docs/OAUTH_SUPPORT.md#enterprise-managed-authorization-emaxaa)
|
||||||
|
for client authentication and an MCP connection example.
|
||||||
|
|
||||||
## Transports
|
## Transports
|
||||||
|
|
||||||
The transport layer is pluggable. Two built-in pairs cover the most common cases:
|
The transport layer is pluggable. Two built-in pairs cover the most common cases:
|
||||||
|
|
|
||||||
|
|
@ -274,8 +274,8 @@ macro_rules! client_handler_methods {
|
||||||
std::future::ready(())
|
std::future::ready(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_info(&self) -> ClientInfo {
|
fn get_info(&self) -> ClientConfig {
|
||||||
ClientInfo::default()
|
ClientConfig::default()
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -292,12 +292,12 @@ pub trait ClientHandler: Sized + 'static {
|
||||||
client_handler_methods!();
|
client_handler_methods!();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Do nothing, with default client info.
|
/// Do nothing, with the default client config.
|
||||||
impl ClientHandler for () {}
|
impl ClientHandler for () {}
|
||||||
|
|
||||||
/// Do nothing, with a specific client info.
|
/// Do nothing, with a specific client config.
|
||||||
impl ClientHandler for ClientInfo {
|
impl ClientHandler for ClientConfig {
|
||||||
fn get_info(&self) -> ClientInfo {
|
fn get_info(&self) -> ClientConfig {
|
||||||
self.clone()
|
self.clone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -422,7 +422,7 @@ macro_rules! impl_client_handler_for_wrapper {
|
||||||
(**self).on_custom_notification(notification, context)
|
(**self).on_custom_notification(notification, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_info(&self) -> ClientInfo {
|
fn get_info(&self) -> ClientConfig {
|
||||||
(**self).get_info()
|
(**self).get_info()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -321,16 +321,59 @@ macro_rules! server_handler_methods {
|
||||||
context: RequestContext<RoleServer>,
|
context: RequestContext<RoleServer>,
|
||||||
) -> impl Future<Output = Result<InitializeResult, McpError>> + MaybeSendFuture + '_ {
|
) -> impl Future<Output = Result<InitializeResult, McpError>> + MaybeSendFuture + '_ {
|
||||||
context.peer.set_peer_info(request.clone());
|
context.peer.set_peer_info(request.clone());
|
||||||
|
std::future::ready(self.negotiate_initialize(&request))
|
||||||
|
}
|
||||||
|
/// Build the `initialize` response for `request`, negotiating the
|
||||||
|
/// protocol version against [`Self::supported_protocol_versions`].
|
||||||
|
///
|
||||||
|
/// This is the whole body of the default [`Self::initialize`] minus its
|
||||||
|
/// `set_peer_info` side effect, so a server that overrides `initialize`
|
||||||
|
/// to add its own can call this instead of restating the negotiation
|
||||||
|
/// rule:
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rmcp::{
|
||||||
|
/// ErrorData as McpError, RoleServer, ServerHandler,
|
||||||
|
/// model::{InitializeRequestParams, InitializeResult, ServerConfig},
|
||||||
|
/// service::RequestContext,
|
||||||
|
/// };
|
||||||
|
///
|
||||||
|
/// struct MyServer;
|
||||||
|
///
|
||||||
|
/// impl ServerHandler for MyServer {
|
||||||
|
/// fn get_info(&self) -> ServerConfig {
|
||||||
|
/// ServerConfig::default()
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// async fn initialize(
|
||||||
|
/// &self,
|
||||||
|
/// request: InitializeRequestParams,
|
||||||
|
/// context: RequestContext<RoleServer>,
|
||||||
|
/// ) -> Result<InitializeResult, McpError> {
|
||||||
|
/// // ... record telemetry, register the peer, etc.
|
||||||
|
/// context.peer.set_peer_info(request.clone());
|
||||||
|
/// self.negotiate_initialize(&request)
|
||||||
|
/// }
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`] when this server
|
||||||
|
/// supports no version that still has an `initialize` handshake.
|
||||||
|
///
|
||||||
|
/// [`ErrorCode::UNSUPPORTED_PROTOCOL_VERSION`]: crate::model::ErrorCode::UNSUPPORTED_PROTOCOL_VERSION
|
||||||
|
fn negotiate_initialize(
|
||||||
|
&self,
|
||||||
|
request: &InitializeRequestParams,
|
||||||
|
) -> Result<InitializeResult, McpError> {
|
||||||
let mut info = self.get_info();
|
let mut info = self.get_info();
|
||||||
let negotiated = negotiate_protocol_version(
|
info.protocol_version = negotiate_protocol_version(
|
||||||
&request.protocol_version,
|
&request.protocol_version,
|
||||||
std::mem::take(&mut info.protocol_version),
|
std::mem::take(&mut info.protocol_version),
|
||||||
&self.supported_protocol_versions(),
|
&self.supported_protocol_versions(),
|
||||||
);
|
)?;
|
||||||
std::future::ready(negotiated.map(|version| {
|
Ok(info)
|
||||||
info.protocol_version = version;
|
|
||||||
info
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
/// Return the protocol versions supported by this server.
|
/// Return the protocol versions supported by this server.
|
||||||
///
|
///
|
||||||
|
|
@ -339,6 +382,10 @@ macro_rules! server_handler_methods {
|
||||||
/// list is advertised by [`Self::discover`], bounds what `initialize`
|
/// list is advertised by [`Self::discover`], bounds what `initialize`
|
||||||
/// negotiation may agree to, and is what per-request versions are
|
/// negotiation may agree to, and is what per-request versions are
|
||||||
/// validated against.
|
/// validated against.
|
||||||
|
///
|
||||||
|
/// To support everything up to some ceiling, use
|
||||||
|
/// [`ProtocolVersion::known_up_to`] rather than filtering
|
||||||
|
/// [`ProtocolVersion::KNOWN_VERSIONS`] by hand.
|
||||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||||
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
|
Cow::Borrowed(ProtocolVersion::KNOWN_VERSIONS)
|
||||||
}
|
}
|
||||||
|
|
@ -553,8 +600,8 @@ macro_rules! server_handler_methods {
|
||||||
std::future::ready(())
|
std::future::ready(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
ServerInfo::default()
|
ServerConfig::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state.
|
/// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state.
|
||||||
|
|
@ -621,6 +668,13 @@ macro_rules! impl_server_handler_for_wrapper {
|
||||||
(**self).initialize(request, context)
|
(**self).initialize(request, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn negotiate_initialize(
|
||||||
|
&self,
|
||||||
|
request: &InitializeRequestParams,
|
||||||
|
) -> Result<InitializeResult, McpError> {
|
||||||
|
(**self).negotiate_initialize(request)
|
||||||
|
}
|
||||||
|
|
||||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||||
(**self).supported_protocol_versions()
|
(**self).supported_protocol_versions()
|
||||||
}
|
}
|
||||||
|
|
@ -785,7 +839,7 @@ macro_rules! impl_server_handler_for_wrapper {
|
||||||
(**self).on_custom_notification(notification, context)
|
(**self).on_custom_notification(notification, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_info(&self) -> ServerInfo {
|
fn get_info(&self) -> ServerConfig {
|
||||||
(**self).get_info()
|
(**self).get_info()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ impl ProtocolVersion {
|
||||||
/// First protocol version that requires SEP-2243 standard HTTP headers.
|
/// First protocol version that requires SEP-2243 standard HTTP headers.
|
||||||
pub const STANDARD_HEADERS: Self = Self::V_2026_07_28;
|
pub const STANDARD_HEADERS: Self = Self::V_2026_07_28;
|
||||||
|
|
||||||
/// All protocol versions known to this SDK.
|
/// All protocol versions known to this SDK, oldest first.
|
||||||
pub const KNOWN_VERSIONS: &[Self] = &[
|
pub const KNOWN_VERSIONS: &[Self] = &[
|
||||||
Self::V_2024_11_05,
|
Self::V_2024_11_05,
|
||||||
Self::V_2025_03_26,
|
Self::V_2025_03_26,
|
||||||
|
|
@ -190,6 +190,43 @@ impl ProtocolVersion {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
&self.0
|
&self.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The known versions up to and including `max`, oldest first.
|
||||||
|
///
|
||||||
|
/// Servers that implement every revision up to some ceiling can return
|
||||||
|
/// this from `supported_protocol_versions` instead of filtering
|
||||||
|
/// [`Self::KNOWN_VERSIONS`] by hand. `max` itself need not be a known
|
||||||
|
/// version; the result is empty when it predates all of them.
|
||||||
|
///
|
||||||
|
/// The result borrows from [`Self::KNOWN_VERSIONS`], so call it directly
|
||||||
|
/// in the method body — it needs no `static` and no `LazyLock`:
|
||||||
|
///
|
||||||
|
/// ```rust,ignore
|
||||||
|
/// const MAX_SUPPORTED: ProtocolVersion = ProtocolVersion::V_2025_11_25;
|
||||||
|
///
|
||||||
|
/// fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||||
|
/// Cow::Borrowed(ProtocolVersion::known_up_to(&MAX_SUPPORTED))
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// # use rmcp::model::ProtocolVersion;
|
||||||
|
/// assert_eq!(
|
||||||
|
/// ProtocolVersion::known_up_to(&ProtocolVersion::V_2025_06_18),
|
||||||
|
/// &[
|
||||||
|
/// ProtocolVersion::V_2024_11_05,
|
||||||
|
/// ProtocolVersion::V_2025_03_26,
|
||||||
|
/// ProtocolVersion::V_2025_06_18,
|
||||||
|
/// ],
|
||||||
|
/// );
|
||||||
|
/// ```
|
||||||
|
pub fn known_up_to(max: &Self) -> &'static [Self] {
|
||||||
|
let count = Self::KNOWN_VERSIONS
|
||||||
|
.iter()
|
||||||
|
.take_while(|version| version.as_str() <= max.as_str())
|
||||||
|
.count();
|
||||||
|
&Self::KNOWN_VERSIONS[..count]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Serialize for ProtocolVersion {
|
impl Serialize for ProtocolVersion {
|
||||||
|
|
@ -1082,7 +1119,58 @@ impl InitializeResult {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A server's own configuration: the protocol version, capabilities,
|
||||||
|
/// implementation identity, and instructions it advertises to clients.
|
||||||
|
///
|
||||||
|
/// This is what `ServerHandler::get_info` returns. It is an alias for
|
||||||
|
/// [`InitializeResult`] because the same value is sent as the `initialize`
|
||||||
|
/// response on the wire.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rmcp::model::{ServerCapabilities, ServerConfig};
|
||||||
|
///
|
||||||
|
/// let config = ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
|
||||||
|
/// .with_instructions("Call `add` to sum two numbers.");
|
||||||
|
/// assert!(config.capabilities.tools.is_some());
|
||||||
|
/// ```
|
||||||
|
pub type ServerConfig = InitializeResult;
|
||||||
|
|
||||||
|
/// A client's own configuration: the protocol version, capabilities, and
|
||||||
|
/// implementation identity it advertises to servers.
|
||||||
|
///
|
||||||
|
/// This is what `ClientHandler::get_info` returns. It is an alias for
|
||||||
|
/// [`InitializeRequestParams`] because the same value is sent as the
|
||||||
|
/// `initialize` request on the wire.
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```
|
||||||
|
/// use rmcp::model::{ClientCapabilities, ClientConfig, Implementation};
|
||||||
|
///
|
||||||
|
/// let config = ClientConfig::new(
|
||||||
|
/// ClientCapabilities::builder().enable_elicitation().build(),
|
||||||
|
/// Implementation::new("my-client", "1.0.0"),
|
||||||
|
/// );
|
||||||
|
/// assert!(config.capabilities.elicitation.is_some());
|
||||||
|
/// ```
|
||||||
|
pub type ClientConfig = InitializeRequestParams;
|
||||||
|
|
||||||
|
/// Deprecated alias for [`ServerConfig`].
|
||||||
|
///
|
||||||
|
/// The name collides with the protocol's `serverInfo` field, which is only the
|
||||||
|
/// [`Implementation`] identity, so `server_info.server_info` was easy to
|
||||||
|
/// misread (#1082).
|
||||||
|
#[deprecated(note = "use `ServerConfig` instead")]
|
||||||
pub type ServerInfo = InitializeResult;
|
pub type ServerInfo = InitializeResult;
|
||||||
|
|
||||||
|
/// Deprecated alias for [`ClientConfig`].
|
||||||
|
///
|
||||||
|
/// The name collides with the protocol's `clientInfo` field, which is only the
|
||||||
|
/// [`Implementation`] identity, so `client_info.client_info` was easy to
|
||||||
|
/// misread (#1082).
|
||||||
|
#[deprecated(note = "use `ClientConfig` instead")]
|
||||||
pub type ClientInfo = InitializeRequestParams;
|
pub type ClientInfo = InitializeRequestParams;
|
||||||
|
|
||||||
/// Information negotiated about a server peer.
|
/// Information negotiated about a server peer.
|
||||||
|
|
@ -1242,18 +1330,18 @@ impl DiscoverResult {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a discovery result from the server's initialization information.
|
/// Create a discovery result from the server's configuration.
|
||||||
pub fn from_server_info(
|
pub fn from_server_info(
|
||||||
supported_versions: Vec<ProtocolVersion>,
|
supported_versions: Vec<ProtocolVersion>,
|
||||||
server_info: ServerInfo,
|
server_config: ServerConfig,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let ServerInfo {
|
let ServerConfig {
|
||||||
capabilities,
|
capabilities,
|
||||||
server_info,
|
server_info,
|
||||||
instructions,
|
instructions,
|
||||||
meta,
|
meta,
|
||||||
..
|
..
|
||||||
} = server_info;
|
} = server_config;
|
||||||
let mut result = Self {
|
let mut result = Self {
|
||||||
result_type: ResultType::COMPLETE,
|
result_type: ResultType::COMPLETE,
|
||||||
supported_versions,
|
supported_versions,
|
||||||
|
|
@ -1295,9 +1383,9 @@ impl ServerPeerInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::derivable_impls)]
|
#[allow(clippy::derivable_impls)]
|
||||||
impl Default for ServerInfo {
|
impl Default for InitializeResult {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
ServerInfo {
|
InitializeResult {
|
||||||
protocol_version: ProtocolVersion::default(),
|
protocol_version: ProtocolVersion::default(),
|
||||||
capabilities: ServerCapabilities::default(),
|
capabilities: ServerCapabilities::default(),
|
||||||
server_info: Implementation::from_build_env(),
|
server_info: Implementation::from_build_env(),
|
||||||
|
|
@ -1308,9 +1396,9 @@ impl Default for ServerInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::derivable_impls)]
|
#[allow(clippy::derivable_impls)]
|
||||||
impl Default for ClientInfo {
|
impl Default for InitializeRequestParams {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
ClientInfo {
|
InitializeRequestParams {
|
||||||
meta: None,
|
meta: None,
|
||||||
protocol_version: ProtocolVersion::default(),
|
protocol_version: ProtocolVersion::default(),
|
||||||
capabilities: ClientCapabilities::default(),
|
capabilities: ClientCapabilities::default(),
|
||||||
|
|
@ -4643,6 +4731,51 @@ mod tests {
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_versions_are_ordered_oldest_first() {
|
||||||
|
// `known_up_to` walks the list as a sorted prefix.
|
||||||
|
assert!(
|
||||||
|
ProtocolVersion::KNOWN_VERSIONS
|
||||||
|
.windows(2)
|
||||||
|
.all(|pair| pair[0].as_str() < pair[1].as_str())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_up_to_includes_the_ceiling_itself() {
|
||||||
|
assert_eq!(
|
||||||
|
ProtocolVersion::known_up_to(&ProtocolVersion::V_2024_11_05),
|
||||||
|
&[ProtocolVersion::V_2024_11_05]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_up_to_the_newest_version_yields_every_known_version() {
|
||||||
|
assert_eq!(
|
||||||
|
ProtocolVersion::known_up_to(&ProtocolVersion::V_2026_07_28),
|
||||||
|
ProtocolVersion::KNOWN_VERSIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_up_to_an_unknown_ceiling_stops_at_the_versions_below_it() {
|
||||||
|
let unknown = ProtocolVersion(Cow::Borrowed("2025-07-01"));
|
||||||
|
assert_eq!(
|
||||||
|
ProtocolVersion::known_up_to(&unknown),
|
||||||
|
&[
|
||||||
|
ProtocolVersion::V_2024_11_05,
|
||||||
|
ProtocolVersion::V_2025_03_26,
|
||||||
|
ProtocolVersion::V_2025_06_18,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_up_to_a_ceiling_below_every_known_version_is_empty() {
|
||||||
|
let ancient = ProtocolVersion(Cow::Borrowed("1999-01-01"));
|
||||||
|
assert!(ProtocolVersion::known_up_to(&ancient).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "transport-streamable-http-client")]
|
#[cfg(feature = "transport-streamable-http-client")]
|
||||||
#[test]
|
#[test]
|
||||||
fn transport_closed_marker_accepts_only_the_process_local_token() {
|
fn transport_closed_marker_accepts_only_the_process_local_token() {
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,14 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
|
||||||
fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
|
fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
fn peer_cancels_subscriptions(_peer: &Peer<Self>) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
#[doc(hidden)]
|
||||||
|
fn is_subscription_request(_request: &Self::Req) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
/// Invalidate any response cache affected by an inbound peer notification.
|
/// Invalidate any response cache affected by an inbound peer notification.
|
||||||
///
|
///
|
||||||
/// The serve loop calls this for every notification *before* subscription
|
/// The serve loop calls this for every notification *before* subscription
|
||||||
|
|
@ -522,6 +530,10 @@ impl ProgressNotificationToken for ServerNotification {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Responder<T> = tokio::sync::oneshot::Sender<T>;
|
type Responder<T> = tokio::sync::oneshot::Sender<T>;
|
||||||
|
struct PendingResponse<T> {
|
||||||
|
responder: Responder<T>,
|
||||||
|
is_subscription: bool,
|
||||||
|
}
|
||||||
type ProgressTimeoutWatchers = Arc<tokio::sync::RwLock<HashMap<ProgressToken, mpsc::Sender<()>>>>;
|
type ProgressTimeoutWatchers = Arc<tokio::sync::RwLock<HashMap<ProgressToken, mpsc::Sender<()>>>>;
|
||||||
type SubscriptionChannel<N> = (mpsc::Sender<N>, usize);
|
type SubscriptionChannel<N> = (mpsc::Sender<N>, usize);
|
||||||
type SubscriptionChannelMap<N> = HashMap<RequestId, SubscriptionChannel<N>>;
|
type SubscriptionChannelMap<N> = HashMap<RequestId, SubscriptionChannel<N>>;
|
||||||
|
|
@ -1298,7 +1310,14 @@ where
|
||||||
{
|
{
|
||||||
let (peer, peer_rx) = Peer::new(Arc::new(AtomicU32RequestIdProvider::default()), peer_info);
|
let (peer, peer_rx) = Peer::new(Arc::new(AtomicU32RequestIdProvider::default()), peer_info);
|
||||||
R::configure_direct_peer(&peer, &service.get_info());
|
R::configure_direct_peer(&peer, &service.get_info());
|
||||||
serve_inner(service, transport.into_transport(), peer, peer_rx, ct)
|
serve_inner(
|
||||||
|
service,
|
||||||
|
transport.into_transport(),
|
||||||
|
peer,
|
||||||
|
peer_rx,
|
||||||
|
VecDeque::new(),
|
||||||
|
ct,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a task that may hold `!Send` state when the `local` feature is active.
|
/// Spawn a task that may hold `!Send` state when the `local` feature is active.
|
||||||
|
|
@ -1323,12 +1342,19 @@ where
|
||||||
tokio::task::spawn_local(future)
|
tokio::task::spawn_local(future)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the service loop over `transport`.
|
||||||
|
///
|
||||||
|
/// `initial_messages` are messages the caller already read from `transport`
|
||||||
|
/// (e.g. the first request of an `initialize`-less session). They are
|
||||||
|
/// dispatched by the loop, in order, before anything else is read from the
|
||||||
|
/// transport, so that their handlers run with the loop draining `peer_rx`.
|
||||||
#[instrument(skip_all)]
|
#[instrument(skip_all)]
|
||||||
fn serve_inner<R, S, T>(
|
fn serve_inner<R, S, T>(
|
||||||
service: S,
|
service: S,
|
||||||
transport: T,
|
transport: T,
|
||||||
peer: Peer<R>,
|
peer: Peer<R>,
|
||||||
mut peer_rx: tokio::sync::mpsc::Receiver<PeerSinkMessage<R>>,
|
mut peer_rx: tokio::sync::mpsc::Receiver<PeerSinkMessage<R>>,
|
||||||
|
initial_messages: VecDeque<RxJsonRpcMessage<R>>,
|
||||||
ct: CancellationToken,
|
ct: CancellationToken,
|
||||||
) -> RunningService<R, S>
|
) -> RunningService<R, S>
|
||||||
where
|
where
|
||||||
|
|
@ -1348,7 +1374,7 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut local_responder_pool =
|
let mut local_responder_pool =
|
||||||
HashMap::<RequestId, Responder<Result<R::PeerResp, ServiceError>>>::new();
|
HashMap::<RequestId, PendingResponse<Result<R::PeerResp, ServiceError>>>::new();
|
||||||
let mut local_ct_pool = HashMap::<RequestId, CancellationToken>::new();
|
let mut local_ct_pool = HashMap::<RequestId, CancellationToken>::new();
|
||||||
let shared_service = Arc::new(service);
|
let shared_service = Arc::new(service);
|
||||||
// for return
|
// for return
|
||||||
|
|
@ -1361,7 +1387,7 @@ where
|
||||||
let current_span = tracing::Span::current();
|
let current_span = tracing::Span::current();
|
||||||
let handle = spawn_service_task(async move {
|
let handle = spawn_service_task(async move {
|
||||||
let mut transport = transport.into_transport();
|
let mut transport = transport.into_transport();
|
||||||
let mut batch_messages = VecDeque::<RxJsonRpcMessage<R>>::new();
|
let mut batch_messages = initial_messages;
|
||||||
let mut send_task_set = tokio::task::JoinSet::<SendTaskResult>::new();
|
let mut send_task_set = tokio::task::JoinSet::<SendTaskResult>::new();
|
||||||
let mut response_send_tasks = tokio::task::JoinSet::<()>::new();
|
let mut response_send_tasks = tokio::task::JoinSet::<()>::new();
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|
@ -1445,7 +1471,7 @@ where
|
||||||
Event::SendTaskResult(SendTaskResult::Request { id, result }) => {
|
Event::SendTaskResult(SendTaskResult::Request { id, result }) => {
|
||||||
if let Err(e) = result
|
if let Err(e) = result
|
||||||
&& let Some(responder) = local_responder_pool.remove(&id) {
|
&& let Some(responder) = local_responder_pool.remove(&id) {
|
||||||
let _ = responder.send(Err(ServiceError::TransportSend(e)));
|
let _ = responder.responder.send(Err(ServiceError::TransportSend(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::SendTaskResult(SendTaskResult::Notification {
|
Event::SendTaskResult(SendTaskResult::Notification {
|
||||||
|
|
@ -1463,7 +1489,7 @@ where
|
||||||
&& let Some(request_id) = ¶m.request_id
|
&& let Some(request_id) = ¶m.request_id
|
||||||
&& let Some(responder) = local_responder_pool.remove(request_id) {
|
&& let Some(responder) = local_responder_pool.remove(request_id) {
|
||||||
tracing::info!(id = %request_id, reason = param.reason, "cancelled");
|
tracing::info!(id = %request_id, reason = param.reason, "cancelled");
|
||||||
let _response_result = responder.send(Err(ServiceError::Cancelled {
|
let _response_result = responder.responder.send(Err(ServiceError::Cancelled {
|
||||||
reason: param.reason.clone(),
|
reason: param.reason.clone(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
@ -1500,7 +1526,10 @@ where
|
||||||
id,
|
id,
|
||||||
responder,
|
responder,
|
||||||
}) => {
|
}) => {
|
||||||
local_responder_pool.insert(id.clone(), responder);
|
local_responder_pool.insert(id.clone(), PendingResponse {
|
||||||
|
responder,
|
||||||
|
is_subscription: R::is_subscription_request(&request),
|
||||||
|
});
|
||||||
let send = transport.send(JsonRpcMessage::request(request, id.clone()));
|
let send = transport.send(JsonRpcMessage::request(request, id.clone()));
|
||||||
{
|
{
|
||||||
let id = id.clone();
|
let id = id.clone();
|
||||||
|
|
@ -1601,31 +1630,38 @@ where
|
||||||
})) => {
|
})) => {
|
||||||
tracing::info!(?notification, "received notification");
|
tracing::info!(?notification, "received notification");
|
||||||
R::invalidate_response_cache(&peer, ¬ification).await;
|
R::invalidate_response_cache(&peer, ¬ification).await;
|
||||||
let cancellation_request_id =
|
let subscription_id =
|
||||||
if let Some(cancelled) = R::peer_cancelled_params(¬ification) {
|
if let Some(cancelled) = R::peer_cancelled_params(¬ification) {
|
||||||
let request_id = cancelled.request_id.clone();
|
let request_id = cancelled.request_id.clone();
|
||||||
if let Some(request_id) = request_id.as_ref() {
|
// Modern servers cancel listen requests; legacy peers cancel
|
||||||
if R::IS_CLIENT {
|
// requests they originated, even when both directions share an ID.
|
||||||
if let Some(responder) =
|
if R::peer_cancels_subscriptions(&peer) {
|
||||||
local_responder_pool.remove(request_id)
|
if let Some(request_id) = request_id.as_ref() {
|
||||||
|
if local_responder_pool.get(request_id)
|
||||||
|
.is_some_and(|pending| pending.is_subscription)
|
||||||
|
&& let Some(pending) = local_responder_pool.remove(request_id)
|
||||||
{
|
{
|
||||||
let _ = responder.send(Err(ServiceError::Cancelled {
|
let _ = pending.responder.send(Err(ServiceError::Cancelled {
|
||||||
reason: cancelled.reason.clone(),
|
reason: cancelled.reason.clone(),
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
tracing::debug!(%request_id, "ignoring cancellation of unknown subscription");
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
} else if let Some(ct) = local_ct_pool.remove(request_id) {
|
}
|
||||||
|
request_id
|
||||||
|
} else {
|
||||||
|
if let Some(request_id) = request_id.as_ref()
|
||||||
|
&& let Some(ct) = local_ct_pool.remove(request_id)
|
||||||
|
{
|
||||||
tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled");
|
tracing::info!(id = %request_id, reason = cancelled.reason, "cancelled");
|
||||||
ct.cancel();
|
ct.cancel();
|
||||||
}
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
request_id
|
|
||||||
} else {
|
} else {
|
||||||
None
|
notification.get_meta().subscription_id()
|
||||||
};
|
};
|
||||||
let subscription_id = notification
|
|
||||||
.get_meta()
|
|
||||||
.subscription_id()
|
|
||||||
.or(cancellation_request_id);
|
|
||||||
if let Some(subscription_id) = subscription_id
|
if let Some(subscription_id) = subscription_id
|
||||||
&& let Some((sender, capacity)) =
|
&& let Some((sender, capacity)) =
|
||||||
peer.subscription_sender(&subscription_id)
|
peer.subscription_sender(&subscription_id)
|
||||||
|
|
@ -1642,7 +1678,7 @@ where
|
||||||
&& let Some(responder) =
|
&& let Some(responder) =
|
||||||
local_responder_pool.remove(&subscription_id)
|
local_responder_pool.remove(&subscription_id)
|
||||||
{
|
{
|
||||||
let _ = responder
|
let _ = responder.responder
|
||||||
.send(Err(ServiceError::SubscriptionLagged { capacity }));
|
.send(Err(ServiceError::SubscriptionLagged { capacity }));
|
||||||
}
|
}
|
||||||
peer.unregister_subscription(&subscription_id);
|
peer.unregister_subscription(&subscription_id);
|
||||||
|
|
@ -1694,7 +1730,7 @@ where
|
||||||
if let Some(responder) =
|
if let Some(responder) =
|
||||||
remove_pending_request(&mut local_responder_pool, &id)
|
remove_pending_request(&mut local_responder_pool, &id)
|
||||||
{
|
{
|
||||||
let response_result = responder.send(Ok(result));
|
let response_result = responder.responder.send(Ok(result));
|
||||||
if let Err(_error) = response_result {
|
if let Err(_error) = response_result {
|
||||||
tracing::warn!(%id, "Error sending response");
|
tracing::warn!(%id, "Error sending response");
|
||||||
}
|
}
|
||||||
|
|
@ -1715,7 +1751,7 @@ where
|
||||||
} else {
|
} else {
|
||||||
ServiceError::McpError(error)
|
ServiceError::McpError(error)
|
||||||
};
|
};
|
||||||
let _response_result = responder.send(Err(service_error));
|
let _response_result = responder.responder.send(Err(service_error));
|
||||||
if let Err(_error) = _response_result {
|
if let Err(_error) = _response_result {
|
||||||
tracing::warn!(%id, "Error sending response");
|
tracing::warn!(%id, "Error sending response");
|
||||||
}
|
}
|
||||||
|
|
@ -1749,6 +1785,17 @@ where
|
||||||
// Then drain any handler responses still in the channel
|
// Then drain any handler responses still in the channel
|
||||||
// (handlers that finished after the loop broke).
|
// (handlers that finished after the loop broke).
|
||||||
while let Some(m) = sink_proxy_rx.recv().await {
|
while let Some(m) = sink_proxy_rx.recv().await {
|
||||||
|
if let Some(id) = match &m {
|
||||||
|
JsonRpcMessage::Response(response) => Some(&response.id),
|
||||||
|
JsonRpcMessage::Error(error) => error.id.as_ref(),
|
||||||
|
_ => None,
|
||||||
|
} {
|
||||||
|
let Some(ct) = local_ct_pool.remove(id) else {
|
||||||
|
tracing::debug!(%id, "dropping response for cancelled request");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
ct.cancel();
|
||||||
|
}
|
||||||
if let Err(error) = transport.send(m).await {
|
if let Err(error) = transport.send(m).await {
|
||||||
tracing::error!(%error, "failed to send pending response during drain");
|
tracing::error!(%error, "failed to send pending response during drain");
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue