Skip to content

Commit 0d48861

Browse files
committed
Add dedicated event queues to fix IO and be transport-agnostic
1 parent 3701594 commit 0d48861

File tree

17 files changed

+647
-2089
lines changed

17 files changed

+647
-2089
lines changed

examples/clients/async-reconnect-client/mcp_async_reconnect_client/client.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,61 @@
1+
import logging
2+
13
import anyio
24
import click
35
from mcp import ClientSession, types
46
from mcp.client.streamable_http import streamablehttp_client
7+
from mcp.shared.context import RequestContext
8+
9+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
10+
logger = logging.getLogger(__name__)
11+
12+
13+
async def elicitation_callback(context: RequestContext[ClientSession, None], params: types.ElicitRequestParams):
14+
"""Handle elicitation requests from the server."""
15+
logger.info(f"Server is asking: {params.message}")
16+
return types.ElicitResult(
17+
action="accept",
18+
content={"continue_processing": True},
19+
)
520

621

722
async def call_async_tool(session: ClientSession, token: str | None):
823
"""Demonstrate calling an async tool."""
9-
print("Calling async tool...")
10-
1124
if not token:
12-
result = await session.call_tool("fetch_website", arguments={"url": "https://modelcontextprotocol.io"})
25+
logger.info("Calling async tool...")
26+
result = await session.call_tool(
27+
"fetch_website",
28+
arguments={"url": "https://modelcontextprotocol.io"},
29+
)
1330
if result.isError:
1431
raise RuntimeError(f"Error calling tool: {result}")
1532
assert result.operation
1633
token = result.operation.token
17-
print(f"Operation started with token: {token}")
34+
logger.info(f"Operation started with token: {token}")
1835

1936
# Poll for completion
2037
while True:
2138
status = await session.get_operation_status(token)
22-
print(f"Status: {status.status}")
39+
logger.info(f"Status: {status.status}")
2340

2441
if status.status == "completed":
2542
final_result = await session.get_operation_result(token)
2643
for content in final_result.result.content:
2744
if isinstance(content, types.TextContent):
28-
print(f"Result: {content.text}")
45+
logger.info(f"Result: {content.text}")
2946
break
3047
elif status.status == "failed":
31-
print(f"Operation failed: {status.error}")
48+
logger.error(f"Operation failed: {status.error}")
3249
break
3350

3451
await anyio.sleep(0.5)
3552

3653

3754
async def run_session(endpoint: str, token: str | None):
3855
async with streamablehttp_client(endpoint) as (read, write, _):
39-
async with ClientSession(read, write, protocol_version="next") as session:
56+
async with ClientSession(
57+
read, write, protocol_version="next", elicitation_callback=elicitation_callback
58+
) as session:
4059
await session.initialize()
4160
await call_async_tool(session, token)
4261

examples/clients/async-reconnect-client/pyproject.toml

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,7 @@ version = "0.1.0"
44
description = "A client for the MCP simple-tool-async server that supports reconnection"
55
readme = "README.md"
66
requires-python = ">=3.10"
7-
authors = [{ name = "Anthropic" }]
8-
keywords = ["mcp", "client", "async"]
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.2.0", "mcp>=1.0.0"]
7+
dependencies = ["anyio>=4.5", "click>=8.2.0", "mcp"]
188

199
[project.scripts]
2010
mcp-async-reconnect-client = "mcp_async_reconnect_client.client:main"
@@ -40,10 +30,4 @@ line-length = 120
4030
target-version = "py310"
4131

4232
[tool.uv]
43-
dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"]
44-
45-
[tool.uv.sources]
46-
mcp = { path = "../../../" }
47-
48-
[[tool.uv.index]]
49-
url = "https://pypi.org/simple"
33+
dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]

examples/clients/async-reconnect-client/uv.lock

Lines changed: 0 additions & 761 deletions
This file was deleted.

examples/clients/simple-auth-client/pyproject.toml

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ classifiers = [
1414
"Programming Language :: Python :: 3",
1515
"Programming Language :: Python :: 3.10",
1616
]
17-
dependencies = [
18-
"click>=8.2.0",
19-
"mcp>=1.0.0",
20-
]
17+
dependencies = ["click>=8.2.0", "mcp>=1.0.0"]
2118

2219
[project.scripts]
2320
mcp-simple-auth-client = "mcp_simple_auth_client.main:cli"
@@ -44,9 +41,3 @@ target-version = "py310"
4441

4542
[tool.uv]
4643
dev-dependencies = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"]
47-
48-
[tool.uv.sources]
49-
mcp = { path = "../../../" }
50-
51-
[[tool.uv.index]]
52-
url = "https://pypi.org/simple"

examples/clients/simple-auth-client/uv.lock

Lines changed: 0 additions & 535 deletions
This file was deleted.

examples/clients/simple-chatbot/uv.lock

Lines changed: 0 additions & 555 deletions
This file was deleted.

examples/servers/sqlite-async-operations/mcp_sqlite_async_operations/server.py

Lines changed: 120 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import json
6+
import logging
67
import sqlite3
78
import time
89
from collections import deque
@@ -13,17 +14,22 @@
1314
import uvicorn
1415
from mcp import types
1516
from mcp.server.fastmcp import FastMCP
17+
from mcp.server.fastmcp.server import Context
1618
from mcp.server.session import ServerSession
1719
from mcp.shared._httpx_utils import create_mcp_http_client
1820
from mcp.shared.async_operations import (
1921
AsyncOperationBroker,
2022
AsyncOperationStore,
23+
OperationEventQueue,
2124
PendingAsyncTask,
2225
ServerAsyncOperation,
2326
ServerAsyncOperationManager,
2427
)
25-
from mcp.shared.context import RequestContext
28+
from mcp.shared.context import RequestContext, SerializableRequestContext
2629
from mcp.types import AsyncOperationStatus, CallToolResult
30+
from pydantic import BaseModel, Field
31+
32+
logger = logging.getLogger(__name__)
2733

2834

2935
class SQLiteAsyncOperationStore(AsyncOperationStore):
@@ -207,6 +213,78 @@ async def cleanup_expired(self) -> int:
207213
return cursor.rowcount
208214

209215

216+
class SQLiteOperationEventQueue(OperationEventQueue):
217+
"""SQLite-based implementation of OperationEventQueue for operation-specific event delivery."""
218+
219+
def __init__(self, db_path: str = "async_operations.db"):
220+
self.db_path = db_path
221+
self._init_db()
222+
223+
def _init_db(self):
224+
"""Initialize the SQLite database for operation event queuing."""
225+
with sqlite3.connect(self.db_path) as conn:
226+
conn.execute("""
227+
CREATE TABLE IF NOT EXISTS operation_events (
228+
id INTEGER PRIMARY KEY AUTOINCREMENT,
229+
operation_token TEXT NOT NULL,
230+
message TEXT NOT NULL,
231+
created_at REAL NOT NULL
232+
)
233+
""")
234+
conn.execute("""
235+
CREATE INDEX IF NOT EXISTS idx_operation_events_token_created
236+
ON operation_events(operation_token, created_at)
237+
""")
238+
conn.commit()
239+
240+
async def enqueue_event(self, operation_token: str, message: types.JSONRPCMessage) -> None:
241+
"""Enqueue an event for a specific operation token."""
242+
message_json = json.dumps(message.model_dump())
243+
created_at = time.time()
244+
245+
with sqlite3.connect(self.db_path) as conn:
246+
conn.execute(
247+
"""
248+
INSERT INTO operation_events (operation_token, message, created_at)
249+
VALUES (?, ?, ?)
250+
""",
251+
(operation_token, message_json, created_at),
252+
)
253+
conn.commit()
254+
255+
async def dequeue_events(self, operation_token: str) -> list[types.JSONRPCMessage]:
256+
"""Dequeue all pending events for a specific operation token."""
257+
with sqlite3.connect(self.db_path) as conn:
258+
conn.row_factory = sqlite3.Row
259+
260+
# Get all events for this operation token
261+
cursor = conn.execute(
262+
"""
263+
SELECT id, message FROM operation_events
264+
WHERE operation_token = ?
265+
ORDER BY created_at
266+
""",
267+
(operation_token,),
268+
)
269+
270+
events: list[types.JSONRPCMessage] = []
271+
event_ids: list[int] = []
272+
273+
for row in cursor:
274+
event_ids.append(row["id"])
275+
message_data = json.loads(row["message"])
276+
message = types.JSONRPCMessage.model_validate(message_data)
277+
events.append(message)
278+
279+
# Delete the dequeued events
280+
if event_ids:
281+
placeholders = ",".join("?" * len(event_ids))
282+
conn.execute(f"DELETE FROM operation_events WHERE id IN ({placeholders})", event_ids)
283+
conn.commit()
284+
285+
return events
286+
287+
210288
class SQLiteAsyncOperationBroker(AsyncOperationBroker):
211289
"""SQLite-based implementation of AsyncOperationBroker for persistent task queuing."""
212290

@@ -234,23 +312,19 @@ def _load_persisted_tasks_sync(self):
234312
if op_row and op_row["status"] in ("completed", "failed", "canceled"):
235313
continue
236314

237-
# Reconstruct serializable parts of RequestContext
238-
from mcp.shared.context import SerializableRequestContext
239-
240-
serializable_context = None
241-
if row["request_id"]:
242-
serializable_context = SerializableRequestContext(
243-
request_id=row["request_id"],
244-
operation_token=row["operation_token"],
245-
meta=json.loads(row["meta"]) if row["meta"] else None,
246-
supports_async=bool(row["supports_async"]),
247-
)
315+
# Reconstruct context - the server will hydrate the session
316+
request_context = SerializableRequestContext(
317+
request_id=row["request_id"],
318+
operation_token=row["operation_token"],
319+
meta=json.loads(row["meta"]) if row["meta"] else None,
320+
supports_async=bool(row["supports_async"]),
321+
)
248322

249323
task = PendingAsyncTask(
250324
token=row["token"],
251325
tool_name=row["tool_name"],
252326
arguments=json.loads(row["arguments"]),
253-
request_context=serializable_context,
327+
request_context=request_context,
254328
)
255329
self._task_queue.append(task)
256330

@@ -329,6 +403,10 @@ async def complete_task(self, token: str) -> None:
329403
conn.commit()
330404

331405

406+
class UserPreferences(BaseModel):
407+
continue_processing: bool = Field(description="Should we continue with the operation?")
408+
409+
332410
@click.command()
333411
@click.option("--port", default=8000, help="Port to listen on for HTTP")
334412
@click.option(
@@ -341,31 +419,54 @@ async def complete_task(self, token: str) -> None:
341419
def main(port: int, transport: str, db_path: str):
342420
"""Run the SQLite async operations example server."""
343421
# Create components with specified database path
422+
operation_event_queue = SQLiteOperationEventQueue(db_path)
344423
broker = SQLiteAsyncOperationBroker(db_path)
345-
store = SQLiteAsyncOperationStore(db_path) # No broker reference needed
346-
manager = ServerAsyncOperationManager(store=store, broker=broker)
347-
mcp = FastMCP("SQLite Async Operations Demo", async_operations=manager)
424+
store = SQLiteAsyncOperationStore(db_path)
425+
manager = ServerAsyncOperationManager(store=store, broker=broker, operation_request_queue=operation_event_queue)
426+
mcp = FastMCP(
427+
"SQLite Async Operations Demo",
428+
operation_event_queue=operation_event_queue,
429+
async_operations=manager,
430+
)
348431

349432
@mcp.tool(invocation_modes=["async"])
350433
async def fetch_website(
351434
url: str,
435+
ctx: Context[ServerSession, None],
352436
) -> list[types.ContentBlock]:
353437
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
354438
async with create_mcp_http_client(headers=headers) as client:
439+
logger.info("Entered fetch_website")
440+
441+
# Simulate delay
355442
await anyio.sleep(10)
443+
444+
# Request approval from user
445+
logger.info("Sending elicitation to confirm")
446+
result = await ctx.elicit(
447+
message=f"Please confirm that you would like to fetch from {url}.",
448+
schema=UserPreferences,
449+
)
450+
logger.info(f"Elicitation result: {result}")
451+
452+
if result.action != "accept" or not result.data.continue_processing:
453+
return [types.TextContent(type="text", text="Operation cancelled by user")]
454+
455+
logger.info(f"Fetching {url}")
356456
response = await client.get(url)
357457
response.raise_for_status()
458+
logger.info("Returning fetch result")
358459
return [types.TextContent(type="text", text=response.text)]
359460

360-
print(f"Starting server with SQLite database: {db_path}")
361-
print("Pending tasks will be automatically restarted on server restart!")
461+
logger.info(f"Starting server with SQLite database: {db_path}")
462+
logger.info("Pending tasks will be automatically restarted on server restart!")
362463

363464
if transport == "stdio":
364465
mcp.run(transport="stdio")
365466
elif transport == "streamable-http":
366467
app = mcp.streamable_http_app()
367468
server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=port, log_level="error"))
368-
print(f"Starting {transport} server on port {port}")
469+
logger.info(f"Starting {transport} server on port {port}")
369470
server.run()
370471
else:
371472
raise ValueError(f"Invalid transport for test server: {transport}")

0 commit comments

Comments
 (0)