feat: Implement SEP-2663 Tasks Extension (#1020)
* feat!: implement SEP-2663 Tasks extension, removing the experimental 2025-11-25 tasks design Reshape tasks from the experimental SEP-1319/1686 core-protocol feature into the official io.modelcontextprotocol/tasks extension (SEP-2663): Model: - Re-model Task (statusMessage, ttlMs nullable, pollIntervalMs) and add DetailedTask with status-discriminated payloads (inputRequests/result/error inlined per spec) - CreateTaskResult now flattens Task with resultType: "task"; add ResultType::TASK and CallToolResponse::Task - Add tasks/update (UpdateTaskParams with MRTR InputResponses); tasks/get and tasks/cancel reworked; remove tasks/list, tasks/result - notifications/tasks now carries a full DetailedTask; removed from client notifications (SEP-2260) Capabilities: - Remove core TasksCapability et al; tasks are declared via the extensions map (enable_tasks() builders, supports_tasks() accessors) - Remove tool-level execution.taskSupport and the _meta.task / TaskMetadata / with_task() opt-in: task creation is server-directed and gated on the per-request client capability Runtime: - Replace OperationProcessor with TaskManager: durable-before-response task creation, input_required round-trips via tasks/update, cooperative cancellation, TTL expiry - Client peer helpers get_task/update_task/cancel_task - Emit Mcp-Name routing header from params.taskId for tasks/* methods (SEP-2243/2663) Macros: - Remove #[task_handler] and the tool execution() attribute Tests/examples/docs updated; schema snapshots regenerated. BREAKING CHANGE: the 2025-11-25 experimental tasks API is removed without a compatibility shim. Clients that do not declare the tasks extension always receive synchronous results. * feat: gate tasks/* methods on the client tasks-extension capability (SEP-2663) When the server advertises io.modelcontextprotocol/tasks but the client did not declare it (per-request _meta clientCapabilities, or initialize-time capabilities in session mode), tasks/get, tasks/update, and tasks/cancel now return -32021 Missing Required Client Capability with the required extension in the error data, instead of falling through to the handler's -32601 default. Servers that do not advertise the extension keep returning -32601. Also add regression tests confirming that unknown taskIds yield -32602 and that a legacy 2025-11-25 'task' param on tools/call is silently ignored. * feat: pass SEP-2663 Tasks extension conformance suite Conformance fixtures (conformance/src/bin/server.rs): - Add the required fixture tools: greet (sync-only), slow_compute, failing_job (task support: required), protocol_error_job, confirm_delete, multi_input, and test_tool_with_task (MRTR -> task escalation), all backed by TaskManager with server-directed task creation gated on the client's tasks-extension capability - Advertise io.modelcontextprotocol/tasks in server capabilities and wire get_task/update_task/cancel_task handlers - Task-required tools reject with -32021 when the client did not declare the extension SDK wire-shape fixes surfaced by the suite: - Add resultType: "complete" to GetTaskResult and introduce TaskAckResult so tasks/update and tasks/cancel acks carry the SEP-2322 discriminator (spec: every non-CreateTaskResult response on the tasks surface is resultType complete); dispatch now returns ServerResult::task_ack - CreateTaskResult gets a strict deserializer requiring resultType: "task" so it does not shadow task-shaped results in untagged unions - Client update_task/cancel_task accept both TaskAckResult and EmptyResult All 9 runnable Tasks extension server scenarios now pass (35/35 checks; tasks-status-notifications remains upstream-skipped), so the corresponding entries are removed from conformance/expected-failures-extensions.yaml. 2025-11-25 server suite (40/40), 2026-07-28 server suite (114/114), draft client suite, and extensions client suite all pass their baselines. * fix: address PR 1020 review feedback on DetailedTask schema and cooperative cancel - DetailedTask's JsonSchema now derives from the actual flattened wire shape (DetailedTaskWire: base Task + optional inputRequests/result/error) instead of approximating with the base Task schema, so generated schemas for GetTaskResult and notifications/tasks document the status-specific payload fields. Golden message schemas regenerated. - TaskManager::cancel_task no longer aborts the underlying future. It records the observable cancelled state and acks immediately (spec's eventually consistent semantics), but lets the operation keep running so it can observe is_cancel_requested() or the error from a woken request_input() and perform cleanup; any late result is discarded. New unit tests cover both the cooperative-cleanup path and waking parked input requests. * fix: make tasks/cancel truly cooperative per SEP-2663 (FEEDBACK_2) - TaskManager::cancel_task no longer forces terminal 'cancelled'. It records the cancellation intent, acks immediately, and wakes parked request_input awaits, but the operation decides its own terminal state: a post-cancel error settles as 'cancelled', while an operation that finishes its work settles as 'completed' — per the spec, 'the task may still reach a non-cancelled terminal status'. - Add TaskContext::cancelled(), a watch-based await for use with tokio::select! as the cooperative cancellation exit path. - Correct the unsupported-notification method string from 'notifications/tasks/status' to 'notifications/tasks' and document that task status notifications are not yet routable through subscriptions/listen (SubscriptionFilter has no taskIds field; upstream check still skipped). - Update conformance slow_compute fixture, task_demo example, and tests to honor cancellation via ctx.cancelled(); lifecycle conformance still 8/8 (all 9 scenarios remain green, 35/35 checks). * fix: sweep and evict expired tasks from TaskManager (FEEDBACK_3) TTL handling previously only ran from get_task and only flipped overdue non-terminal tasks to 'failed', never removing entries — an unbounded leak for long-lived servers, and it made ttl_ms: None = 'unlimited retention' meaningless since everything was retained forever. - Rename expire_overdue to sweep_expired and run it from every entry point (spawn, get_task, update_task, cancel_task). - Track terminal_at on each entry; terminal tasks are evicted after being retained for one further ttl_ms window past their terminal transition, so well-behaved pollers can observe the final state before late tasks/get calls return -32602 (spec: servers may delete expired tasks at any time, and returning task-not-found for purged tasks is compliant behavior). - ttl_ms: None entries are never evicted (spec: unlimited retention); document the retention model on TaskManager, including that there is no background sweeper. - New tests: retention-window eviction, sweep of abandoned tasks via other entry points, unlimited-TTL retention, and error-code assertions (-32602) for unknown ids across get/update/cancel. Reviewed the second feedback item (dedicated task-not-found error code) against SEP-2663 §Protocol Errors and it is incorrect: the SEP explicitly specifies -32602 (Invalid params) for invalid or nonexistent taskIds, which is what unknown_task already returns. Kept -32602; strengthened tests to assert the code. * fix: address Copilot review feedback on PR #1020 - TaskManager: drop the JoinHandle as soon as the operation settles instead of retaining it for the whole retention window, and only store it at spawn time if the task is still non-terminal (avoids keeping a completed handle that the completion path could never clear). - Use RequestContext::client_capabilities() (which applies the initialize-time fallback for session peers) instead of raw context.meta.client_capabilities() in the task_demo example, the README snippet, and the test server — the meta-only form would wrongly treat session-declared tasks clients as unsupported. All 9 Tasks extension conformance scenarios remain green (35/35 checks). * fix: enforce SEP-2663 task-result gating in tools/call dispatch A handler could return CallToolResponse::Task without checking whether the request declared the io.modelcontextprotocol/tasks extension, sending a CreateTaskResult to a client that cannot parse it. The SDK dispatch now rejects that case with -32021 Missing Required Client Capability before the response leaves the server, using RequestContext::client_capabilities() (per-request _meta with initialize-time fallback). Adds a regression test with a deliberately misbehaving handler that always materializes a task; all Tasks extension conformance scenarios remain green (35/35 checks). * fix: align TTL boundary comparisons in sweep_expired Copilot review: ttlMs: 0 never expired immediately because phase 1 used a strict 'elapsed > ttl_ms' comparison, and phase 2 retained terminal tasks at exactly the TTL boundary ('elapsed <= ttl_ms'). Treat elapsed >= ttl_ms as expired in phase 1 and evict when elapsed >= ttl_ms in phase 2, so both phases agree at the boundary and ttlMs: 0 expires/evicts on the first sweep. * fix: strict TaskAckResult deserializer and TASK_REQUIRED_TOOLS consistency - TaskAckResult carried only resultType (+ optional _meta) with a derived Deserialize, so inside the untagged ServerResult union it greedily matched any result object containing a resultType key, shadowing CustomResult and losing data (verified with a probe). Replace with a strict deserializer: deny_unknown_fields and require resultType == "complete". Regression tests cover both the non-matching shapes and the genuine ack shape. - Conformance fixtures: confirm_delete and multi_input rejected non-tasks clients inline, contradicting the TASK_REQUIRED_TOOLS doc/constant. Move them into TASK_REQUIRED_TOOLS (they park on in-task elicitation and have no synchronous fallback) so the upfront -32021 gate is the single source of truth, and drop the now-unreachable inline checks. All 9 Tasks extension conformance scenarios remain green (35/35 checks). * fix: let the operation decide its terminal state via TaskExit After tasks/cancel, any operation error was coerced to terminal 'cancelled', masking real failures (e.g. an unrelated error landing just after a late cancel request) and contradicting the documented 'operation decides its own terminal state' contract. Change TaskFuture's error type from McpError to a new TaskExit enum: - TaskExit::Cancelled — an explicit cooperative-cancellation exit; settles as terminal 'cancelled'. - TaskExit::Error(McpError) — a real failure; settles as terminal 'failed' with the error inlined, even after tasks/cancel was received. From<McpError> for TaskExit keeps '?' ergonomic in task bodies, and request_input() now returns TaskExit directly (its wake-on-cancel path yields TaskExit::Cancelled), so parked operations that propagate it with '?' settle as 'cancelled' automatically. Update the conformance fixtures, task_demo example, and tests; add a regression test asserting a post-cancel unrelated error settles as 'failed' with its error payload preserved. All 9 Tasks extension conformance scenarios remain green (35/35 checks). * fix: allow TaskExit enum to be exhaustive * fix: close spawn/shutdown race, sweep in running_task_count, clarify TTL retention docs Addresses branch-review findings: - spawn() raced with shutdown(): if shutdown drained the task map between the entry insert and the JoinHandle store, the handle was dropped and the operation kept running detached. If the entry is gone at store time, the handle is now aborted instead. - running_task_count() now runs the TTL sweep like every other entry point, so it no longer reports overdue tasks as running (matching the documented sweep-on-every-entry-point behavior). Regression test added. - Document that terminal-task retention intentionally extends one ttl_ms window past the terminal transition (observation grace period) beyond the creation-based lifetime ttlMs advertises on the wire — compliant since SEP-2663 allows deleting expired tasks at any time after the TTL.
This commit is contained in:
parent
ac9c637d04
commit
07abfdfd99
38 changed files with 2950 additions and 3236 deletions
36
README.md
36
README.md
|
|
@ -971,21 +971,33 @@ and [client](examples/clients/src/subscriptions_streamhttp.rs) examples.
|
|||
|
||||
## Tasks (long-running tool invocations)
|
||||
|
||||
`rmcp` supports the [task-based tool invocation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks)
|
||||
flow defined in SEP-1319. Annotate a tool with `execution(task_support = "required" | "optional")`
|
||||
and add `#[task_handler]` to your `ServerHandler` impl — `enqueue_task`, `tasks/list`, `tasks/get`,
|
||||
`tasks/result`, and `tasks/cancel` are generated for you on top of an `OperationProcessor`.
|
||||
`rmcp` implements the [MCP Tasks extension](https://modelcontextprotocol.io/extensions/tasks/overview)
|
||||
(SEP-2663, `io.modelcontextprotocol/tasks`). A client declares the extension in its
|
||||
capabilities; the server then decides per request whether to materialize a `tools/call`
|
||||
as a task, returning a `CreateTaskResult` (`resultType: "task"`). The client polls
|
||||
`tasks/get`, answers in-task input requests via `tasks/update`, and may request
|
||||
cooperative cancellation via `tasks/cancel`. Use `rmcp::task_manager::TaskManager`
|
||||
to manage task lifecycles server-side.
|
||||
|
||||
```rust, ignore
|
||||
#[tool(
|
||||
description = "Sum two numbers after a 2-second delay",
|
||||
execution(task_support = "required")
|
||||
)]
|
||||
async fn slow_sum(/* ... */) -> Result<CallToolResult, McpError> { /* ... */ }
|
||||
// Client: declare the tasks extension capability.
|
||||
let caps = ClientCapabilities::builder().enable_tasks().build();
|
||||
|
||||
#[tool_handler]
|
||||
#[task_handler]
|
||||
impl ServerHandler for TaskDemo {}
|
||||
// Server: decide per request whether to materialize a task.
|
||||
async fn call_tool(&self, request: CallToolRequestParams, context: RequestContext<RoleServer>)
|
||||
-> Result<CallToolResponse, McpError>
|
||||
{
|
||||
let client_supports_tasks = context
|
||||
.client_capabilities()
|
||||
.is_some_and(|caps| caps.supports_tasks());
|
||||
if client_supports_tasks {
|
||||
let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| {
|
||||
Box::pin(async move { /* long-running work -> Ok(CallToolResult) */ })
|
||||
});
|
||||
return Ok(CallToolResponse::Task(CreateTaskResult::new(task)));
|
||||
}
|
||||
// ... fall back to synchronous execution
|
||||
}
|
||||
```
|
||||
|
||||
See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching
|
||||
|
|
|
|||
|
|
@ -12,19 +12,11 @@
|
|||
# When bumping DRAFT_CONFORMANCE_VERSION, review the available extension and
|
||||
# pending scenarios and update this file deliberately.
|
||||
|
||||
server:
|
||||
# SEP-2663 Tasks Extension, tracked in #868.
|
||||
# `tasks-status-notifications` is intentionally absent: the upstream check is
|
||||
# currently skipped, and CI should fail if it becomes active but does not pass.
|
||||
- tasks-lifecycle
|
||||
- tasks-capability-negotiation
|
||||
- tasks-wire-fields
|
||||
- tasks-request-state-removal
|
||||
- tasks-mrtr-input
|
||||
- tasks-request-headers
|
||||
- tasks-dispatch-and-envelope
|
||||
- tasks-required-task-error
|
||||
- tasks-mrtr-composition
|
||||
# The SEP-2663 Tasks Extension server scenarios (tracked in #868) all pass and
|
||||
# were removed from this baseline. `tasks-status-notifications` remains
|
||||
# upstream-skipped pending the subscriptions/listen rewrite; CI will fail if it
|
||||
# becomes active but does not pass.
|
||||
server: []
|
||||
|
||||
client:
|
||||
# Informational OAuth extension scenarios.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ use rmcp::{
|
|||
ErrorData, RoleServer, ServerHandler,
|
||||
model::*,
|
||||
service::{RequestContext, SubscriptionContext, SubscriptionSink},
|
||||
task_manager::{TaskExit, TaskManager, TaskOptions},
|
||||
transport::{
|
||||
StreamableHttpServerConfig, StreamableHttpService,
|
||||
streamable_http_server::session::local::LocalSessionManager,
|
||||
|
|
@ -59,6 +60,7 @@ struct ConformanceServer {
|
|||
next_subscription: Arc<AtomicU64>,
|
||||
log_level: Arc<Mutex<LoggingLevel>>,
|
||||
request_state_codec: RequestStateCodec,
|
||||
tasks: TaskManager,
|
||||
}
|
||||
|
||||
impl ConformanceServer {
|
||||
|
|
@ -69,10 +71,97 @@ impl ConformanceServer {
|
|||
next_subscription: Arc::new(AtomicU64::new(0)),
|
||||
log_level: Arc::new(Mutex::new(LoggingLevel::Debug)),
|
||||
request_state_codec: RequestStateCodec::new(REQUEST_STATE_KEY),
|
||||
tasks: TaskManager::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── SEP-2663 Tasks extension fixtures ──────────────────────────────────────
|
||||
|
||||
/// Fixture tools required by the Tasks extension conformance scenarios.
|
||||
const TASK_FIXTURE_TOOLS: &[&str] = &[
|
||||
"greet",
|
||||
"slow_compute",
|
||||
"failing_job",
|
||||
"protocol_error_job",
|
||||
"confirm_delete",
|
||||
"multi_input",
|
||||
"test_tool_with_task",
|
||||
];
|
||||
|
||||
/// Tools that are registered as task-supporting. `greet` is deliberately
|
||||
/// sync-only.
|
||||
const TASK_SUPPORTING_TOOLS: &[&str] = &[
|
||||
"slow_compute",
|
||||
"failing_job",
|
||||
"protocol_error_job",
|
||||
"confirm_delete",
|
||||
"multi_input",
|
||||
"test_tool_with_task",
|
||||
];
|
||||
|
||||
/// Tools that cannot be serviced without returning a `CreateTaskResult`:
|
||||
/// calling them from a client that did not declare the tasks extension is
|
||||
/// rejected with -32021 before the tool body runs (SEP-2663 §Required
|
||||
/// Capabilities). `failing_job` and `test_tool_with_task` are registered
|
||||
/// this way for the required-task-error and MRTR-composition scenarios;
|
||||
/// `confirm_delete` and `multi_input` must park on in-task elicitation, so
|
||||
/// they have no synchronous fallback either.
|
||||
const TASK_REQUIRED_TOOLS: &[&str] = &[
|
||||
"failing_job",
|
||||
"test_tool_with_task",
|
||||
"confirm_delete",
|
||||
"multi_input",
|
||||
];
|
||||
|
||||
fn task_fixture_tool(name: &str) -> Tool {
|
||||
let (description, schema) = match name {
|
||||
"greet" => (
|
||||
"Sync-only greeting fixture (SEP-2663)",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "name": { "type": "string" } },
|
||||
"required": ["name"]
|
||||
}),
|
||||
),
|
||||
"slow_compute" => (
|
||||
"Task-supporting fixture: sleeps `seconds` then returns a result (SEP-2663)",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"seconds": { "type": "number" },
|
||||
"label": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
),
|
||||
"failing_job" => (
|
||||
"Task-supporting fixture (task support: required): returns a tool execution error (SEP-2663)",
|
||||
json!({ "type": "object", "properties": {} }),
|
||||
),
|
||||
"protocol_error_job" => (
|
||||
"Task-supporting fixture: fails with a protocol-level error (SEP-2663)",
|
||||
json!({ "type": "object", "properties": {} }),
|
||||
),
|
||||
"confirm_delete" => (
|
||||
"Task-supporting fixture: parks on a single elicitation inputRequest (SEP-2663)",
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": { "filename": { "type": "string" } }
|
||||
}),
|
||||
),
|
||||
"multi_input" => (
|
||||
"Task-supporting fixture: parks on two parallel elicitation inputRequests (SEP-2663)",
|
||||
json!({ "type": "object", "properties": {} }),
|
||||
),
|
||||
"test_tool_with_task" => (
|
||||
"MRTR round 1 gathers user_name, round 2 escalates to a task (SEP-2663 composition)",
|
||||
json!({ "type": "object", "properties": {} }),
|
||||
),
|
||||
other => panic!("unknown task fixture tool: {other}"),
|
||||
};
|
||||
Tool::new(name.to_string(), description, json_object(schema))
|
||||
}
|
||||
|
||||
// ─── SEP-2322 MRTR (InputRequiredResult) helpers ────────────────────────────
|
||||
|
||||
fn mrtr_elicitation_request(message: &str, properties: Value, required: Value) -> InputRequest {
|
||||
|
|
@ -118,6 +207,228 @@ impl ConformanceServer {
|
|||
ErrorData::invalid_params("requestState failed integrity verification", None)
|
||||
}
|
||||
|
||||
/// SEP-2663 task fixture tools. The server decides per request whether to
|
||||
/// materialize a task: task-supporting tools create one when the client
|
||||
/// declared the tasks extension capability; otherwise they fall through to
|
||||
/// synchronous execution (except task-*required* tools, which reject with
|
||||
/// -32021).
|
||||
async fn call_task_fixture_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
cx: &RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, ErrorData> {
|
||||
let client_supports_tasks = cx
|
||||
.client_capabilities()
|
||||
.is_some_and(|caps| caps.supports_tasks());
|
||||
let name = request.name.as_ref();
|
||||
let args = request.arguments.clone().unwrap_or_default();
|
||||
|
||||
if TASK_REQUIRED_TOOLS.contains(&name) && !client_supports_tasks {
|
||||
// SEP-2663 §Required Capabilities: this tool cannot be serviced
|
||||
// without returning CreateTaskResult.
|
||||
return Err(ErrorData::missing_required_client_capability(
|
||||
ClientCapabilities::builder().enable_tasks().build(),
|
||||
));
|
||||
}
|
||||
|
||||
let create_task = client_supports_tasks && TASK_SUPPORTING_TOOLS.contains(&name);
|
||||
|
||||
match name {
|
||||
"greet" => {
|
||||
let who = args.get("name").and_then(Value::as_str).unwrap_or("friend");
|
||||
Ok(
|
||||
CallToolResult::success(vec![ContentBlock::text(format!("Hello, {who}!"))])
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
"slow_compute" => {
|
||||
let seconds = args.get("seconds").and_then(Value::as_f64).unwrap_or(1.0);
|
||||
let label = args
|
||||
.get("label")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("compute")
|
||||
.to_string();
|
||||
if create_task {
|
||||
// The lifecycle scenario requires slow_compute to settle
|
||||
// to `cancelled` when tasks/cancel arrives while running;
|
||||
// cancellation is cooperative, so honor it explicitly.
|
||||
let task = self.tasks.spawn(TaskOptions::default(), move |ctx| {
|
||||
Box::pin(async move {
|
||||
tokio::select! {
|
||||
_ = ctx.cancelled() => Err(TaskExit::Cancelled),
|
||||
_ = tokio::time::sleep(
|
||||
std::time::Duration::from_secs_f64(seconds),
|
||||
) => Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
format!("slow_compute({label}) done after {seconds}s"),
|
||||
)])),
|
||||
}
|
||||
})
|
||||
});
|
||||
Ok(CreateTaskResult::new(task).into())
|
||||
} else {
|
||||
tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await;
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||
"slow_compute({label}) done after {seconds}s"
|
||||
))])
|
||||
.into())
|
||||
}
|
||||
}
|
||||
|
||||
"failing_job" => {
|
||||
// Tool execution error: surfaces as status "completed" with
|
||||
// result.isError = true when run as a task.
|
||||
let work = || async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||
Ok(CallToolResult::error(vec![ContentBlock::text(
|
||||
"failing_job: intentional tool execution error",
|
||||
)]))
|
||||
};
|
||||
if create_task {
|
||||
let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| {
|
||||
Box::pin(async move { work().await.map_err(TaskExit::Error) })
|
||||
});
|
||||
Ok(CreateTaskResult::new(task).into())
|
||||
} else {
|
||||
Ok(work().await?.into())
|
||||
}
|
||||
}
|
||||
|
||||
"protocol_error_job" => {
|
||||
// Protocol-level failure: surfaces as status "failed" with an
|
||||
// inlined `error` object when run as a task.
|
||||
let work = || async {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
Err(ErrorData::internal_error(
|
||||
"protocol_error_job: intentional protocol-level failure",
|
||||
None,
|
||||
))
|
||||
};
|
||||
if create_task {
|
||||
let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| {
|
||||
Box::pin(async move { work().await.map_err(TaskExit::Error) })
|
||||
});
|
||||
Ok(CreateTaskResult::new(task).into())
|
||||
} else {
|
||||
work().await.map(CallToolResponse::from)
|
||||
}
|
||||
}
|
||||
|
||||
"confirm_delete" => {
|
||||
let filename = args
|
||||
.get("filename")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("file.txt")
|
||||
.to_string();
|
||||
let task = self.tasks.spawn(TaskOptions::default(), move |ctx| {
|
||||
Box::pin(async move {
|
||||
let response = ctx
|
||||
.request_input(
|
||||
"confirm",
|
||||
mrtr_elicitation_request(
|
||||
&format!("Delete {filename}?"),
|
||||
json!({ "confirm": { "type": "boolean" } }),
|
||||
json!(["confirm"]),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
let confirmed = response
|
||||
.get("content")
|
||||
.and_then(|c| c.get("confirm"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||
"confirm_delete({filename}): confirmed = {confirmed}"
|
||||
))]))
|
||||
})
|
||||
});
|
||||
Ok(CreateTaskResult::new(task).into())
|
||||
}
|
||||
|
||||
"multi_input" => {
|
||||
let task = self.tasks.spawn(TaskOptions::default(), move |ctx| {
|
||||
Box::pin(async move {
|
||||
// Fan out two elicitation requests in parallel so two
|
||||
// keys are pending at once (partial fulfillment check).
|
||||
let first = ctx.request_input(
|
||||
"input-a",
|
||||
mrtr_elicitation_request(
|
||||
"Provide value A",
|
||||
json!({ "value": { "type": "string" } }),
|
||||
json!(["value"]),
|
||||
),
|
||||
);
|
||||
let second = ctx.request_input(
|
||||
"input-b",
|
||||
mrtr_elicitation_request(
|
||||
"Provide value B",
|
||||
json!({ "value": { "type": "string" } }),
|
||||
json!(["value"]),
|
||||
),
|
||||
);
|
||||
let (a, b) = tokio::join!(first, second);
|
||||
let (a, b) = (a?, b?);
|
||||
let get = |v: &Value| {
|
||||
v.get("content")
|
||||
.and_then(|c| c.get("value"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("(none)")
|
||||
.to_string()
|
||||
};
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||
"multi_input: a = {}, b = {}",
|
||||
get(&a),
|
||||
get(&b)
|
||||
))]))
|
||||
})
|
||||
});
|
||||
Ok(CreateTaskResult::new(task).into())
|
||||
}
|
||||
|
||||
"test_tool_with_task" => {
|
||||
// SEP-2663 MRTR → Tasks composition. Round 1 (no inputResponses)
|
||||
// is a plain MRTR InputRequiredResult; round 2 escalates to a
|
||||
// task whose result reflects the gathered user_name.
|
||||
match mrtr_response(request.input_responses.as_ref(), "user_name") {
|
||||
None => {
|
||||
let mut requests = InputRequests::new();
|
||||
requests.insert(
|
||||
"user_name".into(),
|
||||
mrtr_elicitation_request(
|
||||
"What is your name?",
|
||||
json!({ "name": { "type": "string" } }),
|
||||
json!(["name"]),
|
||||
),
|
||||
);
|
||||
Ok(InputRequiredResult::from_input_requests(requests).into())
|
||||
}
|
||||
Some(response) => {
|
||||
let user_name = response
|
||||
.get("content")
|
||||
.and_then(|c| c.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("friend")
|
||||
.to_string();
|
||||
let task = self.tasks.spawn(TaskOptions::default(), move |_ctx| {
|
||||
Box::pin(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
|
||||
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
|
||||
"Hello, {user_name}! (async)"
|
||||
))]))
|
||||
})
|
||||
});
|
||||
Ok(CreateTaskResult::new(task).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
other => Err(ErrorData::invalid_params(
|
||||
format!("Unknown task fixture tool: {other}"),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// SEP-2322 test tools. Each returns an `InputRequiredResult` until the
|
||||
/// client retries with the expected `inputResponses` (and, where used, the
|
||||
/// echoed `requestState`).
|
||||
|
|
@ -400,6 +711,7 @@ impl ServerHandler for ConformanceServer {
|
|||
.enable_tools()
|
||||
.enable_tool_list_changed()
|
||||
.enable_logging()
|
||||
.enable_tasks()
|
||||
.build(),
|
||||
)
|
||||
.with_server_info(Implementation::new("rust-conformance-server", "0.1.0"))
|
||||
|
|
@ -440,6 +752,31 @@ impl ServerHandler for ConformanceServer {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_task(
|
||||
&self,
|
||||
request: GetTaskParams,
|
||||
_cx: RequestContext<RoleServer>,
|
||||
) -> Result<GetTaskResult, ErrorData> {
|
||||
Ok(GetTaskResult::new(self.tasks.get_task(&request.task_id)?))
|
||||
}
|
||||
|
||||
async fn update_task(
|
||||
&self,
|
||||
request: UpdateTaskParams,
|
||||
_cx: RequestContext<RoleServer>,
|
||||
) -> Result<(), ErrorData> {
|
||||
self.tasks
|
||||
.update_task(&request.task_id, request.input_responses)
|
||||
}
|
||||
|
||||
async fn cancel_task(
|
||||
&self,
|
||||
request: CancelTaskParams,
|
||||
_cx: RequestContext<RoleServer>,
|
||||
) -> Result<(), ErrorData> {
|
||||
self.tasks.cancel_task(&request.task_id)
|
||||
}
|
||||
|
||||
async fn list_tools(
|
||||
&self,
|
||||
_request: Option<PaginatedRequestParams>,
|
||||
|
|
@ -678,6 +1015,11 @@ impl ServerHandler for ConformanceServer {
|
|||
json_object(json!({ "type": "object", "properties": {} })),
|
||||
)
|
||||
}))
|
||||
.chain(
|
||||
TASK_FIXTURE_TOOLS
|
||||
.iter()
|
||||
.map(|name| task_fixture_tool(name)),
|
||||
)
|
||||
.collect();
|
||||
Ok(ListToolsResult {
|
||||
tools,
|
||||
|
|
@ -695,6 +1037,9 @@ impl ServerHandler for ConformanceServer {
|
|||
if request.name.starts_with("test_input_required_result_") {
|
||||
return self.call_mrtr_tool(request, &cx.meta).await;
|
||||
}
|
||||
if TASK_FIXTURE_TOOLS.contains(&request.name.as_ref()) {
|
||||
return self.call_task_fixture_tool(request, &cx).await;
|
||||
}
|
||||
let args = request.arguments.unwrap_or_default();
|
||||
let result = match request.name.as_ref() {
|
||||
"test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text(
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ For **getting started** and **full MCP feature documentation**, see the [main RE
|
|||
| [`#[prompt]`][prompt] | Mark a function as an MCP prompt handler |
|
||||
| [`#[prompt_router]`][prompt_router] | Generate a prompt router from an impl block |
|
||||
| [`#[prompt_handler]`][prompt_handler] | Generate `get_prompt` and `list_prompts` handler methods |
|
||||
| [`#[task_handler]`][task_handler] | Wire up the task lifecycle on top of an `OperationProcessor` |
|
||||
|
||||
[tool]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool.html
|
||||
[tool_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.tool_router.html
|
||||
|
|
@ -33,7 +32,6 @@ For **getting started** and **full MCP feature documentation**, see the [main RE
|
|||
[prompt]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt.html
|
||||
[prompt_router]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_router.html
|
||||
[prompt_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.prompt_handler.html
|
||||
[task_handler]: https://docs.rs/rmcp-macros/latest/rmcp_macros/attr.task_handler.html
|
||||
|
||||
## Quick Example
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ mod common;
|
|||
mod prompt;
|
||||
mod prompt_handler;
|
||||
mod prompt_router;
|
||||
mod task_handler;
|
||||
mod tool;
|
||||
mod tool_handler;
|
||||
mod tool_router;
|
||||
|
|
@ -100,7 +99,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream {
|
|||
/// impl MyToolHandler {
|
||||
/// #[tool]
|
||||
/// fn my_tool_a() {
|
||||
///
|
||||
///
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
|
|
@ -110,7 +109,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> TokenStream {
|
|||
/// impl MyToolHandler {
|
||||
/// #[tool]
|
||||
/// fn my_tool_b() {
|
||||
///
|
||||
///
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
|
|
@ -299,17 +298,3 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> TokenStream {
|
|||
.unwrap_or_else(|err| err.to_compile_error())
|
||||
.into()
|
||||
}
|
||||
|
||||
/// # task_handler
|
||||
///
|
||||
/// Generates basic task-handling methods (`enqueue_task` and `list_tasks`) for a server handler
|
||||
/// using a shared \[`OperationProcessor`\]. The default processor expression assumes a
|
||||
/// `self.processor` field holding an `Arc<Mutex<OperationProcessor>>`, but it can be customized
|
||||
/// via `#[task_handler(processor = ...)]`. Because the macro captures `self` inside spawned
|
||||
/// futures, the handler type must implement [`Clone`].
|
||||
#[proc_macro_attribute]
|
||||
pub fn task_handler(attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||
task_handler::task_handler(attr.into(), input.into())
|
||||
.unwrap_or_else(|err| err.to_compile_error())
|
||||
.into()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,286 +0,0 @@
|
|||
use darling::{FromMeta, ast::NestedMeta};
|
||||
use proc_macro2::TokenStream;
|
||||
use quote::{ToTokens, quote};
|
||||
use syn::{Expr, ImplItem, ItemImpl};
|
||||
|
||||
use crate::common::{has_method, has_sibling_handler};
|
||||
|
||||
#[derive(FromMeta)]
|
||||
#[darling(default)]
|
||||
struct TaskHandlerAttribute {
|
||||
processor: Expr,
|
||||
}
|
||||
|
||||
impl Default for TaskHandlerAttribute {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
processor: syn::parse2(quote! { self.processor }).expect("default processor expr"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
||||
let attr_args = NestedMeta::parse_meta_list(attr)?;
|
||||
let TaskHandlerAttribute { processor } = TaskHandlerAttribute::from_list(&attr_args)?;
|
||||
let mut item_impl = syn::parse2::<ItemImpl>(input)?;
|
||||
|
||||
if !has_method("list_tasks", &item_impl) {
|
||||
let list_fn = quote! {
|
||||
async fn list_tasks(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::ListTasksResult, McpError> {
|
||||
let running_ids = (#processor).lock().await.list_running();
|
||||
let total = running_ids.len() as u64;
|
||||
let tasks = running_ids
|
||||
.into_iter()
|
||||
.map(|task_id| {
|
||||
let timestamp = rmcp::task_manager::current_timestamp();
|
||||
rmcp::model::Task::new(
|
||||
task_id,
|
||||
rmcp::model::TaskStatus::Working,
|
||||
timestamp.clone(),
|
||||
timestamp,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(rmcp::model::ListTasksResult::new(tasks))
|
||||
}
|
||||
};
|
||||
item_impl.items.push(syn::parse2::<ImplItem>(list_fn)?);
|
||||
}
|
||||
|
||||
if !has_method("enqueue_task", &item_impl) {
|
||||
let enqueue_fn = quote! {
|
||||
async fn enqueue_task(
|
||||
&self,
|
||||
request: rmcp::model::CallToolRequestParams,
|
||||
context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::CreateTaskResult, McpError> {
|
||||
use rmcp::task_manager::{
|
||||
current_timestamp, OperationDescriptor, OperationMessage, OperationResultTransport,
|
||||
ToolCallTaskResult,
|
||||
};
|
||||
let task_id = context.id.to_string();
|
||||
let operation_name = request.name.to_string();
|
||||
let future_request = request.clone();
|
||||
let future_context = context.clone();
|
||||
let server = self.clone();
|
||||
|
||||
let descriptor = OperationDescriptor::new(task_id.clone(), operation_name)
|
||||
.with_context(context)
|
||||
.with_client_request(rmcp::model::ClientRequest::CallToolRequest(
|
||||
rmcp::model::Request::new(request),
|
||||
));
|
||||
|
||||
let task_result_id = task_id.clone();
|
||||
let future = Box::pin(async move {
|
||||
let result = server
|
||||
.call_tool(future_request, future_context)
|
||||
.await
|
||||
.and_then(|response| match response {
|
||||
rmcp::model::CallToolResponse::Complete(result) => Ok(result),
|
||||
_ => Err(rmcp::ErrorData::internal_error(
|
||||
"input_required is not supported for task-based tool calls",
|
||||
None,
|
||||
)),
|
||||
});
|
||||
Ok(
|
||||
Box::new(ToolCallTaskResult::new(task_result_id, result))
|
||||
as Box<dyn OperationResultTransport>,
|
||||
)
|
||||
});
|
||||
|
||||
(#processor)
|
||||
.lock()
|
||||
.await
|
||||
.submit_operation(OperationMessage::new(descriptor, future))
|
||||
.map_err(|err| rmcp::ErrorData::internal_error(
|
||||
format!("failed to enqueue task: {err}"),
|
||||
None,
|
||||
))?;
|
||||
|
||||
let timestamp = current_timestamp();
|
||||
let task = rmcp::model::Task::new(
|
||||
task_id,
|
||||
rmcp::model::TaskStatus::Working,
|
||||
timestamp.clone(),
|
||||
timestamp,
|
||||
).with_status_message("Task accepted");
|
||||
|
||||
Ok(rmcp::model::CreateTaskResult::new(task))
|
||||
}
|
||||
};
|
||||
item_impl.items.push(syn::parse2::<ImplItem>(enqueue_fn)?);
|
||||
}
|
||||
|
||||
if !has_method("get_task_info", &item_impl) {
|
||||
let get_info_fn = quote! {
|
||||
async fn get_task_info(
|
||||
&self,
|
||||
request: rmcp::model::GetTaskParams,
|
||||
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::GetTaskResult, McpError> {
|
||||
use rmcp::task_manager::current_timestamp;
|
||||
let task_id = request.task_id.clone();
|
||||
let mut processor = (#processor).lock().await;
|
||||
|
||||
// Check completed results first
|
||||
let completed = processor.peek_completed().iter().rev().find(|r| r.descriptor.operation_id == task_id);
|
||||
if let Some(completed_result) = completed {
|
||||
// Determine Finished vs Failed
|
||||
let status = match &completed_result.result {
|
||||
Ok(boxed) => {
|
||||
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
|
||||
match &tool.result {
|
||||
Ok(_) => rmcp::model::TaskStatus::Completed,
|
||||
Err(_) => rmcp::model::TaskStatus::Failed,
|
||||
}
|
||||
} else {
|
||||
rmcp::model::TaskStatus::Completed
|
||||
}
|
||||
}
|
||||
Err(_) => rmcp::model::TaskStatus::Failed,
|
||||
};
|
||||
let timestamp = current_timestamp();
|
||||
let mut task = rmcp::model::Task::new(
|
||||
task_id,
|
||||
status,
|
||||
timestamp.clone(),
|
||||
timestamp,
|
||||
);
|
||||
if let Some(ttl) = completed_result.descriptor.ttl {
|
||||
task = task.with_ttl(ttl);
|
||||
}
|
||||
return Ok(rmcp::model::GetTaskResult::new(task));
|
||||
}
|
||||
|
||||
// If not completed, check running
|
||||
let running = processor.list_running();
|
||||
if running.into_iter().any(|id| id == task_id) {
|
||||
let timestamp = current_timestamp();
|
||||
let task = rmcp::model::Task::new(
|
||||
task_id,
|
||||
rmcp::model::TaskStatus::Working,
|
||||
timestamp.clone(),
|
||||
timestamp,
|
||||
);
|
||||
return Ok(rmcp::model::GetTaskResult::new(task));
|
||||
}
|
||||
|
||||
Err(McpError::resource_not_found(format!("task not found: {}", task_id), None))
|
||||
}
|
||||
};
|
||||
item_impl.items.push(syn::parse2::<ImplItem>(get_info_fn)?);
|
||||
}
|
||||
|
||||
if !has_method("get_task_result", &item_impl) {
|
||||
let get_result_fn = quote! {
|
||||
async fn get_task_result(
|
||||
&self,
|
||||
request: rmcp::model::GetTaskPayloadParams,
|
||||
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::GetTaskPayloadResult, McpError> {
|
||||
use std::time::Duration;
|
||||
let task_id = request.task_id.clone();
|
||||
|
||||
loop {
|
||||
// Scope the lock so we can await outside if needed
|
||||
{
|
||||
let mut processor = (#processor).lock().await;
|
||||
|
||||
if let Some(task_result) = processor.take_completed_result(&task_id) {
|
||||
match task_result.result {
|
||||
Ok(boxed) => {
|
||||
if let Some(tool) = boxed.as_any().downcast_ref::<rmcp::task_manager::ToolCallTaskResult>() {
|
||||
match &tool.result {
|
||||
Ok(call_tool) => {
|
||||
let value = ::rmcp::serde_json::to_value(call_tool).unwrap_or_default();
|
||||
return Ok(rmcp::model::GetTaskPayloadResult::new(value));
|
||||
}
|
||||
Err(err) => return Err(McpError::internal_error(
|
||||
format!("task failed: {}", err),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
return Err(McpError::internal_error("unsupported task result transport", None));
|
||||
}
|
||||
}
|
||||
Err(err) => return Err(McpError::internal_error(
|
||||
format!("task execution error: {}", err),
|
||||
None,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// Not completed yet: if not running, return not found
|
||||
let running = processor.list_running();
|
||||
if !running.iter().any(|id| id == &task_id) {
|
||||
return Err(McpError::resource_not_found(format!("task not found: {}", task_id), None));
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
};
|
||||
item_impl
|
||||
.items
|
||||
.push(syn::parse2::<ImplItem>(get_result_fn)?);
|
||||
}
|
||||
|
||||
if !has_method("cancel_task", &item_impl) {
|
||||
let cancel_fn = quote! {
|
||||
async fn cancel_task(
|
||||
&self,
|
||||
request: rmcp::model::CancelTaskParams,
|
||||
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
|
||||
) -> Result<rmcp::model::CancelTaskResult, McpError> {
|
||||
use rmcp::task_manager::current_timestamp;
|
||||
let task_id = request.task_id;
|
||||
let mut processor = (#processor).lock().await;
|
||||
|
||||
if processor.cancel_task(&task_id) {
|
||||
let timestamp = current_timestamp();
|
||||
let task = rmcp::model::Task::new(
|
||||
task_id,
|
||||
rmcp::model::TaskStatus::Cancelled,
|
||||
timestamp.clone(),
|
||||
timestamp,
|
||||
);
|
||||
return Ok(rmcp::model::CancelTaskResult::new(task));
|
||||
}
|
||||
|
||||
// If already completed, signal it's not cancellable
|
||||
let exists_completed = processor.peek_completed().iter().any(|r| r.descriptor.operation_id == task_id);
|
||||
if exists_completed {
|
||||
return Err(McpError::invalid_request(format!("task already completed: {}", task_id), None));
|
||||
}
|
||||
|
||||
Err(McpError::resource_not_found(format!("task not found: {}", task_id), None))
|
||||
}
|
||||
};
|
||||
item_impl.items.push(syn::parse2::<ImplItem>(cancel_fn)?);
|
||||
}
|
||||
|
||||
// Auto-generate get_info() if not already provided and no sibling tool/prompt handler
|
||||
// will generate it (they take priority since they run as outer attributes).
|
||||
if !has_method("get_info", &item_impl)
|
||||
&& !has_sibling_handler(&item_impl, "tool_handler")
|
||||
&& !has_sibling_handler(&item_impl, "prompt_handler")
|
||||
{
|
||||
let get_info_fn = crate::tool_handler::build_get_info(
|
||||
&item_impl,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
crate::tool_handler::CallerCapability::Tasks,
|
||||
)?;
|
||||
item_impl.items.push(get_info_fn);
|
||||
}
|
||||
|
||||
Ok(item_impl.into_token_stream())
|
||||
}
|
||||
|
|
@ -75,8 +75,6 @@ pub struct ToolAttribute {
|
|||
pub output_schema: Option<Expr>,
|
||||
/// Optional additional tool information.
|
||||
pub annotations: Option<ToolAnnotationsAttribute>,
|
||||
/// Execution-related configuration including task support.
|
||||
pub execution: Option<ToolExecutionAttribute>,
|
||||
/// Optional icons for the tool
|
||||
pub icons: Option<Expr>,
|
||||
/// Optional metadata for the tool
|
||||
|
|
@ -86,13 +84,6 @@ pub struct ToolAttribute {
|
|||
pub local: bool,
|
||||
}
|
||||
|
||||
#[derive(FromMeta, Debug, Default)]
|
||||
#[darling(default)]
|
||||
pub struct ToolExecutionAttribute {
|
||||
/// Task support mode: "forbidden", "optional", or "required"
|
||||
pub task_support: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ResolvedToolAttribute {
|
||||
pub name: String,
|
||||
pub title: Option<String>,
|
||||
|
|
@ -100,7 +91,6 @@ pub struct ResolvedToolAttribute {
|
|||
pub input_schema: Expr,
|
||||
pub output_schema: Option<Expr>,
|
||||
pub annotations: Option<Expr>,
|
||||
pub execution: Option<Expr>,
|
||||
pub icons: Option<Expr>,
|
||||
pub meta: Option<Expr>,
|
||||
}
|
||||
|
|
@ -114,7 +104,6 @@ impl ResolvedToolAttribute {
|
|||
input_schema,
|
||||
output_schema,
|
||||
annotations,
|
||||
execution,
|
||||
icons,
|
||||
meta,
|
||||
} = self;
|
||||
|
|
@ -132,9 +121,6 @@ impl ResolvedToolAttribute {
|
|||
let annotations_call = annotations
|
||||
.map(|a| quote! { .with_annotations(#a) })
|
||||
.unwrap_or_default();
|
||||
let execution_call = execution
|
||||
.map(|e| quote! { .with_execution(#e) })
|
||||
.unwrap_or_default();
|
||||
let icons_call = icons
|
||||
.map(|i| quote! { .with_icons(#i) })
|
||||
.unwrap_or_default();
|
||||
|
|
@ -152,7 +138,6 @@ impl ResolvedToolAttribute {
|
|||
#title_call
|
||||
#output_schema_call
|
||||
#annotations_call
|
||||
#execution_call
|
||||
#icons_call
|
||||
#meta_call
|
||||
}
|
||||
|
|
@ -264,38 +249,6 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
|||
} else {
|
||||
None
|
||||
};
|
||||
let execution_expr = if let Some(execution) = attribute.execution {
|
||||
let ToolExecutionAttribute { task_support } = execution;
|
||||
|
||||
let task_support_expr = if let Some(ts) = task_support {
|
||||
let ts_ident = match ts.as_str() {
|
||||
"forbidden" => quote! { rmcp::model::TaskSupport::Forbidden },
|
||||
"optional" => quote! { rmcp::model::TaskSupport::Optional },
|
||||
"required" => quote! { rmcp::model::TaskSupport::Required },
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
Span::call_site(),
|
||||
format!(
|
||||
"Invalid task_support value '{}'. Expected 'forbidden', 'optional', or 'required'",
|
||||
ts
|
||||
),
|
||||
));
|
||||
}
|
||||
};
|
||||
quote! { Some(#ts_ident) }
|
||||
} else {
|
||||
quote! { None }
|
||||
};
|
||||
|
||||
let token_stream = quote! {
|
||||
rmcp::model::ToolExecution::from_raw(
|
||||
#task_support_expr,
|
||||
)
|
||||
};
|
||||
Some(syn::parse2::<Expr>(token_stream)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Handle output_schema - either explicit or generated from return type
|
||||
let output_schema_expr = attribute.output_schema.or_else(|| {
|
||||
// Try to generate schema from return type
|
||||
|
|
@ -319,7 +272,6 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
|
|||
input_schema: input_schema_expr,
|
||||
output_schema: output_schema_expr,
|
||||
annotations: annotations_expr,
|
||||
execution: execution_expr,
|
||||
title: attribute.title,
|
||||
icons: attribute.icons,
|
||||
meta: attribute.meta,
|
||||
|
|
|
|||
|
|
@ -110,13 +110,12 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
|
|||
pub(crate) enum CallerCapability {
|
||||
Tools,
|
||||
Prompts,
|
||||
Tasks,
|
||||
}
|
||||
|
||||
/// Build a `get_info()` method that returns `ServerInfo` with the appropriate capabilities.
|
||||
///
|
||||
/// The caller declares its own capability via `caller`. Sibling handler attributes
|
||||
/// (`prompt_handler`, `task_handler`, `tool_handler`) are detected automatically
|
||||
/// (`prompt_handler`, `tool_handler`) are detected automatically
|
||||
/// and their capabilities are included.
|
||||
pub(crate) fn build_get_info(
|
||||
item_impl: &ItemImpl,
|
||||
|
|
@ -129,8 +128,6 @@ pub(crate) fn build_get_info(
|
|||
caller == CallerCapability::Tools || has_sibling_handler(item_impl, "tool_handler");
|
||||
let has_prompts =
|
||||
caller == CallerCapability::Prompts || has_sibling_handler(item_impl, "prompt_handler");
|
||||
let has_tasks =
|
||||
caller == CallerCapability::Tasks || has_sibling_handler(item_impl, "task_handler");
|
||||
|
||||
let mut capability_calls = Vec::new();
|
||||
if has_tools {
|
||||
|
|
@ -139,10 +136,6 @@ pub(crate) fn build_get_info(
|
|||
if has_prompts {
|
||||
capability_calls.push(quote! { .enable_prompts() });
|
||||
}
|
||||
if has_tasks {
|
||||
capability_calls.push(quote! { .enable_tasks() });
|
||||
}
|
||||
|
||||
let server_info_expr = match (name, version) {
|
||||
(Some(n), Some(v)) => quote! { rmcp::model::Implementation::new(#n, #v) },
|
||||
(Some(n), None) => {
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ chrono = { version = "0.4.38", default-features = false, features = [
|
|||
default = ["base64", "macros", "server"]
|
||||
local = ["rmcp-macros?/local"]
|
||||
client = ["dep:tokio-stream"]
|
||||
server = ["transport-async-rw", "schemars", "dep:pastey"]
|
||||
server = ["transport-async-rw", "schemars", "dep:pastey", "uuid"]
|
||||
macros = ["dep:rmcp-macros", "dep:pastey"]
|
||||
elicitation = ["dep:url"]
|
||||
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
|
|||
|
||||
fn on_task_status(
|
||||
&self,
|
||||
params: TaskStatusNotificationParam,
|
||||
params: TaskStatusNotificationParams,
|
||||
context: NotificationContext<RoleClient>,
|
||||
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
|
||||
std::future::ready(())
|
||||
|
|
@ -386,7 +386,7 @@ macro_rules! impl_client_handler_for_wrapper {
|
|||
|
||||
fn on_task_status(
|
||||
&self,
|
||||
params: TaskStatusNotificationParam,
|
||||
params: TaskStatusNotificationParams,
|
||||
context: NotificationContext<RoleClient>,
|
||||
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
|
||||
(**self).on_task_status(params, context)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::{borrow::Cow, sync::Arc};
|
|||
|
||||
use crate::{
|
||||
error::ErrorData as McpError,
|
||||
model::{TaskSupport, *},
|
||||
model::*,
|
||||
service::{
|
||||
MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole,
|
||||
SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle,
|
||||
|
|
@ -19,6 +19,34 @@ pub mod tool;
|
|||
pub mod tool_name_validation;
|
||||
pub mod wrapper;
|
||||
|
||||
/// SEP-2663: gate `tasks/*` methods on the client's declared tasks-extension
|
||||
/// capability.
|
||||
///
|
||||
/// - If the server does not advertise the tasks extension, the methods are
|
||||
/// simply unimplemented: `-32601` Method not found.
|
||||
/// - If the server advertises it but the client did not declare it (either in
|
||||
/// the request's `_meta` per-request capabilities or, for session-mode
|
||||
/// peers, at `initialize` time), the spec requires `-32021` Missing
|
||||
/// Required Client Capability with the required capability in `data`.
|
||||
fn validate_tasks_capability<M: ConstString, H: ServerHandler>(
|
||||
handler: &H,
|
||||
context: &RequestContext<RoleServer>,
|
||||
) -> Result<(), McpError> {
|
||||
if !handler.get_info().capabilities.supports_tasks() {
|
||||
return Err(McpError::method_not_found::<M>());
|
||||
}
|
||||
let client_declared = context
|
||||
.client_capabilities()
|
||||
.is_some_and(|caps| caps.supports_tasks());
|
||||
if client_declared {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(McpError::missing_required_client_capability(
|
||||
ClientCapabilities::builder().enable_tasks().build(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl<H: ServerHandler> Service<RoleServer> for H {
|
||||
async fn handle_request(
|
||||
&self,
|
||||
|
|
@ -170,41 +198,20 @@ impl<H: ServerHandler> Service<RoleServer> for H {
|
|||
}
|
||||
}
|
||||
ClientRequest::CallToolRequest(request) => {
|
||||
let is_task = request.params.task.is_some();
|
||||
|
||||
// Validate task support mode per MCP specification
|
||||
if let Some(tool) = self.get_tool(&request.params.name) {
|
||||
match (tool.task_support(), is_task) {
|
||||
// If taskSupport is "required", clients MUST invoke the tool as a task.
|
||||
// Servers MUST return a -32601 (Method not found) error if they don't.
|
||||
(TaskSupport::Required, false) => {
|
||||
return Err(McpError::new(
|
||||
ErrorCode::METHOD_NOT_FOUND,
|
||||
"Tool requires task-based invocation",
|
||||
None,
|
||||
));
|
||||
}
|
||||
// If taskSupport is "forbidden" (default), clients MUST NOT invoke as a task.
|
||||
(TaskSupport::Forbidden, true) => {
|
||||
return Err(McpError::invalid_params(
|
||||
"Tool does not support task-based invocation",
|
||||
None,
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if is_task {
|
||||
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
|
||||
self.enqueue_task(request.params, context.clone())
|
||||
.await
|
||||
.map(ServerResult::CreateTaskResult)
|
||||
} else {
|
||||
self.call_tool(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::from)
|
||||
let client_declared_tasks = context
|
||||
.client_capabilities()
|
||||
.is_some_and(|caps| caps.supports_tasks());
|
||||
let response = self.call_tool(request.params, context).await?;
|
||||
// SEP-2663: the server MUST NOT return CreateTaskResult unless
|
||||
// the request declared the tasks extension capability. Guard
|
||||
// against handlers that fail to check before materializing a
|
||||
// task; such clients cannot parse a task handle.
|
||||
if matches!(response, CallToolResponse::Task(_)) && !client_declared_tasks {
|
||||
return Err(McpError::missing_required_client_capability(
|
||||
ClientCapabilities::builder().enable_tasks().build(),
|
||||
));
|
||||
}
|
||||
Ok(ServerResult::from(response))
|
||||
}
|
||||
ClientRequest::ListToolsRequest(request) => self
|
||||
.list_tools(request.params, context)
|
||||
|
|
@ -214,22 +221,24 @@ impl<H: ServerHandler> Service<RoleServer> for H {
|
|||
.on_custom_request(request, context)
|
||||
.await
|
||||
.map(ServerResult::CustomResult),
|
||||
ClientRequest::ListTasksRequest(request) => self
|
||||
.list_tasks(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::ListTasksResult),
|
||||
ClientRequest::GetTaskRequest(request) => self
|
||||
.get_task_info(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::GetTaskResult),
|
||||
ClientRequest::GetTaskPayloadRequest(request) => self
|
||||
.get_task_result(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::GetTaskPayloadResult),
|
||||
ClientRequest::CancelTaskRequest(request) => self
|
||||
.cancel_task(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::CancelTaskResult),
|
||||
ClientRequest::GetTaskRequest(request) => {
|
||||
validate_tasks_capability::<GetTaskMethod, _>(self, &context)?;
|
||||
self.get_task(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::GetTaskResult)
|
||||
}
|
||||
ClientRequest::UpdateTaskRequest(request) => {
|
||||
validate_tasks_capability::<UpdateTaskMethod, _>(self, &context)?;
|
||||
self.update_task(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::task_ack)
|
||||
}
|
||||
ClientRequest::CancelTaskRequest(request) => {
|
||||
validate_tasks_capability::<CancelTaskMethod, _>(self, &context)?;
|
||||
self.cancel_task(request.params, context)
|
||||
.await
|
||||
.map(ServerResult::task_ack)
|
||||
}
|
||||
};
|
||||
let result = result.and_then(|result| {
|
||||
if matches!(result, ServerResult::InputRequiredResult(_)) && !mrtr_supported {
|
||||
|
|
@ -273,9 +282,6 @@ impl<H: ServerHandler> Service<RoleServer> for H {
|
|||
ClientNotification::RootsListChangedNotification(_notification) => {
|
||||
self.on_roots_list_changed(context).await
|
||||
}
|
||||
ClientNotification::TaskStatusNotification(notification) => {
|
||||
self.on_task_status(notification.params, context).await
|
||||
}
|
||||
ClientNotification::CustomNotification(notification) => {
|
||||
self.on_custom_notification(notification, context).await
|
||||
}
|
||||
|
|
@ -290,16 +296,6 @@ impl<H: ServerHandler> Service<RoleServer> for H {
|
|||
|
||||
macro_rules! server_handler_methods {
|
||||
() => {
|
||||
fn enqueue_task(
|
||||
&self,
|
||||
_request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<CreateTaskResult, McpError>> + MaybeSendFuture + '_ {
|
||||
std::future::ready(Err(McpError::internal_error(
|
||||
"Task processing not implemented".to_string(),
|
||||
None,
|
||||
)))
|
||||
}
|
||||
fn ping(
|
||||
&self,
|
||||
context: RequestContext<RoleServer>,
|
||||
|
|
@ -526,13 +522,6 @@ macro_rules! server_handler_methods {
|
|||
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
|
||||
std::future::ready(())
|
||||
}
|
||||
fn on_task_status(
|
||||
&self,
|
||||
params: TaskStatusNotificationParam,
|
||||
context: NotificationContext<RoleServer>,
|
||||
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
|
||||
std::future::ready(())
|
||||
}
|
||||
fn on_custom_notification(
|
||||
&self,
|
||||
notification: CustomNotification,
|
||||
|
|
@ -546,15 +535,8 @@ macro_rules! server_handler_methods {
|
|||
ServerInfo::default()
|
||||
}
|
||||
|
||||
fn list_tasks(
|
||||
&self,
|
||||
request: Option<PaginatedRequestParams>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<ListTasksResult, McpError>> + MaybeSendFuture + '_ {
|
||||
std::future::ready(Err(McpError::method_not_found::<ListTasksMethod>()))
|
||||
}
|
||||
|
||||
fn get_task_info(
|
||||
/// SEP-2663 `tasks/get`: return the current [`DetailedTask`] state.
|
||||
fn get_task(
|
||||
&self,
|
||||
request: GetTaskParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
|
|
@ -563,20 +545,24 @@ macro_rules! server_handler_methods {
|
|||
std::future::ready(Err(McpError::method_not_found::<GetTaskMethod>()))
|
||||
}
|
||||
|
||||
fn get_task_result(
|
||||
/// SEP-2663 `tasks/update`: accept responses to outstanding in-task
|
||||
/// input requests. Returns an empty acknowledgement on success.
|
||||
fn update_task(
|
||||
&self,
|
||||
request: GetTaskPayloadParams,
|
||||
request: UpdateTaskParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<GetTaskPayloadResult, McpError>> + MaybeSendFuture + '_ {
|
||||
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
|
||||
let _ = (request, context);
|
||||
std::future::ready(Err(McpError::method_not_found::<GetTaskPayloadMethod>()))
|
||||
std::future::ready(Err(McpError::method_not_found::<UpdateTaskMethod>()))
|
||||
}
|
||||
|
||||
/// SEP-2663 `tasks/cancel`: cooperative cancellation. Returns an empty
|
||||
/// acknowledgement; the task's observable status may lag.
|
||||
fn cancel_task(
|
||||
&self,
|
||||
request: CancelTaskParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<CancelTaskResult, McpError>> + MaybeSendFuture + '_ {
|
||||
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
|
||||
let _ = (request, context);
|
||||
std::future::ready(Err(McpError::method_not_found::<CancelTaskMethod>()))
|
||||
}
|
||||
|
|
@ -598,14 +584,6 @@ pub trait ServerHandler: Sized + 'static {
|
|||
macro_rules! impl_server_handler_for_wrapper {
|
||||
($wrapper:ident) => {
|
||||
impl<T: ServerHandler> ServerHandler for $wrapper<T> {
|
||||
fn enqueue_task(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<CreateTaskResult, McpError>> + MaybeSendFuture + '_ {
|
||||
(**self).enqueue_task(request, context)
|
||||
}
|
||||
|
||||
fn ping(
|
||||
&self,
|
||||
context: RequestContext<RoleServer>,
|
||||
|
|
@ -777,14 +755,6 @@ macro_rules! impl_server_handler_for_wrapper {
|
|||
(**self).on_roots_list_changed(context)
|
||||
}
|
||||
|
||||
fn on_task_status(
|
||||
&self,
|
||||
params: TaskStatusNotificationParam,
|
||||
context: NotificationContext<RoleServer>,
|
||||
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
|
||||
(**self).on_task_status(params, context)
|
||||
}
|
||||
|
||||
fn on_custom_notification(
|
||||
&self,
|
||||
notification: CustomNotification,
|
||||
|
|
@ -797,35 +767,27 @@ macro_rules! impl_server_handler_for_wrapper {
|
|||
(**self).get_info()
|
||||
}
|
||||
|
||||
fn list_tasks(
|
||||
&self,
|
||||
request: Option<PaginatedRequestParams>,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<ListTasksResult, McpError>> + MaybeSendFuture + '_ {
|
||||
(**self).list_tasks(request, context)
|
||||
}
|
||||
|
||||
fn get_task_info(
|
||||
fn get_task(
|
||||
&self,
|
||||
request: GetTaskParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<GetTaskResult, McpError>> + MaybeSendFuture + '_ {
|
||||
(**self).get_task_info(request, context)
|
||||
(**self).get_task(request, context)
|
||||
}
|
||||
|
||||
fn get_task_result(
|
||||
fn update_task(
|
||||
&self,
|
||||
request: GetTaskPayloadParams,
|
||||
request: UpdateTaskParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<GetTaskPayloadResult, McpError>> + MaybeSendFuture + '_ {
|
||||
(**self).get_task_result(request, context)
|
||||
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
|
||||
(**self).update_task(request, context)
|
||||
}
|
||||
|
||||
fn cancel_task(
|
||||
&self,
|
||||
request: CancelTaskParams,
|
||||
context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<CancelTaskResult, McpError>> + MaybeSendFuture + '_ {
|
||||
) -> impl Future<Output = Result<(), McpError>> + MaybeSendFuture + '_ {
|
||||
(**self).cancel_task(request, context)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -667,7 +667,6 @@ mod tests {
|
|||
meta: None,
|
||||
name: Cow::Borrowed("requires_params"),
|
||||
arguments: Some(Default::default()),
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -711,7 +710,6 @@ mod tests {
|
|||
meta: None,
|
||||
name: Cow::Borrowed("test_tool"),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crate::{
|
|||
tool::schema_for_output,
|
||||
wrapper::{Json, Parameters},
|
||||
},
|
||||
model::{Icon, JsonObject, MetaObject, ToolAnnotations, ToolExecution},
|
||||
model::{Icon, JsonObject, MetaObject, ToolAnnotations},
|
||||
schemars::JsonSchema,
|
||||
service::{MaybeSend, MaybeSendFuture},
|
||||
};
|
||||
|
|
@ -71,9 +71,6 @@ pub trait ToolBase {
|
|||
fn annotations() -> Option<ToolAnnotations> {
|
||||
None
|
||||
}
|
||||
fn execution() -> Option<ToolExecution> {
|
||||
None
|
||||
}
|
||||
fn icons() -> Option<Vec<Icon>> {
|
||||
None
|
||||
}
|
||||
|
|
@ -111,7 +108,6 @@ pub(crate) fn tool_attribute<T: ToolBase>() -> crate::model::Tool {
|
|||
input_schema: T::input_schema().unwrap_or_else(schema_for_empty_input),
|
||||
output_schema: T::output_schema(),
|
||||
annotations: T::annotations(),
|
||||
execution: T::execution(),
|
||||
icons: T::icons(),
|
||||
meta: T::meta(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ pub struct ToolCallContext<'s, S> {
|
|||
pub service: &'s S,
|
||||
pub name: Cow<'static, str>,
|
||||
pub arguments: Option<JsonObject>,
|
||||
pub task: Option<crate::model::TaskMetadata>,
|
||||
}
|
||||
|
||||
impl<'s, S> ToolCallContext<'s, S> {
|
||||
|
|
@ -48,7 +47,6 @@ impl<'s, S> ToolCallContext<'s, S> {
|
|||
meta: _,
|
||||
name,
|
||||
arguments,
|
||||
task,
|
||||
..
|
||||
}: CallToolRequestParams,
|
||||
request_context: RequestContext<RoleServer>,
|
||||
|
|
@ -58,7 +56,6 @@ impl<'s, S> ToolCallContext<'s, S> {
|
|||
service,
|
||||
name,
|
||||
arguments,
|
||||
task,
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> &str {
|
||||
|
|
@ -120,6 +117,10 @@ impl<T: IntoCallToolResult, E: IntoCallToolResult> IntoCallToolResult for Result
|
|||
"InputRequiredResult cannot be returned from a tool error branch",
|
||||
None,
|
||||
)),
|
||||
Ok(CallToolResponse::Task(_)) => Err(crate::ErrorData::internal_error(
|
||||
"CreateTaskResult cannot be returned from a tool error branch",
|
||||
None,
|
||||
)),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -783,6 +783,8 @@ pub struct ResultType(Cow<'static, str>);
|
|||
impl ResultType {
|
||||
pub const COMPLETE: Self = Self(Cow::Borrowed("complete"));
|
||||
pub const INPUT_REQUIRED: Self = Self(Cow::Borrowed("input_required"));
|
||||
/// SEP-2663 Tasks extension: the result is a task handle ([`CreateTaskResult`]).
|
||||
pub const TASK: Self = Self(Cow::Borrowed("task"));
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
|
|
@ -797,6 +799,11 @@ impl ResultType {
|
|||
pub fn is_complete(&self) -> bool {
|
||||
self.0 == "complete"
|
||||
}
|
||||
|
||||
/// Returns `true` if this is `"task"` (SEP-2663 Tasks extension).
|
||||
pub fn is_task(&self) -> bool {
|
||||
self.0 == "task"
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ResultType {
|
||||
|
|
@ -2740,9 +2747,6 @@ pub struct CreateMessageRequestParams {
|
|||
/// Protocol-level metadata for this request (SEP-1319)
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<RequestMetaObject>,
|
||||
/// Task metadata for async task management (SEP-1319)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<TaskMetadata>,
|
||||
/// The conversation history and current messages
|
||||
pub messages: Vec<SamplingMessage>,
|
||||
/// Preferences for model selection and behavior
|
||||
|
|
@ -2782,21 +2786,11 @@ impl RequestParamsMeta for CreateMessageRequestParams {
|
|||
}
|
||||
}
|
||||
|
||||
impl TaskAugmentedRequestParamsMeta for CreateMessageRequestParams {
|
||||
fn task(&self) -> Option<&TaskMetadata> {
|
||||
self.task.as_ref()
|
||||
}
|
||||
fn task_mut(&mut self) -> &mut Option<TaskMetadata> {
|
||||
&mut self.task
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateMessageRequestParams {
|
||||
/// Create a new CreateMessageRequestParams with required fields.
|
||||
pub fn new(messages: Vec<SamplingMessage>, max_tokens: u32) -> Self {
|
||||
Self {
|
||||
meta: None,
|
||||
task: None,
|
||||
messages,
|
||||
model_preferences: None,
|
||||
system_prompt: None,
|
||||
|
|
@ -3917,9 +3911,6 @@ const_string!(CallToolRequestMethod = "tools/call");
|
|||
///
|
||||
/// Contains the tool name and optional arguments needed to execute
|
||||
/// the tool operation.
|
||||
///
|
||||
/// This implements `TaskAugmentedRequestParamsMeta` as tool calls can be
|
||||
/// long-running and may benefit from task-based execution.
|
||||
#[derive(Default, Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
|
|
@ -3933,9 +3924,6 @@ pub struct CallToolRequestParams {
|
|||
/// Arguments to pass to the tool (must match the tool's input schema)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub arguments: Option<JsonObject>,
|
||||
/// Task metadata for async task management (SEP-1319)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub task: Option<TaskMetadata>,
|
||||
/// Client responses to server-initiated input requests from a previous
|
||||
/// [`InputRequiredResult`]. Present only when retrying after an incomplete result.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -3953,7 +3941,6 @@ impl CallToolRequestParams {
|
|||
meta: None,
|
||||
name: name.into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
}
|
||||
|
|
@ -3965,12 +3952,6 @@ impl CallToolRequestParams {
|
|||
self
|
||||
}
|
||||
|
||||
/// Sets the task metadata for this tool call.
|
||||
pub fn with_task(mut self, task: TaskMetadata) -> Self {
|
||||
self.task = Some(task);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the input responses for an MRTR retry.
|
||||
pub fn with_input_responses(mut self, input_responses: InputResponses) -> Self {
|
||||
self.input_responses = Some(input_responses);
|
||||
|
|
@ -3993,15 +3974,6 @@ impl RequestParamsMeta for CallToolRequestParams {
|
|||
}
|
||||
}
|
||||
|
||||
impl TaskAugmentedRequestParamsMeta for CallToolRequestParams {
|
||||
fn task(&self) -> Option<&TaskMetadata> {
|
||||
self.task.as_ref()
|
||||
}
|
||||
fn task_mut(&mut self) -> &mut Option<TaskMetadata> {
|
||||
&mut self.task
|
||||
}
|
||||
}
|
||||
|
||||
/// Deprecated: Use [`CallToolRequestParams`] instead (SEP-1319 compliance).
|
||||
#[deprecated(since = "0.13.0", note = "Use CallToolRequestParams instead")]
|
||||
pub type CallToolRequestParam = CallToolRequestParams;
|
||||
|
|
@ -4103,25 +4075,20 @@ impl GetPromptResult {
|
|||
}
|
||||
|
||||
// =============================================================================
|
||||
// TASK MANAGEMENT
|
||||
// TASK MANAGEMENT (SEP-2663 Tasks extension: `io.modelcontextprotocol/tasks`)
|
||||
// =============================================================================
|
||||
|
||||
const_string!(GetTaskMethod = "tasks/get");
|
||||
pub type GetTaskRequest = Request<GetTaskMethod, GetTaskParams>;
|
||||
|
||||
#[deprecated(since = "2.0.0", note = "Renamed to GetTaskMethod")]
|
||||
pub type GetTaskInfoMethod = GetTaskMethod;
|
||||
#[deprecated(since = "2.0.0", note = "Renamed to GetTaskRequest")]
|
||||
pub type GetTaskInfoRequest = GetTaskRequest;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct GetTaskParams {
|
||||
/// Protocol-level metadata for this request (SEP-1319)
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<RequestMetaObject>,
|
||||
/// Identifier of the task to query.
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
|
|
@ -4143,44 +4110,37 @@ impl RequestParamsMeta for GetTaskParams {
|
|||
}
|
||||
}
|
||||
|
||||
#[deprecated(since = "2.0.0", note = "Renamed to GetTaskParams")]
|
||||
pub type GetTaskInfoParams = GetTaskParams;
|
||||
|
||||
#[deprecated(since = "0.13.0", note = "Use GetTaskParams instead")]
|
||||
pub type GetTaskInfoParam = GetTaskParams;
|
||||
|
||||
const_string!(ListTasksMethod = "tasks/list");
|
||||
pub type ListTasksRequest = RequestOptionalParam<ListTasksMethod, PaginatedRequestParams>;
|
||||
|
||||
const_string!(GetTaskPayloadMethod = "tasks/result");
|
||||
pub type GetTaskPayloadRequest = Request<GetTaskPayloadMethod, GetTaskPayloadParams>;
|
||||
|
||||
#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadMethod")]
|
||||
pub type GetTaskResultMethod = GetTaskPayloadMethod;
|
||||
#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadRequest")]
|
||||
pub type GetTaskResultRequest = GetTaskPayloadRequest;
|
||||
const_string!(UpdateTaskMethod = "tasks/update");
|
||||
pub type UpdateTaskRequest = Request<UpdateTaskMethod, UpdateTaskParams>;
|
||||
|
||||
/// Parameters for `tasks/update` (SEP-2663): deliver responses to outstanding
|
||||
/// in-task server-to-client requests surfaced via `tasks/get` `inputRequests`.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct GetTaskPayloadParams {
|
||||
/// Protocol-level metadata for this request (SEP-1319)
|
||||
pub struct UpdateTaskParams {
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<RequestMetaObject>,
|
||||
/// Identifier of the task to update.
|
||||
pub task_id: String,
|
||||
/// Responses to outstanding `inputRequests` previously surfaced by the
|
||||
/// server. Each key MUST correspond to a currently-outstanding
|
||||
/// `inputRequests` key.
|
||||
pub input_responses: InputResponses,
|
||||
}
|
||||
|
||||
impl GetTaskPayloadParams {
|
||||
pub fn new(task_id: impl Into<String>) -> Self {
|
||||
impl UpdateTaskParams {
|
||||
pub fn new(task_id: impl Into<String>, input_responses: InputResponses) -> Self {
|
||||
Self {
|
||||
meta: None,
|
||||
task_id: task_id.into(),
|
||||
input_responses,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RequestParamsMeta for GetTaskPayloadParams {
|
||||
impl RequestParamsMeta for UpdateTaskParams {
|
||||
fn meta(&self) -> Option<&RequestMetaObject> {
|
||||
self.meta.as_ref()
|
||||
}
|
||||
|
|
@ -4189,11 +4149,6 @@ impl RequestParamsMeta for GetTaskPayloadParams {
|
|||
}
|
||||
}
|
||||
|
||||
#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")]
|
||||
pub type GetTaskResultParams = GetTaskPayloadParams;
|
||||
#[deprecated(since = "2.0.0", note = "Renamed to GetTaskPayloadParams")]
|
||||
pub type GetTaskResultParam = GetTaskPayloadParams;
|
||||
|
||||
const_string!(CancelTaskMethod = "tasks/cancel");
|
||||
pub type CancelTaskRequest = Request<CancelTaskMethod, CancelTaskParams>;
|
||||
|
||||
|
|
@ -4226,31 +4181,29 @@ impl RequestParamsMeta for CancelTaskParams {
|
|||
}
|
||||
}
|
||||
|
||||
/// Deprecated: Use [`CancelTaskParams`] instead (SEP-1319 compliance).
|
||||
#[deprecated(since = "0.13.0", note = "Use CancelTaskParams instead")]
|
||||
pub type CancelTaskParam = CancelTaskParams;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task status notification (spec `notifications/tasks/status`)
|
||||
// Task status notification (SEP-2663 `notifications/tasks`)
|
||||
// ---------------------------------------------------------------------------
|
||||
const_string!(TaskStatusNotificationMethod = "notifications/tasks/status");
|
||||
const_string!(TaskStatusNotificationMethod = "notifications/tasks");
|
||||
|
||||
/// Parameters for a task status notification (spec `TaskStatusNotificationParams`).
|
||||
///
|
||||
/// The task fields are flattened at the top level: `NotificationParams & Task`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
/// Carries a complete [`DetailedTask`] for the current status, identical to
|
||||
/// what `tasks/get` would have returned at that moment. The task fields are
|
||||
/// flattened at the top level: `NotificationParams & Task`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct TaskStatusNotificationParam {
|
||||
pub struct TaskStatusNotificationParams {
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<NotificationMetaObject>,
|
||||
#[serde(flatten)]
|
||||
pub task: crate::model::Task,
|
||||
pub task: crate::model::DetailedTask,
|
||||
}
|
||||
|
||||
impl TaskStatusNotificationParam {
|
||||
pub fn new(task: crate::model::Task) -> Self {
|
||||
impl TaskStatusNotificationParams {
|
||||
pub fn new(task: crate::model::DetailedTask) -> Self {
|
||||
Self { meta: None, task }
|
||||
}
|
||||
|
||||
|
|
@ -4260,53 +4213,28 @@ impl TaskStatusNotificationParam {
|
|||
}
|
||||
}
|
||||
|
||||
impl From<crate::model::Task> for TaskStatusNotificationParam {
|
||||
fn from(task: crate::model::Task) -> Self {
|
||||
impl From<crate::model::DetailedTask> for TaskStatusNotificationParams {
|
||||
fn from(task: crate::model::DetailedTask) -> Self {
|
||||
Self::new(task)
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for TaskStatusNotificationParam {
|
||||
type Target = crate::model::Task;
|
||||
impl Deref for TaskStatusNotificationParams {
|
||||
type Target = crate::model::DetailedTask;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.task
|
||||
}
|
||||
}
|
||||
|
||||
impl DerefMut for TaskStatusNotificationParam {
|
||||
impl DerefMut for TaskStatusNotificationParams {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.task
|
||||
}
|
||||
}
|
||||
|
||||
pub type TaskStatusNotification =
|
||||
Notification<TaskStatusNotificationMethod, TaskStatusNotificationParam>;
|
||||
/// Deprecated: Use [`GetTaskResult`] instead (spec alignment).
|
||||
#[deprecated(since = "0.15.0", note = "Use GetTaskResult instead")]
|
||||
pub type GetTaskInfoResult = GetTaskResult;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct ListTasksResult {
|
||||
pub tasks: Vec<crate::model::Task>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_cursor: Option<String>,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<MetaObject>,
|
||||
}
|
||||
|
||||
impl ListTasksResult {
|
||||
pub fn new(tasks: Vec<crate::model::Task>) -> Self {
|
||||
Self {
|
||||
tasks,
|
||||
next_cursor: None,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Notification<TaskStatusNotificationMethod, TaskStatusNotificationParams>;
|
||||
|
||||
// =============================================================================
|
||||
// MESSAGE TYPE UNIONS
|
||||
|
|
@ -4380,8 +4308,7 @@ ts_union!(
|
|||
| CallToolRequest
|
||||
| ListToolsRequest
|
||||
| GetTaskRequest
|
||||
| ListTasksRequest
|
||||
| GetTaskPayloadRequest
|
||||
| UpdateTaskRequest
|
||||
| CancelTaskRequest
|
||||
| CustomRequest;
|
||||
);
|
||||
|
|
@ -4405,8 +4332,7 @@ impl ClientRequest {
|
|||
ClientRequest::CallToolRequest(r) => r.method.as_str(),
|
||||
ClientRequest::ListToolsRequest(r) => r.method.as_str(),
|
||||
ClientRequest::GetTaskRequest(r) => r.method.as_str(),
|
||||
ClientRequest::ListTasksRequest(r) => r.method.as_str(),
|
||||
ClientRequest::GetTaskPayloadRequest(r) => r.method.as_str(),
|
||||
ClientRequest::UpdateTaskRequest(r) => r.method.as_str(),
|
||||
ClientRequest::CancelTaskRequest(r) => r.method.as_str(),
|
||||
ClientRequest::CustomRequest(r) => r.method.as_str(),
|
||||
}
|
||||
|
|
@ -4419,7 +4345,6 @@ ts_union!(
|
|||
| ProgressNotification
|
||||
| InitializedNotification
|
||||
| RootsListChangedNotification
|
||||
| TaskStatusNotification
|
||||
| CustomNotification;
|
||||
);
|
||||
|
||||
|
|
@ -4477,12 +4402,13 @@ ts_union!(
|
|||
| ListToolsResult
|
||||
| ElicitResult
|
||||
| CreateTaskResult
|
||||
| ListTasksResult
|
||||
| GetTaskResult
|
||||
| CancelTaskResult
|
||||
| CallToolResult
|
||||
| InputRequiredResult
|
||||
| GetTaskPayloadResult
|
||||
// TaskAckResult must come after CallToolResult/InputRequiredResult in this
|
||||
// untagged union: it only carries `resultType`, so it would otherwise
|
||||
// shadow any result that includes `resultType: "complete"`.
|
||||
| TaskAckResult
|
||||
| EmptyResult
|
||||
| CustomResult
|
||||
;
|
||||
|
|
@ -4492,6 +4418,12 @@ impl ServerResult {
|
|||
pub fn empty(_: ()) -> ServerResult {
|
||||
ServerResult::EmptyResult(EmptyResult {})
|
||||
}
|
||||
|
||||
/// Empty `tasks/update` / `tasks/cancel` acknowledgement carrying the
|
||||
/// SEP-2322 `resultType: "complete"` discriminator (SEP-2663).
|
||||
pub fn task_ack(_: ()) -> ServerResult {
|
||||
ServerResult::TaskAckResult(TaskAckResult::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub type ServerJsonRpcMessage = JsonRpcMessage<ServerRequest, ServerResult, ServerNotification>;
|
||||
|
|
@ -4533,7 +4465,6 @@ mod tests {
|
|||
fn deprecated_aliases_still_resolve() {
|
||||
// 하위호환: 구 이름이 새 타입으로 여전히 resolve되는지 확인.
|
||||
let _: CreateElicitationResult = ElicitResult::new(ElicitationAction::Accept);
|
||||
let _: GetTaskResultParams = GetTaskPayloadParams::new("task-1");
|
||||
let _: ResourceReference = ResourceTemplateReference::new("res://x");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,130 +72,6 @@ pub struct RootsCapabilities {
|
|||
pub list_changed: Option<bool>,
|
||||
}
|
||||
|
||||
/// Task capabilities shared by client and server.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct TasksCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub requests: Option<TaskRequestsCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub list: Option<JsonObject>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cancel: Option<JsonObject>,
|
||||
}
|
||||
|
||||
/// Request types that support task-augmented execution.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct TaskRequestsCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sampling: Option<SamplingTaskCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub elicitation: Option<ElicitationTaskCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<ToolsTaskCapability>,
|
||||
}
|
||||
|
||||
/// Sampling task capability. Deprecated by SEP-2577; remains functional and
|
||||
/// will be removed in a future release.
|
||||
/// See <https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577>.
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct SamplingTaskCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub create_message: Option<JsonObject>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct ElicitationTaskCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub create: Option<JsonObject>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct ToolsTaskCapability {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub call: Option<JsonObject>,
|
||||
}
|
||||
|
||||
impl TasksCapability {
|
||||
/// Default client tasks capability with sampling and elicitation support.
|
||||
pub fn client_default() -> Self {
|
||||
Self {
|
||||
list: Some(JsonObject::new()),
|
||||
cancel: Some(JsonObject::new()),
|
||||
requests: Some(TaskRequestsCapability {
|
||||
sampling: Some(SamplingTaskCapability {
|
||||
create_message: Some(JsonObject::new()),
|
||||
}),
|
||||
elicitation: Some(ElicitationTaskCapability {
|
||||
create: Some(JsonObject::new()),
|
||||
}),
|
||||
tools: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default server tasks capability with tools/call support.
|
||||
pub fn server_default() -> Self {
|
||||
Self {
|
||||
list: Some(JsonObject::new()),
|
||||
cancel: Some(JsonObject::new()),
|
||||
requests: Some(TaskRequestsCapability {
|
||||
sampling: None,
|
||||
elicitation: None,
|
||||
tools: Some(ToolsTaskCapability {
|
||||
call: Some(JsonObject::new()),
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn supports_list(&self) -> bool {
|
||||
self.list.is_some()
|
||||
}
|
||||
|
||||
pub fn supports_cancel(&self) -> bool {
|
||||
self.cancel.is_some()
|
||||
}
|
||||
|
||||
pub fn supports_tools_call(&self) -> bool {
|
||||
self.requests
|
||||
.as_ref()
|
||||
.and_then(|r| r.tools.as_ref())
|
||||
.and_then(|t| t.call.as_ref())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn supports_sampling_create_message(&self) -> bool {
|
||||
self.requests
|
||||
.as_ref()
|
||||
.and_then(|r| r.sampling.as_ref())
|
||||
.and_then(|s| s.create_message.as_ref())
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn supports_elicitation_create(&self) -> bool {
|
||||
self.requests
|
||||
.as_ref()
|
||||
.and_then(|r| r.elicitation.as_ref())
|
||||
.and_then(|e| e.create.as_ref())
|
||||
.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Capability for handling elicitation requests from servers.
|
||||
/// Elicitation allows servers to request interactive input from users during tool execution.
|
||||
/// This capability indicates that a client can handle elicitation requests and present
|
||||
|
|
@ -316,8 +192,16 @@ pub struct ClientCapabilities {
|
|||
/// Capability to handle elicitation requests from servers for interactive user input
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub elicitation: Option<ElicitationCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tasks: Option<TasksCapability>,
|
||||
}
|
||||
|
||||
impl ClientCapabilities {
|
||||
/// Returns `true` if the `io.modelcontextprotocol/tasks` extension
|
||||
/// (SEP-2663) is declared in [`Self::extensions`].
|
||||
pub fn supports_tasks(&self) -> bool {
|
||||
self.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID))
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
|
|
@ -356,8 +240,16 @@ pub struct ServerCapabilities {
|
|||
pub resources: Option<ResourcesCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<ToolsCapability>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tasks: Option<TasksCapability>,
|
||||
}
|
||||
|
||||
impl ServerCapabilities {
|
||||
/// Returns `true` if the `io.modelcontextprotocol/tasks` extension
|
||||
/// (SEP-2663) is declared in [`Self::extensions`].
|
||||
pub fn supports_tasks(&self) -> bool {
|
||||
self.extensions
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.contains_key(super::TASKS_EXTENSION_ID))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
|
|
@ -484,20 +376,12 @@ builder! {
|
|||
prompts: PromptsCapability,
|
||||
resources: ResourcesCapability,
|
||||
tools: ToolsCapability,
|
||||
tasks: TasksCapability
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
impl<
|
||||
const E: bool,
|
||||
const EXT: bool,
|
||||
const L: bool,
|
||||
const C: bool,
|
||||
const P: bool,
|
||||
const R: bool,
|
||||
const TASKS: bool,
|
||||
> ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, EXT, L, C, P, R, true, TASKS>>
|
||||
impl<const E: bool, const EXT: bool, const L: bool, const C: bool, const P: bool, const R: bool>
|
||||
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, EXT, L, C, P, R, true>>
|
||||
{
|
||||
pub fn enable_tool_list_changed(mut self) -> Self {
|
||||
if let Some(c) = self.tools.as_mut() {
|
||||
|
|
@ -508,15 +392,8 @@ impl<
|
|||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
impl<
|
||||
const E: bool,
|
||||
const EXT: bool,
|
||||
const L: bool,
|
||||
const C: bool,
|
||||
const R: bool,
|
||||
const T: bool,
|
||||
const TASKS: bool,
|
||||
> ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, EXT, L, C, true, R, T, TASKS>>
|
||||
impl<const E: bool, const EXT: bool, const L: bool, const C: bool, const R: bool, const T: bool>
|
||||
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, EXT, L, C, true, R, T>>
|
||||
{
|
||||
pub fn enable_prompts_list_changed(mut self) -> Self {
|
||||
if let Some(c) = self.prompts.as_mut() {
|
||||
|
|
@ -527,15 +404,8 @@ impl<
|
|||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
impl<
|
||||
const E: bool,
|
||||
const EXT: bool,
|
||||
const L: bool,
|
||||
const C: bool,
|
||||
const P: bool,
|
||||
const T: bool,
|
||||
const TASKS: bool,
|
||||
> ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, EXT, L, C, P, true, T, TASKS>>
|
||||
impl<const E: bool, const EXT: bool, const L: bool, const C: bool, const P: bool, const T: bool>
|
||||
ServerCapabilitiesBuilder<ServerCapabilitiesBuilderState<E, EXT, L, C, P, true, T>>
|
||||
{
|
||||
pub fn enable_resources_list_changed(mut self) -> Self {
|
||||
if let Some(c) = self.resources.as_mut() {
|
||||
|
|
@ -552,6 +422,18 @@ impl<
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
impl<S> ServerCapabilitiesBuilder<S> {
|
||||
/// Declare support for the `io.modelcontextprotocol/tasks` extension
|
||||
/// (SEP-2663) in the `extensions` capability map.
|
||||
pub fn enable_tasks(mut self) -> Self {
|
||||
self.extensions
|
||||
.get_or_insert_with(ExtensionCapabilities::new)
|
||||
.insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
builder! {
|
||||
ClientCapabilities{
|
||||
|
|
@ -568,13 +450,24 @@ builder! {
|
|||
)]
|
||||
sampling: SamplingCapability,
|
||||
elicitation: ElicitationCapability,
|
||||
tasks: TasksCapability,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
impl<const E: bool, const EXT: bool, const S: bool, const EL: bool, const TASKS: bool>
|
||||
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, EXT, true, S, EL, TASKS>>
|
||||
impl<S> ClientCapabilitiesBuilder<S> {
|
||||
/// Declare support for the `io.modelcontextprotocol/tasks` extension
|
||||
/// (SEP-2663) in the `extensions` capability map.
|
||||
pub fn enable_tasks(mut self) -> Self {
|
||||
self.extensions
|
||||
.get_or_insert_with(ExtensionCapabilities::new)
|
||||
.insert(super::TASKS_EXTENSION_ID.to_string(), JsonObject::new());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
impl<const E: bool, const EXT: bool, const S: bool, const EL: bool>
|
||||
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, EXT, true, S, EL>>
|
||||
{
|
||||
#[deprecated(
|
||||
since = "1.8.0",
|
||||
|
|
@ -589,8 +482,8 @@ impl<const E: bool, const EXT: bool, const S: bool, const EL: bool, const TASKS:
|
|||
}
|
||||
|
||||
#[cfg(any(feature = "server", feature = "macros"))]
|
||||
impl<const E: bool, const EXT: bool, const R: bool, const EL: bool, const TASKS: bool>
|
||||
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, EXT, R, true, EL, TASKS>>
|
||||
impl<const E: bool, const EXT: bool, const R: bool, const EL: bool>
|
||||
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, EXT, R, true, EL>>
|
||||
{
|
||||
/// Enable tool calling in sampling requests
|
||||
#[deprecated(
|
||||
|
|
@ -618,8 +511,8 @@ impl<const E: bool, const EXT: bool, const R: bool, const EL: bool, const TASKS:
|
|||
}
|
||||
|
||||
#[cfg(all(feature = "elicitation", any(feature = "server", feature = "macros")))]
|
||||
impl<const E: bool, const EXT: bool, const R: bool, const S: bool, const TASKS: bool>
|
||||
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, EXT, R, S, true, TASKS>>
|
||||
impl<const E: bool, const EXT: bool, const R: bool, const S: bool>
|
||||
ClientCapabilitiesBuilder<ClientCapabilitiesBuilderState<E, EXT, R, S, true>>
|
||||
{
|
||||
/// Enable JSON Schema validation for elicitation responses in form mode.
|
||||
/// When enabled, the client will validate user input against the requested_schema
|
||||
|
|
@ -679,68 +572,25 @@ mod test {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_capabilities_deserialization() {
|
||||
// Test deserializing from the MCP spec format
|
||||
let json = serde_json::json!({
|
||||
"list": {},
|
||||
"cancel": {},
|
||||
"requests": {
|
||||
"tools": { "call": {} }
|
||||
}
|
||||
});
|
||||
|
||||
let tasks: TasksCapability = serde_json::from_value(json).unwrap();
|
||||
assert!(tasks.list.is_some());
|
||||
assert!(tasks.cancel.is_some());
|
||||
assert!(tasks.requests.is_some());
|
||||
let requests = tasks.requests.unwrap();
|
||||
assert!(requests.tools.is_some());
|
||||
assert!(requests.tools.unwrap().call.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tasks_capability_client_default() {
|
||||
let tasks = TasksCapability::client_default();
|
||||
|
||||
// Verify structure
|
||||
assert!(tasks.supports_list());
|
||||
assert!(tasks.supports_cancel());
|
||||
assert!(tasks.supports_sampling_create_message());
|
||||
assert!(tasks.supports_elicitation_create());
|
||||
assert!(!tasks.supports_tools_call());
|
||||
|
||||
// Verify serialization matches expected format
|
||||
let json = serde_json::to_value(&tasks).unwrap();
|
||||
assert_eq!(json["list"], serde_json::json!({}));
|
||||
assert_eq!(json["cancel"], serde_json::json!({}));
|
||||
fn test_tasks_extension_capability() {
|
||||
// SEP-2663: tasks are declared via the extensions map.
|
||||
let capabilities = ClientCapabilities::builder().enable_tasks().build();
|
||||
assert!(capabilities.supports_tasks());
|
||||
let json = serde_json::to_value(&capabilities).unwrap();
|
||||
assert_eq!(
|
||||
json["requests"]["sampling"]["createMessage"],
|
||||
json["extensions"][crate::model::TASKS_EXTENSION_ID],
|
||||
serde_json::json!({})
|
||||
);
|
||||
|
||||
let server = ServerCapabilities::builder().enable_tasks().build();
|
||||
assert!(server.supports_tasks());
|
||||
let json = serde_json::to_value(&server).unwrap();
|
||||
assert_eq!(
|
||||
json["requests"]["elicitation"]["create"],
|
||||
json["extensions"][crate::model::TASKS_EXTENSION_ID],
|
||||
serde_json::json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tasks_capability_server_default() {
|
||||
let tasks = TasksCapability::server_default();
|
||||
|
||||
// Verify structure
|
||||
assert!(tasks.supports_list());
|
||||
assert!(tasks.supports_cancel());
|
||||
assert!(tasks.supports_tools_call());
|
||||
assert!(!tasks.supports_sampling_create_message());
|
||||
assert!(!tasks.supports_elicitation_create());
|
||||
|
||||
// Verify serialization matches expected format
|
||||
let json = serde_json::to_value(&tasks).unwrap();
|
||||
assert_eq!(json["list"], serde_json::json!({}));
|
||||
assert_eq!(json["cancel"], serde_json::json!({}));
|
||||
assert_eq!(json["requests"]["tools"]["call"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(deprecated)]
|
||||
fn test_client_extensions_capability() {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use serde_json::Value;
|
|||
use super::{
|
||||
ClientCapabilities, ClientNotification, ClientRequest, CustomNotification, CustomRequest,
|
||||
Extensions, Implementation, JsonObject, JsonRpcMessage, LoggingLevel, ProgressToken,
|
||||
ProtocolVersion, RequestId, ServerNotification, ServerRequest, TaskMetadata,
|
||||
ProtocolVersion, RequestId, ServerNotification, ServerRequest,
|
||||
};
|
||||
|
||||
/// Access to the metadata carried by a message envelope's [`Extensions`].
|
||||
|
|
@ -94,21 +94,6 @@ pub trait RequestParamsMeta {
|
|||
}
|
||||
}
|
||||
|
||||
/// Trait for task-augmented request params that contain both `_meta` and `task` fields.
|
||||
///
|
||||
/// Per the MCP 2025-11-25 spec, certain requests (like `tools/call` and `sampling/createMessage`)
|
||||
/// can include a `task` field to signal that the caller wants task-augmented execution.
|
||||
pub trait TaskAugmentedRequestParamsMeta: RequestParamsMeta {
|
||||
/// Get a reference to the task field
|
||||
fn task(&self) -> Option<&TaskMetadata>;
|
||||
/// Get a mutable reference to the task field
|
||||
fn task_mut(&mut self) -> &mut Option<TaskMetadata>;
|
||||
/// Set the task field
|
||||
fn set_task(&mut self, task: TaskMetadata) {
|
||||
*self.task_mut() = Some(task);
|
||||
}
|
||||
}
|
||||
|
||||
impl GetExtensions for CustomNotification {
|
||||
fn extensions(&self) -> &Extensions {
|
||||
&self.extensions
|
||||
|
|
@ -204,8 +189,7 @@ variant_extension! {
|
|||
ListToolsRequest
|
||||
CustomRequest
|
||||
GetTaskRequest
|
||||
ListTasksRequest
|
||||
GetTaskPayloadRequest
|
||||
UpdateTaskRequest
|
||||
CancelTaskRequest
|
||||
}
|
||||
}
|
||||
|
|
@ -226,7 +210,6 @@ variant_extension! {
|
|||
ProgressNotification
|
||||
InitializedNotification
|
||||
RootsListChangedNotification
|
||||
TaskStatusNotification
|
||||
CustomNotification
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::Value;
|
||||
|
||||
use super::{
|
||||
CallToolResult, CreateMessageRequest, ElicitRequest, GetPromptResult, ListRootsRequest,
|
||||
MetaObject, ReadResourceResult, ResultType, ServerResult,
|
||||
CallToolResult, CreateMessageRequest, CreateTaskResult, ElicitRequest, GetPromptResult,
|
||||
ListRootsRequest, MetaObject, ReadResourceResult, ResultType, ServerResult,
|
||||
};
|
||||
|
||||
/// Default maximum number of MRTR rounds a high-level client call will drive.
|
||||
|
|
@ -72,6 +72,18 @@ pub enum InputRequest {
|
|||
ListRoots(ListRootsRequest),
|
||||
}
|
||||
|
||||
// Wire-level equality: two `InputRequest`s are equal if they serialize to the
|
||||
// same JSON. The wrapped request envelopes do not implement `PartialEq`
|
||||
// structurally (they carry `Extensions`).
|
||||
impl PartialEq for InputRequest {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (serde_json::to_value(self), serde_json::to_value(other)) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A map of server-initiated requests that the client must fulfill.
|
||||
///
|
||||
/// Keys are server-assigned string identifiers; values are request objects
|
||||
|
|
@ -95,6 +107,9 @@ pub enum CallToolResponse {
|
|||
Complete(CallToolResult),
|
||||
/// The server requires client-side input before the tool call can complete.
|
||||
InputRequired(InputRequiredResult),
|
||||
/// The server materialized a task for this call (SEP-2663 Tasks extension,
|
||||
/// `resultType: "task"`). The client polls `tasks/get` for the result.
|
||||
Task(CreateTaskResult),
|
||||
}
|
||||
|
||||
impl From<CallToolResult> for CallToolResponse {
|
||||
|
|
@ -114,10 +129,17 @@ impl From<CallToolResponse> for ServerResult {
|
|||
match response {
|
||||
CallToolResponse::Complete(result) => ServerResult::CallToolResult(result),
|
||||
CallToolResponse::InputRequired(result) => ServerResult::InputRequiredResult(result),
|
||||
CallToolResponse::Task(result) => ServerResult::CreateTaskResult(result),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CreateTaskResult> for CallToolResponse {
|
||||
fn from(result: CreateTaskResult) -> Self {
|
||||
Self::Task(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a `prompts/get` request, including the MRTR intermediate result.
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
|
|
|
|||
|
|
@ -497,7 +497,6 @@ mod test {
|
|||
meta: Some(params_meta),
|
||||
name: "my_tool".into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -537,7 +536,6 @@ mod test {
|
|||
meta: None,
|
||||
name: "my_tool".into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -560,7 +558,6 @@ mod test {
|
|||
meta: Some(params_meta),
|
||||
name: "my_tool".into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -580,7 +577,6 @@ mod test {
|
|||
meta: None,
|
||||
name: "my_tool".into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -612,7 +608,6 @@ mod test {
|
|||
meta: Some(params_meta),
|
||||
name: "my_tool".into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -638,7 +633,6 @@ mod test {
|
|||
meta: None,
|
||||
name: "my_tool".into(),
|
||||
arguments: Some(serde_json::Map::from_iter([("x".to_string(), json!(1))])),
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -798,7 +792,6 @@ mod test {
|
|||
meta: None,
|
||||
name: "my_tool".into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
@ -826,7 +819,6 @@ mod test {
|
|||
meta: None,
|
||||
name: "my_tool".into(),
|
||||
arguments: None,
|
||||
task: None,
|
||||
input_responses: None,
|
||||
request_state: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,114 +1,90 @@
|
|||
//! Task types for the MCP Tasks extension (SEP-2663).
|
||||
//!
|
||||
//! Tasks are defined by the official `io.modelcontextprotocol/tasks` extension.
|
||||
//! A server may respond to a supported request (currently `tools/call`) with a
|
||||
//! [`CreateTaskResult`] (`resultType: "task"`) instead of the standard result.
|
||||
//! The client then polls `tasks/get`, answers in-task server-to-client requests
|
||||
//! via `tasks/update`, and may signal cancellation via `tasks/cancel`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::MetaObject;
|
||||
use super::{InputRequests, JsonObject, MetaObject, ResultType};
|
||||
|
||||
/// Metadata for augmenting a request with task execution (spec `TaskMetadata`).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct TaskMetadata {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ttl: Option<u64>,
|
||||
}
|
||||
/// Extension identifier for the MCP Tasks extension (SEP-2663).
|
||||
pub const TASKS_EXTENSION_ID: &str = "io.modelcontextprotocol/tasks";
|
||||
|
||||
impl TaskMetadata {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_ttl(mut self, ttl: u64) -> Self {
|
||||
self.ttl = Some(ttl);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata for associating messages with a task (spec `RelatedTaskMetadata`).
|
||||
///
|
||||
/// Carried in `_meta` under the key `"io.modelcontextprotocol/related-task"`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct RelatedTaskMetadata {
|
||||
pub task_id: String,
|
||||
}
|
||||
|
||||
impl RelatedTaskMetadata {
|
||||
pub fn new(task_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
task_id: task_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The well-known `_meta` key for related-task metadata.
|
||||
pub const META_KEY: &str = "io.modelcontextprotocol/related-task";
|
||||
}
|
||||
|
||||
/// Canonical task lifecycle status as defined by SEP-1686.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
/// Canonical task lifecycle status (SEP-2663).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub enum TaskStatus {
|
||||
/// The receiver accepted the request and is currently working on it.
|
||||
/// The request is currently being processed.
|
||||
#[default]
|
||||
Working,
|
||||
/// The receiver requires additional input before work can continue.
|
||||
/// The server needs input from the client before the task can proceed.
|
||||
InputRequired,
|
||||
/// The underlying operation completed successfully and the result is ready.
|
||||
/// The request completed successfully and the result is available.
|
||||
/// This includes tool calls that returned results with `isError: true`.
|
||||
Completed,
|
||||
/// The underlying operation failed and will not continue.
|
||||
/// The request failed due to a JSON-RPC error during execution.
|
||||
Failed,
|
||||
/// The task was cancelled and will not continue processing.
|
||||
/// The request was cancelled before completion.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Primary Task object that surfaces metadata during the task lifecycle.
|
||||
///
|
||||
/// Per spec, `lastUpdatedAt` and `ttl` are required fields.
|
||||
/// `ttl` is nullable (`null` means unlimited retention).
|
||||
impl TaskStatus {
|
||||
/// Returns `true` for terminal statuses (`completed`, `failed`, `cancelled`).
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
/// Operational metadata about ongoing work (spec `Task`, SEP-2663).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct Task {
|
||||
/// Unique task identifier generated by the receiver.
|
||||
/// Stable identifier for this task, generated by the server.
|
||||
pub task_id: String,
|
||||
/// Current lifecycle status (see [`TaskStatus`]).
|
||||
/// Current task status.
|
||||
pub status: TaskStatus,
|
||||
/// Optional human-readable status message for UI surfaces.
|
||||
/// Optional message describing the current task state.
|
||||
/// This MAY be exposed to the end-user or model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status_message: Option<String>,
|
||||
/// ISO-8601 creation timestamp.
|
||||
/// ISO 8601 timestamp when the task was created.
|
||||
pub created_at: String,
|
||||
/// ISO-8601 timestamp for the most recent status change.
|
||||
/// ISO 8601 timestamp when the task was last updated.
|
||||
pub last_updated_at: String,
|
||||
/// Retention window in milliseconds that the receiver agreed to honor.
|
||||
/// `None` (serialized as `null`) means unlimited retention.
|
||||
pub ttl: Option<u64>,
|
||||
/// Suggested polling interval (milliseconds).
|
||||
/// Time-to-live duration from creation in integer milliseconds; `None`
|
||||
/// (serialized as `null`) means unlimited. The server may discard the task
|
||||
/// after the TTL elapses. This value MAY change over the lifetime of a task.
|
||||
pub ttl_ms: Option<u64>,
|
||||
/// Suggested polling interval in integer milliseconds. Clients SHOULD honor
|
||||
/// this value to avoid overwhelming the server. This value MAY change over
|
||||
/// the lifetime of a task.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub poll_interval: Option<u64>,
|
||||
pub poll_interval_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Task {
|
||||
/// Create a new Task with required fields.
|
||||
/// Create a new task with required fields.
|
||||
pub fn new(
|
||||
task_id: String,
|
||||
task_id: impl Into<String>,
|
||||
status: TaskStatus,
|
||||
created_at: String,
|
||||
last_updated_at: String,
|
||||
created_at: impl Into<String>,
|
||||
last_updated_at: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
task_id,
|
||||
task_id: task_id.into(),
|
||||
status,
|
||||
status_message: None,
|
||||
created_at,
|
||||
last_updated_at,
|
||||
ttl: None,
|
||||
poll_interval: None,
|
||||
created_at: created_at.into(),
|
||||
last_updated_at: last_updated_at.into(),
|
||||
ttl_ms: None,
|
||||
poll_interval_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -119,33 +95,241 @@ impl Task {
|
|||
}
|
||||
|
||||
/// Set the TTL in milliseconds. `None` means unlimited retention.
|
||||
pub fn with_ttl(mut self, ttl: u64) -> Self {
|
||||
self.ttl = Some(ttl);
|
||||
pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self {
|
||||
self.ttl_ms = Some(ttl_ms);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the poll interval in milliseconds.
|
||||
pub fn with_poll_interval(mut self, poll_interval: u64) -> Self {
|
||||
self.poll_interval = Some(poll_interval);
|
||||
/// Set the suggested poll interval in milliseconds.
|
||||
pub fn with_poll_interval_ms(mut self, poll_interval_ms: u64) -> Self {
|
||||
self.poll_interval_ms = Some(poll_interval_ms);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper returned by task-augmented requests (CreateTaskResult in SEP-1686).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
/// Status-specific payload carried alongside the base [`Task`] fields in a
|
||||
/// [`DetailedTask`].
|
||||
///
|
||||
/// Mirrors the spec's `WorkingTask` / `InputRequiredTask` / `CompletedTask` /
|
||||
/// `FailedTask` / `CancelledTask` union: the variant is discriminated by the
|
||||
/// `status` field on the wire, with the payload fields inlined at the top level.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum TaskPayload {
|
||||
/// `status: "working"` — no additional payload.
|
||||
Working,
|
||||
/// `status: "input_required"` — outstanding server-to-client requests.
|
||||
InputRequired {
|
||||
/// Server-to-client requests that need to be fulfilled during task
|
||||
/// execution. Keys are arbitrary identifiers for matching requests
|
||||
/// to responses, unique over the lifetime of the task.
|
||||
input_requests: InputRequests,
|
||||
},
|
||||
/// `status: "completed"` — the final result of the task. The structure
|
||||
/// matches the result type of the original request (e.g. `CallToolResult`).
|
||||
Completed {
|
||||
/// The final result of the original request.
|
||||
result: JsonObject,
|
||||
},
|
||||
/// `status: "failed"` — the JSON-RPC error that caused the task to fail.
|
||||
Failed {
|
||||
/// The JSON-RPC error object.
|
||||
error: JsonObject,
|
||||
},
|
||||
/// `status: "cancelled"` — no additional payload.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl TaskPayload {
|
||||
/// The [`TaskStatus`] this payload corresponds to.
|
||||
pub fn status(&self) -> TaskStatus {
|
||||
match self {
|
||||
Self::Working => TaskStatus::Working,
|
||||
Self::InputRequired { .. } => TaskStatus::InputRequired,
|
||||
Self::Completed { .. } => TaskStatus::Completed,
|
||||
Self::Failed { .. } => TaskStatus::Failed,
|
||||
Self::Cancelled => TaskStatus::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A task with its status-specific payload inlined (spec `DetailedTask`).
|
||||
///
|
||||
/// Used by `tasks/get` responses ([`GetTaskResult`]) and `notifications/tasks`
|
||||
/// ([`TaskStatusNotificationParams`](crate::model::TaskStatusNotificationParams)).
|
||||
/// On the wire, the payload fields (`inputRequests` / `result` / `error`) are
|
||||
/// flattened at the top level next to the base [`Task`] fields, and `status`
|
||||
/// discriminates the variant.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub struct DetailedTask {
|
||||
/// Base task metadata. Its `status` always agrees with the payload.
|
||||
pub task: Task,
|
||||
/// Status-specific payload.
|
||||
pub payload: TaskPayload,
|
||||
}
|
||||
|
||||
impl DetailedTask {
|
||||
/// Build a `DetailedTask`, forcing `task.status` to match the payload.
|
||||
pub fn new(mut task: Task, payload: TaskPayload) -> Self {
|
||||
task.status = payload.status();
|
||||
Self { task, payload }
|
||||
}
|
||||
|
||||
/// The current status.
|
||||
pub fn status(&self) -> TaskStatus {
|
||||
self.task.status
|
||||
}
|
||||
}
|
||||
|
||||
// Wire shape helper: base Task fields + optional payload fields, all flattened.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
struct DetailedTaskWire {
|
||||
#[serde(flatten)]
|
||||
task: Task,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
input_requests: Option<InputRequests>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<JsonObject>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<JsonObject>,
|
||||
}
|
||||
|
||||
impl From<DetailedTask> for DetailedTaskWire {
|
||||
fn from(value: DetailedTask) -> Self {
|
||||
let DetailedTask { task, payload } = value;
|
||||
let (input_requests, result, error) = match payload {
|
||||
TaskPayload::Working | TaskPayload::Cancelled => (None, None, None),
|
||||
TaskPayload::InputRequired { input_requests } => (Some(input_requests), None, None),
|
||||
TaskPayload::Completed { result } => (None, Some(result), None),
|
||||
TaskPayload::Failed { error } => (None, None, Some(error)),
|
||||
};
|
||||
Self {
|
||||
task,
|
||||
input_requests,
|
||||
result,
|
||||
error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<DetailedTaskWire> for DetailedTask {
|
||||
type Error = String;
|
||||
fn try_from(wire: DetailedTaskWire) -> Result<Self, String> {
|
||||
let payload = match wire.task.status {
|
||||
TaskStatus::Working => TaskPayload::Working,
|
||||
TaskStatus::Cancelled => TaskPayload::Cancelled,
|
||||
TaskStatus::InputRequired => TaskPayload::InputRequired {
|
||||
input_requests: wire.input_requests.ok_or_else(|| {
|
||||
"task with status \"input_required\" is missing `inputRequests`".to_owned()
|
||||
})?,
|
||||
},
|
||||
TaskStatus::Completed => TaskPayload::Completed {
|
||||
result: wire.result.ok_or_else(|| {
|
||||
"task with status \"completed\" is missing `result`".to_owned()
|
||||
})?,
|
||||
},
|
||||
TaskStatus::Failed => TaskPayload::Failed {
|
||||
error: wire
|
||||
.error
|
||||
.ok_or_else(|| "task with status \"failed\" is missing `error`".to_owned())?,
|
||||
},
|
||||
};
|
||||
Ok(DetailedTask {
|
||||
task: wire.task,
|
||||
payload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for DetailedTask {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
DetailedTaskWire::from(self.clone()).serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for DetailedTask {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let wire = DetailedTaskWire::deserialize(deserializer)?;
|
||||
Self::try_from(wire).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "schemars")]
|
||||
impl schemars::JsonSchema for DetailedTask {
|
||||
fn schema_name() -> std::borrow::Cow<'static, str> {
|
||||
"DetailedTask".into()
|
||||
}
|
||||
fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
|
||||
// The actual wire shape: base Task fields plus the optional
|
||||
// status-specific payload fields (inputRequests / result / error).
|
||||
<DetailedTaskWire as schemars::JsonSchema>::json_schema(generator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Result returned in lieu of a standard result to indicate the request will
|
||||
/// be processed asynchronously (spec `CreateTaskResult`, `resultType: "task"`).
|
||||
///
|
||||
/// The embedded task is the seed state for the task; the client uses
|
||||
/// `task.task_id` for all subsequent `tasks/get`, `tasks/update`, and
|
||||
/// `tasks/cancel` calls.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct CreateTaskResult {
|
||||
/// Always `"task"`.
|
||||
pub result_type: ResultType,
|
||||
/// Seed state of the newly created task, flattened at the top level.
|
||||
#[serde(flatten)]
|
||||
pub task: Task,
|
||||
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<MetaObject>,
|
||||
}
|
||||
|
||||
// Custom deserializer that requires `resultType: "task"`. Without this,
|
||||
// `CreateTaskResult` would greedily match other task-shaped results (e.g.
|
||||
// `tasks/get` responses, which also carry `taskId`/`status` at the top level
|
||||
// but use `resultType: "complete"`) inside `#[serde(untagged)]` unions such
|
||||
// as `ServerResult`.
|
||||
impl<'de> Deserialize<'de> for CreateTaskResult {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Helper {
|
||||
result_type: ResultType,
|
||||
#[serde(flatten)]
|
||||
task: Task,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<MetaObject>,
|
||||
}
|
||||
let helper = Helper::deserialize(deserializer)?;
|
||||
if !helper.result_type.is_task() {
|
||||
return Err(serde::de::Error::custom(
|
||||
"CreateTaskResult requires resultType to be \"task\"",
|
||||
));
|
||||
}
|
||||
Ok(CreateTaskResult {
|
||||
result_type: helper.result_type,
|
||||
task: helper.task,
|
||||
meta: helper.meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateTaskResult {
|
||||
/// Create a new CreateTaskResult.
|
||||
/// Create a new `CreateTaskResult` from the seed task state.
|
||||
pub fn new(task: Task) -> Self {
|
||||
Self { task, meta: None }
|
||||
Self {
|
||||
result_type: ResultType::TASK,
|
||||
task,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the protocol-level metadata for this result.
|
||||
|
|
@ -155,80 +339,206 @@ impl CreateTaskResult {
|
|||
}
|
||||
}
|
||||
|
||||
/// Response to a `tasks/get` request.
|
||||
/// Response to a `tasks/get` request (spec `GetTaskResult = Result & DetailedTask`).
|
||||
///
|
||||
/// Per spec, `GetTaskResult = allOf[Result, Task]` — the Task fields are
|
||||
/// flattened at the top level, not nested under a `task` key.
|
||||
/// `resultType` is `"complete"` — this is the standard result shape for
|
||||
/// `tasks/get`, not a task handle.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct GetTaskResult {
|
||||
/// Result type discriminator. `tasks/get` responses are standard results:
|
||||
/// `"complete"` (SEP-2322). Absent values deserialize as `"complete"`.
|
||||
#[serde(default)]
|
||||
pub result_type: ResultType,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<MetaObject>,
|
||||
/// The task with status-specific payload inlined.
|
||||
#[serde(flatten)]
|
||||
pub task: Task,
|
||||
pub task: DetailedTask,
|
||||
}
|
||||
|
||||
impl GetTaskResult {
|
||||
pub fn new(task: Task) -> Self {
|
||||
Self { meta: None, task }
|
||||
pub fn new(task: DetailedTask) -> Self {
|
||||
Self {
|
||||
result_type: ResultType::COMPLETE,
|
||||
meta: None,
|
||||
task,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response to a `tasks/result` request.
|
||||
/// Empty acknowledgement for `tasks/update` and `tasks/cancel` (SEP-2663).
|
||||
///
|
||||
/// Per spec, the result structure matches the original request type
|
||||
/// (e.g., `CallToolResult` for `tools/call`). This is represented as
|
||||
/// an open object. The payload is the original request's result
|
||||
/// serialized as a JSON value.
|
||||
/// The spec requires these acks to be empty results carrying the SEP-2322
|
||||
/// `resultType: "complete"` discriminator; task state changes are observed
|
||||
/// via the next `tasks/get`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct GetTaskPayloadResult(pub Value);
|
||||
|
||||
impl GetTaskPayloadResult {
|
||||
/// Create a new GetTaskPayloadResult with the given value.
|
||||
pub fn new(value: Value) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
pub struct TaskAckResult {
|
||||
/// Always `"complete"`.
|
||||
pub result_type: ResultType,
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<MetaObject>,
|
||||
}
|
||||
|
||||
// Custom Deserialize that always fails, so that `GetTaskPayloadResult` is skipped
|
||||
// during `#[serde(untagged)]` enum deserialization (e.g. `ServerResult`).
|
||||
// The payload has the same JSON shape as `CustomResult(Value)`, so they are
|
||||
// indistinguishable. `CustomResult` acts as the catch-all instead.
|
||||
// `GetTaskPayloadResult` should be constructed programmatically via `::new()`.
|
||||
impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult {
|
||||
// Custom deserializer that requires `resultType: "complete"` and rejects any
|
||||
// other fields. Without this, `TaskAckResult` would greedily match arbitrary
|
||||
// result objects carrying a `resultType` key inside `#[serde(untagged)]`
|
||||
// unions such as `ServerResult`, shadowing `CustomResult` and losing data.
|
||||
impl<'de> Deserialize<'de> for TaskAckResult {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
// Consume the value so the deserializer state stays consistent.
|
||||
serde::de::IgnoredAny::deserialize(deserializer)?;
|
||||
Err(serde::de::Error::custom(
|
||||
"GetTaskPayloadResult cannot be deserialized directly; \
|
||||
use CustomResult as the catch-all",
|
||||
))
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Helper {
|
||||
result_type: ResultType,
|
||||
#[serde(rename = "_meta", default)]
|
||||
meta: Option<MetaObject>,
|
||||
}
|
||||
let helper = Helper::deserialize(deserializer)?;
|
||||
if !helper.result_type.is_complete() {
|
||||
return Err(serde::de::Error::custom(
|
||||
"TaskAckResult requires resultType to be \"complete\"",
|
||||
));
|
||||
}
|
||||
Ok(TaskAckResult {
|
||||
result_type: helper.result_type,
|
||||
meta: helper.meta,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Response to a `tasks/cancel` request.
|
||||
///
|
||||
/// Per spec, `CancelTaskResult = allOf[Result, Task]` — same shape as `GetTaskResult`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
||||
#[non_exhaustive]
|
||||
pub struct CancelTaskResult {
|
||||
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
|
||||
pub meta: Option<MetaObject>,
|
||||
#[serde(flatten)]
|
||||
pub task: Task,
|
||||
impl Default for TaskAckResult {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
result_type: ResultType::COMPLETE,
|
||||
meta: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CancelTaskResult {
|
||||
pub fn new(task: Task) -> Self {
|
||||
Self { meta: None, task }
|
||||
impl TaskAckResult {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn base_task(status: TaskStatus) -> Task {
|
||||
Task::new(
|
||||
"task-1",
|
||||
status,
|
||||
"2025-11-25T10:30:00Z",
|
||||
"2025-11-25T10:40:00Z",
|
||||
)
|
||||
.with_ttl_ms(60000)
|
||||
.with_poll_interval_ms(5000)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_task_result_wire_shape() {
|
||||
let result = CreateTaskResult::new(base_task(TaskStatus::Working));
|
||||
let value = serde_json::to_value(&result).unwrap();
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"resultType": "task",
|
||||
"taskId": "task-1",
|
||||
"status": "working",
|
||||
"createdAt": "2025-11-25T10:30:00Z",
|
||||
"lastUpdatedAt": "2025-11-25T10:40:00Z",
|
||||
"ttlMs": 60000,
|
||||
"pollIntervalMs": 5000
|
||||
})
|
||||
);
|
||||
let roundtrip: CreateTaskResult = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(roundtrip, result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ttl_ms_null_means_unlimited() {
|
||||
let mut task = base_task(TaskStatus::Working);
|
||||
task.ttl_ms = None;
|
||||
let value = serde_json::to_value(&task).unwrap();
|
||||
assert_eq!(value["ttlMs"], serde_json::Value::Null);
|
||||
let roundtrip: Task = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(roundtrip.ttl_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_task_completed_roundtrip() {
|
||||
let detailed = DetailedTask::new(
|
||||
base_task(TaskStatus::Working),
|
||||
TaskPayload::Completed {
|
||||
result: serde_json::from_value(json!({
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"isError": false
|
||||
}))
|
||||
.unwrap(),
|
||||
},
|
||||
);
|
||||
// Status is forced to match the payload.
|
||||
assert_eq!(detailed.status(), TaskStatus::Completed);
|
||||
let value = serde_json::to_value(&detailed).unwrap();
|
||||
assert_eq!(value["status"], "completed");
|
||||
assert_eq!(value["result"]["isError"], false);
|
||||
let roundtrip: DetailedTask = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(roundtrip, detailed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_task_input_required_requires_input_requests() {
|
||||
let err = serde_json::from_value::<DetailedTask>(json!({
|
||||
"taskId": "task-1",
|
||||
"status": "input_required",
|
||||
"createdAt": "2025-11-25T10:30:00Z",
|
||||
"lastUpdatedAt": "2025-11-25T10:40:00Z",
|
||||
"ttlMs": null
|
||||
}))
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("inputRequests"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detailed_task_failed_roundtrip() {
|
||||
let detailed = DetailedTask::new(
|
||||
base_task(TaskStatus::Failed),
|
||||
TaskPayload::Failed {
|
||||
error: serde_json::from_value(json!({
|
||||
"code": -32603,
|
||||
"message": "boom"
|
||||
}))
|
||||
.unwrap(),
|
||||
},
|
||||
);
|
||||
let value = serde_json::to_value(&detailed).unwrap();
|
||||
assert_eq!(value["status"], "failed");
|
||||
assert_eq!(value["error"]["code"], -32603);
|
||||
let roundtrip: DetailedTask = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(roundtrip, detailed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_task_result_flattens_detailed_task() {
|
||||
let result = GetTaskResult::new(DetailedTask::new(
|
||||
base_task(TaskStatus::Working),
|
||||
TaskPayload::Working,
|
||||
));
|
||||
let value = serde_json::to_value(&result).unwrap();
|
||||
assert_eq!(value["taskId"], "task-1");
|
||||
assert_eq!(value["status"], "working");
|
||||
let roundtrip: GetTaskResult = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(roundtrip, result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue