Skip to content
Draft
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
43 changes: 43 additions & 0 deletions examples/clients/simple-task-client/README.md
Original file line number Diff line number Diff line change
@@ -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!
```
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import sys

from .main import main

sys.exit(main()) # type: ignore[call-arg]
73 changes: 73 additions & 0 deletions examples/clients/simple-task-client/mcp_simple_task_client/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""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 (
CallToolRequest,
CallToolRequestParams,
CallToolResult,
ClientRequest,
CreateTaskResult,
TaskMetadata,
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.send_request(
ClientRequest(
CallToolRequest(
params=CallToolRequestParams(
name="long_running_task",
arguments={},
task=TaskMetadata(ttl=60000),
)
)
),
CreateTaskResult,
)
task_id = result.task.taskId
print(f"Task created: {task_id}")

# Poll until done
while True:
status = await session.experimental.get_task(task_id)
print(f" Status: {status.status} - {status.statusMessage or ''}")

if status.status == "completed":
break
elif status.status in ("failed", "cancelled"):
print(f"Task ended with status: {status.status}")
return

await asyncio.sleep(0.5)

# 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()
43 changes: 43 additions & 0 deletions examples/clients/simple-task-client/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"]
37 changes: 37 additions & 0 deletions examples/servers/simple-task/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Simple Task Server

A minimal MCP server demonstrating the experimental tasks feature over streamable HTTP.

## Running

```bash
cd examples/servers/simple-task
uv run mcp-simple-task
```

The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change.

## What it does

This server exposes a single tool `long_running_task` that:

1. Must be called as a task (with `task` metadata in the request)
2. Takes ~3 seconds to complete
3. Sends status updates during execution
4. Returns a result when complete

## Usage with the client

In one terminal, start the server:

```bash
cd examples/servers/simple-task
uv run mcp-simple-task
```

In another terminal, run the client:

```bash
cd examples/clients/simple-task-client
uv run mcp-simple-task-client
```
Empty file.
5 changes: 5 additions & 0 deletions examples/servers/simple-task/mcp_simple_task/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import sys

from .server import main

sys.exit(main()) # type: ignore[call-arg]
125 changes: 125 additions & 0 deletions examples/servers/simple-task/mcp_simple_task/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
"""Simple task server demonstrating MCP tasks over streamable HTTP."""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any

import anyio
import click
import mcp.types as types
from anyio.abc import TaskGroup
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.shared.experimental.tasks import InMemoryTaskStore, task_execution
from starlette.applications import Starlette
from starlette.routing import Mount


@dataclass
class AppContext:
task_group: TaskGroup
store: InMemoryTaskStore


@asynccontextmanager
async def lifespan(server: Server[AppContext, Any]) -> AsyncIterator[AppContext]:
store = InMemoryTaskStore()
async with anyio.create_task_group() as tg:
yield AppContext(task_group=tg, store=store)
store.cleanup()


server: Server[AppContext, Any] = Server("simple-task-server", lifespan=lifespan)


@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(
name="long_running_task",
description="A task that takes a few seconds to complete with status updates",
inputSchema={"type": "object", "properties": {}},
)
]


@server.call_tool()
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent] | types.CreateTaskResult:
ctx = server.request_context
app = ctx.lifespan_context

if not ctx.experimental.is_task:
return [types.TextContent(type="text", text="Error: This tool must be called as a task")]

# Create the task
metadata = ctx.experimental.task_metadata
assert metadata is not None
task = await app.store.create_task(metadata)

# Spawn background work
async def do_work() -> None:
async with task_execution(task.taskId, app.store) as task_ctx:
await task_ctx.update_status("Starting work...")
await anyio.sleep(1)

await task_ctx.update_status("Processing step 1...")
await anyio.sleep(1)

await task_ctx.update_status("Processing step 2...")
await anyio.sleep(1)

await task_ctx.complete(
types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")])
)

app.task_group.start_soon(do_work)
return types.CreateTaskResult(task=task)


@server.experimental.get_task()
async def handle_get_task(request: types.GetTaskRequest) -> types.GetTaskResult:
app = server.request_context.lifespan_context
task = await app.store.get_task(request.params.taskId)
if task is None:
raise ValueError(f"Task {request.params.taskId} not found")
return types.GetTaskResult(
taskId=task.taskId,
status=task.status,
statusMessage=task.statusMessage,
createdAt=task.createdAt,
ttl=task.ttl,
pollInterval=task.pollInterval,
)


@server.experimental.get_task_result()
async def handle_get_task_result(request: types.GetTaskPayloadRequest) -> types.GetTaskPayloadResult:
app = server.request_context.lifespan_context
result = await app.store.get_result(request.params.taskId)
if result is None:
raise ValueError(f"Result for task {request.params.taskId} not found")
assert isinstance(result, types.CallToolResult)
return types.GetTaskPayloadResult(**result.model_dump())


@click.command()
@click.option("--port", default=8000, help="Port to listen on")
def main(port: int) -> int:
import uvicorn

session_manager = StreamableHTTPSessionManager(app=server)

@asynccontextmanager
async def app_lifespan(app: Starlette) -> AsyncIterator[None]:
async with session_manager.run():
yield

starlette_app = Starlette(
routes=[Mount("/mcp", app=session_manager.handle_request)],
lifespan=app_lifespan,
)

print(f"Starting server on http://localhost:{port}/mcp")
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
return 0
43 changes: 43 additions & 0 deletions examples/servers/simple-task/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
[project]
name = "mcp-simple-task"
version = "0.1.0"
description = "A simple MCP server demonstrating tasks"
readme = "README.md"
requires-python = ">=3.10"
authors = [{ name = "Anthropic, PBC." }]
keywords = ["mcp", "llm", "tasks"]
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 = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"]

[project.scripts]
mcp-simple-task = "mcp_simple_task.server:main"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["mcp_simple_task"]

[tool.pyright]
include = ["mcp_simple_task"]
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"]
9 changes: 9 additions & 0 deletions src/mcp/client/experimental/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""
Experimental client features.

WARNING: These APIs are experimental and may change without notice.
"""

from mcp.client.experimental.tasks import ExperimentalClientFeatures

__all__ = ["ExperimentalClientFeatures"]
Loading
Loading