Skip to content

Commit 8fd899c

Browse files
committed
feat(server): mode-aware MRTR capability gating (elicitation.url, sampling.tools)
The -32003 gate in input_required_to_result checked only top-level capability presence; a URL-mode elicit request passed against a form-only client, and tool-enabled sampling passed against bare sampling:{}. Spec contracts now enforced: - Elicitation §Capabilities: "Servers MUST NOT send elicitation requests with modes that are not supported by the client"; "an empty capabilities object is equivalent to declaring support for form mode only". URL mode requires elicitation.url; form mode rides the empty object (an explicit url-only object does NOT imply form). - Sampling §Tools in Sampling: "Servers MUST NOT send tool-enabled sampling requests to Clients that have not declared support for tool use via the sampling.tools capability" — tools/toolChoice present requires sampling.tools. Red phase recorded: url_mode_elicitation_requires_the_url_subcapability and tool_enabled_sampling_requires_the_tools_subcapability failed (200 input_required instead of 400 -32003) before the gate refinement. Gates: scripts/ci-gates.sh all → ALL GATES PASSED. Closes spec-compliance driver gaps CF/GAP-CF-1 + CF/GAP-CF-2 (P1).
1 parent 3232b66 commit 8fd899c

4 files changed

Lines changed: 228 additions & 11 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+
- **Mode-aware MRTR capability gating (2026-06-11).** The server's `-32003` gate on `InputRequiredResult` now enforces sub-capabilities, not just top-level presence: URL-mode elicitation requires the client's `elicitation.url` declaration ("Servers MUST NOT send elicitation requests with modes that are not supported by the client"; an empty `elicitation: {}` declares form-only), and tool-enabled sampling (`tools`/`toolChoice` present) requires `sampling.tools` ("Servers MUST NOT send tool-enabled sampling requests to Clients that have not declared support"). Closes spec-compliance driver gaps **CF/GAP-CF-1 + CF/GAP-CF-2 (both P1)**; red-phase wire tests in `mrtr_2026.rs`.
2324
- **`roots/list` removed from the 2026 default surface (2026-06-11).** On 2026-07-28, roots is a client feature: the server requests roots via MRTR input requests and never hosts an inbound `roots/list` RPC; `notifications/roots/list_changed` has no binding in the pinned schema. The builder's `roots/list` + roots-notification registrations are now gated to the `protocol-2025-11-25` opt-in; on the 2026 default they answer 404 + `-32601` like every other non-2026 method. Closes spec-compliance driver gap **CF/GAP-CF-4 (P1)**; red-phase recorded in `error_mapping_2026.rs`.
2425
- **Client MRTR completion for `resources/read` + `prompts/get`, and `resultType` discipline (2026-06-11, ADR-030 revision log).** The bilingual client's `parse_read_resource`/`parse_get_prompt` now surface `InputRequiredResult` as `McpClientError::InputRequired` (previously a serde "missing field" error that discarded `inputRequests`/`requestState`), with retry APIs `read_resource_with_input_responses` / `get_prompt_with_input_responses` mirroring `call_tool_with_input_responses`. All 2026 result parsers now enforce basic §Responses ("a resultType of any value unrecognized by the client MUST be considered invalid"): unknown discriminators are `ProtocolError::InvalidResponse` instead of being treated as complete results. Closes spec-compliance driver gaps CF/GAP-CF-3, PRM/PR-2026-02, RES-G1, PAT/G3, BP-1 (all P1 except PAT/G3). Real-server e2e round-trips added; revert-and-fail recorded.
2526
- **Origin-header validation (DNS-rebinding protection) on the HTTP transport (2026-06-11, ADR-031).** Streamable HTTP §Security requires: "Servers MUST validate the `Origin` header … If the `Origin` header is present and invalid, servers MUST respond with HTTP 403 Forbidden." New `OriginPolicy` on `ServerConfig` — default `SameOriginOrLoopback` (Origin absent → allowed; loopback or Host-matching origins → allowed; anything else → 403), `AllowList(Vec<String>)` additive allowlist, `Disabled` opt-out for upstream-enforced deployments. Enforced at both transport handler entries (streamable + legacy session path), so hyper and Lambda deployments inherit it; OPTIONS preflight and `.well-known` routes exempt. Builder knobs: `HttpMcpServerBuilder::origin_policy` and `McpServer::builder().origin_policy(...)`. Wire tests in `crates/turul-mcp-server/tests/origin_validation_2026.rs` (revert-and-fail proven: disabling the gate fails 3 tests). On the Lambda builder, an explicit CORS configuration derives the policy (`cors_allow_all_origins()` → `Disabled`, an origin list → `AllowList`) unless `.origin_policy()` overrides it — `turul-http-mcp-server`'s blanket `enable_cors` flag deliberately does NOT derive (see ADR-031 §CORS-derived policy). Closes spec-compliance driver gap **TX/GAP-1 (P0)**. **Behavior change:** cross-origin browser clients now require an explicit `AllowList` (previously implicitly admitted via CORS `*`).

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

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,13 @@ fn extract_cursor_from_params(params: &Option<Value>) -> Option<turul_mcp_protoc
4848
/// MRTR (SEP-2322): convert a provider's `McpError::InputRequired` outcome
4949
/// into a successful `InputRequiredResult` value, after enforcing that every
5050
/// input request targets a capability the client declared in the request's
51-
/// `_meta` `clientCapabilities` (undeclared → `-32003`, HTTP 400). Shared by
52-
/// the three methods permitted to return `input_required`: `tools/call`,
53-
/// `resources/read`, `prompts/get`.
51+
/// `_meta` `clientCapabilities` (undeclared → `-32003`, HTTP 400). Gating is
52+
/// mode-aware: URL-mode elicitation needs `elicitation.url`, form mode rides
53+
/// the empty object ("an empty capabilities object is equivalent to declaring
54+
/// support for form mode only"), and tool-enabled sampling (`tools` /
55+
/// `toolChoice` present) needs `sampling.tools`. Shared by the three methods
56+
/// permitted to return `input_required`: `tools/call`, `resources/read`,
57+
/// `prompts/get`.
5458
#[cfg(feature = "protocol-2026-07-28")]
5559
pub(crate) fn input_required_to_result(
5660
input_requests: Option<turul_mcp_protocol::input_required::InputRequests>,
@@ -63,12 +67,37 @@ pub(crate) fn input_required_to_result(
6367
for request in requests.values() {
6468
#[allow(deprecated)]
6569
let missing = match request {
66-
InputRequest::Elicit(_) if caps.elicitation.is_none() => {
67-
Some(serde_json::json!({ "elicitation": {} }))
68-
}
69-
InputRequest::CreateMessage(_) if caps.sampling.is_none() => {
70-
Some(serde_json::json!({ "sampling": {} }))
71-
}
70+
InputRequest::Elicit(elicit) => match &caps.elicitation {
71+
None => Some(serde_json::json!({ "elicitation": {} })),
72+
Some(e) => {
73+
use turul_mcp_protocol::elicitation::ElicitRequestParams;
74+
match &elicit.params {
75+
// URL mode needs the explicit url sub-capability.
76+
ElicitRequestParams::Url(_) if e.url.is_none() => {
77+
Some(serde_json::json!({ "elicitation": { "url": {} } }))
78+
}
79+
// Form mode: an empty capabilities object declares
80+
// form-only support; an explicit url-only object
81+
// does NOT imply form.
82+
ElicitRequestParams::Form(_) if e.form.is_none() && e.url.is_some() => {
83+
Some(serde_json::json!({ "elicitation": { "form": {} } }))
84+
}
85+
_ => None,
86+
}
87+
}
88+
},
89+
InputRequest::CreateMessage(create) => match &caps.sampling {
90+
None => Some(serde_json::json!({ "sampling": {} })),
91+
// Tool-enabled sampling needs the tools sub-capability.
92+
Some(sc)
93+
if (create.params.tools.is_some()
94+
|| create.params.tool_choice.is_some())
95+
&& sc.tools.is_none() =>
96+
{
97+
Some(serde_json::json!({ "sampling": { "tools": {} } }))
98+
}
99+
Some(_) => None,
100+
},
72101
InputRequest::ListRoots(_) if caps.roots.is_none() => {
73102
Some(serde_json::json!({ "roots": {} }))
74103
}

crates/turul-mcp-server/tests/mrtr_2026.rs

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,193 @@ async fn undeclared_capability_is_rejected_with_32003() {
208208
#[allow(dead_code)]
209209
fn _t(_: HashMap<String, String>) {}
210210

211+
// ---- Sub-capability gating (elicitation modes; sampling.tools) ----
212+
213+
/// Demands a URL-mode elicitation — needs `elicitation.url` declared.
214+
#[derive(McpTool, Clone, Default)]
215+
#[tool(name = "url_gated", description = "Needs a URL elicitation", output = String)]
216+
struct UrlGatedTool {}
217+
218+
impl UrlGatedTool {
219+
async fn execute(&self, session: Option<SessionContext>) -> McpResult<String> {
220+
if let Some(session) = &session
221+
&& session.input_responses().is_some()
222+
{
223+
return Ok("done".to_string());
224+
}
225+
let mut requests = InputRequests::new();
226+
requests.insert(
227+
"auth".to_string(),
228+
InputRequest::Elicit(ElicitRequest::new_url(
229+
"Authorize via browser",
230+
"el-1",
231+
"https://example.test/authorize",
232+
)),
233+
);
234+
Err(McpError::InputRequired {
235+
input_requests: Some(requests),
236+
request_state: None,
237+
})
238+
}
239+
}
240+
241+
/// Demands tool-enabled sampling — needs `sampling.tools` declared.
242+
#[derive(McpTool, Clone, Default)]
243+
#[tool(name = "sampler", description = "Needs tool-enabled sampling", output = String)]
244+
struct SamplingGatedTool {}
245+
246+
impl SamplingGatedTool {
247+
async fn execute(&self, session: Option<SessionContext>) -> McpResult<String> {
248+
if let Some(session) = &session
249+
&& session.input_responses().is_some()
250+
{
251+
return Ok("done".to_string());
252+
}
253+
#[allow(deprecated)]
254+
let request = {
255+
use turul_mcp_protocol::sampling::{
256+
CreateMessageRequest, CreateMessageRequestParams, SamplingMessage,
257+
};
258+
let mut params =
259+
CreateMessageRequestParams::new(vec![SamplingMessage::user_text("hi")], 64);
260+
params.tools = Some(vec![turul_mcp_protocol::tools::Tool::new(
261+
"lookup",
262+
turul_mcp_protocol::ToolSchema::object(),
263+
)]);
264+
CreateMessageRequest::new(vec![], 64).with_params(params)
265+
};
266+
let mut requests = InputRequests::new();
267+
requests.insert("s1".to_string(), InputRequest::CreateMessage(request));
268+
Err(McpError::InputRequired {
269+
input_requests: Some(requests),
270+
request_state: None,
271+
})
272+
}
273+
}
274+
275+
async fn start_subcap_server() -> String {
276+
let port = std::net::TcpListener::bind("127.0.0.1:0")
277+
.unwrap()
278+
.local_addr()
279+
.unwrap()
280+
.port();
281+
let server = McpServer::builder()
282+
.name("mrtr-2026-subcap")
283+
.version("0.4.0")
284+
.tool(UrlGatedTool::default())
285+
.tool(SamplingGatedTool::default())
286+
.bind_address(format!("127.0.0.1:{port}").parse().unwrap())
287+
.build()
288+
.expect("build 2026 server");
289+
tokio::spawn(async move {
290+
server.run().await.ok();
291+
});
292+
let url = format!("http://127.0.0.1:{port}/mcp");
293+
let probe = reqwest::Client::new();
294+
for _ in 0..50 {
295+
if probe.get(&url).send().await.is_ok() {
296+
break;
297+
}
298+
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
299+
}
300+
url
301+
}
302+
303+
async fn call_tool_with_caps(
304+
url: &str,
305+
tool: &str,
306+
capabilities: serde_json::Value,
307+
) -> (reqwest::StatusCode, serde_json::Value) {
308+
let client = reqwest::Client::new();
309+
let resp = client
310+
.post(url)
311+
.header("Accept", "application/json")
312+
.header("MCP-Protocol-Version", "2026-07-28")
313+
.header("Mcp-Method", "tools/call")
314+
.header("Mcp-Name", tool)
315+
.json(&serde_json::json!({
316+
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
317+
"params": {
318+
"name": tool,
319+
"arguments": {},
320+
"_meta": meta_with_capabilities(capabilities)
321+
}
322+
}))
323+
.send()
324+
.await
325+
.expect("tools/call POST");
326+
let status = resp.status();
327+
let body: serde_json::Value = resp.json().await.unwrap_or_default();
328+
(status, body)
329+
}
330+
331+
/// Elicitation §Capabilities: "Servers MUST NOT send elicitation requests
332+
/// with modes that are not supported by the client" and "an empty
333+
/// capabilities object is equivalent to declaring support for form mode
334+
/// only" — a URL-mode request against `elicitation: {}` must be -32003.
335+
#[tokio::test]
336+
async fn url_mode_elicitation_requires_the_url_subcapability() {
337+
let url = start_subcap_server().await;
338+
339+
// Form-only declaration (empty object): URL mode rejected.
340+
let (status, body) =
341+
call_tool_with_caps(&url, "url_gated", serde_json::json!({ "elicitation": {} })).await;
342+
assert_eq!(status, 400, "url mode vs form-only client: {body}");
343+
assert_eq!(body["error"]["code"], -32003, "{body}");
344+
345+
// URL declared: passes the gate, input_required comes back.
346+
let (status, body) = call_tool_with_caps(
347+
&url,
348+
"url_gated",
349+
serde_json::json!({ "elicitation": { "url": {} } }),
350+
)
351+
.await;
352+
assert_eq!(status, 200, "{body}");
353+
assert_eq!(body["result"]["resultType"], "input_required", "{body}");
354+
}
355+
356+
/// Elicitation §Capabilities: form mode rides both the empty object and an
357+
/// explicit form declaration.
358+
#[tokio::test]
359+
async fn form_mode_elicitation_passes_with_empty_capability_object() {
360+
let url = start_server().await;
361+
let (status, body) = call_gated_echo(
362+
&url,
363+
serde_json::json!({ "elicitation": {} }),
364+
serde_json::json!({}),
365+
)
366+
.await;
367+
assert_eq!(status, 200, "{body}");
368+
assert_eq!(body["result"]["resultType"], "input_required", "{body}");
369+
}
370+
371+
/// Sampling §Tools in Sampling: "Servers MUST NOT send tool-enabled sampling
372+
/// requests to Clients that have not declared support for tool use via the
373+
/// sampling.tools capability."
374+
#[tokio::test]
375+
async fn tool_enabled_sampling_requires_the_tools_subcapability() {
376+
let url = start_subcap_server().await;
377+
378+
// Bare sampling declaration: tool-enabled request rejected.
379+
let (status, body) =
380+
call_tool_with_caps(&url, "sampler", serde_json::json!({ "sampling": {} })).await;
381+
assert_eq!(
382+
status, 400,
383+
"tool-enabled sampling vs bare sampling: {body}"
384+
);
385+
assert_eq!(body["error"]["code"], -32003, "{body}");
386+
387+
// sampling.tools declared: passes the gate.
388+
let (status, body) = call_tool_with_caps(
389+
&url,
390+
"sampler",
391+
serde_json::json!({ "sampling": { "tools": {} } }),
392+
)
393+
.await;
394+
assert_eq!(status, 200, "{body}");
395+
assert_eq!(body["result"]["resultType"], "input_required", "{body}");
396+
}
397+
211398
// ---- MRTR on resources/read and prompts/get ----
212399

213400
/// Resource that demands an elicitation before serving content.

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -778,11 +778,11 @@ in the same slice as the fix.
778778
- Requirement: "Invalid or expired tokens MUST receive a HTTP 401 response"; "MCP servers MUST implement OAuth 2.0 Protected Resource Metadata (RFC 9728)" — https://modelcontextprotocol.io/specification/draft/basic/authorization (§Token Handling, §Overview #4)
779779
- Current: Implementation exists and is unit-tested (middleware.rs::test_missing_bearer_returns_401; build_http_challenge_response at crates/turul-http-mcp-server/src/streamable_http.rs:2775-2796 adds WWW-Authenticate + Cache-Control: no-store + MCP-Protocol-Version), and Lambda streaming has a transport test (turul-mcp-aws-lambda/src/handler.rs:1533-1634). But none of the nine 2026 suites (crates/turul-mcp-
780780
- Fix: New crates/turul-mcp-server/tests/oauth_2026.rs real-HTTP suite (Builder → handler → wire bytes per CLAUDE.md §Test Coverage Discipline #2/#3), wired into ci.yml + scripts/ci-gates.sh; governed by ADR-021/ADR-022
781-
- [ ] **CF/GAP-CF-1** — No elicitation mode-level capability gating on the server MRTR gate
781+
- [x] **CF/GAP-CF-1** — No elicitation mode-level capability gating on the server MRTR gate**FIXED 2026-06-11** (mode-aware gate in `input_required_to_result`; wire tests `url_mode_elicitation_requires_the_url_subcapability` / `form_mode_elicitation_passes_with_empty_capability_object`, red-phase recorded)
782782
- Requirement: "Servers MUST NOT send elicitation requests with modes that are not supported by the client" and "an empty capabilities object is equivalent to declaring support for form mode only" — https://modelcontextprotocol.io/specification/draft/client/elicitation §Capabilities
783783
- Current: input_required_to_result checks only caps.elicitation.is_none() (crates/turul-mcp-server/src/handlers/mod.rs:66-68). A tool emitting InputRequest::Elicit with ElicitRequestParams::Url passes the gate even when the client declared elicitation:{} (form-only per spec) or elicitation:{form:{}} — the URL-mode request is sent to a client that never declared url support.
784784
- Fix: crates/turul-mcp-server/src/handlers/mod.rs (input_required_to_result): match ElicitRequestParams::Url against caps.elicitation.url, Form against form-or-empty; add -32003 wire tests for the url-vs-form-only case in crates/turul-mcp-server/tests/mrtr_2026.rs. Governed by ADR-027 (2026 spec target);
785-
- [ ] **CF/GAP-CF-2** — No sampling.tools gating for tool-enabled sampling input requests
785+
- [x] **CF/GAP-CF-2** — No sampling.tools gating for tool-enabled sampling input requests**FIXED 2026-06-11** (`tool_enabled_sampling_requires_the_tools_subcapability`, red-phase recorded)
786786
- Requirement: "Servers MUST NOT send tool-enabled sampling requests to Clients that have not declared support for tool use via the sampling.tools capability" — https://modelcontextprotocol.io/specification/draft/client/sampling §Tools in Sampling
787787
- Current: The gate checks only caps.sampling.is_none() (crates/turul-mcp-server/src/handlers/mod.rs:69-70). An InputRequest::CreateMessage whose params carry tools/toolChoice passes when the client declared bare sampling:{} without the tools sub-capability (SamplingCapabilities.tools, crates/turul-mcp-protocol-2026-07-28/src/initialize.rs:113-117 — the binding even documents 'Server MUST get an error if it
788788
- Fix: crates/turul-mcp-server/src/handlers/mod.rs (input_required_to_result): when InputRequest::CreateMessage params.tools/tool_choice is Some, require caps.sampling.tools; add a -32003 wire test in crates/turul-mcp-server/tests/mrtr_2026.rs. Governed by ADR-027. Optionally also gate include_context this

0 commit comments

Comments
 (0)