Skip to content

Commit 9bd2aa8

Browse files
committed
client
1 parent 10c605a commit 9bd2aa8

File tree

20 files changed

+1038
-0
lines changed

20 files changed

+1038
-0
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Simple Task Client
2+
3+
A minimal MCP client demonstrating polling for task results over streamable HTTP.
4+
5+
## Running
6+
7+
First, start the simple-task server in another terminal:
8+
9+
```bash
10+
cd examples/servers/simple-task
11+
uv run mcp-simple-task
12+
```
13+
14+
Then run the client:
15+
16+
```bash
17+
cd examples/clients/simple-task-client
18+
uv run mcp-simple-task-client
19+
```
20+
21+
Use `--url` to connect to a different server.
22+
23+
## What it does
24+
25+
1. Connects to the server via streamable HTTP
26+
2. Calls the `long_running_task` tool as a task
27+
3. Polls the task status until completion
28+
4. Retrieves and prints the result
29+
30+
## Expected output
31+
32+
```text
33+
Available tools: ['long_running_task']
34+
35+
Calling tool as a task...
36+
Task created: <task-id>
37+
Status: working - Starting work...
38+
Status: working - Processing step 1...
39+
Status: working - Processing step 2...
40+
Status: completed -
41+
42+
Result: Task completed!
43+
```

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

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
3+
from .main import main
4+
5+
sys.exit(main()) # type: ignore[call-arg]
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Simple task client demonstrating MCP tasks polling over streamable HTTP."""
2+
3+
import asyncio
4+
5+
import click
6+
from mcp import ClientSession
7+
from mcp.client.streamable_http import streamablehttp_client
8+
from mcp.types import (
9+
CallToolRequest,
10+
CallToolRequestParams,
11+
CallToolResult,
12+
ClientRequest,
13+
CreateTaskResult,
14+
TaskMetadata,
15+
TextContent,
16+
)
17+
18+
19+
async def run(url: str) -> None:
20+
async with streamablehttp_client(url) as (read, write, _):
21+
async with ClientSession(read, write) as session:
22+
await session.initialize()
23+
24+
# List tools
25+
tools = await session.list_tools()
26+
print(f"Available tools: {[t.name for t in tools.tools]}")
27+
28+
# Call the tool as a task
29+
print("\nCalling tool as a task...")
30+
result = await session.send_request(
31+
ClientRequest(
32+
CallToolRequest(
33+
params=CallToolRequestParams(
34+
name="long_running_task",
35+
arguments={},
36+
task=TaskMetadata(ttl=60000),
37+
)
38+
)
39+
),
40+
CreateTaskResult,
41+
)
42+
task_id = result.task.taskId
43+
print(f"Task created: {task_id}")
44+
45+
# Poll until done
46+
while True:
47+
status = await session.experimental.get_task(task_id)
48+
print(f" Status: {status.status} - {status.statusMessage or ''}")
49+
50+
if status.status == "completed":
51+
break
52+
elif status.status in ("failed", "cancelled"):
53+
print(f"Task ended with status: {status.status}")
54+
return
55+
56+
await asyncio.sleep(0.5)
57+
58+
# Get the result
59+
task_result = await session.experimental.get_task_result(task_id, CallToolResult)
60+
content = task_result.content[0]
61+
if isinstance(content, TextContent):
62+
print(f"\nResult: {content.text}")
63+
64+
65+
@click.command()
66+
@click.option("--url", default="http://localhost:8000/mcp", help="Server URL")
67+
def main(url: str) -> int:
68+
asyncio.run(run(url))
69+
return 0
70+
71+
72+
if __name__ == "__main__":
73+
main()
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "mcp-simple-task-client"
3+
version = "0.1.0"
4+
description = "A simple MCP client demonstrating task polling"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Anthropic, PBC." }]
8+
keywords = ["mcp", "llm", "tasks", "client"]
9+
license = { text = "MIT" }
10+
classifiers = [
11+
"Development Status :: 4 - Beta",
12+
"Intended Audience :: Developers",
13+
"License :: OSI Approved :: MIT License",
14+
"Programming Language :: Python :: 3",
15+
"Programming Language :: Python :: 3.10",
16+
]
17+
dependencies = ["click>=8.0", "mcp"]
18+
19+
[project.scripts]
20+
mcp-simple-task-client = "mcp_simple_task_client.main:main"
21+
22+
[build-system]
23+
requires = ["hatchling"]
24+
build-backend = "hatchling.build"
25+
26+
[tool.hatch.build.targets.wheel]
27+
packages = ["mcp_simple_task_client"]
28+
29+
[tool.pyright]
30+
include = ["mcp_simple_task_client"]
31+
venvPath = "."
32+
venv = ".venv"
33+
34+
[tool.ruff.lint]
35+
select = ["E", "F", "I"]
36+
ignore = []
37+
38+
[tool.ruff]
39+
line-length = 120
40+
target-version = "py310"
41+
42+
[dependency-groups]
43+
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Simple Task Server
2+
3+
A minimal MCP server demonstrating the experimental tasks feature over streamable HTTP.
4+
5+
## Running
6+
7+
```bash
8+
cd examples/servers/simple-task
9+
uv run mcp-simple-task
10+
```
11+
12+
The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change.
13+
14+
## What it does
15+
16+
This server exposes a single tool `long_running_task` that:
17+
18+
1. Must be called as a task (with `task` metadata in the request)
19+
2. Takes ~3 seconds to complete
20+
3. Sends status updates during execution
21+
4. Returns a result when complete
22+
23+
## Usage with the client
24+
25+
In one terminal, start the server:
26+
27+
```bash
28+
cd examples/servers/simple-task
29+
uv run mcp-simple-task
30+
```
31+
32+
In another terminal, run the client:
33+
34+
```bash
35+
cd examples/clients/simple-task-client
36+
uv run mcp-simple-task-client
37+
```

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

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import sys
2+
3+
from .server import main
4+
5+
sys.exit(main()) # type: ignore[call-arg]
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Simple task server demonstrating MCP tasks over streamable HTTP."""
2+
3+
from collections.abc import AsyncIterator
4+
from contextlib import asynccontextmanager
5+
from dataclasses import dataclass
6+
from typing import Any
7+
8+
import anyio
9+
import click
10+
import mcp.types as types
11+
from anyio.abc import TaskGroup
12+
from mcp.server.lowlevel import Server
13+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
14+
from mcp.shared.experimental.tasks import InMemoryTaskStore, task_execution
15+
from starlette.applications import Starlette
16+
from starlette.routing import Mount
17+
18+
19+
@dataclass
20+
class AppContext:
21+
task_group: TaskGroup
22+
store: InMemoryTaskStore
23+
24+
25+
@asynccontextmanager
26+
async def lifespan(server: Server[AppContext, Any]) -> AsyncIterator[AppContext]:
27+
store = InMemoryTaskStore()
28+
async with anyio.create_task_group() as tg:
29+
yield AppContext(task_group=tg, store=store)
30+
store.cleanup()
31+
32+
33+
server: Server[AppContext, Any] = Server("simple-task-server", lifespan=lifespan)
34+
35+
36+
@server.list_tools()
37+
async def list_tools() -> list[types.Tool]:
38+
return [
39+
types.Tool(
40+
name="long_running_task",
41+
description="A task that takes a few seconds to complete with status updates",
42+
inputSchema={"type": "object", "properties": {}},
43+
)
44+
]
45+
46+
47+
@server.call_tool()
48+
async def handle_call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent] | types.CreateTaskResult:
49+
ctx = server.request_context
50+
app = ctx.lifespan_context
51+
52+
if not ctx.experimental.is_task:
53+
return [types.TextContent(type="text", text="Error: This tool must be called as a task")]
54+
55+
# Create the task
56+
metadata = ctx.experimental.task_metadata
57+
assert metadata is not None
58+
task = await app.store.create_task(metadata)
59+
60+
# Spawn background work
61+
async def do_work() -> None:
62+
async with task_execution(task.taskId, app.store) as task_ctx:
63+
await task_ctx.update_status("Starting work...")
64+
await anyio.sleep(1)
65+
66+
await task_ctx.update_status("Processing step 1...")
67+
await anyio.sleep(1)
68+
69+
await task_ctx.update_status("Processing step 2...")
70+
await anyio.sleep(1)
71+
72+
await task_ctx.complete(
73+
types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")])
74+
)
75+
76+
app.task_group.start_soon(do_work)
77+
return types.CreateTaskResult(task=task)
78+
79+
80+
@server.experimental.get_task()
81+
async def handle_get_task(request: types.GetTaskRequest) -> types.GetTaskResult:
82+
app = server.request_context.lifespan_context
83+
task = await app.store.get_task(request.params.taskId)
84+
if task is None:
85+
raise ValueError(f"Task {request.params.taskId} not found")
86+
return types.GetTaskResult(
87+
taskId=task.taskId,
88+
status=task.status,
89+
statusMessage=task.statusMessage,
90+
createdAt=task.createdAt,
91+
ttl=task.ttl,
92+
pollInterval=task.pollInterval,
93+
)
94+
95+
96+
@server.experimental.get_task_result()
97+
async def handle_get_task_result(request: types.GetTaskPayloadRequest) -> types.GetTaskPayloadResult:
98+
app = server.request_context.lifespan_context
99+
result = await app.store.get_result(request.params.taskId)
100+
if result is None:
101+
raise ValueError(f"Result for task {request.params.taskId} not found")
102+
assert isinstance(result, types.CallToolResult)
103+
return types.GetTaskPayloadResult(**result.model_dump())
104+
105+
106+
@click.command()
107+
@click.option("--port", default=8000, help="Port to listen on")
108+
def main(port: int) -> int:
109+
import uvicorn
110+
111+
session_manager = StreamableHTTPSessionManager(app=server)
112+
113+
@asynccontextmanager
114+
async def app_lifespan(app: Starlette) -> AsyncIterator[None]:
115+
async with session_manager.run():
116+
yield
117+
118+
starlette_app = Starlette(
119+
routes=[Mount("/mcp", app=session_manager.handle_request)],
120+
lifespan=app_lifespan,
121+
)
122+
123+
print(f"Starting server on http://localhost:{port}/mcp")
124+
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
125+
return 0
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "mcp-simple-task"
3+
version = "0.1.0"
4+
description = "A simple MCP server demonstrating tasks"
5+
readme = "README.md"
6+
requires-python = ">=3.10"
7+
authors = [{ name = "Anthropic, PBC." }]
8+
keywords = ["mcp", "llm", "tasks"]
9+
license = { text = "MIT" }
10+
classifiers = [
11+
"Development Status :: 4 - Beta",
12+
"Intended Audience :: Developers",
13+
"License :: OSI Approved :: MIT License",
14+
"Programming Language :: Python :: 3",
15+
"Programming Language :: Python :: 3.10",
16+
]
17+
dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"]
18+
19+
[project.scripts]
20+
mcp-simple-task = "mcp_simple_task.server:main"
21+
22+
[build-system]
23+
requires = ["hatchling"]
24+
build-backend = "hatchling.build"
25+
26+
[tool.hatch.build.targets.wheel]
27+
packages = ["mcp_simple_task"]
28+
29+
[tool.pyright]
30+
include = ["mcp_simple_task"]
31+
venvPath = "."
32+
venv = ".venv"
33+
34+
[tool.ruff.lint]
35+
select = ["E", "F", "I"]
36+
ignore = []
37+
38+
[tool.ruff]
39+
line-length = 120
40+
target-version = "py310"
41+
42+
[dependency-groups]
43+
dev = ["pyright>=1.1.378", "ruff>=0.6.9"]

0 commit comments

Comments
 (0)