Add pyright strict mode on the whole project (#1254)

This commit is contained in:
Marcelo Trylesinski 2025-08-11 19:56:37 +02:00 • committed by GitHub
commit c7671e470c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 652 additions and 674 deletions

View file

@ -6,6 +6,9 @@ on:
permissions: permissions:
contents: read contents: read
env:
COLUMNS: 150
jobs: jobs:
pre-commit: pre-commit:
runs-on: ubuntu-latest runs-on: ubuntu-latest

View file

@ -197,6 +197,7 @@ from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession
# Mock database class for example # Mock database class for example
@ -242,7 +243,7 @@ mcp = FastMCP("My App", lifespan=app_lifespan)
# Access type-safe lifespan context in tools # Access type-safe lifespan context in tools
@mcp.tool() @mcp.tool()
def query_db(ctx: Context) -> str: def query_db(ctx: Context[ServerSession, AppContext]) -> str:
"""Tool that uses initialized resources.""" """Tool that uses initialized resources."""
db = ctx.request_context.lifespan_context.db db = ctx.request_context.lifespan_context.db
return db.query() return db.query()
@ -314,12 +315,13 @@ Tools can optionally receive a Context object by including a parameter with the
<!-- snippet-source examples/snippets/servers/tool_progress.py --> <!-- snippet-source examples/snippets/servers/tool_progress.py -->
```python ```python
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession
mcp = FastMCP(name="Progress Example") mcp = FastMCP(name="Progress Example")
@mcp.tool() @mcp.tool()
async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str:
"""Execute a task with progress updates.""" """Execute a task with progress updates."""
await ctx.info(f"Starting: {task_name}") await ctx.info(f"Starting: {task_name}")
@ -445,7 +447,7 @@ def get_user(user_id: str) -> UserProfile:
# Classes WITHOUT type hints cannot be used for structured output # Classes WITHOUT type hints cannot be used for structured output
class UntypedConfig: class UntypedConfig:
def __init__(self, setting1, setting2): def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType]
self.setting1 = setting1 self.setting1 = setting1
self.setting2 = setting2 self.setting2 = setting2
@ -571,12 +573,13 @@ The Context object provides the following capabilities:
<!-- snippet-source examples/snippets/servers/tool_progress.py --> <!-- snippet-source examples/snippets/servers/tool_progress.py -->
```python ```python
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession
mcp = FastMCP(name="Progress Example") mcp = FastMCP(name="Progress Example")
@mcp.tool() @mcp.tool()
async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str:
"""Execute a task with progress updates.""" """Execute a task with progress updates."""
await ctx.info(f"Starting: {task_name}") await ctx.info(f"Starting: {task_name}")
@ -694,6 +697,7 @@ Request additional information from users. This example shows an Elicitation dur
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession
mcp = FastMCP(name="Elicitation Example") mcp = FastMCP(name="Elicitation Example")
@ -709,12 +713,7 @@ class BookingPreferences(BaseModel):
@mcp.tool() @mcp.tool()
async def book_table( async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str:
date: str,
time: str,
party_size: int,
ctx: Context,
) -> str:
"""Book a table with date availability check.""" """Book a table with date availability check."""
# Check if date is available # Check if date is available
if date == "2024-12-25": if date == "2024-12-25":
@ -750,13 +749,14 @@ Tools can interact with LLMs through sampling (generating text):
<!-- snippet-source examples/snippets/servers/sampling.py --> <!-- snippet-source examples/snippets/servers/sampling.py -->
```python ```python
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession
from mcp.types import SamplingMessage, TextContent from mcp.types import SamplingMessage, TextContent
mcp = FastMCP(name="Sampling Example") mcp = FastMCP(name="Sampling Example")
@mcp.tool() @mcp.tool()
async def generate_poem(topic: str, ctx: Context) -> str: async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str:
"""Generate a poem using LLM sampling.""" """Generate a poem using LLM sampling."""
prompt = f"Write a short poem about {topic}" prompt = f"Write a short poem about {topic}"
@ -785,12 +785,13 @@ Tools can send logs and notifications through the context:
<!-- snippet-source examples/snippets/servers/notifications.py --> <!-- snippet-source examples/snippets/servers/notifications.py -->
```python ```python
from mcp.server.fastmcp import Context, FastMCP from mcp.server.fastmcp import Context, FastMCP
from mcp.server.session import ServerSession
mcp = FastMCP(name="Notifications Example") mcp = FastMCP(name="Notifications Example")
@mcp.tool() @mcp.tool()
async def process_data(data: str, ctx: Context) -> str: async def process_data(data: str, ctx: Context[ServerSession, None]) -> str:
"""Process data with logging.""" """Process data with logging."""
# Different log levels # Different log levels
await ctx.debug(f"Debug: Processing '{data}'") await ctx.debug(f"Debug: Processing '{data}'")
@ -1244,6 +1245,7 @@ Run from the repository root:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Any
import mcp.server.stdio import mcp.server.stdio
import mcp.types as types import mcp.types as types
@ -1272,7 +1274,7 @@ class Database:
@asynccontextmanager @asynccontextmanager
async def server_lifespan(_server: Server) -> AsyncIterator[dict]: async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]:
"""Manage server startup and shutdown lifecycle.""" """Manage server startup and shutdown lifecycle."""
# Initialize resources on startup # Initialize resources on startup
db = await Database.connect() db = await Database.connect()
@ -1304,7 +1306,7 @@ async def handle_list_tools() -> list[types.Tool]:
@server.call_tool() @server.call_tool()
async def query_db(name: str, arguments: dict) -> list[types.TextContent]: async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
"""Handle database query tool call.""" """Handle database query tool call."""
if name != "query_db": if name != "query_db":
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")
@ -1558,7 +1560,7 @@ server_params = StdioServerParameters(
# Optional: create a sampling callback # Optional: create a sampling callback
async def handle_sampling_message( async def handle_sampling_message(
context: RequestContext, params: types.CreateMessageRequestParams context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams
) -> types.CreateMessageResult: ) -> types.CreateMessageResult:
print(f"Sampling request: {params.messages}") print(f"Sampling request: {params.messages}")
return types.CreateMessageResult( return types.CreateMessageResult(

View file

@ -188,9 +188,7 @@ class SimpleAuthClient:
# Create OAuth authentication handler using the new interface # Create OAuth authentication handler using the new interface
oauth_auth = OAuthClientProvider( oauth_auth = OAuthClientProvider(
server_url=self.server_url.replace("/mcp", ""), server_url=self.server_url.replace("/mcp", ""),
client_metadata=OAuthClientMetadata.model_validate( client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict),
client_metadata_dict
),
storage=InMemoryTokenStorage(), storage=InMemoryTokenStorage(),
redirect_handler=_default_redirect_handler, redirect_handler=_default_redirect_handler,
callback_handler=callback_handler, callback_handler=callback_handler,
@ -322,9 +320,7 @@ class SimpleAuthClient:
await self.call_tool(tool_name, arguments) await self.call_tool(tool_name, arguments)
else: else:
print( print("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'")
"❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'"
)
except KeyboardInterrupt: except KeyboardInterrupt:
print("\n\n👋 Goodbye!") print("\n\n👋 Goodbye!")

View file

@ -39,7 +39,7 @@ select = ["E", "F", "I"]
ignore = [] ignore = []
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 120
target-version = "py310" target-version = "py310"
[tool.uv] [tool.uv]

View file

@ -12,9 +12,7 @@ from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client from mcp.client.stdio import stdio_client
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
)
class Configuration: class Configuration:
@ -75,29 +73,19 @@ class Server:
async def initialize(self) -> None: async def initialize(self) -> None:
"""Initialize the server connection.""" """Initialize the server connection."""
command = ( command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"]
shutil.which("npx")
if self.config["command"] == "npx"
else self.config["command"]
)
if command is None: if command is None:
raise ValueError("The command must be a valid string and cannot be None.") raise ValueError("The command must be a valid string and cannot be None.")
server_params = StdioServerParameters( server_params = StdioServerParameters(
command=command, command=command,
args=self.config["args"], args=self.config["args"],
env={**os.environ, **self.config["env"]} env={**os.environ, **self.config["env"]} if self.config.get("env") else None,
if self.config.get("env")
else None,
) )
try: try:
stdio_transport = await self.exit_stack.enter_async_context( stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
stdio_client(server_params)
)
read, write = stdio_transport read, write = stdio_transport
session = await self.exit_stack.enter_async_context( session = await self.exit_stack.enter_async_context(ClientSession(read, write))
ClientSession(read, write)
)
await session.initialize() await session.initialize()
self.session = session self.session = session
except Exception as e: except Exception as e:
@ -122,10 +110,7 @@ class Server:
for item in tools_response: for item in tools_response:
if isinstance(item, tuple) and item[0] == "tools": if isinstance(item, tuple) and item[0] == "tools":
tools.extend( tools.extend(Tool(tool.name, tool.description, tool.inputSchema, tool.title) for tool in item[1])
Tool(tool.name, tool.description, tool.inputSchema, tool.title)
for tool in item[1]
)
return tools return tools
@ -164,9 +149,7 @@ class Server:
except Exception as e: except Exception as e:
attempt += 1 attempt += 1
logging.warning( logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.")
f"Error executing tool: {e}. Attempt {attempt} of {retries}."
)
if attempt < retries: if attempt < retries:
logging.info(f"Retrying in {delay} seconds...") logging.info(f"Retrying in {delay} seconds...")
await asyncio.sleep(delay) await asyncio.sleep(delay)
@ -209,9 +192,7 @@ class Tool:
args_desc = [] args_desc = []
if "properties" in self.input_schema: if "properties" in self.input_schema:
for param_name, param_info in self.input_schema["properties"].items(): for param_name, param_info in self.input_schema["properties"].items():
arg_desc = ( arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}"
f"- {param_name}: {param_info.get('description', 'No description')}"
)
if param_name in self.input_schema.get("required", []): if param_name in self.input_schema.get("required", []):
arg_desc += " (required)" arg_desc += " (required)"
args_desc.append(arg_desc) args_desc.append(arg_desc)
@ -281,10 +262,7 @@ class LLMClient:
logging.error(f"Status code: {status_code}") logging.error(f"Status code: {status_code}")
logging.error(f"Response details: {e.response.text}") logging.error(f"Response details: {e.response.text}")
return ( return f"I encountered an error: {error_message}. Please try again or rephrase your request."
f"I encountered an error: {error_message}. "
"Please try again or rephrase your request."
)
class ChatSession: class ChatSession:
@ -323,17 +301,13 @@ class ChatSession:
tools = await server.list_tools() tools = await server.list_tools()
if any(tool.name == tool_call["tool"] for tool in tools): if any(tool.name == tool_call["tool"] for tool in tools):
try: try:
result = await server.execute_tool( result = await server.execute_tool(tool_call["tool"], tool_call["arguments"])
tool_call["tool"], tool_call["arguments"]
)
if isinstance(result, dict) and "progress" in result: if isinstance(result, dict) and "progress" in result:
progress = result["progress"] progress = result["progress"]
total = result["total"] total = result["total"]
percentage = (progress / total) * 100 percentage = (progress / total) * 100
logging.info( logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)")
f"Progress: {progress}/{total} ({percentage:.1f}%)"
)
return f"Tool execution result: {result}" return f"Tool execution result: {result}"
except Exception as e: except Exception as e:
@ -408,9 +382,7 @@ class ChatSession:
final_response = self.llm_client.get_response(messages) final_response = self.llm_client.get_response(messages)
logging.info("\nFinal response: %s", final_response) logging.info("\nFinal response: %s", final_response)
messages.append( messages.append({"role": "assistant", "content": final_response})
{"role": "assistant", "content": final_response}
)
else: else:
messages.append({"role": "assistant", "content": llm_response}) messages.append({"role": "assistant", "content": llm_response})
@ -426,10 +398,7 @@ async def main() -> None:
"""Initialize and run the chat session.""" """Initialize and run the chat session."""
config = Configuration() config = Configuration()
server_config = config.load_config("servers_config.json") server_config = config.load_config("servers_config.json")
servers = [ servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()]
Server(name, srv_config)
for name, srv_config in server_config["mcpServers"].items()
]
llm_client = LLMClient(config.llm_api_key) llm_client = LLMClient(config.llm_api_key)
chat_session = ChatSession(servers, llm_client) chat_session = ChatSession(servers, llm_client)
await chat_session.start() await chat_session.start()

View file

@ -41,7 +41,7 @@ select = ["E", "F", "I"]
ignore = [] ignore = []
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 120
target-version = "py310" target-version = "py310"
[tool.uv] [tool.uv]

View file

@ -45,7 +45,8 @@ class ResourceServerSettings(BaseSettings):
# RFC 8707 resource validation # RFC 8707 resource validation
oauth_strict: bool = False oauth_strict: bool = False
def __init__(self, **data): # TODO(Marcelo): Is this even needed? I didn't have time to check.
def __init__(self, **data: Any):
"""Initialize settings with values from environment variables.""" """Initialize settings with values from environment variables."""
super().__init__(**data) super().__init__(**data)

View file

@ -46,7 +46,7 @@ class SimpleAuthSettings(BaseSettings):
mcp_scope: str = "user" mcp_scope: str = "user"
class SimpleOAuthProvider(OAuthAuthorizationServerProvider): class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
""" """
Simple OAuth provider for demo purposes. Simple OAuth provider for demo purposes.
@ -116,7 +116,7 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider):
<p>This is a simplified authentication demo. Use the demo credentials below:</p> <p>This is a simplified authentication demo. Use the demo credentials below:</p>
<p><strong>Username:</strong> demo_user<br> <p><strong>Username:</strong> demo_user<br>
<strong>Password:</strong> demo_password</p> <strong>Password:</strong> demo_password</p>
<form action="{self.server_url.rstrip("/")}/login/callback" method="post"> <form action="{self.server_url.rstrip("/")}/login/callback" method="post">
<input type="hidden" name="state" value="{state}"> <input type="hidden" name="state" value="{state}">
<div class="form-group"> <div class="form-group">
@ -264,7 +264,8 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider):
"""Exchange refresh token - not supported in this example.""" """Exchange refresh token - not supported in this example."""
raise NotImplementedError("Refresh tokens not supported") raise NotImplementedError("Refresh tokens not supported")
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works.
async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore
"""Revoke a token.""" """Revoke a token."""
if token in self.tokens: if token in self.tokens:
del self.tokens[token] del self.tokens[token]

View file

@ -1,6 +1,7 @@
"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" """Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
import logging import logging
from typing import Any
from mcp.server.auth.provider import AccessToken, TokenVerifier from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
@ -79,13 +80,13 @@ class IntrospectionTokenVerifier(TokenVerifier):
logger.warning(f"Token introspection failed: {e}") logger.warning(f"Token introspection failed: {e}")
return None return None
def _validate_resource(self, token_data: dict) -> bool: def _validate_resource(self, token_data: dict[str, Any]) -> bool:
"""Validate token was issued for this resource server.""" """Validate token was issued for this resource server."""
if not self.server_url or not self.resource_url: if not self.server_url or not self.resource_url:
return False # Fail if strict validation requested but URLs missing return False # Fail if strict validation requested but URLs missing
# Check 'aud' claim first (standard JWT audience) # Check 'aud' claim first (standard JWT audience)
aud = token_data.get("aud") aud: list[str] | str | None = token_data.get("aud")
if isinstance(aud, list): if isinstance(aud, list):
for audience in aud: for audience in aud:
if self._is_valid_resource(audience): if self._is_valid_resource(audience):

View file

@ -2,22 +2,19 @@ import anyio
import click import click
import mcp.types as types import mcp.types as types
from mcp.server.lowlevel import Server from mcp.server.lowlevel import Server
from starlette.requests import Request
def create_messages( def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]:
context: str | None = None, topic: str | None = None
) -> list[types.PromptMessage]:
"""Create the messages for the prompt.""" """Create the messages for the prompt."""
messages = [] messages: list[types.PromptMessage] = []
# Add context if provided # Add context if provided
if context: if context:
messages.append( messages.append(
types.PromptMessage( types.PromptMessage(
role="user", role="user",
content=types.TextContent( content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"),
type="text", text=f"Here is some relevant context: {context}"
),
) )
) )
@ -28,11 +25,7 @@ def create_messages(
else: else:
prompt += "whatever questions I may have." prompt += "whatever questions I may have."
messages.append( messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt)))
types.PromptMessage(
role="user", content=types.TextContent(type="text", text=prompt)
)
)
return messages return messages
@ -54,8 +47,7 @@ def main(port: int, transport: str) -> int:
types.Prompt( types.Prompt(
name="simple", name="simple",
title="Simple Assistant Prompt", title="Simple Assistant Prompt",
description="A simple prompt that can take optional context and topic " description="A simple prompt that can take optional context and topic arguments",
"arguments",
arguments=[ arguments=[
types.PromptArgument( types.PromptArgument(
name="context", name="context",
@ -72,9 +64,7 @@ def main(port: int, transport: str) -> int:
] ]
@app.get_prompt() @app.get_prompt()
async def get_prompt( async def get_prompt(name: str, arguments: dict[str, str] | None = None) -> types.GetPromptResult:
name: str, arguments: dict[str, str] | None = None
) -> types.GetPromptResult:
if name != "simple": if name != "simple":
raise ValueError(f"Unknown prompt: {name}") raise ValueError(f"Unknown prompt: {name}")
@ -82,9 +72,7 @@ def main(port: int, transport: str) -> int:
arguments = {} arguments = {}
return types.GetPromptResult( return types.GetPromptResult(
messages=create_messages( messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")),
context=arguments.get("context"), topic=arguments.get("topic")
),
description="A simple prompt with optional context and topic arguments", description="A simple prompt with optional context and topic arguments",
) )
@ -96,13 +84,9 @@ def main(port: int, transport: str) -> int:
sse = SseServerTransport("/messages/") sse = SseServerTransport("/messages/")
async def handle_sse(request): async def handle_sse(request: Request):
async with sse.connect_sse( async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage]
request.scope, request.receive, request._send await app.run(streams[0], streams[1], app.create_initialization_options())
) as streams:
await app.run(
streams[0], streams[1], app.create_initialization_options()
)
return Response() return Response()
starlette_app = Starlette( starlette_app = Starlette(
@ -121,9 +105,7 @@ def main(port: int, transport: str) -> int:
async def arun(): async def arun():
async with stdio_server() as streams: async with stdio_server() as streams:
await app.run( await app.run(streams[0], streams[1], app.create_initialization_options())
streams[0], streams[1], app.create_initialization_options()
)
anyio.run(arun) anyio.run(arun)

View file

@ -40,7 +40,7 @@ select = ["E", "F", "I"]
ignore = [] ignore = []
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 120
target-version = "py310" target-version = "py310"
[tool.uv] [tool.uv]

View file

@ -3,6 +3,7 @@ import click
import mcp.types as types import mcp.types as types
from mcp.server.lowlevel import Server from mcp.server.lowlevel import Server
from pydantic import AnyUrl, FileUrl from pydantic import AnyUrl, FileUrl
from starlette.requests import Request
SAMPLE_RESOURCES = { SAMPLE_RESOURCES = {
"greeting": { "greeting": {
@ -63,13 +64,9 @@ def main(port: int, transport: str) -> int:
sse = SseServerTransport("/messages/") sse = SseServerTransport("/messages/")
async def handle_sse(request): async def handle_sse(request: Request):
async with sse.connect_sse( async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage]
request.scope, request.receive, request._send await app.run(streams[0], streams[1], app.create_initialization_options())
) as streams:
await app.run(
streams[0], streams[1], app.create_initialization_options()
)
return Response() return Response()
starlette_app = Starlette( starlette_app = Starlette(
@ -88,9 +85,7 @@ def main(port: int, transport: str) -> int:
async def arun(): async def arun():
async with stdio_server() as streams: async with stdio_server() as streams:
await app.run( await app.run(streams[0], streams[1], app.create_initialization_options())
streams[0], streams[1], app.create_initialization_options()
)
anyio.run(arun) anyio.run(arun)

View file

@ -40,7 +40,7 @@ select = ["E", "F", "I"]
ignore = [] ignore = []
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 120
target-version = "py310" target-version = "py310"
[tool.uv] [tool.uv]

View file

@ -1,6 +1,7 @@
import contextlib import contextlib
import logging import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any
import anyio import anyio
import click import click
@ -41,7 +42,7 @@ def main(
app = Server("mcp-streamable-http-stateless-demo") app = Server("mcp-streamable-http-stateless-demo")
@app.call_tool() @app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]: async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]:
ctx = app.request_context ctx = app.request_context
interval = arguments.get("interval", 1.0) interval = arguments.get("interval", 1.0)
count = arguments.get("count", 5) count = arguments.get("count", 5)
@ -61,10 +62,7 @@ def main(
return [ return [
types.TextContent( types.TextContent(
type="text", type="text",
text=( text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"),
f"Sent {count} notifications with {interval}s interval"
f" for caller: {caller}"
),
) )
] ]
@ -73,10 +71,7 @@ def main(
return [ return [
types.Tool( types.Tool(
name="start-notification-stream", name="start-notification-stream",
description=( description=("Sends a stream of notifications with configurable count and interval"),
"Sends a stream of notifications with configurable count"
" and interval"
),
inputSchema={ inputSchema={
"type": "object", "type": "object",
"required": ["interval", "count", "caller"], "required": ["interval", "count", "caller"],
@ -91,9 +86,7 @@ def main(
}, },
"caller": { "caller": {
"type": "string", "type": "string",
"description": ( "description": ("Identifier of the caller to include in notifications"),
"Identifier of the caller to include in notifications"
),
}, },
}, },
}, },
@ -108,9 +101,7 @@ def main(
stateless=True, stateless=True,
) )
async def handle_streamable_http( async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
scope: Scope, receive: Receive, send: Send
) -> None:
await session_manager.handle_request(scope, receive, send) await session_manager.handle_request(scope, receive, send)
@contextlib.asynccontextmanager @contextlib.asynccontextmanager

View file

@ -29,8 +29,8 @@ select = ["E", "F", "I"]
ignore = [] ignore = []
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 120
target-version = "py310" target-version = "py310"
[tool.uv] [tool.uv]
dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]

View file

@ -10,13 +10,7 @@ from collections import deque
from dataclasses import dataclass from dataclasses import dataclass
from uuid import uuid4 from uuid import uuid4
from mcp.server.streamable_http import ( from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
EventCallback,
EventId,
EventMessage,
EventStore,
StreamId,
)
from mcp.types import JSONRPCMessage from mcp.types import JSONRPCMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -54,14 +48,10 @@ class InMemoryEventStore(EventStore):
# event_id -> EventEntry for quick lookup # event_id -> EventEntry for quick lookup
self.event_index: dict[EventId, EventEntry] = {} self.event_index: dict[EventId, EventEntry] = {}
async def store_event( async def store_event(self, stream_id: StreamId, message: JSONRPCMessage) -> EventId:
self, stream_id: StreamId, message: JSONRPCMessage
) -> EventId:
"""Stores an event with a generated event ID.""" """Stores an event with a generated event ID."""
event_id = str(uuid4()) event_id = str(uuid4())
event_entry = EventEntry( event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message)
event_id=event_id, stream_id=stream_id, message=message
)
# Get or create deque for this stream # Get or create deque for this stream
if stream_id not in self.streams: if stream_id not in self.streams:

View file

@ -1,6 +1,7 @@
import contextlib import contextlib
import logging import logging
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any
import anyio import anyio
import click import click
@ -45,7 +46,7 @@ def main(
app = Server("mcp-streamable-http-demo") app = Server("mcp-streamable-http-demo")
@app.call_tool() @app.call_tool()
async def call_tool(name: str, arguments: dict) -> list[types.ContentBlock]: async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]:
ctx = app.request_context ctx = app.request_context
interval = arguments.get("interval", 1.0) interval = arguments.get("interval", 1.0)
count = arguments.get("count", 5) count = arguments.get("count", 5)
@ -54,10 +55,7 @@ def main(
# Send the specified number of notifications with the given interval # Send the specified number of notifications with the given interval
for i in range(count): for i in range(count):
# Include more detailed message for resumability demonstration # Include more detailed message for resumability demonstration
notification_msg = ( notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected"
f"[{i + 1}/{count}] Event from '{caller}' - "
f"Use Last-Event-ID to resume if disconnected"
)
await ctx.session.send_log_message( await ctx.session.send_log_message(
level="info", level="info",
data=notification_msg, data=notification_msg,
@ -79,10 +77,7 @@ def main(
return [ return [
types.TextContent( types.TextContent(
type="text", type="text",
text=( text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"),
f"Sent {count} notifications with {interval}s interval"
f" for caller: {caller}"
),
) )
] ]
@ -91,10 +86,7 @@ def main(
return [ return [
types.Tool( types.Tool(
name="start-notification-stream", name="start-notification-stream",
description=( description=("Sends a stream of notifications with configurable count and interval"),
"Sends a stream of notifications with configurable count"
" and interval"
),
inputSchema={ inputSchema={
"type": "object", "type": "object",
"required": ["interval", "count", "caller"], "required": ["interval", "count", "caller"],
@ -109,9 +101,7 @@ def main(
}, },
"caller": { "caller": {
"type": "string", "type": "string",
"description": ( "description": ("Identifier of the caller to include in notifications"),
"Identifier of the caller to include in notifications"
),
}, },
}, },
}, },
@ -136,9 +126,7 @@ def main(
) )
# ASGI handler for streamable HTTP connections # ASGI handler for streamable HTTP connections
async def handle_streamable_http( async def handle_streamable_http(scope: Scope, receive: Receive, send: Send) -> None:
scope: Scope, receive: Receive, send: Send
) -> None:
await session_manager.handle_request(scope, receive, send) await session_manager.handle_request(scope, receive, send)
@contextlib.asynccontextmanager @contextlib.asynccontextmanager

View file

@ -29,8 +29,8 @@ select = ["E", "F", "I"]
ignore = [] ignore = []
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 120
target-version = "py310" target-version = "py310"
[tool.uv] [tool.uv]
dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] dev-dependencies = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]

View file

@ -1,16 +1,17 @@
from typing import Any
import anyio import anyio
import click import click
import mcp.types as types import mcp.types as types
from mcp.server.lowlevel import Server from mcp.server.lowlevel import Server
from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared._httpx_utils import create_mcp_http_client
from starlette.requests import Request
async def fetch_website( async def fetch_website(
url: str, url: str,
) -> list[types.ContentBlock]: ) -> list[types.ContentBlock]:
headers = { headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"
}
async with create_mcp_http_client(headers=headers) as client: async with create_mcp_http_client(headers=headers) as client:
response = await client.get(url) response = await client.get(url)
response.raise_for_status() response.raise_for_status()
@ -29,7 +30,7 @@ def main(port: int, transport: str) -> int:
app = Server("mcp-website-fetcher") app = Server("mcp-website-fetcher")
@app.call_tool() @app.call_tool()
async def fetch_tool(name: str, arguments: dict) -> list[types.ContentBlock]: async def fetch_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]:
if name != "fetch": if name != "fetch":
raise ValueError(f"Unknown tool: {name}") raise ValueError(f"Unknown tool: {name}")
if "url" not in arguments: if "url" not in arguments:
@ -64,13 +65,9 @@ def main(port: int, transport: str) -> int:
sse = SseServerTransport("/messages/") sse = SseServerTransport("/messages/")
async def handle_sse(request): async def handle_sse(request: Request):
async with sse.connect_sse( async with sse.connect_sse(request.scope, request.receive, request._send) as streams: # type: ignore[reportPrivateUsage]
request.scope, request.receive, request._send await app.run(streams[0], streams[1], app.create_initialization_options())
) as streams:
await app.run(
streams[0], streams[1], app.create_initialization_options()
)
return Response() return Response()
starlette_app = Starlette( starlette_app = Starlette(
@ -89,9 +86,7 @@ def main(port: int, transport: str) -> int:
async def arun(): async def arun():
async with stdio_server() as streams: async with stdio_server() as streams:
await app.run( await app.run(streams[0], streams[1], app.create_initialization_options())
streams[0], streams[1], app.create_initialization_options()
)
anyio.run(arun) anyio.run(arun)

View file

@ -40,7 +40,7 @@ select = ["E", "F", "I"]
ignore = [] ignore = []
[tool.ruff] [tool.ruff]
line-length = 88 line-length = 120
target-version = "py310" target-version = "py310"
[tool.uv] [tool.uv]

Some files were not shown because too many files have changed in this diff Show more