Skip to content

Commit bdf0c32

Browse files
authored
fix: address 2025-11-25 conformance audit findings (#951)
* fix: interpret task ttl as milliseconds * fix: use text/plain for default text mime type * fix: include resource param in token refresh * test: align conformance prompt args with runner * ci: run server conformance suite on PRs * ci: build client bin and gate pending scenarios
1 parent 8e44af4 commit bdf0c32

7 files changed

Lines changed: 169 additions & 19 deletions

File tree

.github/workflows/conformance.yml

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
name: Conformance
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
workflow_dispatch:
9+
10+
concurrency:
11+
group: conformance-${{ github.ref }}
12+
cancel-in-progress: true
13+
14+
env:
15+
# Pinned for reproducible runs; bump deliberately when the suite updates.
16+
CONFORMANCE_VERSION: "0.1.16"
17+
18+
jobs:
19+
server:
20+
runs-on: ubuntu-latest
21+
permissions:
22+
contents: read
23+
steps:
24+
- uses: actions/checkout@v7
25+
26+
- name: Install Rust toolchain
27+
uses: dtolnay/rust-toolchain@stable
28+
29+
- uses: Swatinem/rust-cache@v2
30+
31+
# Build the whole package (server + client bins): the conformance crate is
32+
# excluded from the workspace default-members, so this is the only CI job
33+
# that catches compile breakage in it.
34+
- name: Build conformance binaries
35+
run: cargo build -p mcp-conformance
36+
37+
- name: Start conformance server
38+
run: |
39+
PORT=8001 ./target/debug/conformance-server &
40+
echo $! > server.pid
41+
for _ in $(seq 1 30); do
42+
if curl -s -o /dev/null http://127.0.0.1:8001/mcp; then
43+
exit 0
44+
fi
45+
sleep 1
46+
done
47+
echo "conformance server did not become ready" >&2
48+
exit 1
49+
50+
- name: Run server conformance suite
51+
run: |
52+
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
53+
--url http://127.0.0.1:8001/mcp \
54+
--spec-version 2025-11-25 \
55+
-o conformance-results
56+
57+
# These pass today but are excluded from the default "active" suite;
58+
# run them explicitly so regressions are still caught.
59+
- name: Run pending scenarios
60+
run: |
61+
for scenario in json-schema-2020-12 server-sse-polling; do
62+
npx -y "@modelcontextprotocol/conformance@${CONFORMANCE_VERSION}" server \
63+
--url http://127.0.0.1:8001/mcp \
64+
--scenario "$scenario" \
65+
-o conformance-results
66+
done
67+
68+
- name: Stop conformance server
69+
if: always()
70+
run: kill "$(cat server.pid)" 2>/dev/null || true
71+
72+
- name: Upload results
73+
if: always()
74+
uses: actions/upload-artifact@v7
75+
with:
76+
name: conformance-server-results
77+
path: conformance-results

conformance/src/bin/server.rs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -656,11 +656,11 @@ impl ServerHandler for ConformanceServer {
656656
"test_prompt_with_arguments",
657657
Some("A test prompt that accepts arguments"),
658658
Some(vec![
659-
PromptArgument::new("name")
660-
.with_description("The name to greet")
659+
PromptArgument::new("arg1")
660+
.with_description("First test argument")
661661
.with_required(true),
662-
PromptArgument::new("style")
663-
.with_description("The greeting style")
662+
PromptArgument::new("arg2")
663+
.with_description("Second test argument")
664664
.with_required(false),
665665
]),
666666
),
@@ -692,14 +692,11 @@ impl ServerHandler for ConformanceServer {
692692
.with_description("A simple test prompt")),
693693
"test_prompt_with_arguments" => {
694694
let args = request.arguments.unwrap_or_default();
695-
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("World");
696-
let style = args
697-
.get("style")
698-
.and_then(|v| v.as_str())
699-
.unwrap_or("friendly");
695+
let arg1 = args.get("arg1").and_then(|v| v.as_str()).unwrap_or("");
696+
let arg2 = args.get("arg2").and_then(|v| v.as_str()).unwrap_or("");
700697
Ok(GetPromptResult::new(vec![PromptMessage::new_text(
701698
Role::User,
702-
format!("Please greet {} in a {} style.", name, style),
699+
format!("Prompt with arguments: arg1='{}', arg2='{}'", arg1, arg2),
703700
)])
704701
.with_description("A prompt with arguments"))
705702
}

crates/rmcp/src/model/content.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ impl ContentBlock {
297297
ContentBlock::Resource(EmbeddedResource::new(
298298
ResourceContents::TextResourceContents {
299299
uri: uri.into(),
300-
mime_type: Some("text".to_string()),
300+
mime_type: Some("text/plain".to_string()),
301301
text: content.into(),
302302
meta: None,
303303
},

crates/rmcp/src/model/resource.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ impl ResourceContents {
193193
pub fn text(text: impl Into<String>, uri: impl Into<String>) -> Self {
194194
Self::TextResourceContents {
195195
uri: uri.into(),
196-
mime_type: Some("text".into()),
196+
mime_type: Some("text/plain".into()),
197197
text: text.into(),
198198
meta: None,
199199
}

crates/rmcp/src/task_manager.rs

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ impl OperationDescriptor {
4949
self
5050
}
5151

52+
/// Time-to-live in milliseconds, matching `TaskMetadata.ttl` from the MCP spec.
5253
pub fn with_ttl(mut self, ttl: u64) -> Self {
5354
self.ttl = Some(ttl);
5455
self
@@ -75,7 +76,11 @@ pub trait OperationResultTransport: Send + Sync + 'static {
7576
}
7677

7778
// ===== Operation Processor =====
78-
pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300; // 5 minutes
79+
#[deprecated(note = "use DEFAULT_TASK_TIMEOUT_MS; ttl values are milliseconds per the MCP spec")]
80+
pub const DEFAULT_TASK_TIMEOUT_SECS: u64 = 300;
81+
/// Default execution timeout (5 minutes), in milliseconds, applied when a
82+
/// descriptor does not specify a `ttl`.
83+
pub const DEFAULT_TASK_TIMEOUT_MS: u64 = 300_000;
7984
/// Operation processor that coordinates extractors and handlers
8085
pub struct OperationProcessor {
8186
/// Currently running tasks keyed by id
@@ -165,13 +170,13 @@ impl OperationProcessor {
165170
fn spawn_async_task(&mut self, message: OperationMessage) {
166171
let OperationMessage { descriptor, future } = message;
167172
let task_id = descriptor.operation_id.clone();
168-
let timeout_secs = descriptor.ttl.or(Some(DEFAULT_TASK_TIMEOUT_SECS));
173+
let timeout_ms = descriptor.ttl.or(Some(DEFAULT_TASK_TIMEOUT_MS));
169174
let sender = self.task_result_sender.clone();
170175
let descriptor_for_result = descriptor.clone();
171176

172177
let timed_future = async move {
173-
if let Some(secs) = timeout_secs {
174-
match timeout(Duration::from_secs(secs), future).await {
178+
if let Some(ms) = timeout_ms {
179+
match timeout(Duration::from_millis(ms), future).await {
175180
Ok(result) => result,
176181
Err(_) => Err(Error::TaskError("Operation timed out".to_string())),
177182
}
@@ -191,7 +196,7 @@ impl OperationProcessor {
191196
let running_task = RunningTask {
192197
task_handle: handle,
193198
started_at: std::time::Instant::now(),
194-
timeout: timeout_secs,
199+
timeout: timeout_ms,
195200
descriptor,
196201
};
197202
self.running_tasks.insert(task_id, running_task);
@@ -213,7 +218,7 @@ impl OperationProcessor {
213218

214219
for (task_id, task) in &self.running_tasks {
215220
if let Some(timeout_duration) = task.timeout {
216-
if now.duration_since(task.started_at).as_secs() > timeout_duration {
221+
if now.duration_since(task.started_at).as_millis() > u128::from(timeout_duration) {
217222
task.task_handle.abort();
218223
timed_out_tasks.push(task_id.clone());
219224
}

crates/rmcp/src/transport/auth.rs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1687,7 +1687,10 @@ impl AuthorizationManager {
16871687
debug!("refresh token present, attempting refresh");
16881688

16891689
let refresh_token_value = RefreshToken::new(refresh_token.secret().to_string());
1690-
let mut refresh_request = oauth_client.exchange_refresh_token(&refresh_token_value);
1690+
let mut refresh_request = oauth_client
1691+
.exchange_refresh_token(&refresh_token_value)
1692+
// RFC 8707: the resource indicator is required on token requests, including refreshes
1693+
.add_extra_param("resource", self.base_url.to_string());
16911694
let mut refresh_scopes = stored_credentials.granted_scopes;
16921695
self.add_offline_access_if_supported(&mut refresh_scopes);
16931696
for scope in refresh_scopes {
@@ -5489,6 +5492,42 @@ mod tests {
54895492
assert_eq!(scope_parts, vec!["read", "write"]);
54905493
}
54915494

5495+
#[tokio::test]
5496+
async fn refresh_token_includes_resource_parameter() {
5497+
let (base_url, captured) = start_token_server().await;
5498+
5499+
let mut manager = manager_with_metadata(Some(AuthorizationMetadata {
5500+
authorization_endpoint: format!("{}/authorize", base_url),
5501+
token_endpoint: format!("{}/token", base_url),
5502+
..Default::default()
5503+
}))
5504+
.await;
5505+
manager.configure_client(test_client_config()).unwrap();
5506+
5507+
let stored = StoredCredentials {
5508+
client_id: "my-client".to_string(),
5509+
token_response: Some(make_token_response_with_refresh(
5510+
"old-token",
5511+
"my-refresh-token",
5512+
)),
5513+
granted_scopes: vec![],
5514+
token_received_at: Some(AuthorizationManager::now_epoch_secs()),
5515+
};
5516+
manager.credential_store.save(stored).await.unwrap();
5517+
5518+
manager.refresh_token().await.unwrap();
5519+
5520+
let body = captured.lock().unwrap().take().unwrap();
5521+
let params: std::collections::HashMap<_, _> = url::form_urlencoded::parse(body.as_bytes())
5522+
.into_owned()
5523+
.collect();
5524+
assert_eq!(
5525+
params.get("resource").map(String::as_str),
5526+
Some("http://localhost/"),
5527+
"refresh requests must carry the RFC 8707 resource parameter, got body: {body}"
5528+
);
5529+
}
5530+
54925531
#[tokio::test]
54935532
async fn refresh_token_adds_offline_access_when_as_supports_it() {
54945533
let (base_url, captured) = start_token_server().await;

crates/rmcp/tests/test_task.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,38 @@ async fn rejects_duplicate_operation_ids() {
8080
assert!(format!("{err}").contains("already running"));
8181
}
8282

83+
#[tokio::test]
84+
async fn ttl_is_interpreted_as_milliseconds() {
85+
let mut processor = OperationProcessor::new();
86+
let descriptor = OperationDescriptor::new("slow", "dummy").with_ttl(50);
87+
let future = Box::pin(async {
88+
tokio::time::sleep(Duration::from_millis(500)).await;
89+
Ok(Box::new(DummyTransport {
90+
id: "slow".to_string(),
91+
value: 0,
92+
}) as Box<dyn OperationResultTransport>)
93+
});
94+
95+
processor
96+
.submit_operation(OperationMessage::new(descriptor, future))
97+
.expect("submit operation");
98+
99+
tokio::time::sleep(Duration::from_millis(200)).await;
100+
let results = processor.peek_completed();
101+
assert_eq!(
102+
results.len(),
103+
1,
104+
"50ms ttl should have timed out the operation well within 200ms"
105+
);
106+
match &results[0].result {
107+
Err(err) => assert!(
108+
err.to_string().contains("timed out"),
109+
"unexpected error: {err}"
110+
),
111+
Ok(_) => panic!("expected the operation to time out, but it completed"),
112+
}
113+
}
114+
83115
#[test]
84116
fn task_status_notification_param_preserves_meta() {
85117
let raw = json!({

0 commit comments

Comments
 (0)