Skip to content

Commit ac07723

Browse files
committed
feat(ext-tasks): bilingual client task surface (slice B)
Opt-in ext-tasks feature on turul-mcp-client: - declared_capabilities.ext_tasks declares io.modelcontextprotocol/tasks in every 2026 request's _meta clientCapabilities.extensions (unit tests pin both the declared shape and the no-opt-in absence) - call_tool_or_task(name, args) -> ToolCallOutcome::Completed | Task — the server is the sole decider (SEP-2663), so the API is the honest union; plain call_tool keeps the strict BP-1 unknown-resultType rejection - task_get / task_update / task_cancel bind the lifecycle; task_wait polls to a terminal status honoring pollIntervalMs (clamped 50ms-30s) 4 real-server e2e tests (ext_tasks_e2e_2026.rs; gates + CI step): task→poll→completed, sync fallback for undeclared clients, task_update resuming an input_required task, cancel-to-cancelled. Full client suite 162-passing under the feature. Revert-and-fail recorded: suppressing the capability emission fails task_outcome_polls_to_completion with a sync result (also live-proving the server's progressive enhancement). Full ci-gates.sh all green.
1 parent 1cd49af commit ac07723

9 files changed

Lines changed: 449 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ jobs:
7676
run: cargo test -p turul-mcp-server --no-default-features --features http,sse,ext-tasks --test ext_tasks_2026
7777
- name: Extension crates standalone
7878
run: cargo test -p turul-mcp-ext-tasks -p turul-mcp-ext-apps
79+
- name: Tasks extension client e2e
80+
run: cargo test -p turul-mcp-client --features ext-tasks --test ext_tasks_e2e_2026
7981

8082
opt-in-2025:
8183
name: Opt-in lane (2025-11-25)

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+
- **Tasks-extension client surface (2026-06-12, SEP-2663 — slice B).** New opt-in `ext-tasks` feature on `turul-mcp-client`: `declared_capabilities.ext_tasks = true` declares `io.modelcontextprotocol/tasks` in every 2026 request's `_meta` `clientCapabilities.extensions`; `call_tool_or_task(name, args)` returns `ToolCallOutcome::Completed | Task` (the server is the sole decider — the typed union replaces guessing); `task_get`/`task_update`/`task_cancel` bind the lifecycle methods; `task_wait` polls to a terminal status honoring the server's `pollIntervalMs` (clamped 50ms–30s). The strict BP-1 parser is untouched — plain `call_tool` still rejects unknown `resultType`s; only the ext-aware path accepts `"task"`. 4 real-server e2e tests (`ext_tasks_e2e_2026.rs`, gates + CI): task→poll→completed, sync fallback for undeclared clients, `task_update` resuming an `input_required` task, cancel-to-cancelled; revert-and-fail recorded (suppressing the capability emission fails the task-outcome test with a sync result).
2324
- **Tasks-extension server runtime (2026-06-12, SEP-2663 — closes driver gap G1).** New opt-in `ext-tasks` feature on `turul-mcp-server` (off by default per SEP-2133): `.with_ext_tasks(store)` advertises `io.modelcontextprotocol/tasks` in `server/discover`'s `capabilities.extensions` and registers `tasks/get`/`tasks/update`/`tasks/cancel`; `.ext_task_tool(tool)` / `.ext_task_tool_required(tool)` mark tools for task election — a request whose per-request `_meta` `clientCapabilities.extensions` declares the extension gets a durable `CreateTaskResult` (UUIDv4 bearer-grade id, store written BEFORE the response) with a spawned worker; undeclared requests run synchronously (progressive enhancement) or, for `_required` tools, get `-32003` with the upstream `data.requiredCapabilities.extensions` shape. **MRTR bridge**: a task tool returning `McpError::InputRequired` parks its task in `input_required`; `tasks/update` validates response keys against outstanding requests (partial delivery keeps it parked) and resumes the worker with the responses injected through the same session-extension keys as the sync retry leg — tool code is identical under both execution models. `tasks/cancel` is cooperative (aborts the worker, drops input waiters, acks terminal tasks). `notifications/tasks` rides `subscriptions/listen`: the transport honors a `taskIds` filter iff the extension is advertised (keyed off the capability map — no transport dependency on the ext crate), echoes it in the ack, and delivers per-taskId. `turul-mcp-ext-tasks` gains the `TaskStore` trait + `TaskState` + `InMemoryTaskStore` (no tokio in the public API). 9 wire e2e tests (`ext_tasks_2026.rs`, wired into gates + CI); revert-and-fail recorded. Dispatcher design recorded in ADR-028 (2026-06-12 entry).
2425
- **`turul-mcp-ext-apps` 0.1.0 scaffold (2026-06-12, SEP-1865).** Spec-neutral extension crate binding the MCP-side Apps surface: extension identifier `io.modelcontextprotocol/ui` (the ADR-028 table's `/apps` guess corrected against upstream), client capability (`UiClientCapabilities.mimeTypes` + the `text/html;profile=mcp-app` HTML-views gate), tool `_meta.ui` (`UiToolMeta`: `resourceUri`, `visibility` model/app), and UI-resource `_meta.ui` (`UiResourceMeta`: CSP domain lists, sandbox permissions, dedicated origin, `prefersBorder`). The host↔view iframe protocol is deliberately not bound (app/host SDK scope). Vendored spec pinned at `modelcontextprotocol/ext-apps@ca1d2989`; 5 wire-shape compliance tests.
2526
- **Versioning/cancellation/elicitation P2 trio (2026-06-12).** (1) **VER-4**: the headerless-`initialize` rejection (400 + `-32001`) now carries `error.data.supported` naming this build's protocol versions — a true legacy client's only diagnostic; wire test `headerless_initialize_rejection_names_supported_versions`, red-phase recorded. (2) **PAT/G10**: dedicated `CancelledNotificationHandler` extracts `requestId` + `reason` from inbound `notifications/cancelled` into a structured log line ("Both parties SHOULD log cancellation reasons"); accepted-and-ignored semantics unchanged. (3) **CF/GAP-CF-8**: new `turul_mcp_builders::validate_elicit_content(schema, content)` validates elicited form content against the requesting schema (required/unknown keys, primitive types, string-length/numeric bounds, integer-ness, enum membership across the 2026 enum-union shapes; format assertions annotation-only by design) — central enforcement is impossible on the stateless lane (leg-1 schema not retained), so tools call it on the MRTR retry; wired into `mrtr-elicitation-server` and live-verified. Plus **BP-5** (COMPLIANCE.md §"Supported JSON Schema dialects" — the documentation the SHOULD asks for) and **UTIL/COMP-3** (relevance/fuzzy/rate-limit completion SHOULDs dispositioned: provider semantics + middleware rate limiting). Driver summary now 305 ✅ / 68 🟡 / 5 ❌ / 12 🧪 / 100 ➖ — the 5 remaining ❌ all carry recorded dispositions.

crates/turul-mcp-client/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ description = "Comprehensive MCP client library with multi-transport support"
2020
# the alias's protocol features and trip the spec mutex (e.g. linking this
2121
# client and a 2026-default server into one test binary).
2222
turul-mcp-protocol-2025-11-25 = { workspace = true }
23+
turul-mcp-ext-tasks = { workspace = true, optional = true }
2324
turul-mcp-protocol-2026-07-28 = { workspace = true, optional = true }
2425
turul-rpc = { workspace = true }
2526

@@ -62,6 +63,9 @@ all-transports = ["http", "sse", "stdio"]
6263
# Bilingual (default) links both protocol crates; one McpClient negotiates the
6364
# wire spec per connection at connect() time.
6465
client-bilingual = ["dep:turul-mcp-protocol-2026-07-28"]
66+
# Tasks extension (io.modelcontextprotocol/tasks, SEP-2663) client surface —
67+
# 2026-07-28 connections only; off by default per SEP-2133.
68+
ext-tasks = ["dep:turul-mcp-ext-tasks"]
6569
# Narrowing: each speaks exactly ONE wire spec. Requires --no-default-features
6670
# (otherwise client-bilingual is also active and the mutex fires). The frozen
6771
# 2025-11-25 protocol crate is always linked as the shared type vocabulary;
@@ -75,7 +79,7 @@ wiremock = "0.6"
7579
# Real-server e2e: bilingual client against an in-process 2026 stateless server.
7680
# Default features (http, sse -> protocol-2026-07-28); no alias-mutex conflict
7781
# because this crate links the versioned protocol crates directly, not the alias.
78-
turul-mcp-server = { path = "../turul-mcp-server", version = "0.4.0" }
82+
turul-mcp-server = { path = "../turul-mcp-server", version = "0.4.0", features = ["ext-tasks"] }
7983
turul-mcp-derive = { path = "../turul-mcp-derive", version = "0.4.0" }
8084
# The derive macros expand to `turul_mcp_protocol::` / `turul_mcp_builders::`
8185
# paths, so the e2e test target needs both (2026 default, matching the dev

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

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1379,6 +1379,99 @@ impl McpClient {
13791379
.into())
13801380
}
13811381

1382+
/// `tools/call` that accepts EITHER a synchronous completion or a
1383+
/// `CreateTaskResult` (`resultType: "task"`, SEP-2663). Requires a
1384+
/// 2026-07-28 connection and `declared_capabilities.ext_tasks = true`
1385+
/// (servers MUST NOT return task handles to clients that did not declare
1386+
/// the extension).
1387+
#[cfg(feature = "ext-tasks")]
1388+
pub async fn call_tool_or_task(
1389+
&self,
1390+
name: &str,
1391+
arguments: Value,
1392+
) -> McpClientResult<ToolCallOutcome> {
1393+
if self.negotiated_version().await != Some(crate::version::McpVersion::V2026_07_28) {
1394+
return Err(crate::error::ProtocolError::MethodNotFound(
1395+
"the Tasks extension requires a 2026-07-28 connection".to_string(),
1396+
)
1397+
.into());
1398+
}
1399+
let extra = json!({ "name": name, "arguments": arguments });
1400+
self.send_2026_07_28_with_extra_headers(
1401+
"tools/call",
1402+
extra,
1403+
&[("Mcp-Name".to_string(), name.to_string())],
1404+
|result| {
1405+
if result.get("resultType").and_then(|v| v.as_str())
1406+
== Some(turul_mcp_ext_tasks::RESULT_TYPE_TASK)
1407+
{
1408+
let task: turul_mcp_ext_tasks::CreateTaskResult =
1409+
serde_json::from_value(result.clone())?;
1410+
return Ok(ToolCallOutcome::Task(task));
1411+
}
1412+
crate::protocol::v2026_07_28::parse_call_tool(result)
1413+
.map(ToolCallOutcome::Completed)
1414+
},
1415+
)
1416+
.await
1417+
}
1418+
1419+
/// `tasks/get` — poll one task's state (SEP-2663).
1420+
#[cfg(feature = "ext-tasks")]
1421+
pub async fn task_get(
1422+
&self,
1423+
task_id: &str,
1424+
) -> McpClientResult<turul_mcp_ext_tasks::DetailedTask> {
1425+
self.send_2026_07_28("tasks/get", json!({ "taskId": task_id }), |result| {
1426+
let r: turul_mcp_ext_tasks::GetTaskResult = serde_json::from_value(result.clone())?;
1427+
Ok(r.task)
1428+
})
1429+
.await
1430+
}
1431+
1432+
/// `tasks/update` — deliver input responses to an `input_required` task
1433+
/// (SEP-2663). `input_responses` is the `{key: InputResponse}` map whose
1434+
/// keys answer the task's outstanding `inputRequests`.
1435+
#[cfg(feature = "ext-tasks")]
1436+
pub async fn task_update(&self, task_id: &str, input_responses: Value) -> McpClientResult<()> {
1437+
self.send_2026_07_28(
1438+
"tasks/update",
1439+
json!({ "taskId": task_id, "inputResponses": input_responses }),
1440+
|_| Ok(()),
1441+
)
1442+
.await
1443+
}
1444+
1445+
/// `tasks/cancel` — cooperative cancellation (SEP-2663); the ack does not
1446+
/// guarantee a `cancelled` terminal status.
1447+
#[cfg(feature = "ext-tasks")]
1448+
pub async fn task_cancel(&self, task_id: &str) -> McpClientResult<()> {
1449+
self.send_2026_07_28("tasks/cancel", json!({ "taskId": task_id }), |_| Ok(()))
1450+
.await
1451+
}
1452+
1453+
/// Poll `tasks/get` until the task reaches a terminal status, honoring
1454+
/// the server's `pollIntervalMs` hint (clamped to [50ms, 30s]; default
1455+
/// 500ms when the server sends none).
1456+
#[cfg(feature = "ext-tasks")]
1457+
pub async fn task_wait(
1458+
&self,
1459+
task_id: &str,
1460+
) -> McpClientResult<turul_mcp_ext_tasks::DetailedTask> {
1461+
loop {
1462+
let task = self.task_get(task_id).await?;
1463+
if task.status().is_terminal() {
1464+
return Ok(task);
1465+
}
1466+
let interval_ms = task
1467+
.fields()
1468+
.poll_interval_ms
1469+
.unwrap_or(500.0)
1470+
.clamp(50.0, 30_000.0);
1471+
tokio::time::sleep(std::time::Duration::from_millis(interval_ms as u64)).await;
1472+
}
1473+
}
1474+
13821475
/// List available resources (returns cached result if available)
13831476
///
13841477
/// Returns the FIRST page only — use
@@ -2123,6 +2216,18 @@ fn value_to_request_params(params: Value) -> Option<RequestParams> {
21232216
}
21242217
}
21252218

2219+
/// Outcome of [`McpClient::call_tool_or_task`] (SEP-2663): the server is the
2220+
/// sole decider of task materialization.
2221+
#[cfg(feature = "ext-tasks")]
2222+
#[derive(Debug)]
2223+
pub enum ToolCallOutcome {
2224+
/// The call completed synchronously with an ordinary tool result.
2225+
Completed(CallToolResult),
2226+
/// The server elected a task — poll with
2227+
/// [`McpClient::task_wait`]/[`McpClient::task_get`].
2228+
Task(turul_mcp_ext_tasks::CreateTaskResult),
2229+
}
2230+
21262231
/// A live `subscriptions/listen` stream: the acknowledged filter subset, the
21272232
/// server-assigned subscription id, and the ordered notification feed.
21282233
/// Dropping this closes the stream (= cancels the subscription).

crates/turul-mcp-client/src/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ pub struct DeclaredCapabilities {
6060
pub sampling_context: bool,
6161
/// Can answer `roots/list` input requests (deprecated per SEP-2577).
6262
pub roots: bool,
63+
/// Declares the Tasks extension (`io.modelcontextprotocol/tasks`,
64+
/// SEP-2663) in every 2026 request's `_meta` `clientCapabilities.extensions`
65+
/// — servers may then answer task-electing calls with a `CreateTaskResult`
66+
/// instead of the normal result. Use the `call_tool_or_task`/`task_*`
67+
/// client APIs (requires the `ext-tasks` cargo feature).
68+
pub ext_tasks: bool,
6369
}
6470

6571
/// Client identification information

crates/turul-mcp-client/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ pub mod transport;
242242
pub mod version;
243243

244244
// Re-export main types
245+
#[cfg(feature = "ext-tasks")]
246+
pub use client::ToolCallOutcome;
245247
/// High-level MCP client with session management and automatic reconnection
246248
pub use client::{
247249
McpClient, McpClientBuilder, NotificationCallback, SubscriptionStream, ToolCallResponse,

crates/turul-mcp-client/src/protocol/v2026_07_28.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,12 @@ pub(crate) fn request_meta(
3939
},
4040
),
4141
roots: declared.roots.then(Default::default),
42+
extensions: declared.ext_tasks.then(|| {
43+
std::collections::HashMap::from([(
44+
"io.modelcontextprotocol/tasks".to_string(),
45+
serde_json::json!({}),
46+
)])
47+
}),
4248
..Default::default()
4349
};
4450
p::meta::RequestMetaObject::new(
@@ -284,6 +290,29 @@ mod tests {
284290

285291
/// Sub-capability declarations reach the wire shapes the gating servers
286292
/// check: elicitation.url and sampling.tools/.context.
293+
#[test]
294+
fn ext_tasks_declaration_rides_request_meta_extensions() {
295+
let declared = crate::config::DeclaredCapabilities {
296+
ext_tasks: true,
297+
..Default::default()
298+
};
299+
let meta = request_meta("t", "1", &declared);
300+
let v = serde_json::to_value(&meta).unwrap();
301+
assert_eq!(
302+
v["io.modelcontextprotocol/clientCapabilities"]["extensions"]["io.modelcontextprotocol/tasks"],
303+
serde_json::json!({})
304+
);
305+
306+
let none = request_meta("t", "1", &Default::default());
307+
let v = serde_json::to_value(&none).unwrap();
308+
assert!(
309+
v["io.modelcontextprotocol/clientCapabilities"]
310+
.get("extensions")
311+
.is_none(),
312+
"no declaration without the opt-in: {v}"
313+
);
314+
}
315+
287316
#[test]
288317
fn sub_capabilities_map_into_request_meta() {
289318
let declared = crate::config::DeclaredCapabilities {
@@ -293,6 +322,7 @@ mod tests {
293322
sampling_tools: true,
294323
sampling_context: false,
295324
roots: false,
325+
ext_tasks: false,
296326
};
297327
let meta = request_meta("t", "1", &declared);
298328
let v = serde_json::to_value(&meta).unwrap();

0 commit comments

Comments
 (0)