Skip to content

Commit cd744e5

Browse files
committed
add task helpers to context
1 parent bedc297 commit cd744e5

File tree

6 files changed

+299
-6
lines changed

6 files changed

+299
-6
lines changed

examples/clients/simple-task-client/mcp_simple_task_client/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ async def run(url: str) -> None:
2727

2828
# Call the tool as a task
2929
print("\nCalling tool as a task...")
30+
31+
# TODO: make helper for this
3032
result = await session.send_request(
3133
ClientRequest(
3234
CallToolRequest(

examples/servers/simple-task/mcp_simple_task/server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ async def list_tools() -> list[types.Tool]:
4040
name="long_running_task",
4141
description="A task that takes a few seconds to complete with status updates",
4242
inputSchema={"type": "object", "properties": {}},
43+
execution=types.ToolExecution(task="always"),
4344
)
4445
]
4546

@@ -49,8 +50,8 @@ async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.T
4950
ctx = server.request_context
5051
app = ctx.lifespan_context
5152

52-
if not ctx.experimental.is_task:
53-
return [types.TextContent(type="text", text="Error: This tool must be called as a task")]
53+
# Validate task mode - raises McpError(-32601) if client didn't use task augmentation
54+
ctx.experimental.validate_task_mode("always")
5455

5556
# Create the task
5657
metadata = ctx.experimental.task_metadata

src/mcp/server/lowlevel/server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -713,13 +713,17 @@ async def _handle_request(
713713

714714
# Set our global state that can be retrieved via
715715
# app.get_request_context()
716+
client_capabilities = session.client_params.capabilities if session.client_params else None
716717
token = request_ctx.set(
717718
RequestContext(
718719
message.request_id,
719720
message.request_meta,
720721
session,
721722
lifespan_context,
722-
Experimental(task_metadata=message.request_params.task if message.request_params else None),
723+
Experimental(
724+
task_metadata=message.request_params.task if message.request_params else None,
725+
_client_capabilities=client_capabilities,
726+
),
723727
request=request_data,
724728
)
725729
)

src/mcp/shared/context.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,18 @@
33

44
from typing_extensions import TypeVar
55

6+
from mcp import McpError
67
from mcp.shared.session import BaseSession
7-
from mcp.types import RequestId, RequestParams, TaskMetadata
8+
from mcp.types import (
9+
METHOD_NOT_FOUND,
10+
ClientCapabilities,
11+
ErrorData,
12+
RequestId,
13+
RequestParams,
14+
TaskExecutionMode,
15+
TaskMetadata,
16+
Tool,
17+
)
818

919
SessionT = TypeVar("SessionT", bound=BaseSession[Any, Any, Any, Any, Any])
1020
LifespanContextT = TypeVar("LifespanContextT")
@@ -13,12 +23,111 @@
1323

1424
@dataclass
1525
class Experimental:
26+
"""
27+
Experimental features context for task-augmented requests.
28+
29+
Provides helpers for validating task execution compatibility.
30+
"""
31+
1632
task_metadata: TaskMetadata | None = None
33+
_client_capabilities: ClientCapabilities | None = field(default=None, repr=False)
1734

1835
@property
1936
def is_task(self) -> bool:
37+
"""Check if this request is task-augmented."""
2038
return self.task_metadata is not None
2139

40+
@property
41+
def client_supports_tasks(self) -> bool:
42+
"""Check if the client declared task support."""
43+
if self._client_capabilities is None:
44+
return False
45+
return self._client_capabilities.tasks is not None
46+
47+
def validate_task_mode(
48+
self,
49+
tool_task_mode: TaskExecutionMode | None,
50+
*,
51+
raise_error: bool = True,
52+
) -> ErrorData | None:
53+
"""
54+
Validate that the request is compatible with the tool's task execution mode.
55+
56+
Per MCP spec:
57+
- "always": Clients MUST invoke as task. Server returns -32601 if not.
58+
- "never" (or None): Clients MUST NOT invoke as task. Server returns -32601 if they do.
59+
- "optional": Either is acceptable.
60+
61+
Args:
62+
tool_task_mode: The tool's execution.task value ("never", "optional", "always", or None)
63+
raise_error: If True, raises McpError on validation failure. If False, returns ErrorData.
64+
65+
Returns:
66+
None if valid, ErrorData if invalid and raise_error=False
67+
68+
Raises:
69+
McpError: If invalid and raise_error=True
70+
"""
71+
72+
mode = tool_task_mode or "never"
73+
74+
error: ErrorData | None = None
75+
76+
if mode == "always" and not self.is_task:
77+
error = ErrorData(
78+
code=METHOD_NOT_FOUND,
79+
message="This tool requires task-augmented invocation",
80+
)
81+
elif mode == "never" and self.is_task:
82+
error = ErrorData(
83+
code=METHOD_NOT_FOUND,
84+
message="This tool does not support task-augmented invocation",
85+
)
86+
87+
if error is not None and raise_error:
88+
raise McpError(error)
89+
90+
return error
91+
92+
def validate_for_tool(
93+
self,
94+
tool: Tool,
95+
*,
96+
raise_error: bool = True,
97+
) -> ErrorData | None:
98+
"""
99+
Validate that the request is compatible with the given tool.
100+
101+
Convenience wrapper around validate_task_mode that extracts the mode from a Tool.
102+
103+
Args:
104+
tool: The Tool definition
105+
raise_error: If True, raises McpError on validation failure.
106+
107+
Returns:
108+
None if valid, ErrorData if invalid and raise_error=False
109+
"""
110+
mode = tool.execution.task if tool.execution else None
111+
return self.validate_task_mode(mode, raise_error=raise_error)
112+
113+
def can_use_tool(self, tool_task_mode: TaskExecutionMode | None) -> bool:
114+
"""
115+
Check if this client can use a tool with the given task mode.
116+
117+
Useful for filtering tool lists or providing warnings.
118+
Returns False if tool requires "always" but client doesn't support tasks.
119+
120+
Args:
121+
tool_task_mode: The tool's execution.task value
122+
123+
Returns:
124+
True if the client can use this tool, False otherwise
125+
"""
126+
mode = tool_task_mode or "never"
127+
if mode == "always" and not self.client_supports_tasks:
128+
return False
129+
return True
130+
22131

23132
@dataclass
24133
class RequestContext(Generic[SessionT, LifespanContextT, RequestT]):

src/mcp/types.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
Role = Literal["user", "assistant"]
4040
RequestId = Annotated[int, Field(strict=True)] | str
4141
AnyFunction: TypeAlias = Callable[..., Any]
42-
TaskHint = Literal["never", "optional", "always"]
42+
TaskExecutionMode = Literal["never", "optional", "always"]
4343

4444

4545
class TaskMetadata(BaseModel):
@@ -1126,7 +1126,7 @@ class ToolExecution(BaseModel):
11261126

11271127
model_config = ConfigDict(extra="allow")
11281128

1129-
task: Literal["never", "optional", "always"] | None = None
1129+
task: TaskExecutionMode | None = None
11301130
"""
11311131
Indicates whether this tool supports task-augmented execution.
11321132
This allows clients to handle long-running operations through polling

tests/shared/test_context.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Tests for the RequestContext and Experimental classes."""
2+
3+
import pytest
4+
5+
from mcp.shared.context import Experimental
6+
from mcp.shared.exceptions import McpError
7+
from mcp.types import (
8+
METHOD_NOT_FOUND,
9+
ClientCapabilities,
10+
ClientTasksCapability,
11+
TaskMetadata,
12+
Tool,
13+
ToolExecution,
14+
)
15+
16+
# --- Experimental.is_task ---
17+
18+
19+
def test_is_task_true_when_metadata_present() -> None:
20+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
21+
assert exp.is_task is True
22+
23+
24+
def test_is_task_false_when_no_metadata() -> None:
25+
exp = Experimental(task_metadata=None)
26+
assert exp.is_task is False
27+
28+
29+
# --- Experimental.client_supports_tasks ---
30+
31+
32+
def test_client_supports_tasks_true() -> None:
33+
exp = Experimental(_client_capabilities=ClientCapabilities(tasks=ClientTasksCapability()))
34+
assert exp.client_supports_tasks is True
35+
36+
37+
def test_client_supports_tasks_false_no_tasks() -> None:
38+
exp = Experimental(_client_capabilities=ClientCapabilities())
39+
assert exp.client_supports_tasks is False
40+
41+
42+
def test_client_supports_tasks_false_no_capabilities() -> None:
43+
exp = Experimental(_client_capabilities=None)
44+
assert exp.client_supports_tasks is False
45+
46+
47+
# --- Experimental.validate_task_mode ---
48+
49+
50+
def test_validate_task_mode_always_with_task_is_valid() -> None:
51+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
52+
error = exp.validate_task_mode("always", raise_error=False)
53+
assert error is None
54+
55+
56+
def test_validate_task_mode_always_without_task_returns_error() -> None:
57+
exp = Experimental(task_metadata=None)
58+
error = exp.validate_task_mode("always", raise_error=False)
59+
assert error is not None
60+
assert error.code == METHOD_NOT_FOUND
61+
assert "requires task-augmented" in error.message
62+
63+
64+
def test_validate_task_mode_always_without_task_raises_by_default() -> None:
65+
exp = Experimental(task_metadata=None)
66+
with pytest.raises(McpError) as exc_info:
67+
exp.validate_task_mode("always")
68+
assert exc_info.value.error.code == METHOD_NOT_FOUND
69+
70+
71+
def test_validate_task_mode_never_without_task_is_valid() -> None:
72+
exp = Experimental(task_metadata=None)
73+
error = exp.validate_task_mode("never", raise_error=False)
74+
assert error is None
75+
76+
77+
def test_validate_task_mode_never_with_task_returns_error() -> None:
78+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
79+
error = exp.validate_task_mode("never", raise_error=False)
80+
assert error is not None
81+
assert error.code == METHOD_NOT_FOUND
82+
assert "does not support task-augmented" in error.message
83+
84+
85+
def test_validate_task_mode_never_with_task_raises_by_default() -> None:
86+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
87+
with pytest.raises(McpError) as exc_info:
88+
exp.validate_task_mode("never")
89+
assert exc_info.value.error.code == METHOD_NOT_FOUND
90+
91+
92+
def test_validate_task_mode_none_treated_as_never() -> None:
93+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
94+
error = exp.validate_task_mode(None, raise_error=False)
95+
assert error is not None
96+
assert "does not support task-augmented" in error.message
97+
98+
99+
def test_validate_task_mode_optional_with_task_is_valid() -> None:
100+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
101+
error = exp.validate_task_mode("optional", raise_error=False)
102+
assert error is None
103+
104+
105+
def test_validate_task_mode_optional_without_task_is_valid() -> None:
106+
exp = Experimental(task_metadata=None)
107+
error = exp.validate_task_mode("optional", raise_error=False)
108+
assert error is None
109+
110+
111+
# --- Experimental.validate_for_tool ---
112+
113+
114+
def test_validate_for_tool_with_execution_always() -> None:
115+
exp = Experimental(task_metadata=None)
116+
tool = Tool(
117+
name="test",
118+
description="test",
119+
inputSchema={"type": "object"},
120+
execution=ToolExecution(task="always"),
121+
)
122+
error = exp.validate_for_tool(tool, raise_error=False)
123+
assert error is not None
124+
assert "requires task-augmented" in error.message
125+
126+
127+
def test_validate_for_tool_without_execution() -> None:
128+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
129+
tool = Tool(
130+
name="test",
131+
description="test",
132+
inputSchema={"type": "object"},
133+
execution=None,
134+
)
135+
error = exp.validate_for_tool(tool, raise_error=False)
136+
assert error is not None
137+
assert "does not support task-augmented" in error.message
138+
139+
140+
def test_validate_for_tool_optional_with_task() -> None:
141+
exp = Experimental(task_metadata=TaskMetadata(ttl=60000))
142+
tool = Tool(
143+
name="test",
144+
description="test",
145+
inputSchema={"type": "object"},
146+
execution=ToolExecution(task="optional"),
147+
)
148+
error = exp.validate_for_tool(tool, raise_error=False)
149+
assert error is None
150+
151+
152+
# --- Experimental.can_use_tool ---
153+
154+
155+
def test_can_use_tool_always_with_task_support() -> None:
156+
exp = Experimental(_client_capabilities=ClientCapabilities(tasks=ClientTasksCapability()))
157+
assert exp.can_use_tool("always") is True
158+
159+
160+
def test_can_use_tool_always_without_task_support() -> None:
161+
exp = Experimental(_client_capabilities=ClientCapabilities())
162+
assert exp.can_use_tool("always") is False
163+
164+
165+
def test_can_use_tool_optional_without_task_support() -> None:
166+
exp = Experimental(_client_capabilities=ClientCapabilities())
167+
assert exp.can_use_tool("optional") is True
168+
169+
170+
def test_can_use_tool_never_without_task_support() -> None:
171+
exp = Experimental(_client_capabilities=ClientCapabilities())
172+
assert exp.can_use_tool("never") is True
173+
174+
175+
def test_can_use_tool_none_without_task_support() -> None:
176+
exp = Experimental(_client_capabilities=ClientCapabilities())
177+
assert exp.can_use_tool(None) is True

0 commit comments

Comments
 (0)