Add type checker to examples/client (#1837)
This commit is contained in:
parent
3863f203e9
commit
1fd557afdc
7 changed files with 65 additions and 37 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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...")
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue