SEP-1686: Tasks (#1645)

This commit is contained in:
Max Isbey 2025-11-28 18:51:58 +00:00 • committed by GitHub
commit c92bb2f7ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 14230 additions and 120 deletions

View file

@ -0,0 +1,43 @@
# Simple Task Client
A minimal MCP client demonstrating polling for task results over streamable HTTP.
## Running
First, start the simple-task server in another terminal:
```bash
cd examples/servers/simple-task
uv run mcp-simple-task
```
Then run the client:
```bash
cd examples/clients/simple-task-client
uv run mcp-simple-task-client
```
Use `--url` to connect to a different server.
## What it does
1. Connects to the server via streamable HTTP
2. Calls the `long_running_task` tool as a task
3. Polls the task status until completion
4. Retrieves and prints the result
## Expected output
```text
Available tools: ['long_running_task']
Calling tool as a task...
Task created: <task-id>
Status: working - Starting work...
Status: working - Processing step 1...
Status: working - Processing step 2...
Status: completed -
Result: Task completed!
```

View file

@ -0,0 +1,5 @@
import sys
from .main import main
sys.exit(main()) # type: ignore[call-arg]

View file

@ -0,0 +1,55 @@
"""Simple task client demonstrating MCP tasks polling over streamable HTTP."""
import asyncio
import click
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import CallToolResult, TextContent
async def run(url: str) -> None:
async with streamablehttp_client(url) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
# List tools
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
# Call the tool as a task
print("\nCalling tool as a task...")
result = await session.experimental.call_tool_as_task(
"long_running_task",
arguments={},
ttl=60000,
)
task_id = result.task.taskId
print(f"Task created: {task_id}")
# Poll until done (respects server's pollInterval hint)
async for status in session.experimental.poll_task(task_id):
print(f" Status: {status.status} - {status.statusMessage or ''}")
# Check final status
if status.status != "completed":
print(f"Task ended with status: {status.status}")
return
# Get the result
task_result = await session.experimental.get_task_result(task_id, CallToolResult)
content = task_result.content[0]
if isinstance(content, TextContent):
print(f"\nResult: {content.text}")
@click.command()
@click.option("--url", default="http://localhost:8000/mcp", help="Server URL")
def main(url: str) -> int:
asyncio.run(run(url))
return 0
if __name__ == "__main__":
main()

View file

@ -0,0 +1,43 @@
[project]
name = "mcp-simple-task-client"
version = "0.1.0"
description = "A simple MCP client demonstrating task polling"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "llm", "tasks", "client"]
license = { text = "MIT" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
dependencies = ["click>=8.0", "mcp"]
[project.scripts]
mcp-simple-task-client = "mcp_simple_task_client.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_task_client"]
[tool.pyright]
include = ["mcp_simple_task_client"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[dependency-groups]
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]

View file

@ -0,0 +1,87 @@
# Simple Interactive Task Client
A minimal MCP client demonstrating responses to interactive tasks (elicitation and sampling).
## Running
First, start the interactive task server in another terminal:
```bash
cd examples/servers/simple-task-interactive
uv run mcp-simple-task-interactive
```
Then run the client:
```bash
cd examples/clients/simple-task-interactive-client
uv run mcp-simple-task-interactive-client
```
Use `--url` to connect to a different server.
## What it does
1. Connects to the server via streamable HTTP
2. Calls `confirm_delete` - server asks for confirmation, client responds via terminal
3. Calls `write_haiku` - server requests LLM completion, client returns a hardcoded haiku
## Key concepts
### Elicitation callback
```python
async def elicitation_callback(context, params) -> ElicitResult:
# Handle user input request from server
return ElicitResult(action="accept", content={"confirm": True})
```
### Sampling callback
```python
async def sampling_callback(context, params) -> CreateMessageResult:
# Handle LLM completion request from server
return CreateMessageResult(model="...", role="assistant", content=...)
```
### Using call_tool_as_task
```python
# Call a tool as a task (returns immediately with task reference)
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})
task_id = result.task.taskId
# Get result - this delivers elicitation/sampling requests and blocks until complete
final = await session.experimental.get_task_result(task_id, CallToolResult)
```
**Important**: The `get_task_result()` call is what triggers the delivery of elicitation
and sampling requests to your callbacks. It blocks until the task completes and returns
the final result.
## Expected output
```text
Available tools: ['confirm_delete', 'write_haiku']
--- Demo 1: Elicitation ---
Calling confirm_delete tool...
Task created: <task-id>
[Elicitation] Server asks: Are you sure you want to delete 'important.txt'?
Your response (y/n): y
[Elicitation] Responding with: confirm=True
Result: Deleted 'important.txt'
--- Demo 2: Sampling ---
Calling write_haiku tool...
Task created: <task-id>
[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves
[Sampling] Responding with haiku
Result:
Haiku:
Cherry blossoms fall
Softly on the quiet pond
Spring whispers goodbye
```

View file

@ -0,0 +1,5 @@
import sys
from .main import main
sys.exit(main()) # type: ignore[call-arg]

View file

@ -0,0 +1,138 @@
"""Simple interactive task client demonstrating elicitation and sampling responses.
This example demonstrates the spec-compliant polling pattern:
1. Poll tasks/get watching for status changes
2. On input_required, call tasks/result to receive elicitation/sampling requests
3. Continue until terminal status, then retrieve final result
"""
import asyncio
from typing import Any
import click
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.context import RequestContext
from mcp.types import (
CallToolResult,
CreateMessageRequestParams,
CreateMessageResult,
ElicitRequestParams,
ElicitResult,
TextContent,
)
async def elicitation_callback(
context: RequestContext[ClientSession, Any],
params: ElicitRequestParams,
) -> ElicitResult:
"""Handle elicitation requests from the server."""
print(f"\n[Elicitation] Server asks: {params.message}")
# Simple terminal prompt
response = input("Your response (y/n): ").strip().lower()
confirmed = response in ("y", "yes", "true", "1")
print(f"[Elicitation] Responding with: confirm={confirmed}")
return ElicitResult(action="accept", content={"confirm": confirmed})
async def sampling_callback(
context: RequestContext[ClientSession, Any],
params: CreateMessageRequestParams,
) -> CreateMessageResult:
"""Handle sampling requests from the server."""
# Get the prompt from the first message
prompt = "unknown"
if params.messages:
content = params.messages[0].content
if isinstance(content, TextContent):
prompt = content.text
print(f"\n[Sampling] Server requests LLM completion for: {prompt}")
# Return a hardcoded haiku (in real use, call your LLM here)
haiku = """Cherry blossoms fall
Softly on the quiet pond
Spring whispers goodbye"""
print("[Sampling] Responding with haiku")
return CreateMessageResult(
model="mock-haiku-model",
role="assistant",
content=TextContent(type="text", text=haiku),
)
def get_text(result: CallToolResult) -> str:
"""Extract text from a CallToolResult."""
if result.content and isinstance(result.content[0], TextContent):
return result.content[0].text
return "(no text)"
async def run(url: str) -> None:
async with streamablehttp_client(url) as (read, write, _):
async with ClientSession(
read,
write,
elicitation_callback=elicitation_callback,
sampling_callback=sampling_callback,
) as session:
await session.initialize()
# List tools
tools = await session.list_tools()
print(f"Available tools: {[t.name for t in tools.tools]}")
# Demo 1: Elicitation (confirm_delete)
print("\n--- Demo 1: Elicitation ---")
print("Calling confirm_delete tool...")
elicit_task = await session.experimental.call_tool_as_task("confirm_delete", {"filename": "important.txt"})
elicit_task_id = elicit_task.task.taskId
print(f"Task created: {elicit_task_id}")
# Poll until terminal, calling tasks/result on input_required
async for status in session.experimental.poll_task(elicit_task_id):
print(f"[Poll] Status: {status.status}")
if status.status == "input_required":
# Server needs input - tasks/result delivers the elicitation request
elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult)
break
else:
# poll_task exited due to terminal status
elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult)
print(f"Result: {get_text(elicit_result)}")
# Demo 2: Sampling (write_haiku)
print("\n--- Demo 2: Sampling ---")
print("Calling write_haiku tool...")
sampling_task = await session.experimental.call_tool_as_task("write_haiku", {"topic": "autumn leaves"})
sampling_task_id = sampling_task.task.taskId
print(f"Task created: {sampling_task_id}")
# Poll until terminal, calling tasks/result on input_required
async for status in session.experimental.poll_task(sampling_task_id):
print(f"[Poll] Status: {status.status}")
if status.status == "input_required":
sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult)
break
else:
sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult)
print(f"Result:\n{get_text(sampling_result)}")
@click.command()
@click.option("--url", default="http://localhost:8000/mcp", help="Server URL")
def main(url: str) -> int:
asyncio.run(run(url))
return 0
if __name__ == "__main__":
main()

View file

@ -0,0 +1,43 @@
[project]
name = "mcp-simple-task-interactive-client"
version = "0.1.0"
description = "A simple MCP client demonstrating interactive task responses"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "llm", "tasks", "client", "elicitation", "sampling"]
license = { text = "MIT" }
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
]
dependencies = ["click>=8.0", "mcp"]
[project.scripts]
mcp-simple-task-interactive-client = "mcp_simple_task_interactive_client.main:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_task_interactive_client"]
[tool.pyright]
include = ["mcp_simple_task_interactive_client"]
venvPath = "."
venv = ".venv"
[tool.ruff.lint]
select = ["E", "F", "I"]
ignore = []
[tool.ruff]
line-length = 120
target-version = "py310"
[dependency-groups]
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]