Skip to content

Commit 3ad1111

Browse files
committed
feat: server wire-edges P2 batch — ALL 73 driver gaps now closed
Final batch: 29 fixed + 4 dispositioned (with BP-3/PAT-G9/CF-9/SCHEMA-G5 rationale recorded in the register). The register stands at 73/73 closed (1/1 P0, 14/14 P1, 58/58 P2 — 52 fixed, 6 dispositioned total). Behavior: - null request ids → 400 + -32600 pre-dispatch, both lanes (MCP forbids them; turul-rpc keeps its base-JSON-RPC Null variant) — revert-and-fail proven. - Invalid pagination cursors → -32602 at all five list sites (cursor must be one the server issued) — revert-and-fail proven. NOTE: three 2025-lane e2e tests asserted the old non-spec leniency (fabricated "test_cursor" succeeding silently) — migrated WITH the contract; the 2025 pagination spec carries the same SHOULD. - completion/complete is no longer a default handler: unconfigured server → 404 + -32601. Default handler counts migrated (2026: 14, 2025: 21). - Blob resource contents validated as base64 before shipping. - prompts/list carries title/icons/_meta; initialize rejection names supported versions in error.data; X-Accel-Buffering: no on streaming responses; tool-name format warnings at registration (SHOULD-level); Mcp-Param message whitespace runs collapsed. - New session helpers: notify_elicitation_complete(elicitation_id), notify_request_progress_with_message(progress, total, message). - PromptAnnotations moved protocol→builders (no schema counterpart in DRAFT-2026-v1 — protocol-purity). Fallout fixed across prompts-server, prompts-test-server, tests/prompts bin, a glob ambiguity, and a real macro-hygiene bug (prompt! expanded a bare PromptAnnotations relying on call-site scope — now fully qualified). Tests: wire_edges_2026.rs (10 — null id, _meta branches, initialize versions, unknown tool/resource/prompt codes, list determinism + pagination walk + cacheable fields, blob encoding, prompt descriptors, completion -32601, logging capability), numeric Mcp-Param compare, roots/sampling -32003 capability arms, SEP-2577 marker tripwire (reverting Slice A'' now fails the protocol crate's tests). Gates: scripts/ci-gates.sh all → ALL GATES PASSED.
1 parent 839b122 commit 3ad1111

26 files changed

Lines changed: 1061 additions & 161 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+
- **Server wire-edges P2 batch — the FINAL open driver gaps (2026-06-11).** All 73 audit gaps are now closed (52 fixed, 6 dispositioned with recorded rationale). Behavior: null request ids → 400 + -32600 pre-dispatch (MCP forbids them; turul-rpc's base-JSON-RPC Null variant stays); invalid pagination cursors → -32602 at all five list sites; `completion/complete` no longer a default handler (unconfigured server → 404 + -32601); blob resource contents validated as base64 before shipping; prompts/list carries title/icons/_meta; the initialize rejection names supported versions in error.data; `X-Accel-Buffering: no` on streaming responses; tool-name format warnings at registration; Mcp-Param message whitespace runs collapsed; `notify_elicitation_complete` + `notify_request_progress_with_message` session helpers; `PromptAnnotations` moved protocol→builders (no schema counterpart — purity). Tests: `wire_edges_2026.rs` (10), numeric Mcp-Param compare, roots/sampling -32003 arms, SEP-2577 marker tripwire (reverting Slice A'' now fails CI). Dispositions: schema-dialect validation (documented limitation), progress rate-limiting (middleware layer per ADR-012), sampling message-shape constraints (deprecated surface), tool-Err-vs-isError (deliberate AGENTS.md-documented contract: `Err` = protocol error, `CallToolResult::error` = model-visible). EXAMPLES_PIN capture date corrected. Closes **CHG/G4, CHG/G6, DEP-GAP-3, BP-2/3/4, VER-2, PAT/G5/G9, TX/GAP-3/4/5, CF/GAP-CF-6/7/9, DISC-4, PRM-2026-01/04/05, RES-G3/G6/G7, TOOLS-G3/G4/G6/G7, UTIL/COMP-2, UTIL/PAG-1/2, UTIL/LOG-2, SCHEMA/G2/G3/G5**.
2324
- **Client capability/discovery P2 batch (2026-06-11).** (1) The `server/discover` body is now retained for the connection: `DiscoveredServer` (capabilities, instructions, serverInfo, supportedVersions) with `discovered_server()`/`server_capabilities()`/`server_instructions()` accessors. (2) `-32004` negotiation honors `error.data.supported`: fallback only when 2025-11-25 is mutually supported, otherwise the error names the server's list ("select a mutually supported version … or surface an error"). (3) Era detection no longer keys on one code: structured `-32602` also classifies as a legacy-server fallback signal per "commonly -32601 or -32602" (the prior -32602→abort unit pin migrated WITH the contract). (4) `DeclaredCapabilities` gains `elicitation_url`/`sampling_tools`/`sampling_context`, mapped into the spec's sub-capability shapes in every request `_meta`. (5) `call_tool` auto-recovers from `-32001` Mcp-Param rejections: one `tools/list` refresh + one retry per the SEP-2243 client-behavior note. (6) New `call_tool_with_progress(name, args, token, on_progress)`: SSE-framed request with `_meta.progressToken`, progress params delivered to the callback before the final result (real-server e2e). (7) `McpClientError::is_resource_not_found()` accepts `-32602` and the backwards-compat `-32002`. (8) First-page contract documented on the convenience list APIs (use `*_paginated` for full walks). structuredContent validation dispositioned (apps bring their own validator — no 2020-12 validator dependency for a SHOULD). Closes driver gaps **ARCH/GAP-ARCH-1, ARCH/GAP-ARCH-2, DISC-1, VER-3, CF/GAP-CF-5, TX/GAP-6, TX/GAP-7, RES-G4, UTIL/PAG-3, PAT/G4, TOOLS-G2**.
2425
- **Subscriptions/cancellation P2 batch (2026-06-11, mostly tests).** New wire coverage: concurrent `subscriptions/listen` streams each receive exactly their filtered subset stamped with their own `subscriptionId`, and `notifications/message` never rides a listen stream (MUST NOT); dropping one subscription leaves siblings delivering; progress notifications stop at the final response (MUST); MRTR negative paths (neither-field `InputRequired` → server error; `InputRequired` escaping `completion/complete` → error, never `input_required`); unrecognized `logLevel` → `-32602`. Code: `notifications/cancelled` is now an explicitly registered notification on both lanes (202, never 404 — note: the 202 wire contract for true notifications pre-existed via the transport's fire-and-forget path; the registration adds sibling parity and the request-shaped consistency). Cancellation of in-flight work on Streamable HTTP remains the stream-close mechanism; cross-request correlation by id is impossible without sessions on the stateless lane, so inbound cancelled notifications are accepted and ignored per "Invalid cancellation notifications SHOULD be ignored". Server-shutdown stream teardown dispositioned (socket-close; no graceful-shutdown API exists). Closes driver gaps **PAT/G6, PAT/G7, PAT/G8, TOOLS-G5, UTIL/LOG-1, SCHEMA/G1**.
2526
- **OAuth/security P2 batch (2026-06-11).** (1) *Malformed Authorization → 400*: a present-but-unparseable `Authorization` header (wrong scheme, empty/multi-token Bearer) now answers 400 + `error="invalid_request"` (RFC 6750 §3.1) instead of the missing-credentials 401 — `RequestContext::authorization_malformed` is set by both transports; wire-tested. (2) *Runtime scope enforcement*: `OAuthResourceMiddleware::with_required_scopes` rejects tokens missing a required scope with 403 + `error="insufficient_scope"` per Authorization §Insufficient Scope; unit-tested with minted HS256 tokens. (3) *offline_access guard*: `ProtectedResourceMetadata::with_scopes` filters `offline_access` with a warning (resource servers SHOULD NOT advertise it). (4) *Sessionless-ping auth*: on the 2025-11-25 lane, the `allow_unauthenticated_ping` bypass now runs AFTER the pre-session auth phase — it waives the session requirement only, matching its documented contract ("the full middleware stack still runs"); new 2025-lane wire test `tests/ping_auth_2025.rs` wired into the gates. (5) *Session-user binding (AUTH-7)*: dispositioned by design — claims stay request-scoped per ADR-021 D2; deployments needing binding implement it via middleware + session state; moot on the sessionless 2026 lane. Closes spec-compliance driver gaps **AUTH-2, AUTH-3, AUTH-5, AUTH-6 (fixed) + AUTH-7 (dispositioned)** — the OAuth/security P2 theme is closed.

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

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1234,6 +1234,32 @@ impl StreamableHttpHandler {
12341234
}
12351235
};
12361236

1237+
// MCP forbids null request ids ("Unlike base JSON-RPC, the ID MUST
1238+
// NOT be null") — reject before any dispatch. turul-rpc's RequestId
1239+
// retains a Null variant for base-JSON-RPC compatibility, so the MCP
1240+
// transport enforces the stricter contract here.
1241+
if let JsonRpcMessage::Request(req) = &message
1242+
&& matches!(req.id, turul_rpc::RequestId::Null)
1243+
{
1244+
let err = turul_rpc::JsonRpcError::new(
1245+
Some(req.id.clone()),
1246+
turul_rpc::error::JsonRpcErrorObject::invalid_request(Some(serde_json::json!(
1247+
"request id MUST NOT be null (MCP basic §Requests)"
1248+
))),
1249+
);
1250+
let body = serde_json::to_string(&err).unwrap_or_else(|_| "{}".to_string());
1251+
return Response::builder()
1252+
.status(StatusCode::BAD_REQUEST)
1253+
.header(CONTENT_TYPE, "application/json")
1254+
.header("MCP-Protocol-Version", context.protocol_version.as_str())
1255+
.body(
1256+
Full::new(Bytes::from(body))
1257+
.map_err(|never| match never {})
1258+
.boxed_unsync(),
1259+
)
1260+
.unwrap();
1261+
}
1262+
12371263
// --- Pre-session auth phase (D4) ---
12381264
// Extract Bearer token using hardened parser (D6). A present-but-
12391265
// unparseable Authorization header is flagged so auth middleware can
@@ -1712,10 +1738,19 @@ impl StreamableHttpHandler {
17121738
&& self.dispatcher.default_handler.is_none()
17131739
{
17141740
debug!("Unknown 2026 method '{}' → 404 + -32601", req.method);
1715-
let err = turul_rpc::JsonRpcError::new(
1716-
Some(req.id.clone()),
1717-
turul_rpc::error::JsonRpcErrorObject::method_not_found(&req.method),
1718-
);
1741+
// Versioning §Backward Compatibility: "A server that supports only
1742+
// modern versions SHOULD name the protocol versions it supports in
1743+
// any error it returns to an initialize request" — legacy clients
1744+
// have no fall-forward mechanism; this may be their only
1745+
// diagnostic.
1746+
let mut error_object =
1747+
turul_rpc::error::JsonRpcErrorObject::method_not_found(&req.method);
1748+
if req.method == "initialize" {
1749+
error_object.data = Some(serde_json::json!({
1750+
"supported": [turul_mcp_protocol::MCP_VERSION]
1751+
}));
1752+
}
1753+
let err = turul_rpc::JsonRpcError::new(Some(req.id.clone()), error_object);
17191754
let body = serde_json::to_string(&err).unwrap_or_else(|_| "{}".to_string());
17201755
return Response::builder()
17211756
.status(StatusCode::NOT_FOUND)
@@ -2477,6 +2512,10 @@ impl StreamableHttpHandler {
24772512
.status(StatusCode::OK)
24782513
.header(CONTENT_TYPE, content_type)
24792514
.header("Transfer-Encoding", "chunked") // Key: Enable chunked encoding!
2515+
// nginx-family proxies buffer streaming responses by default,
2516+
// which holds SSE frames back from the client until the stream
2517+
// closes — defeating request-scoped notifications.
2518+
.header("X-Accel-Buffering", "no")
24802519
.header("Cache-Control", "no-cache")
24812520
.body(http_body_util::BodyExt::boxed_unsync(body))
24822521
.unwrap();

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ impl HasPromptArguments for DynamicPrompt {
250250
}
251251

252252
impl HasPromptAnnotations for DynamicPrompt {
253-
fn annotations(&self) -> Option<&turul_mcp_protocol::prompts::PromptAnnotations> {
253+
fn annotations(&self) -> Option<&crate::traits::prompt_traits::PromptAnnotations> {
254254
None // Annotations not currently implemented
255255
}
256256
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ impl HasPromptArguments for Prompt {
117117
}
118118

119119
impl HasPromptAnnotations for Prompt {
120-
fn annotations(&self) -> Option<&turul_mcp_protocol::prompts::PromptAnnotations> {
120+
fn annotations(&self) -> Option<&crate::traits::prompt_traits::PromptAnnotations> {
121121
// Prompt struct doesn't have annotations field in current protocol
122122
None
123123
}

crates/turul-mcp-builders/src/traits/prompt_traits.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,29 @@ use std::collections::HashMap;
88

99
// Import protocol types (spec-defined)
1010
use turul_mcp_protocol::Prompt;
11-
use turul_mcp_protocol::prompts::{PromptAnnotations, PromptArgument};
11+
use turul_mcp_protocol::prompts::PromptArgument;
12+
13+
/// Framework-side prompt annotations (display hints). This is a FRAMEWORK
14+
/// type — the wire `Prompt` carries no annotations object in DRAFT-2026-v1;
15+
/// the schema's display affordances are `title` and `icons` on the prompt
16+
/// itself.
17+
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
18+
pub struct PromptAnnotations {
19+
/// Display name (precedence: Prompt.title > Prompt.name)
20+
#[serde(skip_serializing_if = "Option::is_none")]
21+
pub title: Option<String>,
22+
}
23+
24+
impl PromptAnnotations {
25+
pub fn new() -> Self {
26+
Self::default()
27+
}
28+
29+
pub fn with_title(mut self, title: impl Into<String>) -> Self {
30+
self.title = Some(title.into());
31+
self
32+
}
33+
}
1234

1335
pub trait HasPromptMetadata {
1436
/// Programmatic identifier (fallback display name)

crates/turul-mcp-derive/src/macros/prompt.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ pub fn prompt_declarative_impl(input: TokenStream) -> Result<TokenStream> {
195195
}
196196

197197
impl turul_mcp_builders::HasPromptAnnotations for GeneratedPrompt {
198-
fn annotations(&self) -> Option<&PromptAnnotations> { None }
198+
fn annotations(&self) -> Option<&turul_mcp_builders::traits::PromptAnnotations> { None }
199199
}
200200

201201
impl turul_mcp_builders::HasPromptMeta for GeneratedPrompt {

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

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

6060
#[automatically_derived]
6161
impl turul_mcp_builders::traits::HasPromptAnnotations for #struct_name {
62-
fn annotations(&self) -> Option<&turul_mcp_protocol::prompts::PromptAnnotations> {
62+
fn annotations(&self) -> Option<&turul_mcp_builders::traits::PromptAnnotations> {
6363
None
6464
}
6565
}

crates/turul-mcp-protocol-2026-07-28/schema/EXAMPLES_PIN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ maintained alongside the spec at:
77
- **Subpath**: `schema/draft/examples`
88
- **Commit SHA** (pin used by `git checkout`): `1304c8fe5f079358a0ff22b6afdb675dc96d75d1`
99
- **Tree SHA** (audit only — subpath digest at this commit): `9f9415b427c4db6f7ad375ca7b86d1a5ee955072`
10-
- **Captured**: 2026-05-24
10+
- **Captured**: 2026-06-10 (pin advanced with the 1bf94a60… schema re-pin; see ADR-027 revision log)
1111

1212
The compliance harness (`src/compliance/`) fetches the pinned tree via
1313
shallow sparse `git clone`, caches under `target/upstream-fixtures/` for

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,3 +600,36 @@ impl turul_rpc::r#async::ToJsonRpcError for McpError {
600600
McpError::to_error_object(self)
601601
}
602602
}
603+
604+
#[cfg(test)]
605+
mod sep_2577_marker_tripwire {
606+
/// SEP-2577 absorbed `#[deprecated]` markers onto the Roots, Sampling,
607+
/// and Logging surfaces. This tripwire fails if a refactor drops them:
608+
/// each named item's declaration must be preceded by a deprecation
609+
/// attribute within the few lines above it.
610+
#[test]
611+
fn deprecation_markers_are_present() {
612+
for (source, items) in [
613+
(
614+
include_str!("roots.rs"),
615+
&["pub struct Root ", "pub struct ListRootsRequest"][..],
616+
),
617+
(
618+
include_str!("sampling.rs"),
619+
&["pub struct CreateMessageRequest ", "pub struct ModelHint"][..],
620+
),
621+
(include_str!("logging.rs"), &["pub enum LoggingLevel"][..]),
622+
] {
623+
for item in items {
624+
let pos = source
625+
.find(item)
626+
.unwrap_or_else(|| panic!("{item} not found"));
627+
let preceding = &source[pos.saturating_sub(2000)..pos];
628+
assert!(
629+
preceding.contains("#[deprecated"),
630+
"{item} must carry a #[deprecated] marker (SEP-2577)"
631+
);
632+
}
633+
}
634+
}
635+
}

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

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,6 @@ use serde::{Deserialize, Serialize};
77
use serde_json::Value;
88
use std::collections::HashMap;
99

10-
/// Prompt annotations structure (matches TypeScript PromptAnnotations)
11-
#[derive(Debug, Clone, Serialize, Deserialize)]
12-
pub struct PromptAnnotations {
13-
/// Display name (precedence: Prompt.title > Prompt.name)
14-
#[serde(skip_serializing_if = "Option::is_none")]
15-
pub title: Option<String>,
16-
// Additional annotation fields can be added here as needed
17-
}
18-
19-
impl Default for PromptAnnotations {
20-
fn default() -> Self {
21-
Self::new()
22-
}
23-
}
24-
25-
impl PromptAnnotations {
26-
pub fn new() -> Self {
27-
Self { title: None }
28-
}
29-
30-
pub fn with_title(mut self, title: impl Into<String>) -> Self {
31-
self.title = Some(title.into());
32-
self
33-
}
34-
}
35-
3610
/// A prompt descriptor (matches TypeScript Prompt interface exactly)
3711
#[derive(Debug, Clone, Serialize, Deserialize)]
3812
#[serde(rename_all = "camelCase")]

0 commit comments

Comments
 (0)