Skip to content

Commit df6c3f0

Browse files
fix(tasks): expose execution.taskSupport on tools (#635)
* fix(tasks): expose execution.taskSupport on tools * feat: implement taskSupport validation on server
1 parent 1794fe1 commit df6c3f0

10 files changed

Lines changed: 539 additions & 2 deletions

File tree

crates/rmcp-macros/src/tool.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,29 @@ pub struct ToolAttribute {
8989
pub output_schema: Option<Expr>,
9090
/// Optional additional tool information.
9191
pub annotations: Option<ToolAnnotationsAttribute>,
92+
/// Execution-related configuration including task support.
93+
pub execution: Option<ToolExecutionAttribute>,
9294
/// Optional icons for the tool
9395
pub icons: Option<Expr>,
9496
/// Optional metadata for the tool
9597
pub meta: Option<Expr>,
9698
}
9799

100+
#[derive(FromMeta, Debug, Default)]
101+
#[darling(default)]
102+
pub struct ToolExecutionAttribute {
103+
/// Task support mode: "forbidden", "optional", or "required"
104+
pub task_support: Option<String>,
105+
}
106+
98107
pub struct ResolvedToolAttribute {
99108
pub name: String,
100109
pub title: Option<String>,
101110
pub description: Option<Expr>,
102111
pub input_schema: Expr,
103112
pub output_schema: Option<Expr>,
104113
pub annotations: Expr,
114+
pub execution: Expr,
105115
pub icons: Option<Expr>,
106116
pub meta: Option<Expr>,
107117
}
@@ -115,6 +125,7 @@ impl ResolvedToolAttribute {
115125
input_schema,
116126
output_schema,
117127
annotations,
128+
execution,
118129
icons,
119130
meta,
120131
} = self;
@@ -155,6 +166,7 @@ impl ResolvedToolAttribute {
155166
input_schema: #input_schema,
156167
output_schema: #output_schema,
157168
annotations: #annotations,
169+
execution: #execution,
158170
icons: #icons,
159171
meta: #meta,
160172
}
@@ -263,6 +275,38 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
263275
} else {
264276
none_expr()?
265277
};
278+
let execution_expr = if let Some(execution) = attribute.execution {
279+
let ToolExecutionAttribute { task_support } = execution;
280+
281+
let task_support_expr = if let Some(ts) = task_support {
282+
let ts_ident = match ts.as_str() {
283+
"forbidden" => quote! { rmcp::model::TaskSupport::Forbidden },
284+
"optional" => quote! { rmcp::model::TaskSupport::Optional },
285+
"required" => quote! { rmcp::model::TaskSupport::Required },
286+
_ => {
287+
return Err(syn::Error::new(
288+
Span::call_site(),
289+
format!(
290+
"Invalid task_support value '{}'. Expected 'forbidden', 'optional', or 'required'",
291+
ts
292+
),
293+
));
294+
}
295+
};
296+
quote! { Some(#ts_ident) }
297+
} else {
298+
quote! { None }
299+
};
300+
301+
let token_stream = quote! {
302+
Some(rmcp::model::ToolExecution {
303+
task_support: #task_support_expr,
304+
})
305+
};
306+
syn::parse2::<Expr>(token_stream)?
307+
} else {
308+
none_expr()?
309+
};
266310
// Handle output_schema - either explicit or generated from return type
267311
let output_schema_expr = attribute.output_schema.or_else(|| {
268312
// Try to generate schema from return type
@@ -286,6 +330,7 @@ pub fn tool(attr: TokenStream, input: TokenStream) -> syn::Result<TokenStream> {
286330
input_schema: input_schema_expr,
287331
output_schema: output_schema_expr,
288332
annotations: annotations_expr,
333+
execution: execution_expr,
289334
title: attribute.title,
290335
icons: attribute.icons,
291336
meta: attribute.meta,

crates/rmcp-macros/src/tool_handler.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,18 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
5656
})
5757
}
5858
};
59+
60+
let get_tool_fn = quote! {
61+
fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> {
62+
#router.get(name).cloned()
63+
}
64+
};
65+
5966
let tool_call_fn = syn::parse2::<ImplItem>(tool_call_fn)?;
6067
let tool_list_fn = syn::parse2::<ImplItem>(tool_list_fn)?;
68+
let get_tool_fn = syn::parse2::<ImplItem>(get_tool_fn)?;
6169
item_impl.items.push(tool_call_fn);
6270
item_impl.items.push(tool_list_fn);
71+
item_impl.items.push(get_tool_fn);
6372
Ok(item_impl.into_token_stream())
6473
}

crates/rmcp/src/handler/server.rs

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::sync::Arc;
22

33
use crate::{
44
error::ErrorData as McpError,
5-
model::*,
5+
model::{TaskSupport, *},
66
service::{NotificationContext, RequestContext, RoleServer, Service, ServiceRole},
77
};
88

@@ -65,7 +65,32 @@ impl<H: ServerHandler> Service<RoleServer> for H {
6565
.await
6666
.map(ServerResult::empty),
6767
ClientRequest::CallToolRequest(request) => {
68-
if request.params.task.is_some() {
68+
let is_task = request.params.task.is_some();
69+
70+
// Validate task support mode per MCP specification
71+
if let Some(tool) = self.get_tool(&request.params.name) {
72+
match (tool.task_support(), is_task) {
73+
// If taskSupport is "required", clients MUST invoke the tool as a task.
74+
// Servers MUST return a -32601 (Method not found) error if they don't.
75+
(TaskSupport::Required, false) => {
76+
return Err(McpError::new(
77+
ErrorCode::METHOD_NOT_FOUND,
78+
"Tool requires task-based invocation",
79+
None,
80+
));
81+
}
82+
// If taskSupport is "forbidden" (default), clients MUST NOT invoke as a task.
83+
(TaskSupport::Forbidden, true) => {
84+
return Err(McpError::invalid_params(
85+
"Tool does not support task-based invocation",
86+
None,
87+
));
88+
}
89+
_ => {}
90+
}
91+
}
92+
93+
if is_task {
6994
tracing::info!("Enqueueing task for tool call: {}", request.params.name);
7095
self.enqueue_task(request.params, context.clone())
7196
.await
@@ -241,6 +266,13 @@ pub trait ServerHandler: Sized + Send + Sync + 'static {
241266
) -> impl Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
242267
std::future::ready(Ok(ListToolsResult::default()))
243268
}
269+
/// Get a tool definition by name.
270+
///
271+
/// The default implementation returns `None`, which bypasses validation.
272+
/// When using `#[tool_handler]`, this method is automatically implemented.
273+
fn get_tool(&self, _name: &str) -> Option<Tool> {
274+
None
275+
}
244276
fn on_custom_request(
245277
&self,
246278
request: CustomRequest,
@@ -445,6 +477,10 @@ macro_rules! impl_server_handler_for_wrapper {
445477
(**self).list_tools(request, context)
446478
}
447479

480+
fn get_tool(&self, name: &str) -> Option<Tool> {
481+
(**self).get_tool(name)
482+
}
483+
448484
fn on_custom_request(
449485
&self,
450486
request: CustomRequest,

crates/rmcp/src/handler/server/router/tool.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,13 @@ where
254254
pub fn list_all(&self) -> Vec<crate::model::Tool> {
255255
self.map.values().map(|item| item.attr.clone()).collect()
256256
}
257+
258+
/// Get a tool definition by name.
259+
///
260+
/// Returns the tool if found, or `None` if no tool with the given name exists.
261+
pub fn get(&self, name: &str) -> Option<&crate::model::Tool> {
262+
self.map.get(name).map(|r| &r.attr)
263+
}
257264
}
258265

259266
impl<S> std::ops::Add<ToolRouter<S>> for ToolRouter<S>

crates/rmcp/src/model/tool.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ pub struct Tool {
2929
#[serde(skip_serializing_if = "Option::is_none")]
3030
/// Optional additional tool information.
3131
pub annotations: Option<ToolAnnotations>,
32+
/// Execution-related configuration including task support mode.
33+
#[serde(skip_serializing_if = "Option::is_none")]
34+
pub execution: Option<ToolExecution>,
3235
/// Optional list of icons for the tool
3336
#[serde(skip_serializing_if = "Option::is_none")]
3437
pub icons: Option<Vec<Icon>>,
@@ -37,6 +40,55 @@ pub struct Tool {
3740
pub meta: Option<Meta>,
3841
}
3942

43+
/// Per-tool task support mode as defined in the MCP specification.
44+
///
45+
/// This enum indicates whether a tool supports task-based invocation,
46+
/// allowing clients to know how to properly call the tool.
47+
///
48+
/// See [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).
49+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
50+
#[serde(rename_all = "lowercase")]
51+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
52+
pub enum TaskSupport {
53+
/// Clients MUST NOT invoke this tool as a task (default behavior).
54+
#[default]
55+
Forbidden,
56+
/// Clients MAY invoke this tool as either a task or a normal call.
57+
Optional,
58+
/// Clients MUST invoke this tool as a task.
59+
Required,
60+
}
61+
62+
/// Execution-related configuration for a tool.
63+
///
64+
/// This struct contains settings that control how a tool should be executed,
65+
/// including task support configuration.
66+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
67+
#[serde(rename_all = "camelCase")]
68+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
69+
pub struct ToolExecution {
70+
/// Indicates whether this tool supports task-based invocation.
71+
///
72+
/// When not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.
73+
/// When set to `Optional`, clients MAY invoke this tool as a task or normal call.
74+
/// When set to `Required`, clients MUST invoke this tool as a task.
75+
#[serde(skip_serializing_if = "Option::is_none")]
76+
pub task_support: Option<TaskSupport>,
77+
}
78+
79+
impl ToolExecution {
80+
/// Create a new empty ToolExecution configuration.
81+
pub fn new() -> Self {
82+
Self::default()
83+
}
84+
85+
/// Set the task support mode.
86+
pub fn with_task_support(mut self, task_support: TaskSupport) -> Self {
87+
self.task_support = Some(task_support);
88+
self
89+
}
90+
}
91+
4092
/// Additional properties describing a Tool to clients.
4193
///
4294
/// NOTE: all properties in ToolAnnotations are **hints**.
@@ -152,6 +204,7 @@ impl Tool {
152204
input_schema: input_schema.into(),
153205
output_schema: None,
154206
annotations: None,
207+
execution: None,
155208
icons: None,
156209
meta: None,
157210
}
@@ -164,6 +217,24 @@ impl Tool {
164217
}
165218
}
166219

220+
/// Set the execution configuration for this tool.
221+
pub fn with_execution(self, execution: ToolExecution) -> Self {
222+
Tool {
223+
execution: Some(execution),
224+
..self
225+
}
226+
}
227+
228+
/// Returns the task support mode for this tool.
229+
///
230+
/// Returns `TaskSupport::Forbidden` if not explicitly set.
231+
pub fn task_support(&self) -> TaskSupport {
232+
self.execution
233+
.as_ref()
234+
.and_then(|e| e.task_support)
235+
.unwrap_or_default()
236+
}
237+
167238
/// Set the output schema using a type that implements JsonSchema
168239
///
169240
/// # Panics

crates/rmcp/tests/test_deserialization/tool_list_result.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@
1919
],
2020
"additionalProperties": false,
2121
"$schema": "http://json-schema.org/draft-07/schema#"
22+
},
23+
"execution": {
24+
"taskSupport": "optional"
2225
}
2326
}
2427
]

crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2731,6 +2731,26 @@
27312731
}
27322732
]
27332733
},
2734+
"TaskSupport": {
2735+
"description": "Per-tool task support mode as defined in the MCP specification.\n\nThis enum indicates whether a tool supports task-based invocation,\nallowing clients to know how to properly call the tool.\n\nSee [Tool-Level Negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/tasks#tool-level-negotiation).",
2736+
"oneOf": [
2737+
{
2738+
"description": "Clients MUST NOT invoke this tool as a task (default behavior).",
2739+
"type": "string",
2740+
"const": "forbidden"
2741+
},
2742+
{
2743+
"description": "Clients MAY invoke this tool as either a task or a normal call.",
2744+
"type": "string",
2745+
"const": "optional"
2746+
},
2747+
{
2748+
"description": "Clients MUST invoke this tool as a task.",
2749+
"type": "string",
2750+
"const": "required"
2751+
}
2752+
]
2753+
},
27342754
"TasksCapability": {
27352755
"description": "Task capabilities shared by client and server.",
27362756
"type": "object",
@@ -2896,6 +2916,17 @@
28962916
"null"
28972917
]
28982918
},
2919+
"execution": {
2920+
"description": "Execution-related configuration including task support mode.",
2921+
"anyOf": [
2922+
{
2923+
"$ref": "#/definitions/ToolExecution"
2924+
},
2925+
{
2926+
"type": "null"
2927+
}
2928+
]
2929+
},
28992930
"icons": {
29002931
"description": "Optional list of icons for the tool",
29012932
"type": [
@@ -2977,6 +3008,23 @@
29773008
}
29783009
}
29793010
},
3011+
"ToolExecution": {
3012+
"description": "Execution-related configuration for a tool.\n\nThis struct contains settings that control how a tool should be executed,\nincluding task support configuration.",
3013+
"type": "object",
3014+
"properties": {
3015+
"taskSupport": {
3016+
"description": "Indicates whether this tool supports task-based invocation.\n\nWhen not present or set to `Forbidden`, clients MUST NOT invoke this tool as a task.\nWhen set to `Optional`, clients MAY invoke this tool as a task or normal call.\nWhen set to `Required`, clients MUST invoke this tool as a task.",
3017+
"anyOf": [
3018+
{
3019+
"$ref": "#/definitions/TaskSupport"
3020+
},
3021+
{
3022+
"type": "null"
3023+
}
3024+
]
3025+
}
3026+
}
3027+
},
29803028
"ToolListChangedNotificationMethod": {
29813029
"type": "string",
29823030
"format": "const",

0 commit comments

Comments
 (0)