Skip to content

Commit 4ccb269

Browse files
committed
feat(server): surface the request progressToken to handlers (PAT/G2)
Tools could not emit spec-compliant progress on the 2026 path: the request's _meta.progressToken never reached handlers, and the progress spec requires "notifications MUST only reference tokens that were provided in an active request". - tools/call + resources/read handlers inject the raw token as the mcp:progressToken session extension (alongside the mcp:mrtr:* and mcp:logLevel injections). - SessionContext::progress_token() typed accessor; notify_request_progress(progress, total) emits notifications/progress referencing exactly the request's token and no-ops (returns false) when the request declared none. - Numeric tokens round-trip as JSON numbers end-to-end: the session→StreamManager bridge dropped non-string tokens via as_str() and stringified the rest — it now deserializes the raw string-or-number value into the typed field. Tests: progress_2026.rs — string echo, numeric round-trip (caught the bridge bug), no-token → no notifications, typed-parse pin. Revert-and-fail recorded: dropping the tools/call injection fails the string and numeric wire tests. Gates: scripts/ci-gates.sh all → ALL GATES PASSED. Closes spec-compliance driver gap PAT/G2 (P1).
1 parent 134703e commit 4ccb269

6 files changed

Lines changed: 259 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
2121
### Added
2222

23+
- **Request-scoped progress on the 2026 path (2026-06-11).** Tools and resources can now emit spec-compliant `notifications/progress`: the request's `_meta.progressToken` is surfaced through the session extensions (`SessionContext::progress_token()`), and the new `notify_request_progress(progress, total)` references exactly that token — no-op when the request declared none ("Progress notifications MUST only reference tokens that were provided in an active request"). Numeric tokens now round-trip as JSON numbers end-to-end; the session→StreamManager bridge previously dropped non-string tokens (`as_str()`) and stringified the rest. Wire tests in `progress_2026.rs` (string echo, numeric round-trip, no-token-no-notifications); revert-and-fail recorded. Closes spec-compliance driver gap **PAT/G2 (P1)**.
2324
- **Real-HTTP OAuth acceptance on the 2026 default transport (2026-06-11, tests + manifest).** New `crates/turul-mcp-server/tests/oauth_2026.rs`: missing/garbage bearers → 401 with the RFC 9728 `WWW-Authenticate` challenge (`resource_metadata=`, `error="invalid_token"`), 401 outranks the missing-`_meta` 400 (auth before validation), and both RFC 9728 well-known routes (root + path form) serve the metadata unauthenticated — all through Builder → `server.run()` → wire. To ride `turul-mcp-server`'s dev-deps without tripping the ADR-029 spec mutex, `turul-mcp-oauth` is now spec-neutral: its transport/storage deps drop default features and it gains its own `protocol-2025-11-25`/`protocol-2026-07-28` forwarding features (default 2026 standalone; unification supplies the spec when used with `default-features = false`). Closes spec-compliance driver gap **AUTH-1 (P1)**.
2425
- **Regression nets for two MUST-level client behaviors (2026-06-11, tests only).** (1) `bilingual_client_falls_back_on_400_with_32004_body` pins the Versioning §Backward Compatibility wire path: HTTP 400 whose body carries structured `-32004` + `data.supported` → fall back to 2025-11-25 through the real probe (a bare 4xx still aborts). (2) `invalid_x_mcp_header_tools_are_excluded_from_tools_list` pins Tools §x-mcp-header: a tool definition with a constraint-violating `x-mcp-header` value MUST be excluded from `tools/list` while valid tools survive. Both revert-and-fail proven against their pre-existing implementations. Closes spec-compliance driver gaps **VER-1 + TOOLS-G1 (both P1)**.
2526
- **`completion/complete` now dispatches to registered `McpCompletion` providers (2026-06-11).** Providers registered via `.completion_provider(...)` were stored but never consulted — the handler always answered with hardcoded placeholder values and ignored its input. The handler now parses typed `CompleteRequestParams` (malformed input → `-32602`, including reference-type literals `"ref/prompt"`/`"ref/resource"` that the untagged union would otherwise accept open-ended), routes deterministically (exact reference match first, `can_handle` fallback; priority desc then insertion order — provider storage moved from `HashMap` to `Vec` to make the tiebreak stable), runs the provider's `validate_request`, and enforces the spec's 100-item `completion.values` cap (truncation sets `total`/`hasMore`). No matching provider → empty values (the placeholder junk is gone). The same gap existed verbatim in `LambdaMcpServerBuilder` (providers stored, static handler answered) — mirrored fix there. Closes spec-compliance driver gaps **UTIL/COMP-1 (P1)** and **UTIL/COMP-3 (P2)**; red-phase wire tests in `discover_stateless_2026.rs`.

crates/turul-mcp-server/src/handlers/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,11 @@ impl McpHandler for ResourcesReadHandler {
901901
Value::String(state.clone()),
902902
);
903903
}
904+
if let Some(ref token) = read_params.meta.progress_token
905+
&& let Ok(v) = serde_json::to_value(token)
906+
{
907+
ctx.extensions.insert("mcp:progressToken".to_string(), v);
908+
}
904909
}
905910
session
906911
};

crates/turul-mcp-server/src/server.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1915,6 +1915,16 @@ impl JsonRpcHandler for SessionAwareToolHandler {
19151915
{
19161916
session.extensions.insert("mcp:logLevel".to_string(), v);
19171917
}
1918+
// Progress opt-in: surface the request's progressToken so the
1919+
// tool can emit notifications/progress referencing it
1920+
// (SessionContext::notify_request_progress).
1921+
if let Some(ref token) = call_params.meta.progress_token
1922+
&& let Ok(v) = serde_json::to_value(token)
1923+
{
1924+
session
1925+
.extensions
1926+
.insert("mcp:progressToken".to_string(), v);
1927+
}
19181928
}
19191929
ctx
19201930
};

crates/turul-mcp-server/src/session.rs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,43 @@ impl SessionContext {
357357
.and_then(|v| v.as_str().map(String::from))
358358
}
359359

360+
/// The request's `_meta.progressToken`, when the caller opted in to
361+
/// `notifications/progress` (populated by the 2026 tools/call and
362+
/// resources/read handlers).
363+
#[cfg(feature = "protocol-2026-07-28")]
364+
pub fn progress_token(&self) -> Option<turul_mcp_protocol::meta::ProgressToken> {
365+
self.extensions
366+
.get("mcp:progressToken")
367+
.and_then(|v| serde_json::from_value(v.clone()).ok())
368+
}
369+
370+
/// Emit a request-scoped `notifications/progress` referencing the
371+
/// REQUEST's `_meta.progressToken`, preserving its JSON type (string or
372+
/// number). No-op (returns `false`) when the request declared no token —
373+
/// "Progress notifications MUST only reference tokens that were provided
374+
/// in an active request."
375+
#[cfg(feature = "protocol-2026-07-28")]
376+
pub async fn notify_request_progress(&self, progress: f64, total: Option<f64>) -> bool {
377+
let Some(token) = self.extensions.get("mcp:progressToken").cloned() else {
378+
return false;
379+
};
380+
let mut params = std::collections::HashMap::new();
381+
params.insert("progressToken".to_string(), token);
382+
params.insert("progress".to_string(), serde_json::json!(progress));
383+
if let Some(total) = total {
384+
params.insert("total".to_string(), serde_json::json!(total));
385+
}
386+
let notification = turul_rpc::JsonRpcNotification::new_with_object_params(
387+
"notifications/progress".to_string(),
388+
params,
389+
);
390+
self.notify(SessionEvent::Notification(
391+
serde_json::to_value(notification).unwrap(),
392+
))
393+
.await;
394+
true
395+
}
396+
360397
/// Send a custom notification to this session (async)
361398
pub async fn notify(&self, event: SessionEvent) {
362399
debug!(
@@ -780,10 +817,13 @@ async fn parse_and_send_notification_with_broadcaster(
780817
}
781818
}
782819
"notifications/progress" => {
820+
// ProgressToken is string-or-number — deserialize the raw
821+
// value so numeric tokens round-trip as JSON numbers.
783822
if let Some(params) = json_value.get("params")
784-
&& let Some(token) = params.get("progressToken").and_then(|v| v.as_str())
823+
&& let Some(raw_token) = params.get("progressToken")
824+
&& let Ok(progress_token) = serde_json::from_value(raw_token.clone())
785825
{
786-
debug!("📊 Progress notification detected: token={}", token);
826+
debug!("📊 Progress notification detected: token={}", raw_token);
787827

788828
// Get progress value
789829
let progress = params
@@ -795,7 +835,7 @@ async fn parse_and_send_notification_with_broadcaster(
795835
let notification = ProgressNotification {
796836
method: "notifications/progress".to_string(),
797837
params: turul_mcp_protocol::notifications::ProgressNotificationParams {
798-
progress_token: token.to_string().into(),
838+
progress_token,
799839
progress,
800840
total: params.get("total").and_then(|v| v.as_f64()),
801841
message: params
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//! Wire-level acceptance for request-scoped progress on the 2026 path.
2+
//!
3+
//! Progress §Behavior: "Progress notifications MUST only reference tokens
4+
//! that: Were provided in an active request; Are associated with an
5+
//! in-progress operation." The caller opts in via `_meta.progressToken`;
6+
//! a request without one gets no `notifications/progress`, and the echoed
7+
//! token preserves the caller's JSON type (string or number).
8+
//!
9+
//! Built only under the 2026 feature; compiles to nothing under 2025-11-25.
10+
#![cfg(feature = "protocol-2026-07-28")]
11+
12+
use std::time::Duration;
13+
14+
use futures::StreamExt;
15+
use turul_mcp_derive::McpTool;
16+
use turul_mcp_server::prelude::*;
17+
18+
/// Emits one request-scoped progress notification mid-execution.
19+
#[derive(McpTool, Clone, Default)]
20+
#[tool(name = "worker", description = "Works with progress", output = String)]
21+
struct WorkerTool {}
22+
23+
impl WorkerTool {
24+
async fn execute(&self, session: Option<SessionContext>) -> McpResult<String> {
25+
if let Some(session) = session {
26+
session.notify_request_progress(0.5, Some(1.0)).await;
27+
}
28+
Ok("done".to_string())
29+
}
30+
}
31+
32+
async fn start_server() -> String {
33+
let port = std::net::TcpListener::bind("127.0.0.1:0")
34+
.unwrap()
35+
.local_addr()
36+
.unwrap()
37+
.port();
38+
39+
let server = McpServer::builder()
40+
.name("progress-2026-test")
41+
.version("0.4.0")
42+
.tool(WorkerTool::default())
43+
.bind_address(format!("127.0.0.1:{port}").parse().unwrap())
44+
.build()
45+
.expect("build 2026 server");
46+
47+
tokio::spawn(async move {
48+
server.run().await.ok();
49+
});
50+
51+
let url = format!("http://127.0.0.1:{port}/mcp");
52+
let client = reqwest::Client::new();
53+
for _ in 0..50 {
54+
if client.get(&url).send().await.is_ok() {
55+
break;
56+
}
57+
tokio::time::sleep(Duration::from_millis(20)).await;
58+
}
59+
url
60+
}
61+
62+
/// POST a `worker` tools/call with SSE framing; return every `data:` JSON
63+
/// payload observed on the response stream.
64+
async fn call_worker(
65+
url: &str,
66+
progress_token: Option<serde_json::Value>,
67+
) -> Vec<serde_json::Value> {
68+
let mut meta = serde_json::json!({
69+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
70+
"io.modelcontextprotocol/clientInfo": { "name": "test-client", "version": "1.0.0" },
71+
"io.modelcontextprotocol/clientCapabilities": {}
72+
});
73+
if let (Some(m), Some(token)) = (meta.as_object_mut(), progress_token) {
74+
m.insert("progressToken".to_string(), token);
75+
}
76+
77+
let client = reqwest::Client::new();
78+
let resp = client
79+
.post(url)
80+
.header("Accept", "application/json, text/event-stream")
81+
.header("MCP-Protocol-Version", "2026-07-28")
82+
.header("Mcp-Method", "tools/call")
83+
.header("Mcp-Name", "worker")
84+
.json(&serde_json::json!({
85+
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
86+
"params": { "name": "worker", "arguments": {}, "_meta": meta }
87+
}))
88+
.send()
89+
.await
90+
.expect("worker POST");
91+
assert_eq!(resp.status(), 200);
92+
93+
let mut events = Vec::new();
94+
let mut buffer = String::new();
95+
let mut stream = resp.bytes_stream();
96+
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
97+
loop {
98+
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
99+
if remaining.is_zero() {
100+
break;
101+
}
102+
match tokio::time::timeout(remaining, stream.next()).await {
103+
Ok(Some(Ok(chunk))) => buffer.push_str(&String::from_utf8_lossy(&chunk)),
104+
_ => break,
105+
}
106+
let mut done = false;
107+
while let Some(pos) = buffer.find("\n\n") {
108+
let event: String = buffer.drain(..pos + 2).collect();
109+
for line in event.lines() {
110+
if let Some(data) = line.strip_prefix("data: ")
111+
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(data)
112+
{
113+
if json.get("result").is_some() || json.get("error").is_some() {
114+
done = true;
115+
}
116+
events.push(json);
117+
}
118+
}
119+
}
120+
if done {
121+
break;
122+
}
123+
}
124+
events
125+
}
126+
127+
fn progress_notifications(events: &[serde_json::Value]) -> Vec<&serde_json::Value> {
128+
events
129+
.iter()
130+
.filter(|e| e["method"] == "notifications/progress")
131+
.collect()
132+
}
133+
134+
#[tokio::test]
135+
async fn progress_echoes_the_request_string_token() {
136+
let url = start_server().await;
137+
let events = call_worker(&url, Some(serde_json::json!("tok-1"))).await;
138+
let progress = progress_notifications(&events);
139+
assert!(
140+
!progress.is_empty(),
141+
"a declared progressToken must opt in to notifications/progress: {events:?}"
142+
);
143+
assert_eq!(
144+
progress[0]["params"]["progressToken"],
145+
serde_json::json!("tok-1"),
146+
"the notification must reference the REQUEST's token: {progress:?}"
147+
);
148+
assert_eq!(progress[0]["params"]["progress"], serde_json::json!(0.5));
149+
assert_eq!(progress[0]["params"]["total"], serde_json::json!(1.0));
150+
}
151+
152+
#[tokio::test]
153+
async fn progress_preserves_a_numeric_token() {
154+
let url = start_server().await;
155+
let events = call_worker(&url, Some(serde_json::json!(7))).await;
156+
let progress = progress_notifications(&events);
157+
assert!(!progress.is_empty(), "{events:?}");
158+
assert_eq!(
159+
progress[0]["params"]["progressToken"],
160+
serde_json::json!(7),
161+
"a numeric token must round-trip as a JSON number: {progress:?}"
162+
);
163+
}
164+
165+
#[tokio::test]
166+
async fn no_token_means_no_progress_notifications() {
167+
let url = start_server().await;
168+
let events = call_worker(&url, None).await;
169+
assert!(
170+
!events.is_empty(),
171+
"the final response must arrive on the stream"
172+
);
173+
assert!(
174+
progress_notifications(&events).is_empty(),
175+
"MUST only reference tokens provided in an active request — no token, \
176+
no notifications/progress: {events:?}"
177+
);
178+
}
179+
180+
/// A numeric progressToken must survive the typed params parse.
181+
#[test]
182+
fn numeric_token_parses_into_call_params() {
183+
let params = serde_json::json!({
184+
"name": "worker", "arguments": {},
185+
"_meta": {
186+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
187+
"io.modelcontextprotocol/clientInfo": { "name": "t", "version": "1" },
188+
"io.modelcontextprotocol/clientCapabilities": {},
189+
"progressToken": 7
190+
}
191+
});
192+
let typed: turul_mcp_protocol::tools::CallToolRequestParams =
193+
serde_json::from_value(params).expect("parse");
194+
assert!(
195+
typed.meta.progress_token.is_some(),
196+
"numeric progressToken must survive the typed parse: {:?}",
197+
typed.meta
198+
);
199+
}

docs/plans/2026-07-28-spec-compliance.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -766,7 +766,7 @@ in the same slice as the fix.
766766
- Requirement: "Streamable HTTP: Closing the SSE response stream is the cancellation signal. The server MUST treat a client disconnect as cancellation of that request." — https://modelcontextprotocol.io/specification/draft/basic/patterns/cancellation
767767
- Current: crates/turul-http-mcp-server/src/streamable_http.rs:2285-2308 spawns run_middleware_and_dispatch detached from the response body; when the client disconnects, the mpsc body receiver drops but the handler keeps executing to completion (only sender.send fails at :2213-2216). No cancellation signal is plumbed to handlers — CancellationHandle (crates/turul-mcp-server/src/cancellation.rs) is wired only
768768
- Fix: crates/turul-http-mcp-server/src/streamable_http.rs (observe body drop / wrap dispatch in an abortable scope) + expose a cancellation signal on SessionContext in crates/turul-mcp-server. Governed by ADR-027 (2026 transport contract); needs an ADR update or new ADR section plus a wire-layer test per
769-
- [ ] **PAT/G2** — Request progressToken never surfaced to handlers — compliant progress emission is impossible for tools
769+
- [x] **PAT/G2** — Request progressToken never surfaced to handlers — **FIXED 2026-06-11** (`mcp:progressToken` extension injected at the tools/call + resources/read handlers; `SessionContext::progress_token()` accessor + `notify_request_progress(progress, total)` no-op-without-token emitter; numeric tokens round-trip as JSON numbers — fixing a stringifying bridge in session.rs; wire tests `progress_2026.rs`, revert-and-fail proven)
770770
- Requirement: "Progress notifications MUST only reference tokens that: Were provided in an active request; Are associated with an in-progress operation." — https://modelcontextprotocol.io/specification/draft/basic/patterns/progress
771771
- Current: On the 2026 path the tools/resources/prompts handlers extract only mcp:mrtr:* extensions (crates/turul-mcp-server/src/handlers/mod.rs:765-780); SessionContext has no accessor for the request's _meta.progressToken (session.rs:339-358), and handlers/mod.rs:99-106 strips progressToken from echoed meta. A tool wanting to emit progress must invent a token (the in-repo example does: tests/sse_progress_d
772772
- Fix: crates/turul-mcp-server/src/handlers/mod.rs (extract _meta.progressToken per request) + src/session.rs (SessionContext::progress_token() accessor; have notify_progress default to it). ADR-027 governs; add a 2026 wire test asserting the emitted notification carries the caller's token.

0 commit comments

Comments
 (0)