chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
# Simple Auth Client Example
|
||||
|
||||
A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport.
|
||||
|
||||
## Features
|
||||
|
||||
- OAuth 2.0 authentication with PKCE
|
||||
- Support for both StreamableHTTP and SSE transports
|
||||
- Interactive command-line interface
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd examples/clients/simple-auth-client
|
||||
uv sync --reinstall
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Start an MCP server with OAuth support
|
||||
|
||||
The simple-auth server example provides three server configurations. See [examples/servers/simple-auth/README.md](../../servers/simple-auth/README.md) for full details.
|
||||
|
||||
#### Option A: New Architecture (Recommended)
|
||||
|
||||
Separate Authorization Server and Resource Server:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start Authorization Server on port 9000
|
||||
cd examples/servers/simple-auth
|
||||
uv run mcp-simple-auth-as --port=9000
|
||||
|
||||
# Terminal 2: Start Resource Server on port 8001
|
||||
cd examples/servers/simple-auth
|
||||
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
|
||||
```
|
||||
|
||||
#### Option B: Legacy Server (Backwards Compatibility)
|
||||
|
||||
```bash
|
||||
# Single server that acts as both AS and RS (port 8000)
|
||||
cd examples/servers/simple-auth
|
||||
uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http
|
||||
```
|
||||
|
||||
### 2. Run the client
|
||||
|
||||
```bash
|
||||
# Connect to Resource Server (new architecture, default port 8001)
|
||||
MCP_SERVER_PORT=8001 uv run mcp-simple-auth-client
|
||||
|
||||
# Connect to Legacy Server (port 8000)
|
||||
uv run mcp-simple-auth-client
|
||||
|
||||
# Use SSE transport
|
||||
MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client
|
||||
```
|
||||
|
||||
### 3. Complete OAuth flow
|
||||
|
||||
The client will open your browser for authentication. After completing OAuth, you can use commands:
|
||||
|
||||
- `list` - List available tools
|
||||
- `call <tool_name> [args]` - Call a tool with optional JSON arguments
|
||||
- `quit` - Exit
|
||||
|
||||
## Example
|
||||
|
||||
```markdown
|
||||
🚀 Simple MCP Auth Client
|
||||
Connecting to: http://localhost:8001/mcp
|
||||
Transport type: streamable-http
|
||||
|
||||
🔗 Attempting to connect to http://localhost:8001/mcp...
|
||||
📡 Opening StreamableHTTP transport connection with auth...
|
||||
Opening browser for authorization: http://localhost:9000/authorize?...
|
||||
|
||||
✅ Connected to MCP server at http://localhost:8001/mcp
|
||||
|
||||
mcp> list
|
||||
📋 Available tools:
|
||||
1. get_time
|
||||
Description: Get the current server time.
|
||||
|
||||
mcp> call get_time
|
||||
🔧 Tool 'get_time' result:
|
||||
{"current_time": "2024-01-15T10:30:00", "timezone": "UTC", ...}
|
||||
|
||||
mcp> quit
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
|---------------------|-------------|---------|
|
||||
| `MCP_SERVER_PORT` | Port number of the MCP server | `8000` |
|
||||
| `MCP_TRANSPORT_TYPE` | Transport type: `streamable-http` or `sse` | `streamable-http` |
|
||||
| `MCP_CLIENT_METADATA_URL` | Optional URL for client metadata (CIMD) | None |
|
||||
@@ -0,0 +1 @@
|
||||
"""Simple OAuth client for MCP simple-auth server."""
|
||||
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple MCP client example with OAuth authentication support.
|
||||
|
||||
This client connects to an MCP server using streamable HTTP transport with OAuth.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations as _annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from mcp.client._transport import ReadStream, WriteStream
|
||||
from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.sse import sse_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
from mcp.shared.message import SessionMessage
|
||||
|
||||
|
||||
class InMemoryTokenStorage(TokenStorage):
|
||||
"""Simple in-memory token storage implementation."""
|
||||
|
||||
def __init__(self):
|
||||
self._tokens: OAuthToken | None = None
|
||||
self._client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self._tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self._tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self._client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self._client_info = client_info
|
||||
|
||||
|
||||
class CallbackHandler(BaseHTTPRequestHandler):
|
||||
"""Simple HTTP handler to capture OAuth callback."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request: Any,
|
||||
client_address: tuple[str, int],
|
||||
server: socketserver.BaseServer,
|
||||
callback_data: dict[str, Any],
|
||||
):
|
||||
"""Initialize with callback data storage."""
|
||||
self.callback_data = callback_data
|
||||
super().__init__(request, client_address, server)
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET request from OAuth redirect."""
|
||||
parsed = urlparse(self.path)
|
||||
query_params = parse_qs(parsed.query)
|
||||
|
||||
if "code" in query_params:
|
||||
self.callback_data["authorization_code"] = query_params["code"][0]
|
||||
self.callback_data["state"] = query_params.get("state", [None])[0]
|
||||
self.callback_data["iss"] = query_params.get("iss", [None])[0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
<script>setTimeout(() => window.close(), 2000);</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
elif "error" in query_params:
|
||||
self.callback_data["error"] = query_params["error"][0]
|
||||
self.send_response(400)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(
|
||||
f"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Failed</h1>
|
||||
<p>Error: {query_params["error"][0]}</p>
|
||||
<p>You can close this window and return to the terminal.</p>
|
||||
</body>
|
||||
</html>
|
||||
""".encode()
|
||||
)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, format: str, *args: Any):
|
||||
"""Suppress default logging."""
|
||||
|
||||
|
||||
class CallbackServer:
|
||||
"""Simple server to handle OAuth callbacks."""
|
||||
|
||||
def __init__(self, port: int = 3000):
|
||||
self.port = port
|
||||
self.server = None
|
||||
self.thread = None
|
||||
self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None}
|
||||
|
||||
def _create_handler_with_data(self):
|
||||
"""Create a handler class with access to callback data."""
|
||||
callback_data = self.callback_data
|
||||
|
||||
class DataCallbackHandler(CallbackHandler):
|
||||
def __init__(
|
||||
self,
|
||||
request: BaseHTTPRequestHandler,
|
||||
client_address: tuple[str, int],
|
||||
server: socketserver.BaseServer,
|
||||
):
|
||||
super().__init__(request, client_address, server, callback_data)
|
||||
|
||||
return DataCallbackHandler
|
||||
|
||||
def start(self):
|
||||
"""Start the callback server in a background thread."""
|
||||
handler_class = self._create_handler_with_data()
|
||||
self.server = HTTPServer(("localhost", self.port), handler_class)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
print(f"🖥️ Started callback server on http://localhost:{self.port}")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the callback server."""
|
||||
if self.server:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
if self.thread:
|
||||
self.thread.join(timeout=1)
|
||||
|
||||
def wait_for_callback(self, timeout: int = 300):
|
||||
"""Wait for OAuth callback with timeout."""
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
if self.callback_data["authorization_code"]:
|
||||
return self.callback_data["authorization_code"]
|
||||
elif self.callback_data["error"]:
|
||||
raise Exception(f"OAuth error: {self.callback_data['error']}")
|
||||
time.sleep(0.1)
|
||||
raise Exception("Timeout waiting for OAuth callback")
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""The received state parameter."""
|
||||
return self.callback_data["state"]
|
||||
|
||||
@property
|
||||
def iss(self):
|
||||
"""The received iss parameter."""
|
||||
return self.callback_data["iss"]
|
||||
|
||||
|
||||
class SimpleAuthClient:
|
||||
"""Simple MCP client with auth support."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str,
|
||||
transport_type: str = "streamable-http",
|
||||
client_metadata_url: str | None = None,
|
||||
):
|
||||
self.server_url = server_url
|
||||
self.transport_type = transport_type
|
||||
self.client_metadata_url = client_metadata_url
|
||||
self.session: ClientSession | None = None
|
||||
|
||||
async def connect(self):
|
||||
"""Connect to the MCP server."""
|
||||
print(f"🔗 Attempting to connect to {self.server_url}...")
|
||||
|
||||
try:
|
||||
callback_server = CallbackServer(port=3030)
|
||||
callback_server.start()
|
||||
|
||||
async def callback_handler() -> AuthorizationCodeResult:
|
||||
"""Wait for OAuth callback and return auth code, state, and iss."""
|
||||
print("⏳ Waiting for authorization callback...")
|
||||
try:
|
||||
auth_code = callback_server.wait_for_callback(timeout=300)
|
||||
return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss)
|
||||
finally:
|
||||
callback_server.stop()
|
||||
|
||||
client_metadata_dict = {
|
||||
"client_name": "Simple Auth Client",
|
||||
"redirect_uris": ["http://localhost:3030/callback"],
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
}
|
||||
|
||||
async def _default_redirect_handler(authorization_url: str) -> None:
|
||||
"""Default redirect handler that opens the URL in a browser."""
|
||||
print(f"Opening browser for authorization: {authorization_url}")
|
||||
webbrowser.open(authorization_url)
|
||||
|
||||
# Create OAuth authentication handler using the new interface
|
||||
# Use client_metadata_url to enable CIMD when the server supports it
|
||||
oauth_auth = OAuthClientProvider(
|
||||
server_url=self.server_url.replace("/mcp", ""),
|
||||
client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict),
|
||||
storage=InMemoryTokenStorage(),
|
||||
redirect_handler=_default_redirect_handler,
|
||||
callback_handler=callback_handler,
|
||||
client_metadata_url=self.client_metadata_url,
|
||||
)
|
||||
|
||||
# Create transport with auth handler based on transport type
|
||||
if self.transport_type == "sse":
|
||||
print("📡 Opening SSE transport connection with auth...")
|
||||
async with sse_client(
|
||||
url=self.server_url,
|
||||
auth=oauth_auth,
|
||||
timeout=60.0,
|
||||
) as (read_stream, write_stream):
|
||||
await self._run_session(read_stream, write_stream)
|
||||
else:
|
||||
print("📡 Opening StreamableHTTP transport connection with auth...")
|
||||
async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client:
|
||||
async with streamable_http_client(url=self.server_url, http_client=custom_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
):
|
||||
await self._run_session(read_stream, write_stream)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to connect: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
async def _run_session(
|
||||
self,
|
||||
read_stream: ReadStream[SessionMessage | Exception],
|
||||
write_stream: WriteStream[SessionMessage],
|
||||
):
|
||||
"""Run the MCP session with the given streams."""
|
||||
print("🤝 Initializing MCP session...")
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
self.session = session
|
||||
print("⚡ Starting session initialization...")
|
||||
await session.initialize()
|
||||
print("✨ Session initialization complete!")
|
||||
|
||||
print(f"\n✅ Connected to MCP server at {self.server_url}")
|
||||
|
||||
# Run interactive loop
|
||||
await self.interactive_loop()
|
||||
|
||||
async def list_tools(self):
|
||||
"""List available tools from the server."""
|
||||
if not self.session:
|
||||
print("❌ Not connected to server")
|
||||
return
|
||||
|
||||
try:
|
||||
result = await self.session.list_tools()
|
||||
if hasattr(result, "tools") and result.tools:
|
||||
print("\n📋 Available tools:")
|
||||
for i, tool in enumerate(result.tools, 1):
|
||||
print(f"{i}. {tool.name}")
|
||||
if tool.description:
|
||||
print(f" Description: {tool.description}")
|
||||
print()
|
||||
else:
|
||||
print("No tools available")
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to list tools: {e}")
|
||||
|
||||
async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None):
|
||||
"""Call a specific tool."""
|
||||
if not self.session:
|
||||
print("❌ Not connected to server")
|
||||
return
|
||||
|
||||
try:
|
||||
result = await self.session.call_tool(tool_name, arguments or {})
|
||||
print(f"\n🔧 Tool '{tool_name}' result:")
|
||||
if hasattr(result, "content"):
|
||||
for content in result.content:
|
||||
if content.type == "text":
|
||||
print(content.text)
|
||||
else:
|
||||
print(content)
|
||||
else:
|
||||
print(result)
|
||||
except Exception as e:
|
||||
print(f"❌ Failed to call tool '{tool_name}': {e}")
|
||||
|
||||
async def interactive_loop(self):
|
||||
"""Run interactive command loop."""
|
||||
print("\n🎯 Interactive MCP Client")
|
||||
print("Commands:")
|
||||
print(" list - List available tools")
|
||||
print(" call <tool_name> [args] - Call a tool")
|
||||
print(" quit - Exit the client")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
command = input("mcp> ").strip()
|
||||
|
||||
if not command:
|
||||
continue
|
||||
|
||||
if command == "quit":
|
||||
break
|
||||
|
||||
elif command == "list":
|
||||
await self.list_tools()
|
||||
|
||||
elif command.startswith("call "):
|
||||
parts = command.split(maxsplit=2)
|
||||
tool_name = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if not tool_name:
|
||||
print("❌ Please specify a tool name")
|
||||
continue
|
||||
|
||||
# Parse arguments (simple JSON-like format)
|
||||
arguments: dict[str, Any] = {}
|
||||
if len(parts) > 2:
|
||||
import json
|
||||
|
||||
try:
|
||||
arguments = json.loads(parts[2])
|
||||
except json.JSONDecodeError:
|
||||
print("❌ Invalid arguments format (expected JSON)")
|
||||
continue
|
||||
|
||||
await self.call_tool(tool_name, arguments)
|
||||
|
||||
else:
|
||||
print("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 Goodbye!")
|
||||
break
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main entry point."""
|
||||
# Default server URL - can be overridden with environment variable
|
||||
# Most MCP streamable HTTP servers use /mcp as the endpoint
|
||||
server_url = os.getenv("MCP_SERVER_PORT", 8000)
|
||||
transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http")
|
||||
client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL")
|
||||
server_url = (
|
||||
f"http://localhost:{server_url}/mcp"
|
||||
if transport_type == "streamable-http"
|
||||
else f"http://localhost:{server_url}/sse"
|
||||
)
|
||||
|
||||
print("🚀 Simple MCP Auth Client")
|
||||
print(f"Connecting to: {server_url}")
|
||||
print(f"Transport type: {transport_type}")
|
||||
if client_metadata_url:
|
||||
print(f"Client metadata URL: {client_metadata_url}")
|
||||
|
||||
# Start connection flow - OAuth will be handled automatically
|
||||
client = SimpleAuthClient(server_url, transport_type, client_metadata_url)
|
||||
await client.connect()
|
||||
|
||||
|
||||
def cli():
|
||||
"""CLI entry point for uv script."""
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "mcp-simple-auth-client"
|
||||
version = "0.1.0"
|
||||
description = "A simple OAuth client for the MCP simple-auth server"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "oauth", "client", "auth"]
|
||||
license = { text = "MIT" }
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
dependencies = ["click>=8.2.0", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-auth-client = "mcp_simple_auth_client.main:cli"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_auth_client"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_auth_client"]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = []
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"]
|
||||
@@ -0,0 +1,113 @@
|
||||
# MCP Simple Chatbot
|
||||
|
||||
This example demonstrates how to integrate the Model Context Protocol (MCP) into a simple CLI chatbot. The implementation showcases MCP's flexibility by supporting multiple tools through MCP servers and is compatible with any LLM provider that follows OpenAI API standards.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10
|
||||
- `python-dotenv`
|
||||
- `requests`
|
||||
- `mcp`
|
||||
- `uvicorn`
|
||||
|
||||
## Installation
|
||||
|
||||
1. **Install the dependencies:**
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **Set up environment variables:**
|
||||
|
||||
Create a `.env` file in the root directory and add your API key:
|
||||
|
||||
```plaintext
|
||||
LLM_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
**Note:** The current implementation is configured to use the Groq API endpoint (`https://api.groq.com/openai/v1/chat/completions`) with the `llama-3.2-90b-vision-preview` model. If you plan to use a different LLM provider, you'll need to modify the `LLMClient` class in `main.py` to use the appropriate endpoint URL and model parameters.
|
||||
|
||||
3. **Configure servers:**
|
||||
|
||||
The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers.
|
||||
Here's an example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"sqlite": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-sqlite", "--db-path", "./test.db"]
|
||||
},
|
||||
"puppeteer": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-puppeteer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables are supported as well. Pass them as you would with the Claude Desktop App.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"server_name": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-name", "--additional-args"],
|
||||
"env": {
|
||||
"API_KEY": "your_api_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Run the client:**
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
2. **Interact with the assistant:**
|
||||
|
||||
The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers.
|
||||
|
||||
3. **Exit the session:**
|
||||
|
||||
Type `quit` or `exit` to end the session.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Tool Discovery**: Tools are automatically discovered from configured servers.
|
||||
- **System Prompt**: Tools are dynamically included in the system prompt, allowing the LLM to understand available capabilities.
|
||||
- **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js.
|
||||
|
||||
### Class Structure
|
||||
|
||||
- **Configuration**: Manages environment variables and server configurations
|
||||
- **Server**: Handles MCP server initialization, tool discovery, and execution
|
||||
- **Tool**: Represents individual tools with their properties and formatting
|
||||
- **LLMClient**: Manages communication with the LLM provider
|
||||
- **ChatSession**: Orchestrates the interaction between user, LLM, and tools
|
||||
|
||||
### Logic Flow
|
||||
|
||||
1. **Tool Integration**:
|
||||
- Tools are dynamically discovered from MCP servers
|
||||
- Tool descriptions are automatically included in system prompt
|
||||
- Tool execution is handled through standardized MCP protocol
|
||||
|
||||
2. **Runtime Flow**:
|
||||
- User input is received
|
||||
- Input is sent to LLM with context of available tools
|
||||
- LLM response is parsed:
|
||||
- If it's a tool call → execute tool and return result
|
||||
- If it's a direct response → return to user
|
||||
- Tool results are sent back to LLM for interpretation
|
||||
- Final response is presented to user
|
||||
@@ -0,0 +1 @@
|
||||
LLM_API_KEY=gsk_1234567890
|
||||
@@ -0,0 +1,421 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from contextlib import AsyncExitStack
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
|
||||
|
||||
|
||||
class Configuration:
|
||||
"""Manages configuration and environment variables for the MCP client."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize configuration with environment variables."""
|
||||
self.load_env()
|
||||
self.api_key = os.getenv("LLM_API_KEY")
|
||||
|
||||
@staticmethod
|
||||
def load_env() -> None:
|
||||
"""Load environment variables from .env file."""
|
||||
load_dotenv()
|
||||
|
||||
@staticmethod
|
||||
def load_config(file_path: str) -> dict[str, Any]:
|
||||
"""Load server configuration from JSON file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the JSON configuration file.
|
||||
|
||||
Returns:
|
||||
Dict containing server configuration.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If configuration file doesn't exist.
|
||||
JSONDecodeError: If configuration file is invalid JSON.
|
||||
"""
|
||||
with open(file_path, "r") as f:
|
||||
return json.load(f)
|
||||
|
||||
@property
|
||||
def llm_api_key(self) -> str:
|
||||
"""Get the LLM API key.
|
||||
|
||||
Returns:
|
||||
The API key as a string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the API key is not found in environment variables.
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise ValueError("LLM_API_KEY not found in environment variables")
|
||||
return self.api_key
|
||||
|
||||
|
||||
class Server:
|
||||
"""Manages MCP server connections and tool execution."""
|
||||
|
||||
def __init__(self, name: str, config: dict[str, Any]) -> None:
|
||||
self.name: str = name
|
||||
self.config: dict[str, Any] = config
|
||||
self.stdio_context: Any | None = None
|
||||
self.session: ClientSession | None = None
|
||||
self._cleanup_lock: asyncio.Lock = asyncio.Lock()
|
||||
self.exit_stack: AsyncExitStack = AsyncExitStack()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the server connection."""
|
||||
command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"]
|
||||
if command is None:
|
||||
raise ValueError("The command must be a valid string and cannot be None.")
|
||||
|
||||
server_params = StdioServerParameters(
|
||||
command=command,
|
||||
args=self.config["args"],
|
||||
env={**os.environ, **self.config["env"]} if self.config.get("env") else None,
|
||||
)
|
||||
try:
|
||||
stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params))
|
||||
read, write = stdio_transport
|
||||
session = await self.exit_stack.enter_async_context(ClientSession(read, write))
|
||||
await session.initialize()
|
||||
self.session = session
|
||||
except Exception as e:
|
||||
logging.error(f"Error initializing server {self.name}: {e}")
|
||||
await self.cleanup()
|
||||
raise
|
||||
|
||||
async def list_tools(self) -> list[Tool]:
|
||||
"""List available tools from the server.
|
||||
|
||||
Returns:
|
||||
A list of available tools.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the server is not initialized.
|
||||
"""
|
||||
if not self.session:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
|
||||
tools_response = await self.session.list_tools()
|
||||
tools: list[Tool] = []
|
||||
|
||||
for item in tools_response:
|
||||
if item[0] == "tools":
|
||||
tools.extend(Tool(tool.name, tool.description, tool.input_schema, tool.title) for tool in item[1])
|
||||
|
||||
return tools
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: dict[str, Any],
|
||||
retries: int = 2,
|
||||
delay: float = 1.0,
|
||||
) -> Any:
|
||||
"""Execute a tool with retry mechanism.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to execute.
|
||||
arguments: Tool arguments.
|
||||
retries: Number of retry attempts.
|
||||
delay: Delay between retries in seconds.
|
||||
|
||||
Returns:
|
||||
Tool execution result.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If server is not initialized.
|
||||
Exception: If tool execution fails after all retries.
|
||||
"""
|
||||
if not self.session:
|
||||
raise RuntimeError(f"Server {self.name} not initialized")
|
||||
|
||||
attempt = 0
|
||||
while attempt < retries:
|
||||
try:
|
||||
logging.info(f"Executing {tool_name}...")
|
||||
result = await self.session.call_tool(tool_name, arguments)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
attempt += 1
|
||||
logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.")
|
||||
if attempt < retries:
|
||||
logging.info(f"Retrying in {delay} seconds...")
|
||||
await asyncio.sleep(delay)
|
||||
else:
|
||||
logging.error("Max retries reached. Failing.")
|
||||
raise
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up server resources."""
|
||||
async with self._cleanup_lock:
|
||||
try:
|
||||
await self.exit_stack.aclose()
|
||||
self.session = None
|
||||
self.stdio_context = None
|
||||
except Exception as e:
|
||||
logging.error(f"Error during cleanup of server {self.name}: {e}")
|
||||
|
||||
|
||||
class Tool:
|
||||
"""Represents a tool with its properties and formatting."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
input_schema: dict[str, Any],
|
||||
title: str | None = None,
|
||||
) -> None:
|
||||
self.name: str = name
|
||||
self.title: str | None = title
|
||||
self.description: str = description
|
||||
self.input_schema: dict[str, Any] = input_schema
|
||||
|
||||
def format_for_llm(self) -> str:
|
||||
"""Format tool information for LLM.
|
||||
|
||||
Returns:
|
||||
A formatted string describing the tool.
|
||||
"""
|
||||
args_desc: list[str] = []
|
||||
if "properties" in self.input_schema:
|
||||
for param_name, param_info in self.input_schema["properties"].items():
|
||||
arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}"
|
||||
if param_name in self.input_schema.get("required", []):
|
||||
arg_desc += " (required)"
|
||||
args_desc.append(arg_desc)
|
||||
|
||||
# Build the formatted output with title as a separate field
|
||||
output = f"Tool: {self.name}\n"
|
||||
|
||||
# Add human-readable title if available
|
||||
if self.title:
|
||||
output += f"User-readable title: {self.title}\n"
|
||||
|
||||
output += f"""Description: {self.description}
|
||||
Arguments:
|
||||
{chr(10).join(args_desc)}
|
||||
"""
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class LLMClient:
|
||||
"""Manages communication with the LLM provider."""
|
||||
|
||||
def __init__(self, api_key: str) -> None:
|
||||
self.api_key: str = api_key
|
||||
|
||||
def get_response(self, messages: list[dict[str, str]]) -> str:
|
||||
"""Get a response from the LLM.
|
||||
|
||||
Args:
|
||||
messages: A list of message dictionaries.
|
||||
|
||||
Returns:
|
||||
The LLM's response as a string.
|
||||
|
||||
Raises:
|
||||
httpx.RequestError: If the request to the LLM fails.
|
||||
"""
|
||||
url = "https://api.groq.com/openai/v1/chat/completions"
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
payload = {
|
||||
"messages": messages,
|
||||
"model": "meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
"top_p": 1,
|
||||
"stream": False,
|
||||
"stop": None,
|
||||
}
|
||||
|
||||
try:
|
||||
with httpx.Client() as client:
|
||||
response = client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
except httpx.RequestError as e:
|
||||
error_message = f"Error getting LLM response: {str(e)}"
|
||||
logging.error(error_message)
|
||||
|
||||
if isinstance(e, httpx.HTTPStatusError):
|
||||
status_code = e.response.status_code
|
||||
logging.error(f"Status code: {status_code}")
|
||||
logging.error(f"Response details: {e.response.text}")
|
||||
|
||||
return f"I encountered an error: {error_message}. Please try again or rephrase your request."
|
||||
|
||||
|
||||
class ChatSession:
|
||||
"""Orchestrates the interaction between user, LLM, and tools."""
|
||||
|
||||
def __init__(self, servers: list[Server], llm_client: LLMClient) -> None:
|
||||
self.servers: list[Server] = servers
|
||||
self.llm_client: LLMClient = llm_client
|
||||
|
||||
async def cleanup_servers(self) -> None:
|
||||
"""Clean up all servers properly."""
|
||||
for server in reversed(self.servers):
|
||||
try:
|
||||
await server.cleanup()
|
||||
except Exception as e:
|
||||
logging.warning(f"Warning during final cleanup: {e}")
|
||||
|
||||
async def process_llm_response(self, llm_response: str) -> str:
|
||||
"""Process the LLM response and execute tools if needed.
|
||||
|
||||
Args:
|
||||
llm_response: The response from the LLM.
|
||||
|
||||
Returns:
|
||||
The result of tool execution or the original response.
|
||||
"""
|
||||
import json
|
||||
|
||||
def _clean_json_string(json_string: str) -> str:
|
||||
"""Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced."""
|
||||
import re
|
||||
|
||||
pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$"
|
||||
return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip()
|
||||
|
||||
try:
|
||||
tool_call = json.loads(_clean_json_string(llm_response))
|
||||
if "tool" in tool_call and "arguments" in tool_call:
|
||||
logging.info(f"Executing tool: {tool_call['tool']}")
|
||||
logging.info(f"With arguments: {tool_call['arguments']}")
|
||||
|
||||
for server in self.servers:
|
||||
tools = await server.list_tools()
|
||||
if any(tool.name == tool_call["tool"] for tool in tools):
|
||||
try:
|
||||
result = await server.execute_tool(tool_call["tool"], tool_call["arguments"])
|
||||
|
||||
if isinstance(result, dict) and "progress" in result:
|
||||
progress = result["progress"] # type: ignore
|
||||
total = result["total"] # type: ignore
|
||||
percentage = (progress / total) * 100 # type: ignore
|
||||
logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)")
|
||||
|
||||
return f"Tool execution result: {result}"
|
||||
except Exception as e:
|
||||
error_msg = f"Error executing tool: {str(e)}"
|
||||
logging.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
return f"No server found with tool: {tool_call['tool']}"
|
||||
return llm_response
|
||||
except json.JSONDecodeError:
|
||||
return llm_response
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Main chat session handler."""
|
||||
try:
|
||||
for server in self.servers:
|
||||
try:
|
||||
await server.initialize()
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to initialize server: {e}")
|
||||
await self.cleanup_servers()
|
||||
return
|
||||
|
||||
all_tools: list[Tool] = []
|
||||
for server in self.servers:
|
||||
tools = await server.list_tools()
|
||||
all_tools.extend(tools)
|
||||
|
||||
tools_description = "\n".join([tool.format_for_llm() for tool in all_tools])
|
||||
|
||||
system_message = (
|
||||
"You are a helpful assistant with access to these tools:\n\n"
|
||||
f"{tools_description}\n"
|
||||
"Choose the appropriate tool based on the user's question. "
|
||||
"If no tool is needed, reply directly.\n\n"
|
||||
"IMPORTANT: When you need to use a tool, you must ONLY respond with "
|
||||
"the exact JSON object format below, nothing else:\n"
|
||||
"{\n"
|
||||
' "tool": "tool-name",\n'
|
||||
' "arguments": {\n'
|
||||
' "argument-name": "value"\n'
|
||||
" }\n"
|
||||
"}\n\n"
|
||||
"After receiving a tool's response:\n"
|
||||
"1. Transform the raw data into a natural, conversational response\n"
|
||||
"2. Keep responses concise but informative\n"
|
||||
"3. Focus on the most relevant information\n"
|
||||
"4. Use appropriate context from the user's question\n"
|
||||
"5. Avoid simply repeating the raw data\n\n"
|
||||
"Please use only the tools that are explicitly defined above."
|
||||
)
|
||||
|
||||
messages = [{"role": "system", "content": system_message}]
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input("You: ").strip().lower()
|
||||
if user_input in ["quit", "exit"]:
|
||||
logging.info("\nExiting...")
|
||||
break
|
||||
|
||||
messages.append({"role": "user", "content": user_input})
|
||||
|
||||
llm_response = self.llm_client.get_response(messages)
|
||||
logging.info("\nAssistant: %s", llm_response)
|
||||
|
||||
result = await self.process_llm_response(llm_response)
|
||||
|
||||
if result != llm_response:
|
||||
messages.append({"role": "assistant", "content": llm_response})
|
||||
messages.append({"role": "system", "content": result})
|
||||
|
||||
final_response = self.llm_client.get_response(messages)
|
||||
logging.info("\nFinal response: %s", final_response)
|
||||
messages.append({"role": "assistant", "content": final_response})
|
||||
else:
|
||||
messages.append({"role": "assistant", "content": llm_response})
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logging.info("\nExiting...")
|
||||
break
|
||||
|
||||
finally:
|
||||
await self.cleanup_servers()
|
||||
|
||||
|
||||
async def run() -> None:
|
||||
"""Initialize and run the chat session."""
|
||||
config = Configuration()
|
||||
server_config = config.load_config("servers_config.json")
|
||||
servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()]
|
||||
llm_client = LLMClient(config.llm_api_key)
|
||||
chat_session = ChatSession(servers, llm_client)
|
||||
await chat_session.start()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
mcp>=1.0.0
|
||||
uvicorn>=0.32.1
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"sqlite": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-sqlite", "--db-path", "./test.db"]
|
||||
},
|
||||
"puppeteer": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-puppeteer"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
[project]
|
||||
name = "mcp-simple-chatbot"
|
||||
version = "0.1.0"
|
||||
description = "A simple CLI chatbot using the Model Context Protocol (MCP)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "chatbot", "cli"]
|
||||
license = { text = "MIT" }
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
]
|
||||
dependencies = [
|
||||
"python-dotenv>=1.0.0",
|
||||
"mcp",
|
||||
"uvicorn>=0.32.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-chatbot = "mcp_simple_chatbot.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_chatbot"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_chatbot"]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = []
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"]
|
||||
@@ -0,0 +1,30 @@
|
||||
# MCP SSE Polling Demo Client
|
||||
|
||||
Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699).
|
||||
|
||||
## Features
|
||||
|
||||
- Connects to SSE polling demo server
|
||||
- Automatically reconnects when server closes SSE stream
|
||||
- Resumes from Last-Event-ID to avoid missing messages
|
||||
- Respects server-provided retry interval
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# First start the server:
|
||||
uv run mcp-sse-polling-demo --port 3000
|
||||
|
||||
# Then run this client:
|
||||
uv run mcp-sse-polling-client --url http://localhost:3000/mcp
|
||||
|
||||
# Custom options:
|
||||
uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
- `--url`: Server URL (default: <http://localhost:3000/mcp>)
|
||||
- `--items`: Number of items to process (default: 10)
|
||||
- `--checkpoint-every`: Checkpoint interval (default: 3)
|
||||
- `--log-level`: Logging level (default: DEBUG)
|
||||
@@ -0,0 +1 @@
|
||||
"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks."""
|
||||
@@ -0,0 +1,102 @@
|
||||
"""SSE Polling Demo Client
|
||||
|
||||
Demonstrates the client-side auto-reconnect for SSE polling pattern.
|
||||
|
||||
This client connects to the SSE Polling Demo server and calls process_batch,
|
||||
which triggers periodic server-side stream closes. The client automatically
|
||||
reconnects using Last-Event-ID and resumes receiving messages.
|
||||
|
||||
Run with:
|
||||
# First start the server:
|
||||
uv run mcp-sse-polling-demo --port 3000
|
||||
|
||||
# Then run this client:
|
||||
uv run mcp-sse-polling-client --url http://localhost:3000/mcp
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import click
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
|
||||
async def run_demo(url: str, items: int, checkpoint_every: int) -> None:
|
||||
"""Run the SSE polling demo."""
|
||||
print(f"\n{'=' * 60}")
|
||||
print("SSE Polling Demo Client")
|
||||
print(f"{'=' * 60}")
|
||||
print(f"Server URL: {url}")
|
||||
print(f"Processing {items} items with checkpoints every {checkpoint_every}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
async with streamable_http_client(url) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
# Initialize the connection
|
||||
print("Initializing connection...")
|
||||
await session.initialize()
|
||||
print("Connected!\n")
|
||||
|
||||
# List available tools
|
||||
tools = await session.list_tools()
|
||||
print(f"Available tools: {[t.name for t in tools.tools]}\n")
|
||||
|
||||
# Call the process_batch tool
|
||||
print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n")
|
||||
print("-" * 40)
|
||||
|
||||
result = await session.call_tool(
|
||||
"process_batch",
|
||||
{
|
||||
"items": items,
|
||||
"checkpoint_every": checkpoint_every,
|
||||
},
|
||||
)
|
||||
|
||||
print("-" * 40)
|
||||
if result.content:
|
||||
content = result.content[0]
|
||||
text = getattr(content, "text", str(content))
|
||||
print(f"\nResult: {text}")
|
||||
else:
|
||||
print("\nResult: No content")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--url",
|
||||
default="http://localhost:3000/mcp",
|
||||
help="Server URL",
|
||||
)
|
||||
@click.option(
|
||||
"--items",
|
||||
default=10,
|
||||
help="Number of items to process",
|
||||
)
|
||||
@click.option(
|
||||
"--checkpoint-every",
|
||||
default=3,
|
||||
help="Checkpoint interval",
|
||||
)
|
||||
@click.option(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
help="Logging level",
|
||||
)
|
||||
def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None:
|
||||
"""Run the SSE Polling Demo client."""
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
# Suppress noisy HTTP client logging
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
asyncio.run(run_demo(url, items, checkpoint_every))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
[project]
|
||||
name = "mcp-sse-polling-client"
|
||||
version = "0.1.0"
|
||||
description = "Demo client for SSE polling with auto-reconnect"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "sse", "polling", "client"]
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["click>=8.2.0", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-sse-polling-client = "mcp_sse_polling_client.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_sse_polling_client"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_sse_polling_client"]
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
ignore = []
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"]
|
||||
Reference in New Issue
Block a user