fix(tasks): expose execution.taskSupport on tools (#635)

* fix(tasks): expose execution.taskSupport on tools

* feat: implement taskSupport validation on server
This commit is contained in:
Luca Chang 2026-02-03 16:17:36 -08:00 • committed by GitHub
commit df6c3f0665
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 539 additions and 2 deletions

View file

@ -2,7 +2,7 @@ use std::sync::Arc;
use crate::{
error::ErrorData as McpError,
model::*,
model::{TaskSupport, *},
service::{NotificationContext, RequestContext, RoleServer, Service, ServiceRole},
};
@ -65,7 +65,32 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.await
.map(ServerResult::empty),
ClientRequest::CallToolRequest(request) => {
if request.params.task.is_some() {
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
@ -241,6 +266,13 @@ pub trait ServerHandler: Sized + Send + Sync + 'static {
) -> impl Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
std::future::ready(Ok(ListToolsResult::default()))
}
/// Get a tool definition by name.
///
/// The default implementation returns `None`, which bypasses validation.
/// When using `#[tool_handler]`, this method is automatically implemented.
fn get_tool(&self, _name: &str) -> Option<Tool> {
None
}
fn on_custom_request(
&self,
request: CustomRequest,
@ -445,6 +477,10 @@ macro_rules! impl_server_handler_for_wrapper {
(**self).list_tools(request, context)
}
fn get_tool(&self, name: &str) -> Option<Tool> {
(**self).get_tool(name)
}
fn on_custom_request(
&self,
request: CustomRequest,

View file

@ -254,6 +254,13 @@ where
pub fn list_all(&self) -> Vec<crate::model::Tool> {
self.map.values().map(|item| item.attr.clone()).collect()
}
/// Get a tool definition by name.
///
/// Returns the tool if found, or `None` if no tool with the given name exists.
pub fn get(&self, name: &str) -> Option<&crate::model::Tool> {
self.map.get(name).map(|r| &r.attr)
}
}
impl<S> std::ops::Add<ToolRouter<S>> for ToolRouter<S>

View file

@ -29,6 +29,9 @@ pub struct Tool {
#[serde(skip_serializing_if = "Option::is_none")]
/// Optional additional tool information.
pub annotations: Option<ToolAnnotations>,
/// Execution-related configuration including task support mode.
#[serde(skip_serializing_if = "Option::is_none")]
pub execution: Option<ToolExecution>,
/// Optional list of icons for the tool
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
@ -37,6 +40,55 @@ pub struct Tool {
pub meta: Option<Meta>,
}
/// Per-tool task support mode as defined in the MCP specification.
///
/// This enum indicates whether a tool supports task-based invocation,
/// allowing clients to know how to properly call the tool.
///
/// See [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum TaskSupport {
/// Clients MUST NOT invoke this tool as a task (default behavior).
#[default]
Forbidden,
/// Clients MAY invoke this tool as either a task or a normal call.
Optional,
/// Clients MUST invoke this tool as a task.
Required,
}
/// Execution-related configuration for a tool.
///
/// This struct contains settings that control how a tool should be executed,
/// including task support configuration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ToolExecution {
/// Indicates whether this tool supports task-based invocation.
///
/// When not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.
/// When set to `Optional`, clients MAY invoke this tool as a task or normal call.
/// When set to `Required`, clients MUST invoke this tool as a task.
#[serde(skip_serializing_if = "Option::is_none")]
pub task_support: Option<TaskSupport>,
}
impl ToolExecution {
/// Create a new empty ToolExecution configuration.
pub fn new() -> Self {
Self::default()
}
/// Set the task support mode.
pub fn with_task_support(mut self, task_support: TaskSupport) -> Self {
self.task_support = Some(task_support);
self
}
}
/// Additional properties describing a Tool to clients.
///
/// NOTE: all properties in ToolAnnotations are **hints**.
@ -152,6 +204,7 @@ impl Tool {
input_schema: input_schema.into(),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
}
@ -164,6 +217,24 @@ impl Tool {
}
}
/// Set the execution configuration for this tool.
pub fn with_execution(self, execution: ToolExecution) -> Self {
Tool {
execution: Some(execution),
..self
}
}
/// Returns the task support mode for this tool.
///
/// Returns `TaskSupport::Forbidden` if not explicitly set.
pub fn task_support(&self) -> TaskSupport {
self.execution
.as_ref()
.and_then(|e| e.task_support)
.unwrap_or_default()
}
/// Set the output schema using a type that implements JsonSchema
///
/// # Panics