Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions .github/workflows/python-test-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,11 @@ jobs:
# The dependency pin is `mcp<2`, so the unit-test matrix only ever
# exercises the 1.x line and the 2.x branches of `_compat` run against
# mocks there. This job force-installs the 2.x line over the pin to verify
# against the real package that `import strands` succeeds and the MCP
# client suite passes. The task tests are excluded: 2.x moved tasks to the
# SEP-2663 extension and the client still implements the 1.x experimental
# API, so the task workflow is 1.x-only until that lands. Pinned to
# `2.0.*` so upstream 2.x releases cannot break unrelated PRs; bump
# deliberately.
# against the real package that `import strands` succeeds, the MCP client
# suite passes, and SEP-2663 task support works. The 1.x experimental task
# tests are excluded: 2.x moved tasks to the SEP-2663 extension, so that
# workflow is 1.x-only. Pinned exactly so upstream 2.x releases cannot
# break unrelated PRs; bump deliberately.
runs-on: strands-agents_ubuntu-latest_4-core
timeout-minutes: 10
permissions:
Expand Down Expand Up @@ -126,7 +125,7 @@ jobs:
"pytest-timeout>=2.0.0,<3.0.0" \
"pytest-cov>=6.0.0,<8.0.0" \
"moto>=5.1.0,<6.0.0"
pip install --no-cache-dir "mcp==2.0.*"
pip install --no-cache-dir "mcp==2.0.1"

- name: Verify import and version flag
run: python -c "import strands; from strands.tools.mcp import _compat; assert _compat.MCP_V2"
Expand Down
36 changes: 14 additions & 22 deletions strands-py/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,10 @@ class XModel(Model):

## MCP Tasks (Experimental)

The SDK supports MCP task-augmented execution for long-running tools. This feature is experimental and aligns with the MCP specification 2025-11-25.
The SDK supports MCP task-augmented execution for long-running tools. This feature is experimental and subject to change. Which task protocol runs depends on the installed `mcp` line:

### Overview

Task-augmented execution allows tools to run asynchronously with a workflow:
1. Create task via `call_tool_as_task`
2. Poll for completion via `poll_task`
3. Get result via `get_task_result`
- **mcp 2.x**: finalized SEP-2663 Tasks (protocol `2026-07-28`) — `tools/call` returns a direct result or a task handle, followed by `tasks/get` / `tasks/update` / `tasks/cancel`.
- **mcp 1.x** (the runtime pin `mcp<2.0.0`): the legacy 2025-11-25 flow — `call_tool_as_task`, `poll_task`, `get_task_result`.

### Configuration

Expand All @@ -233,38 +229,34 @@ Enable tasks by passing a `TasksConfig` to `MCPClient`:
from datetime import timedelta
from strands.tools.mcp import MCPClient, TasksConfig

# Enable with defaults (ttl=1min, poll_timeout=5min)
# Enable with defaults
client = MCPClient(transport, tasks_config={})

# Or configure explicitly
client = MCPClient(
transport,
tasks_config=TasksConfig(
ttl=timedelta(minutes=2), # Task time-to-live
poll_timeout=timedelta(minutes=10), # Polling timeout
poll_timeout=timedelta(minutes=10), # Overall task deadline (default 5min)
request_timeout=timedelta(minutes=1), # Per lifecycle request (default 1min)
poll_interval=timedelta(seconds=1), # Fallback when the server omits pollIntervalMs
ttl=timedelta(minutes=2), # Legacy 1.x task time-to-live (default 1min)
),
)
```

### Tool Support Levels

MCP tools declare their task support via `execution.taskSupport`:
- `TASK_REQUIRED`: Tool must use task-augmented execution
- `TASK_OPTIONAL`: Tool can use tasks if client opts in
- `TASK_FORBIDDEN`: Tool does not support tasks (default)

### Decision Logic

Task-augmented execution is used when ALL conditions are met:
1. Client opts in via `tasks_config` (not None)
2. Server advertises task capability (`tasks.requests.tools.call`)
3. Tool's `taskSupport` is `required` or `optional`
2. Server advertises task capability (on 1.x, `tasks.requests.tools.call`; on 2.x, the `io.modelcontextprotocol/tasks` extension)
3. On the mcp 1.x line only: tool's `execution.taskSupport` is `required` or `optional` (the finalized extension has no tool-level setting)

### Key Files

- `src/strands/tools/mcp/mcp_tasks.py` - `TasksConfig` and defaults
- `src/strands/tools/mcp/mcp_client.py` - Task execution logic (`_call_tool_as_task_and_poll_async`)
- `tests/strands/tools/mcp/test_mcp_client_tasks.py` - Unit tests
- `src/strands/tools/mcp/mcp_tasks.py` - `TasksConfig`, task result models, and defaults
- `src/strands/tools/mcp/mcp_client.py` - Task execution logic (2.x: `_call_tool_with_task_and_poll_async`; 1.x: `_call_tool_as_task_and_poll_async`) and the public task lifecycle methods (`submit_tool_*`, `get_task_*`, `update_task_*`, `cancel_task_*`)
- `tests/strands/tools/mcp/test_mcp_client_tasks.py` - Unit tests (1.x flow)
- `tests/strands/tools/mcp/test_mcp_client_tasks_v2.py` - Unit tests (2.x flow; runs in the MCP 2.x Compat CI job)
- `tests_integ/mcp/test_mcp_client_tasks.py` - Integration tests
- `tests_integ/mcp/task_echo_server.py` - Test server with task support

Expand Down
28 changes: 27 additions & 1 deletion strands-py/src/strands/tools/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,21 @@

from .mcp_agent_tool import MCPAgentTool
from .mcp_client import MCPClient, MCPServerConfig, ToolFilters
from .mcp_tasks import TasksConfig
from .mcp_tasks import (
MCPCallToolResult,
MCPCancelTaskResult,
MCPCreateTaskResult,
MCPGetTaskResult,
MCPInputRequest,
MCPInputRequests,
MCPInputResponse,
MCPInputResponses,
MCPTask,
MCPTaskError,
MCPTaskStatus,
MCPUpdateTaskResult,
TasksConfig,
)
from .mcp_types import MCPClientCredentials, MCPTransport, ToolsChanged, ToolsChangedCallback

__all__ = [
Expand All @@ -17,6 +31,18 @@
"MCPClientCredentials",
"MCPServerConfig",
"MCPTransport",
"MCPCallToolResult",
"MCPCancelTaskResult",
"MCPCreateTaskResult",
"MCPGetTaskResult",
"MCPInputRequest",
"MCPInputRequests",
"MCPInputResponse",
"MCPInputResponses",
"MCPTask",
"MCPTaskError",
"MCPTaskStatus",
"MCPUpdateTaskResult",
"TasksConfig",
"ToolFilters",
"ToolsChanged",
Expand Down
73 changes: 72 additions & 1 deletion strands-py/src/strands/tools/mcp/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@
"read_resource",
"read_timeout",
"resource_templates",
"server_task_capable",
"streamable_http_transport",
"structured_content",
"task_session_kwargs",
"task_support",
"tools_changed_subscription",
]
Expand Down Expand Up @@ -126,6 +128,8 @@ async def call_tool(
read_timeout_seconds: timedelta | None,
progress_callback: Any,
meta: Any,
*,
allow_claimed: bool = False,
) -> Any:
"""Call a tool on an active session, on either `mcp` major line.

Expand All @@ -139,9 +143,11 @@ async def call_tool(
read_timeout_seconds: Timeout for each request round, if any.
progress_callback: Callback for progress notifications, if any.
meta: Request metadata (`_meta`) to send with the call, if any.
allow_claimed: Return extension-claimed results without resolving them.

Returns:
The terminal `CallToolResult`.
The terminal `CallToolResult`, or an unresolved extension result when
``allow_claimed`` is enabled.

Raises:
MCPError: An embedded input request's callback declined it.
Expand All @@ -153,6 +159,7 @@ async def call_tool(
)

timeout = read_timeout(read_timeout_seconds)
claim_options = {"allow_claimed": True} if allow_claimed else {}

async def call_once(input_responses: Any, request_state: str | None) -> Any:
return await session.call_tool( # type: ignore[call-arg]
Expand All @@ -164,6 +171,7 @@ async def call_once(input_responses: Any, request_state: str | None) -> Any:
input_responses=input_responses,
request_state=request_state,
allow_input_required=True,
**claim_options,
)

return await _drive_input_required(session, call_once)
Expand Down Expand Up @@ -444,6 +452,69 @@ def task_support(tool: Any) -> str | None:
return support


def server_task_capable(capabilities: ServerCapabilities | None) -> bool:
"""Check whether a server supports the installed line's task protocol.

MCP 1.x advertises the legacy task capability under ``tasks``. MCP 2.x
advertises finalized SEP-2663 support through the extension registry.

Args:
capabilities: Capabilities negotiated with the server.

Returns:
Whether the server advertised compatible task support.
"""
if capabilities is None:
return False
if MCP_V2:
from .mcp_tasks import _TASKS_EXTENSION

extensions = getattr(capabilities, "extensions", None)
return extensions is not None and _TASKS_EXTENSION in extensions
return (
capabilities.tasks is not None
and capabilities.tasks.requests is not None
and capabilities.tasks.requests.tools is not None
and capabilities.tasks.requests.tools.call is not None
)


def task_session_kwargs(enabled: bool) -> dict[str, Any]:
"""Build MCP 2.x ``ClientSession`` options for finalized Tasks support.

The extension claim teaches the 2.x result codec to parse ``resultType:
task``. Strands consumes the task handle itself, so the claim resolver is
intentionally unreachable on the low-level ``ClientSession`` path.

Args:
enabled: Whether the caller opted into task support.

Returns:
Additional keyword arguments for ``ClientSession``.
"""
if not enabled or not MCP_V2:
return {}

from mcp.client.extension import ResultClaim # type: ignore[import-not-found]

from .mcp_tasks import _TASKS_EXTENSION, _TASKS_PROTOCOL_VERSION, MCPCreateTaskResult

async def unexpected_resolver(result: MCPCreateTaskResult, context: Any) -> Any:
_ = (result, context)
raise RuntimeError("MCP task claims are resolved by MCPClient")

claim = ResultClaim(
result_type="task",
model=MCPCreateTaskResult,
resolve=unexpected_resolver,
protocol_versions=frozenset({_TASKS_PROTOCOL_VERSION}),
)
return {
"extensions": {_TASKS_EXTENSION: {}},
"result_claims": {_TASKS_EXTENSION: (claim,)},
}


async def negotiate_session(session: ClientSession) -> tuple[str | None, ServerCapabilities | None]:
"""Negotiate the connection on an entered session, on either `mcp` major line.

Expand Down
Loading
Loading