Skip to content

Commit f913dc3

Browse files
committed
fix(protocol-2026-07-28): burn down OUTSTANDING.md compliance punch list
Schema pin 6e4cba2d re-verified byte-identical upstream before acting. Wire/contract: RequestMetaObject hand-written Serialize collision guard (extra cannot duplicate progressToken / io.modelcontextprotocol/* keys); ElicitResult.content values retyped to ElicitResultValue (string|number|boolean|string[], objects rejected; builders keep Value signatures via feature-gated conversion); CompletionReference strict type-discriminator deserialize (ref/resource | ref/prompt, unknown rejected); CompleteResult.completion.total u32 -> f64 (CompletionHandler cfg-split: frozen 2025-11-25 crate keeps u32); RootsCapabilities reduced to schema's empty object; CreateMessageRequestParams.metadata retyped to JSON-object convention; dead SamplingRequest/SamplingResult removed. Server: under protocol-2026-07-28, notifications/progress and notifications/message are no longer registered as inbound client notifications (absent from ClientNotification union); HTTP 202 semantics and outbound progress unchanged; 2025-11-25 lane unchanged. Tests: prompt-shape compliance assertions deepened to full field coverage; DRAFT-2026-v1 fixtures modernized to "2026-07-28"; new round-trip/rejection/collision suites. Revert-and-fail recorded per behavior change (collision tests red against derived Serialize; CompletionReference rejection tests red against derived Deserialize; dispatch-narrowing red via handler-count assertion; prompt-shape strength proven by serde-rename mutation). completion.values @Maxitems 100 closed by disposition: enforcement already lives (tested) in CompletionHandler; constructor truncation would lossily pre-empt the total count. Comment/doc corrections across content/caching/tools/meta/notifications/completion per repo comment rules. Sole surviving punch-list item: SubscriptionsListenResult graceful-close emission (needs shutdown-signal infra; spec-legal as-is). Gates: protocol crate 394 (compliance features), builders+server+http 433, lambda 103; 2025-11-25 combos green except pre-existing test_pagination_with_invalid_cursor (stash-bisected: fails pre-slice).
1 parent 006256b commit f913dc3

23 files changed

Lines changed: 773 additions & 98 deletions

CHANGELOG.md

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

103103
### Fixed (2026-07-13)
104104

105+
- **OUTSTANDING.md compliance punch-list burned down** (schema pin `6e4cba2d…` re-verified byte-identical upstream first — findings actioned as written). Wire/contract fixes in `turul-mcp-protocol-2026-07-28`: `RequestMetaObject` gained the hand-written `Serialize` collision guard (a caller-populated `extra` entry can no longer duplicate `progressToken` or an `io.modelcontextprotocol/*` reserved key on the wire — same pattern as `SubscriptionsListenResultMeta`); `ElicitResult.content` values retyped from `Value` to `ElicitResultValue` (`string | number | boolean | string[]` per schema, object values now rejected on deserialize; builders keep their `Value`-based public signatures via an infallible feature-gated conversion); `CompletionReference` deserialize now dispatches strictly on the `type` discriminator (`ref/resource` / `ref/prompt`; unknown or missing → rejected, previously silently matched structurally); `CompleteResult.completion.total` `u32`→`f64` (schema `number`; `CompletionHandler` cfg-split since the frozen 2025-11-25 crate keeps `u32`); `RootsCapabilities` reduced to the schema's empty object (removed the no-op `listChanged` field); `CreateMessageRequestParams.metadata` retyped `Option<Value>`→`Option<HashMap<String, Value>>`; dead non-spec `SamplingRequest`/`SamplingResult` removed. Server: under `protocol-2026-07-28`, `notifications/progress` and `notifications/message` are no longer registered as inbound client-notification handlers (both are absent from the schema's `ClientNotification` union; HTTP 202 semantics unchanged, outbound server→client progress untouched, 2025-11-25 lane unchanged — `notifications_2026.rs`). Tests: prompt-shape compliance assertions deepened to full field coverage; `DRAFT-2026-v1` fixture strings modernized to `"2026-07-28"`; new round-trip/rejection/collision suites, each behavior change with revert-and-fail evidence. Comment/doc corrections across content.rs / caching.rs / tools.rs / meta.rs / notifications.rs / completion.rs per the repo comment rules. `completion.values` `@maxItems 100` closed by disposition: enforcement already lives (tested) in the server dispatch layer; constructor-level truncation would lossily pre-empt the `total` count. Sole surviving punch-list item: `SubscriptionsListenResult` graceful-close emission (needs shutdown-signal infrastructure; spec-legal as-is). See OUTSTANDING.md §Addendum 2026-07-13.
105106
- **Lambda transport routed the 2026-07-28 lane to the sessionless legacy handler — tools received `session: None`.** `LambdaMcpHandler::handle()` (buffered, non-streaming Lambda runtime) delegated every request to `SessionMcpHandler`, and `handle_streaming()` trusted the request's `MCP-Protocol-Version` header for routing — but on a `protocol-2026-07-28` build only `StreamableHttpHandler` mints the stateless core's internal per-request session and enforces SEP-2243 Server Validation. Result: a middleware-authenticated `tools/call` over Lambda reached the tool with no `SessionContext` (field symptom: `-32602 "session required"` even though `SessionInjection::set_state` had run), and the buffered lane bypassed header validation, the POST-only surface, and unknown-method 404 mapping entirely. Both entry points now mirror `server.rs`: under `protocol-2026-07-28` all requests go to the streamable handler (the JSON-framed 2026 path returns buffered bodies, so the non-streaming Lambda runtime is unaffected); the `protocol-2025-11-25` lane keeps its existing unconditional `SessionMcpHandler` delegation for `handle()`, and header-based routing for `handle_streaming()`, unchanged. Closes the ADR-029 §Consequences "Lambda simplification" drift (one Lambda = one feature = one protocol). Tests: `stateless_session_2026_07_28.rs` (production path `LambdaMcpServerBuilder → handler() → handle()`: middleware-injected state readable via `SessionContext::get_typed_state` in the tool, missing `MCP-Protocol-Version` → 400/`-32020`, middleware `Unauthenticated` short-circuit → `-32001`; all three fail with the routing fix reverted — revert-and-fail recorded). `middleware_parity.rs` (drives the `initialize`/`Mcp-Session-Id` lifecycle) is now gated `protocol-2025-11-25` — it only passed on the 2026 default because of the wrong legacy routing.
106107

107108
### Changed

OUTSTANDING.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# OUTSTANDING — MCP 2026-07-28 draft-spec compliance & release-docs review
22

3-
**Status: PARKED.** Snapshot date: 2026-07-02. Schema content sha256: `6e4cba2d…`. Fixture pin: `60dc69e9…`. Reason: awaiting upstream 2026-07-28 draft stabilization / release proximity before investing further compliance-review effort. **On resume, the first action must be re-fetching and diffing the live draft schema against `6e4cba2d…`** — do not assume the findings or test results below still hold; the draft has drifted underneath this document twice already this week. This file is a temporary 0.4 punch list, not a permanent record (see §6) — fold the surviving open items into `docs/plans/2026-07-28-spec-compliance.md` and delete this file during final release preparation.
3+
**Status: BURNED DOWN (2026-07-13) — one open item remains.** Snapshot date: 2026-07-02; resumed 2026-07-13. This file is a temporary 0.4 punch list, not a permanent record (see §6) — fold the surviving open item into `docs/plans/2026-07-28-spec-compliance.md` and delete this file during final release preparation.
4+
5+
## Addendum 2026-07-13 — resume + burn-down
6+
7+
- **Resume precondition satisfied.** Live `schema/draft/schema.ts` re-fetched and diffed against the `6e4cba2d…` pin: byte-for-byte identical, zero upstream commits touching `schema/` since 2026-07-02 (only a typedoc devDependency bump under `docs/specification/draft` on 2026-07-07). The published draft changelog contains nothing beyond what the vendored README's revision log already accounts for. The findings below were therefore actioned as written.
8+
- **Closed this pass** (all §3 low-severity items, plus two of §2's three disclosed gaps): `RequestMetaObject.extra` collision guard (hand-written `Serialize`, same pattern as `SubscriptionsListenResultMeta`); 2026-lane dispatch narrowing — `notifications/progress` / `notifications/message` no longer registered as client notifications under `protocol-2026-07-28` (202 semantics unchanged; 2025-11-25 lane untouched); `ElicitResult.content` values retyped to `ElicitResultValue` (`string | number | boolean | string[]`); `CompletionReference` strict `type`-discriminator deserialize; `CompleteResult.completion.total` u32→f64; `RootsCapabilities` reduced to the schema's empty object; `CreateMessageRequestParams.metadata` retyped to the JSONObject convention; dead `SamplingRequest`/`SamplingResult` removed; prompt-shape compliance assertions deepened; `DRAFT-2026-v1` test fixtures modernized to `"2026-07-28"`; all §3 comment/doc corrections (content.rs `_meta`, caching.rs module doc, tools.rs ADR-citation + overclaim, meta.rs tombstone, notifications.rs / completion.rs orphaned docs, architecture-review.md stale-count annotation).
9+
- **Closed by disposition, not code:** `completion.values` `@maxItems 100` producer guard — enforcement already exists and is tested at the server dispatch layer (`CompletionHandler` truncates to 100 and captures the pre-truncation count into `total`; `completion_values_are_capped_at_100`); a constructor-level guard would be a second, lossy truncation path. Documented on the field instead (`completion_result_new_does_not_truncate`).
10+
- **Still open (the sole survivor):** `SubscriptionsListenResult` graceful-close emission — schema-bound and wire-tested, but the server has no shutdown-signal infrastructure to emit it from (no `CancellationToken` / `tokio::signal` wiring). Spec-legal as-is (abrupt transport close carries no response); needs a dedicated feature slice.
11+
- **Discovered while gating (pre-existing, not a 2026-compliance item):** `turul-mcp-server`'s `tests::pagination_integration_tests::test_pagination_with_invalid_cursor` fails on the `protocol-2025-11-25,http,sse` combo — confirmed failing on the pre-burn-down tree via stash-bisect, so it predates this pass. Untriaged.
12+
13+
---
414

515
Date: 2026-07-02 · Branch: `feat/turul-mcp-protocol-2026-07-28` · Current pin: vendored DRAFT-2026-v1 `schema.ts` @ upstream commit with content sha256 `6e4cba2d…` (wire string `"2026-07-28"`), governing spec: <https://modelcontextprotocol.io/specification/draft> (base protocol: <https://modelcontextprotocol.io/specification/draft/basic>).
616

crates/turul-mcp-builders/src/elicitation.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -534,12 +534,50 @@ impl ElicitationBuilder {
534534
}
535535
}
536536

537+
/// Convert a raw JSON value into the wire-typed elicitation result content
538+
/// value for the active protocol version.
539+
///
540+
/// 2026-07-28 restricts `ElicitResult.content` values to
541+
/// `string | number | boolean | string[]` ([`ElicitResultValue`](turul_mcp_protocol::elicitation::ElicitResultValue));
542+
/// these callers have no fallible return path, so a shape outside that union
543+
/// (e.g. a nested object) is stringified via its JSON text rather than
544+
/// silently dropped. 2025-11-25 keeps the untyped `Value` as-is.
545+
#[cfg(feature = "protocol-2026-07-28")]
546+
fn to_elicit_result_value(value: Value) -> turul_mcp_protocol::elicitation::ElicitResultValue {
547+
use turul_mcp_protocol::elicitation::ElicitResultValue;
548+
match value {
549+
Value::String(s) => ElicitResultValue::String(s),
550+
Value::Number(n) => ElicitResultValue::Number(n.as_f64().unwrap_or(0.0)),
551+
Value::Bool(b) => ElicitResultValue::Boolean(b),
552+
Value::Array(items) => ElicitResultValue::StringArray(
553+
items
554+
.into_iter()
555+
.map(|item| {
556+
item.as_str()
557+
.map(str::to_string)
558+
.unwrap_or_else(|| item.to_string())
559+
})
560+
.collect(),
561+
),
562+
other => ElicitResultValue::String(other.to_string()),
563+
}
564+
}
565+
566+
#[cfg(feature = "protocol-2025-11-25")]
567+
fn to_elicit_result_value(value: Value) -> Value {
568+
value
569+
}
570+
537571
/// Result builder for creating elicitation responses
538572
pub struct ElicitResultBuilder;
539573

540574
impl ElicitResultBuilder {
541575
/// Create an accept result with content
542576
pub fn accept(content: HashMap<String, Value>) -> ElicitResult {
577+
let content = content
578+
.into_iter()
579+
.map(|(k, v)| (k, to_elicit_result_value(v)))
580+
.collect();
543581
ElicitResult::accept(content)
544582
}
545583

@@ -556,13 +594,16 @@ impl ElicitResultBuilder {
556594
/// Create an accept result with a single field
557595
pub fn accept_single(field_name: impl Into<String>, value: Value) -> ElicitResult {
558596
let mut content = HashMap::new();
559-
content.insert(field_name.into(), value);
597+
content.insert(field_name.into(), to_elicit_result_value(value));
560598
ElicitResult::accept(content)
561599
}
562600

563601
/// Create an accept result with multiple fields from key-value pairs
564602
pub fn accept_fields(fields: Vec<(String, Value)>) -> ElicitResult {
565-
let content = fields.into_iter().collect();
603+
let content = fields
604+
.into_iter()
605+
.map(|(k, v)| (k, to_elicit_result_value(v)))
606+
.collect();
566607
ElicitResult::accept(content)
567608
}
568609
}
@@ -955,6 +996,14 @@ mod tests {
955996
// Accept with single field
956997
let single_result = ElicitResultBuilder::accept_single("name", json!("John"));
957998
assert!(matches!(single_result.action, ElicitAction::Accept));
999+
#[cfg(feature = "protocol-2026-07-28")]
1000+
assert_eq!(
1001+
single_result.content.as_ref().unwrap().get("name"),
1002+
Some(&turul_mcp_protocol::elicitation::ElicitResultValue::String(
1003+
"John".to_string()
1004+
))
1005+
);
1006+
#[cfg(feature = "protocol-2025-11-25")]
9581007
assert_eq!(
9591008
single_result.content.as_ref().unwrap().get("name"),
9601009
Some(&json!("John"))

crates/turul-mcp-builders/src/prelude.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub use crate::traits::*;
1818
// Re-export all builders
1919
#[cfg(feature = "protocol-2025-11-25")]
2020
pub use crate::completion::CompletionBuilder;
21-
pub use crate::elicitation::ElicitationBuilder;
21+
pub use crate::elicitation::{ElicitResultBuilder, ElicitationBuilder};
2222
#[cfg(feature = "protocol-2025-11-25")]
2323
pub use crate::logging::LoggingBuilder;
2424
#[cfg(feature = "protocol-2025-11-25")]

crates/turul-mcp-derive/src/completion_derive.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ pub fn derive_mcp_completion_impl(input: DeriveInput) -> Result<TokenStream> {
9393

9494
let filtered = self.filter_completions(completions, current_value);
9595
let completion_result = turul_mcp_protocol::completion::CompletionResult::new(filtered)
96-
.with_total(3)
96+
.with_total(3.0)
9797
.with_has_more(false);
9898

9999
Ok(turul_mcp_protocol::completion::CompleteResult::new(completion_result))

crates/turul-mcp-derive/src/elicitation_derive.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ pub fn derive_mcp_elicitation_impl(input: DeriveInput) -> Result<TokenStream> {
7272
use std::collections::HashMap;
7373
let mut content = HashMap::new();
7474
content.insert("response".to_string(), serde_json::Value::String("User input collected".to_string()));
75-
Ok(turul_mcp_protocol::elicitation::ElicitResult::accept(content))
75+
Ok(turul_mcp_builders::elicitation::ElicitResultBuilder::accept(content))
7676
}
7777
}
7878
};

crates/turul-mcp-protocol-2026-07-28/src/caching.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
//!
1212
//! ## Embedding pattern
1313
//!
14-
//! The schema's `extends CacheableResult` becomes `#[serde(flatten)] cache:
15-
//! CacheableResult` in Rust. See `ListToolsResult`, `ReadResourceResult`, etc.
14+
//! Result structs carry `ttl_ms` and `cache_scope` as explicit struct fields,
15+
//! paired with a `with_cache()` builder method that populates them from a
16+
//! `CacheableResult` value. See `ListToolsResult`, `ReadResourceResult`, etc.
1617
1718
use serde::{Deserialize, Serialize};
1819

0 commit comments

Comments
 (0)