Add type checker to examples/client (#1837)

This commit is contained in:
Marcelo Trylesinski 2026-01-07 17:08:18 +01:00 • committed by GitHub
commit 1fd557afdc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 65 additions and 37 deletions

View file

@ -29,7 +29,8 @@ import json
import logging import logging
import os import os
import sys import sys
from urllib.parse import ParseResult, parse_qs, urlparse from typing import Any, cast
from urllib.parse import parse_qs, urlparse
import httpx import httpx
from mcp import ClientSession from mcp import ClientSession
@ -39,12 +40,12 @@ from mcp.client.auth.extensions.client_credentials import (
PrivateKeyJWTOAuthProvider, PrivateKeyJWTOAuthProvider,
SignedJWTParameters, SignedJWTParameters,
) )
from mcp.client.streamable_http import streamablehttp_client from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from pydantic import AnyUrl from pydantic import AnyUrl
def get_conformance_context() -> dict: def get_conformance_context() -> dict[str, Any]:
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable.""" """Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
if not context_json: if not context_json:
@ -116,9 +117,9 @@ class ConformanceOAuthCallbackHandler:
# Check for redirect response # Check for redirect response
if response.status_code in (301, 302, 303, 307, 308): if response.status_code in (301, 302, 303, 307, 308):
location = response.headers.get("location") location = cast(str, response.headers.get("location"))
if location: if location:
redirect_url: ParseResult = urlparse(location) redirect_url = urlparse(location)
query_params: dict[str, list[str]] = parse_qs(redirect_url.query) query_params: dict[str, list[str]] = parse_qs(redirect_url.query)
if "code" in query_params: if "code" in query_params:
@ -259,12 +260,8 @@ async def run_client_credentials_basic_client(server_url: str) -> None:
async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: async def _run_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
"""Common session logic for all OAuth flows.""" """Common session logic for all OAuth flows."""
# Connect using streamable HTTP transport with OAuth # Connect using streamable HTTP transport with OAuth
async with streamablehttp_client( client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
url=server_url, async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream, _):
auth=oauth_auth,
timeout=30.0,
sse_read_timeout=60.0,
) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
# Initialize the session # Initialize the session
await session.initialize() await session.initialize()

View file

@ -6,21 +6,26 @@ This client connects to an MCP server using streamable HTTP transport with OAuth
""" """
from __future__ import annotations as _annotations
import asyncio import asyncio
import os import os
import socketserver
import threading import threading
import time import time
import webbrowser import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any from typing import Any, Callable
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
import httpx import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.session import ClientSession from mcp.client.session import ClientSession
from mcp.client.sse import sse_client from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.message import SessionMessage
class InMemoryTokenStorage(TokenStorage): class InMemoryTokenStorage(TokenStorage):
@ -46,7 +51,13 @@ class InMemoryTokenStorage(TokenStorage):
class CallbackHandler(BaseHTTPRequestHandler): class CallbackHandler(BaseHTTPRequestHandler):
"""Simple HTTP handler to capture OAuth callback.""" """Simple HTTP handler to capture OAuth callback."""
def __init__(self, request, client_address, server, callback_data): def __init__(
self,
request: Any,
client_address: tuple[str, int],
server: socketserver.BaseServer,
callback_data: dict[str, Any],
):
"""Initialize with callback data storage.""" """Initialize with callback data storage."""
self.callback_data = callback_data self.callback_data = callback_data
super().__init__(request, client_address, server) super().__init__(request, client_address, server)
@ -91,15 +102,14 @@ class CallbackHandler(BaseHTTPRequestHandler):
self.send_response(404) self.send_response(404)
self.end_headers() self.end_headers()
def log_message(self, format, *args): def log_message(self, format: str, *args: Any):
"""Suppress default logging.""" """Suppress default logging."""
pass
class CallbackServer: class CallbackServer:
"""Simple server to handle OAuth callbacks.""" """Simple server to handle OAuth callbacks."""
def __init__(self, port=3000): def __init__(self, port: int = 3000):
self.port = port self.port = port
self.server = None self.server = None
self.thread = None self.thread = None
@ -110,7 +120,12 @@ class CallbackServer:
callback_data = self.callback_data callback_data = self.callback_data
class DataCallbackHandler(CallbackHandler): class DataCallbackHandler(CallbackHandler):
def __init__(self, request, client_address, server): def __init__(
self,
request: BaseHTTPRequestHandler,
client_address: tuple[str, int],
server: socketserver.BaseServer,
):
super().__init__(request, client_address, server, callback_data) super().__init__(request, client_address, server, callback_data)
return DataCallbackHandler return DataCallbackHandler
@ -131,7 +146,7 @@ class CallbackServer:
if self.thread: if self.thread:
self.thread.join(timeout=1) self.thread.join(timeout=1)
def wait_for_callback(self, timeout=300): def wait_for_callback(self, timeout: int = 300):
"""Wait for OAuth callback with timeout.""" """Wait for OAuth callback with timeout."""
start_time = time.time() start_time = time.time()
while time.time() - start_time < timeout: while time.time() - start_time < timeout:
@ -225,7 +240,12 @@ class SimpleAuthClient:
traceback.print_exc() traceback.print_exc()
async def _run_session(self, read_stream, write_stream, get_session_id): async def _run_session(
self,
read_stream: MemoryObjectReceiveStream[SessionMessage | Exception],
write_stream: MemoryObjectSendStream[SessionMessage],
get_session_id: Callable[[], str | None] | None = None,
):
"""Run the MCP session with the given streams.""" """Run the MCP session with the given streams."""
print("🤝 Initializing MCP session...") print("🤝 Initializing MCP session...")
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
@ -314,7 +334,7 @@ class SimpleAuthClient:
continue continue
# Parse arguments (simple JSON-like format) # Parse arguments (simple JSON-like format)
arguments = {} arguments: dict[str, Any] = {}
if len(parts) > 2: if len(parts) > 2:
import json import json

View file

@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
@ -93,7 +95,7 @@ class Server:
await self.cleanup() await self.cleanup()
raise raise
async def list_tools(self) -> list[Any]: async def list_tools(self) -> list[Tool]:
"""List available tools from the server. """List available tools from the server.
Returns: Returns:
@ -106,10 +108,10 @@ class Server:
raise RuntimeError(f"Server {self.name} not initialized") raise RuntimeError(f"Server {self.name} not initialized")
tools_response = await self.session.list_tools() tools_response = await self.session.list_tools()
tools = [] tools: list[Tool] = []
for item in tools_response: for item in tools_response:
if isinstance(item, tuple) and item[0] == "tools": if item[0] == "tools":
tools.extend(Tool(tool.name, tool.description, tool.inputSchema, tool.title) for tool in item[1]) tools.extend(Tool(tool.name, tool.description, tool.inputSchema, tool.title) for tool in item[1])
return tools return tools
@ -189,7 +191,7 @@ class Tool:
Returns: Returns:
A formatted string describing the tool. A formatted string describing the tool.
""" """
args_desc = [] args_desc: list[str] = []
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 = f"- {param_name}: {param_info.get('description', 'No description')}" arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}"
@ -311,9 +313,9 @@ class ChatSession:
result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) result = await server.execute_tool(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"] # type: ignore
total = result["total"] total = result["total"] # type: ignore
percentage = (progress / total) * 100 percentage = (progress / total) * 100 # type: ignore
logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)") logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)")
return f"Tool execution result: {result}" return f"Tool execution result: {result}"
@ -338,7 +340,7 @@ class ChatSession:
await self.cleanup_servers() await self.cleanup_servers()
return return
all_tools = [] all_tools: list[Tool] = []
for server in self.servers: for server in self.servers:
tools = await server.list_tools() tools = await server.list_tools()
all_tools.extend(tools) all_tools.extend(tools)

View file

@ -4,12 +4,12 @@ import asyncio
import click import click
from mcp import ClientSession from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client from mcp.client.streamable_http import streamable_http_client
from mcp.types import CallToolResult, TextContent from mcp.types import CallToolResult, TextContent
async def run(url: str) -> None: async def run(url: str) -> None:
async with streamablehttp_client(url) as (read, write, _): async with streamable_http_client(url) as (read, write, _):
async with ClientSession(read, write) as session: async with ClientSession(read, write) as session:
await session.initialize() await session.initialize()
@ -28,12 +28,13 @@ async def run(url: str) -> None:
task_id = result.task.taskId task_id = result.task.taskId
print(f"Task created: {task_id}") print(f"Task created: {task_id}")
status = None
# Poll until done (respects server's pollInterval hint) # Poll until done (respects server's pollInterval hint)
async for status in session.experimental.poll_task(task_id): async for status in session.experimental.poll_task(task_id):
print(f" Status: {status.status} - {status.statusMessage or ''}") print(f" Status: {status.status} - {status.statusMessage or ''}")
# Check final status # Check final status
if status.status != "completed": if status and status.status != "completed":
print(f"Task ended with status: {status.status}") print(f"Task ended with status: {status.status}")
return return

View file

@ -11,7 +11,7 @@ from typing import Any
import click import click
from mcp import ClientSession from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client from mcp.client.streamable_http import streamable_http_client
from mcp.shared.context import RequestContext from mcp.shared.context import RequestContext
from mcp.types import ( from mcp.types import (
CallToolResult, CallToolResult,
@ -73,7 +73,7 @@ def get_text(result: CallToolResult) -> str:
async def run(url: str) -> None: async def run(url: str) -> None:
async with streamablehttp_client(url) as (read, write, _): async with streamable_http_client(url) as (read, write, _):
async with ClientSession( async with ClientSession(
read, read,
write, write,

View file

@ -20,7 +20,7 @@ import logging
import click import click
from mcp import ClientSession from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client from mcp.client.streamable_http import streamable_http_client
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,7 +34,7 @@ async def run_demo(url: str, items: int, checkpoint_every: int) -> None:
print(f"Processing {items} items with checkpoints every {checkpoint_every}") print(f"Processing {items} items with checkpoints every {checkpoint_every}")
print(f"{'=' * 60}\n") print(f"{'=' * 60}\n")
async with streamablehttp_client(url) as (read_stream, write_stream, _): async with streamable_http_client(url) as (read_stream, write_stream, _):
async with ClientSession(read_stream, write_stream) as session: async with ClientSession(read_stream, write_stream) as session:
# Initialize the connection # Initialize the connection
print("Initializing connection...") print("Initializing connection...")

View file

@ -93,7 +93,13 @@ packages = ["src/mcp"]
[tool.pyright] [tool.pyright]
typeCheckingMode = "strict" typeCheckingMode = "strict"
include = ["src/mcp", "tests", "examples/servers", "examples/snippets"] include = [
"src/mcp",
"tests",
"examples/servers",
"examples/snippets",
"examples/clients",
]
venvPath = "." venvPath = "."
venv = ".venv" venv = ".venv"
# The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. # The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error.
@ -102,7 +108,9 @@ venv = ".venv"
# those private functions instead of testing the private functions directly. It makes it easier to maintain the code source # those private functions instead of testing the private functions directly. It makes it easier to maintain the code source
# and refactor code that is not public. # and refactor code that is not public.
executionEnvironments = [ executionEnvironments = [
{ root = "tests", extraPaths = ["."], reportUnusedFunction = false, reportPrivateUsage = false }, { root = "tests", extraPaths = [
".",
], reportUnusedFunction = false, reportPrivateUsage = false },
{ root = "examples/servers", reportUnusedFunction = false }, { root = "examples/servers", reportUnusedFunction = false },
] ]