Skip to content

Commit c99c6ac

Browse files
committed
feat(ext-tasks): example pair + docs riders (slice C)
examples/ext-tasks-server (port 8645, server + client bins, both live-verified): - crunch: 2s long-runner — declared client gets a task handle and task_wait polls at the server's pollIntervalMs to the result; undeclared client blocks ~2s for the synchronous answer (progressive enhancement contrast in one walkthrough) - deploy: mid-task elicited approval — input_required → tasks/update → 'deployed billing-api ✅'; the tool body is byte-identical MRTR code that works under both execution models EXAMPLES.md: pairing row + feature row + counts (54 active; count-vs- rows sweep green). Gate/CI step builds the example; fixed the step's cargo invocation (--bins cannot repeat — single trailing flag covers all selected packages; caught by the gate run itself). Full ci-gates.sh all green.
1 parent ac07723 commit c99c6ac

9 files changed

Lines changed: 452 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
- name: 2026 client example (pairs with minimal-server)
7272
run: cargo build -p streamable-http-client
7373
- name: Client-using examples (not in default-members)
74-
run: cargo build -p mrtr-elicitation-server --bins -p bilingual-fleet-client
74+
run: cargo build -p mrtr-elicitation-server -p bilingual-fleet-client -p ext-tasks-server --bins
7575
- name: Tasks extension (SEP-2663, opt-in feature)
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

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+
- **`ext-tasks-server` example pair (2026-06-12, SEP-2663 — slice C).** Task-electing server (port 8645: `crunch` long-runner + `deploy` with mid-task elicited approval) and a client walkthrough bin driving the full lifecycle — task handle → `task_wait` polling at the server's `pollIntervalMs``input_required``tasks/update` → completed, plus the progressive-enhancement contrast (the same tool blocks ~2s and answers synchronously for an undeclared client). Live-verified end to end; EXAMPLES.md pairing row + counts (54 active); built by the client-using-examples gate step.
2324
- **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).
2425
- **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).
2526
- **`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.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ members = [
3333
"examples/derive-macro-server",
3434
"examples/dynamic-tools-server",
3535
"examples/elicitation-server",
36+
"examples/ext-tasks-server",
3637
"examples/function-macro-server",
3738
"examples/function-resource-server",
3839
"examples/lambda-mcp-client",

EXAMPLES.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# MCP Framework Examples
22

3-
This document provides a comprehensive overview of all **53 active examples** in the MCP Framework (plus `examples/archived/` — grown by 5 in the 2026-06-12 archive slice, see `docs/plans/2026-07-28-examples-review.md`), organized by learning progression from basic concepts to advanced implementations.
3+
This document provides a comprehensive overview of all **54 active examples** in the MCP Framework (plus `examples/archived/` — grown by 5 in the 2026-06-12 archive slice, see `docs/plans/2026-07-28-examples-review.md`), organized by learning progression from basic concepts to advanced implementations.
44

55
**✅ All active examples compile under their lane's CI gates** (2026-07-28 default lane
66
+ per-manifest 2025-11-25 pins). Per-example functional re-verification against the
@@ -17,6 +17,7 @@ lane** — run the server first, then point the client at it.
1717
|---|---|---|---|
1818
| `streamable-http-client` | **2026-07-28** | `minimal-server` (port 8641); point at `notification-server` (port 8005) for live subscription deliveries | The canonical 2026 stateless pair: `connect()` negotiation, discover retention, `call_tool`, request-scoped progress, and the ack-first `subscriptions_listen` stream |
1919
| `mrtr-elicitation-client` (in `mrtr-elicitation-server`) | **2026-07-28** | `mrtr-elicitation-server` (port 8642) | The MRTR round trip: `input_required` → answer → `call_tool_with_input_responses` |
20+
| `ext-tasks-client` (in `ext-tasks-server`) | **2026-07-28** | `ext-tasks-server` (port 8645) | SEP-2663 task lifecycle: `call_tool_or_task` → poll → `tasks/update` → completed; sync fallback for undeclared clients |
2021
| `bilingual-fleet-client` | **both** | any mix (demo: `minimal-server` + `client-initialise-server`) | One binary sweeping a mixed 2025/2026 fleet — per-connection negotiation |
2122
| `streamable-http-client-2025-11-25` | 2025-11-25 | `client-initialise-server` (alternatives: any 2025-pinned server) | Hand-parsed 2025 POST SSE framing |
2223
| `client-initialise-report` | 2025-11-25 (wire-pinned) | `client-initialise-server` | Raw-wire lifecycle compliance probe |
@@ -73,7 +74,7 @@ cargo run -p minimal-server
7374
| **function-resource-server** | 8008 | ✅ VALIDATED | Function-based resources | Resource function patterns |
7475
| **session-aware-resource-server** | 8008 | ✅ VALIDATED | Session-aware resources (2025-11-25 pinned) | Session context integration on the stateful lane |
7576

76-
## 🟢 **FEATURE-SPECIFIC SERVERS** (13 examples) - Specialized MCP Features
77+
## 🟢 **FEATURE-SPECIFIC SERVERS** (14 examples) - Specialized MCP Features
7778

7879
| Example | Port | Status | Description | Key Features |
7980
|---------|------|--------|-------------|--------------|
@@ -88,6 +89,7 @@ cargo run -p minimal-server
8889
| **mrtr-elicitation-server** | 8642 | ✅ VALIDATED | MRTR elicitation round trip (2026) | `InputRequired` → retry with `inputResponses`; paired client bin |
8990
| **origin-policy-server** | 8643 | ✅ VALIDATED | Origin validation / DNS-rebinding (2026) | `OriginPolicy` default/AllowList/Disabled; 403 matrix verified live |
9091
| **header-bound-tools-server** | 8644 | ✅ VALIDATED | SEP-2243 header-bound tool params (2026) | `x-mcp-header``Mcp-Param-*` mirroring; -32001 contract verified live |
92+
| **ext-tasks-server** | 8645 | ✅ VALIDATED | Tasks extension (SEP-2663, 2026) | Task election + polling + `tasks/update` mid-task input; paired client bin; live-verified |
9193
| **oauth-resource-server** | 8080 | ✅ VALIDATED | OAuth 2.1 Resource Server (RFC 9728) | JWKS Bearer validation, PRM well-known routes, `--required-scope` → 403 insufficient_scope |
9294
| **dynamic-tools-server** | 8484 | ✅ VALIDATED | Dynamic tool registration (2025-pinned) | `ToolRegistry` + `notifications/tools/list_changed` on the stateful lane |
9395

@@ -180,7 +182,7 @@ These examples demonstrate MCP 2025-11-25 type construction without starting a s
180182

181183
## 🚨 **COMPREHENSIVE VALIDATION RESULTS**
182184

183-
### **ALL 53 ACTIVE EXAMPLES COMPILE UNDER THEIR LANE'S CI GATES**
185+
### **ALL 54 ACTIVE EXAMPLES COMPILE UNDER THEIR LANE'S CI GATES**
184186
**0.4.0 branch (MCP 2026-07-28 default, 2025-11-25 opt-in) — last reconciled 2026-06-12.**
185187
The per-example functional verification ledger lives in
186188
`docs/plans/2026-07-28-examples-review.md`; the 2026-06-12 migrate slice
@@ -189,7 +191,7 @@ re-verified its six examples live on the wire.
189191
- **Getting Started** - 5 examples (all tool creation levels)
190192
- **Session Storage** - 3 examples (SQLite, PostgreSQL, DynamoDB)
191193
- **Resource Servers** - 6 examples (session-aware resources)
192-
- **Feature-Specific** - 13 examples (prompts, sampling, elicitation, MRTR, origin policy, header binding, OAuth RS, etc.)
194+
- **Feature-Specific** - 14 examples (prompts, sampling, elicitation, MRTR, tasks extension, origin policy, header binding, OAuth RS, etc.)
193195
- **Advanced/Composite** - audit-trail, pagination, icon showcase
194196
- **Session & State** - stateful-server + session-aware-resource-server (2025-pinned) and the storage-backend trio
195197
- **Client Examples** - see the pairs table above (9 pairing rows)
@@ -210,7 +212,7 @@ re-verified its six examples live on the wire.
210212
- **Live-verified**: migrated and new examples were verified by running them and executing their own printed commands
211213

212214
### 📊 **Statistics**
213-
- **Total Examples**: 53 active (29 archived in `examples/archived/`)
215+
- **Total Examples**: 54 active (29 archived in `examples/archived/`)
214216
- **Session state (2025-pinned lane)**: stateful-server, session-aware-resource-server, and the logging/session test fixtures demonstrate cross-request session state on the opt-in lane; 2026-default examples use request-scoped context or app-owned storage instead
215217
- **Client-Server Pairs**: 9 pairing-table rows validating communication patterns
216218
- **Task Support**: 3 examples demonstrating MCP 2025-11-25 task lifecycle (InMemory storage)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Uses the ext-tasks features of both server and client, so it stays out of
2+
# default-members (like the other client-using examples) and is built by an
3+
# explicit gate step.
4+
[package]
5+
name = "ext-tasks-server"
6+
version = "0.4.0"
7+
edition.workspace = true
8+
authors.workspace = true
9+
license.workspace = true
10+
11+
[dependencies]
12+
turul-mcp-server = { workspace = true, features = ["ext-tasks"] }
13+
turul-mcp-protocol.workspace = true
14+
turul-mcp-builders.workspace = true
15+
turul-mcp-ext-tasks.workspace = true
16+
turul-mcp-client = { workspace = true, features = ["ext-tasks"] }
17+
async-trait.workspace = true
18+
tokio.workspace = true
19+
tracing.workspace = true
20+
tracing-subscriber.workspace = true
21+
serde_json.workspace = true
22+
23+
[[bin]]
24+
name = "ext-tasks-server"
25+
path = "src/main.rs"
26+
test = false
27+
28+
[[bin]]
29+
name = "ext-tasks-client"
30+
path = "src/client.rs"
31+
test = false
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Tasks Extension (`io.modelcontextprotocol/tasks`, SEP-2663)
2+
3+
Durable poll handles instead of blocking on long-running tool calls — the
4+
2026-07-28 replacement for the 2025 core task lifecycle (no `tasks/list`, no
5+
blocking `tasks/result`; polling via `tasks/get`, mid-task input via the new
6+
`tasks/update`).
7+
8+
```bash
9+
cargo run -p ext-tasks-server # server, port 8645
10+
cargo run -p ext-tasks-server --bin ext-tasks-client # client walkthrough
11+
```
12+
13+
## What the walkthrough shows (live-verified)
14+
15+
```text
16+
→ crunch(7) with the extension declared
17+
← task 45588cc8… (Working, pollIntervalMs 500) — polling…
18+
← completed: "49"
19+
20+
→ deploy(billing-api)
21+
← task 72434fe1… — polling for the approval request…
22+
← input_required: Approve deployment of billing-api?
23+
→ tasks/update: approved = true
24+
← completed: "deployed billing-api ✅"
25+
26+
→ crunch(3) WITHOUT declaring the extension
27+
← synchronous (blocked ~2s): "9"
28+
```
29+
30+
## The lifecycle
31+
32+
1. **Declare** — the client puts `io.modelcontextprotocol/tasks` in every
33+
request's `_meta` `clientCapabilities.extensions`
34+
(`declared_capabilities.ext_tasks = true`). The server advertises the same
35+
in `server/discover`.
36+
2. **Elect** — the server is the sole decider: a task-electing tool answers a
37+
declared client with `CreateTaskResult` (`resultType: "task"`), durably
38+
stored before the response. Undeclared clients get the ordinary
39+
synchronous result — same tool, progressive enhancement.
40+
3. **Poll**`tasks/get` until terminal, honoring `pollIntervalMs`
41+
(`McpClient::task_wait` does this).
42+
4. **Mid-task input**`deploy` returns `McpError::InputRequired` exactly
43+
like a synchronous MRTR tool; under task election the runtime parks the
44+
task in `input_required` and `tasks/update` resumes it. **Tool code is
45+
identical under both execution models.**
46+
5. **Terminal**`completed` carries the result the sync call would have
47+
returned; `failed` carries the JSON-RPC error; `tasks/cancel` is
48+
cooperative.
49+
50+
## Server side
51+
52+
```rust
53+
McpServer::builder()
54+
.with_ext_tasks(Arc::new(InMemoryTaskStore::new())) // advertises + registers tasks/*
55+
.ext_task_tool(CrunchTool::new()) // elect when declared, sync otherwise
56+
.ext_task_tool(DeployTool::new())
57+
// .ext_task_tool_required(...) // -32003 when undeclared
58+
```
59+
60+
Requires the `ext-tasks` cargo feature on `turul-mcp-server` (extensions are
61+
off by default per SEP-2133).
62+
63+
## Client side
64+
65+
```rust
66+
let mut config = ClientConfig::default();
67+
config.declared_capabilities.ext_tasks = true;
68+
// ...
69+
match client.call_tool_or_task("crunch", json!({"n": 7})).await? {
70+
ToolCallOutcome::Task(t) => {
71+
let done = client.task_wait(&t.task.fields.task_id).await?;
72+
}
73+
ToolCallOutcome::Completed(r) => { /* server chose sync */ }
74+
}
75+
```
76+
77+
Requires the `ext-tasks` cargo feature on `turul-mcp-client`.
78+
79+
## See also
80+
81+
- `mrtr-elicitation-server` — the synchronous MRTR round trip the `deploy`
82+
tool also works under
83+
- `crates/turul-mcp-server/tests/ext_tasks_2026.rs` — the wire contract suite
84+
(incl. `-32003` for required tools and `taskIds`-filtered
85+
`notifications/tasks` over `subscriptions/listen`)
86+
- `tasks-e2e-inmemory-server` — the 2025-11-25 core task lifecycle this
87+
extension replaces (kept on the pinned 2025 lane)
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//! # Tasks Extension Client (SEP-2663)
2+
//!
3+
//! The client leg against `ext-tasks-server`:
4+
//!
5+
//! 1. Declare the extension (`declared_capabilities.ext_tasks = true`).
6+
//! 2. `call_tool_or_task("crunch")` → a task handle; `task_wait` polls
7+
//! (honoring `pollIntervalMs`) to the completed result.
8+
//! 3. `call_tool_or_task("deploy")` → the task parks in `input_required`;
9+
//! answer the elicited approval via `task_update`; poll to completion.
10+
//! 4. The same `crunch` from an UNDECLARED client → ordinary synchronous
11+
//! result (progressive enhancement).
12+
13+
use serde_json::json;
14+
use turul_mcp_client::transport::HttpTransport;
15+
use turul_mcp_client::{ClientConfig, McpClient, McpVersion, ToolCallOutcome};
16+
use turul_mcp_ext_tasks::DetailedTask;
17+
18+
#[tokio::main]
19+
async fn main() -> Result<(), Box<dyn std::error::Error>> {
20+
tracing_subscriber::fmt()
21+
.with_env_filter(
22+
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "warn".into()),
23+
)
24+
.init();
25+
26+
let url = std::env::args()
27+
.nth(1)
28+
.unwrap_or_else(|| "http://127.0.0.1:8645/mcp".to_string());
29+
println!("Connecting to {url} (start it with: cargo run -p ext-tasks-server)");
30+
31+
let mut config = ClientConfig::default();
32+
config.declared_capabilities.ext_tasks = true;
33+
let client = McpClient::new(Box::new(HttpTransport::new(&url)?), config);
34+
client.connect().await?;
35+
if client.negotiated_version().await != Some(McpVersion::V2026_07_28) {
36+
println!("Peer is not a 2026-07-28 server — the Tasks extension needs the 2026 wire.");
37+
return Ok(());
38+
}
39+
40+
// 1. Long-running call → durable task handle, poll to completion.
41+
println!("\n→ crunch(7) with the extension declared");
42+
match client
43+
.call_tool_or_task("crunch", json!({ "n": 7 }))
44+
.await?
45+
{
46+
ToolCallOutcome::Task(task) => {
47+
println!(
48+
"← task {} ({:?}, pollIntervalMs {:?}) — polling…",
49+
task.task.fields.task_id, task.task.status, task.task.fields.poll_interval_ms
50+
);
51+
let done = client.task_wait(&task.task.fields.task_id).await?;
52+
if let DetailedTask::Completed { result, .. } = done {
53+
println!("← completed: {}", result["content"][0]["text"]);
54+
}
55+
}
56+
ToolCallOutcome::Completed(r) => println!("← completed synchronously: {r:?}"),
57+
}
58+
59+
// 2. Mid-task input: deploy parks in input_required until tasks/update.
60+
println!("\n→ deploy(billing-api)");
61+
let ToolCallOutcome::Task(task) = client
62+
.call_tool_or_task("deploy", json!({ "service": "billing-api" }))
63+
.await?
64+
else {
65+
println!("← unexpectedly synchronous");
66+
return Ok(());
67+
};
68+
let task_id = task.task.fields.task_id.clone();
69+
println!("← task {task_id} — polling for the approval request…");
70+
loop {
71+
let t = client.task_get(&task_id).await?;
72+
match &t {
73+
DetailedTask::InputRequired { input_requests, .. } => {
74+
let msg = input_requests
75+
.get("approval")
76+
.and_then(|r| serde_json::to_value(r).ok())
77+
.and_then(|v| {
78+
v.pointer("/params/message")
79+
.and_then(|m| m.as_str().map(String::from))
80+
})
81+
.unwrap_or_default();
82+
println!("← input_required: {msg}");
83+
println!("→ tasks/update: approved = true");
84+
client
85+
.task_update(
86+
&task_id,
87+
json!({ "approval": { "action": "accept", "content": { "approved": true } } }),
88+
)
89+
.await?;
90+
break;
91+
}
92+
t if t.status().is_terminal() => break,
93+
_ => tokio::time::sleep(std::time::Duration::from_millis(100)).await,
94+
}
95+
}
96+
let done = client.task_wait(&task_id).await?;
97+
if let DetailedTask::Completed { result, .. } = done {
98+
println!("← completed: {}", result["content"][0]["text"]);
99+
}
100+
101+
// 3. Progressive enhancement: an UNDECLARED client gets the sync result.
102+
println!("\n→ crunch(3) WITHOUT declaring the extension");
103+
let plain = McpClient::new(Box::new(HttpTransport::new(&url)?), ClientConfig::default());
104+
plain.connect().await?;
105+
match plain.call_tool_or_task("crunch", json!({ "n": 3 })).await? {
106+
ToolCallOutcome::Completed(r) => {
107+
let v = serde_json::to_value(&r)?;
108+
println!("← synchronous (blocked ~2s): {}", v["content"][0]["text"]);
109+
}
110+
ToolCallOutcome::Task(_) => println!("← BUG: task handle for an undeclared client"),
111+
}
112+
113+
println!("\nDone — task ids are durable handles; re-run tasks/get any time before TTL.");
114+
Ok(())
115+
}

0 commit comments

Comments
 (0)