同步完整源码 - 2026-05-25
This commit is contained in:
commit
7dbee81445
|
|
@ -0,0 +1,118 @@
|
|||
Review the pull request: $ARGUMENTS
|
||||
|
||||
Follow these steps carefully. Use the `gh` CLI for all GitHub interactions.
|
||||
|
||||
## Step 1: Resolve the PR
|
||||
|
||||
Parse `$ARGUMENTS` to determine the PR. It can be:
|
||||
|
||||
- A full URL like `https://github.com/owner/repo/pull/123`
|
||||
- A `owner/repo#123` reference
|
||||
- A bare number like `123` (use the current repo)
|
||||
- A description — search for it with `gh pr list --search "<description>" --limit 5` and pick the best match
|
||||
|
||||
Once resolved, fetch the PR metadata:
|
||||
|
||||
```bash
|
||||
gh pr view <PR> --json number,title,body,author,state,baseRefName,headRefName,url,labels,milestone,additions,deletions,changedFiles,createdAt,updatedAt,mergedAt,reviewDecision,reviews,assignees
|
||||
```
|
||||
|
||||
## Step 2: Gather the diff
|
||||
|
||||
Get the full diff of the PR:
|
||||
|
||||
```bash
|
||||
gh pr diff <PR>
|
||||
```
|
||||
|
||||
If the diff is very large (>3000 lines), focus on the most important files first and summarize the rest.
|
||||
|
||||
## Step 3: Collect PR discussion context
|
||||
|
||||
Fetch all comments and review threads:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate
|
||||
gh api repos/{owner}/{repo}/issues/{number}/comments --paginate
|
||||
gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate
|
||||
```
|
||||
|
||||
Pay attention to:
|
||||
|
||||
- Reviewer feedback and requested changes
|
||||
- Author responses and explanations
|
||||
- Any unresolved conversations
|
||||
- Approval or rejection status
|
||||
|
||||
## Step 4: Find and read linked issues
|
||||
|
||||
Look for issue references in:
|
||||
|
||||
- The PR body (patterns like `#123`, `fixes #123`, `closes #123`, `resolves #123`)
|
||||
- The PR branch name (patterns like `issue-123`, `fix/123`)
|
||||
- Commit messages
|
||||
|
||||
For each linked issue, fetch its content:
|
||||
|
||||
```bash
|
||||
gh issue view <number> --json title,body,comments,labels,state
|
||||
```
|
||||
|
||||
Read through issue comments to understand the original problem, user reports, and any discussed solutions.
|
||||
|
||||
## Step 5: Analyze and validate
|
||||
|
||||
With all context gathered, analyze the PR critically:
|
||||
|
||||
1. **Intent alignment**: Does the code change actually solve the problem described in the PR and/or linked issues?
|
||||
2. **Completeness**: Are there aspects of the issue or requested feature that the PR doesn't address?
|
||||
3. **Scope**: Does the PR include changes unrelated to the stated goal? Are there unnecessary modifications?
|
||||
4. **Correctness**: Based on the diff, are there obvious bugs, edge cases, or logic errors?
|
||||
5. **Testing**: Does the PR include tests? Are they meaningful and do they cover the important cases?
|
||||
6. **Breaking changes**: Could this PR break existing functionality or APIs?
|
||||
7. **Unresolved feedback**: Are there reviewer comments that haven't been addressed?
|
||||
|
||||
## Step 6: Produce the review summary
|
||||
|
||||
Present the summary in this format:
|
||||
|
||||
---
|
||||
|
||||
### PR Review: `<title>` (<url>)
|
||||
|
||||
**Author:** <author> | **Status:** <state> | **Review decision:** <decision>
|
||||
**Base:** `<base>` ← `<head>` | **Changed files:** <n> | **+<additions> / -<deletions>**
|
||||
|
||||
#### Problem
|
||||
|
||||
<1-3 sentences describing what problem this PR is trying to solve, based on the PR description and linked issues>
|
||||
|
||||
#### Solution
|
||||
|
||||
<1-3 sentences describing the approach taken in the code>
|
||||
|
||||
#### Key changes
|
||||
|
||||
<Bulleted list of the most important changes, grouped by theme. Include file paths.>
|
||||
|
||||
#### Linked issues
|
||||
|
||||
<List of linked issues with their title, state, and a one-line summary of the discussion>
|
||||
|
||||
#### Discussion highlights
|
||||
|
||||
<Summary of important comments from reviewers and the author. Flag any unresolved threads.>
|
||||
|
||||
#### Concerns
|
||||
|
||||
<List any issues found during validation: bugs, missing tests, scope creep, unaddressed feedback, etc. If none, say "No concerns found.">
|
||||
|
||||
#### Verdict
|
||||
|
||||
<One of: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION, with a brief justification>
|
||||
|
||||
#### Suggested action
|
||||
|
||||
<Clear recommendation for the reviewer: what to approve, what to push back on, what to ask about>
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856
|
||||
543961968c0634e93d919d509cce23a1d6a56c21
|
||||
|
||||
# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553
|
||||
89e9c43acf7e23cf766357d776ec1ce63ac2c58e
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Generated
|
||||
uv.lock linguist-generated=true
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
name: 🐛 MCP Python SDK Bug
|
||||
description: Report a bug or unexpected behavior in the MCP Python SDK
|
||||
labels: ["need confirmation"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for contributing to the MCP Python SDK! ✊
|
||||
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Initial Checks
|
||||
description: Just making sure you're using the latest version of MCP Python SDK.
|
||||
options:
|
||||
- label: I confirm that I'm using the latest version of MCP Python SDK
|
||||
required: true
|
||||
- label: I confirm that I searched for my issue in https://github.com/modelcontextprotocol/python-sdk/issues before opening this issue
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Please explain what you're seeing and what you would expect to see.
|
||||
|
||||
Please provide as much detail as possible to make understanding and solving your problem as quick as possible. 🙏
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: example
|
||||
attributes:
|
||||
label: Example Code
|
||||
description: >
|
||||
If applicable, please add a self-contained,
|
||||
[minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example)
|
||||
demonstrating the bug.
|
||||
|
||||
placeholder: |
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
...
|
||||
render: Python
|
||||
|
||||
- type: textarea
|
||||
id: version
|
||||
attributes:
|
||||
label: Python & MCP Python SDK
|
||||
description: |
|
||||
Which version of Python and MCP Python SDK are you using?
|
||||
render: Text
|
||||
validations:
|
||||
required: true
|
||||
|
|
@ -0,0 +1 @@
|
|||
blank_issues_enabled: false
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
name: 🚀 MCP Python SDK Feature Request
|
||||
description: "Suggest a new feature for the MCP Python SDK"
|
||||
labels: ["feature request"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for contributing to the MCP Python SDK! ✊
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
Please give as much detail as possible about the feature you would like to suggest. 🙏
|
||||
|
||||
You might like to add:
|
||||
* A demo of how code might look when using the feature
|
||||
* Your use case(s) for the feature
|
||||
* Reference to other projects that have a similar feature
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: references
|
||||
attributes:
|
||||
label: References
|
||||
description: |
|
||||
Please add any links or references that might help us understand your feature request better. 📚
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
name: ❓ MCP Python SDK Question
|
||||
description: "Ask a question about the MCP Python SDK"
|
||||
labels: ["question"]
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: Thank you for reaching out to the MCP Python SDK community! We're here to help! 🤝
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: |
|
||||
Please provide as much detail as possible about your question. 🙏
|
||||
|
||||
You might like to include:
|
||||
* Code snippets showing what you've tried
|
||||
* Error messages you're encountering (if any)
|
||||
* Expected vs actual behavior
|
||||
* Your use case and what you're trying to achieve
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: |
|
||||
Please provide any additional context that might help us better understand your question, such as:
|
||||
* Your MCP Python SDK version
|
||||
* Your Python version
|
||||
* Relevant configuration or environment details 📝
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
"""MCP unified conformance test client.
|
||||
|
||||
This client is designed to work with the @modelcontextprotocol/conformance npm package.
|
||||
It handles all conformance test scenarios via environment variables and CLI arguments.
|
||||
|
||||
Contract:
|
||||
- MCP_CONFORMANCE_SCENARIO env var -> scenario name
|
||||
- MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios)
|
||||
- Server URL as last CLI argument (sys.argv[1])
|
||||
- Must exit 0 within 30 seconds
|
||||
|
||||
Scenarios:
|
||||
initialize - Connect, initialize, list tools, close
|
||||
tools_call - Connect, call add_numbers(a=5, b=3), close
|
||||
sse-retry - Connect, call test_reconnection, close
|
||||
elicitation-sep1034-client-defaults - Elicitation with default accept callback
|
||||
auth/client-credentials-jwt - Client credentials with private_key_jwt
|
||||
auth/client-credentials-basic - Client credentials with client_secret_basic
|
||||
auth/* - Authorization code flow (default for auth scenarios)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, cast
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp import ClientSession, types
|
||||
from mcp.client.auth import OAuthClientProvider, TokenStorage
|
||||
from mcp.client.auth.extensions.client_credentials import (
|
||||
ClientCredentialsOAuthProvider,
|
||||
PrivateKeyJWTOAuthProvider,
|
||||
SignedJWTParameters,
|
||||
)
|
||||
from mcp.client.context import ClientRequestContext
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
|
||||
# Set up logging to stderr (stdout is for conformance test output)
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type for async scenario handler functions
|
||||
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]
|
||||
|
||||
# Registry of scenario handlers
|
||||
HANDLERS: dict[str, ScenarioHandler] = {}
|
||||
|
||||
|
||||
def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]:
|
||||
"""Register a scenario handler."""
|
||||
|
||||
def decorator(fn: ScenarioHandler) -> ScenarioHandler:
|
||||
HANDLERS[name] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def get_conformance_context() -> dict[str, Any]:
|
||||
"""Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable."""
|
||||
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
|
||||
if not context_json:
|
||||
raise RuntimeError(
|
||||
"MCP_CONFORMANCE_CONTEXT environment variable not set. "
|
||||
"Expected JSON with client_id, client_secret, and/or private_key_pem."
|
||||
)
|
||||
try:
|
||||
return json.loads(context_json)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e
|
||||
|
||||
|
||||
class InMemoryTokenStorage(TokenStorage):
|
||||
"""Simple in-memory token storage for conformance testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
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 ConformanceOAuthCallbackHandler:
|
||||
"""OAuth callback handler that automatically fetches the authorization URL
|
||||
and extracts the auth code, without requiring user interaction.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._auth_code: str | None = None
|
||||
self._state: str | None = None
|
||||
|
||||
async def handle_redirect(self, authorization_url: str) -> None:
|
||||
"""Fetch the authorization URL and extract the auth code from the redirect."""
|
||||
logger.debug(f"Fetching authorization URL: {authorization_url}")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
authorization_url,
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
if response.status_code in (301, 302, 303, 307, 308):
|
||||
location = cast(str, response.headers.get("location"))
|
||||
if location:
|
||||
redirect_url = urlparse(location)
|
||||
query_params: dict[str, list[str]] = parse_qs(redirect_url.query)
|
||||
|
||||
if "code" in query_params:
|
||||
self._auth_code = query_params["code"][0]
|
||||
state_values = query_params.get("state")
|
||||
self._state = state_values[0] if state_values else None
|
||||
logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...")
|
||||
return
|
||||
else:
|
||||
raise RuntimeError(f"No auth code in redirect URL: {location}")
|
||||
else:
|
||||
raise RuntimeError(f"No redirect location received from {authorization_url}")
|
||||
else:
|
||||
raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}")
|
||||
|
||||
async def handle_callback(self) -> tuple[str, str | None]:
|
||||
"""Return the captured auth code and state."""
|
||||
if self._auth_code is None:
|
||||
raise RuntimeError("No authorization code available - was handle_redirect called?")
|
||||
auth_code = self._auth_code
|
||||
state = self._state
|
||||
self._auth_code = None
|
||||
self._state = None
|
||||
return auth_code, state
|
||||
|
||||
|
||||
# --- Scenario Handlers ---
|
||||
|
||||
|
||||
@register("initialize")
|
||||
async def run_initialize(server_url: str) -> None:
|
||||
"""Connect, initialize, list tools, close."""
|
||||
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
logger.debug("Initialized successfully")
|
||||
await session.list_tools()
|
||||
logger.debug("Listed tools successfully")
|
||||
|
||||
|
||||
@register("tools_call")
|
||||
async def run_tools_call(server_url: str) -> None:
|
||||
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
|
||||
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
await session.list_tools()
|
||||
result = await session.call_tool("add_numbers", {"a": 5, "b": 3})
|
||||
logger.debug(f"add_numbers result: {result}")
|
||||
|
||||
|
||||
@register("sse-retry")
|
||||
async def run_sse_retry(server_url: str) -> None:
|
||||
"""Connect, initialize, list tools, call test_reconnection, close."""
|
||||
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
await session.list_tools()
|
||||
result = await session.call_tool("test_reconnection", {})
|
||||
logger.debug(f"test_reconnection result: {result}")
|
||||
|
||||
|
||||
async def default_elicitation_callback(
|
||||
context: ClientRequestContext,
|
||||
params: types.ElicitRequestParams,
|
||||
) -> types.ElicitResult | types.ErrorData:
|
||||
"""Accept elicitation and apply defaults from the schema (SEP-1034)."""
|
||||
content: dict[str, str | int | float | bool | list[str] | None] = {}
|
||||
|
||||
# For form mode, extract defaults from the requested_schema
|
||||
if isinstance(params, types.ElicitRequestFormParams):
|
||||
schema = params.requested_schema
|
||||
logger.debug(f"Elicitation schema: {schema}")
|
||||
properties = schema.get("properties", {})
|
||||
for prop_name, prop_schema in properties.items():
|
||||
if "default" in prop_schema:
|
||||
content[prop_name] = prop_schema["default"]
|
||||
logger.debug(f"Applied defaults: {content}")
|
||||
|
||||
return types.ElicitResult(action="accept", content=content)
|
||||
|
||||
|
||||
@register("elicitation-sep1034-client-defaults")
|
||||
async def run_elicitation_defaults(server_url: str) -> None:
|
||||
"""Connect with elicitation callback that applies schema defaults."""
|
||||
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=default_elicitation_callback
|
||||
) as session:
|
||||
await session.initialize()
|
||||
await session.list_tools()
|
||||
result = await session.call_tool("test_client_elicitation_defaults", {})
|
||||
logger.debug(f"test_client_elicitation_defaults result: {result}")
|
||||
|
||||
|
||||
@register("auth/client-credentials-jwt")
|
||||
async def run_client_credentials_jwt(server_url: str) -> None:
|
||||
"""Client credentials flow with private_key_jwt authentication."""
|
||||
context = get_conformance_context()
|
||||
client_id = context.get("client_id")
|
||||
private_key_pem = context.get("private_key_pem")
|
||||
signing_algorithm = context.get("signing_algorithm", "ES256")
|
||||
|
||||
if not client_id:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
|
||||
if not private_key_pem:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'")
|
||||
|
||||
jwt_params = SignedJWTParameters(
|
||||
issuer=client_id,
|
||||
subject=client_id,
|
||||
signing_algorithm=signing_algorithm,
|
||||
signing_key=private_key_pem,
|
||||
)
|
||||
|
||||
oauth_auth = PrivateKeyJWTOAuthProvider(
|
||||
server_url=server_url,
|
||||
storage=InMemoryTokenStorage(),
|
||||
client_id=client_id,
|
||||
assertion_provider=jwt_params.create_assertion_provider(),
|
||||
)
|
||||
|
||||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
@register("auth/client-credentials-basic")
|
||||
async def run_client_credentials_basic(server_url: str) -> None:
|
||||
"""Client credentials flow with client_secret_basic authentication."""
|
||||
context = get_conformance_context()
|
||||
client_id = context.get("client_id")
|
||||
client_secret = context.get("client_secret")
|
||||
|
||||
if not client_id:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'")
|
||||
if not client_secret:
|
||||
raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'")
|
||||
|
||||
oauth_auth = ClientCredentialsOAuthProvider(
|
||||
server_url=server_url,
|
||||
storage=InMemoryTokenStorage(),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
token_endpoint_auth_method="client_secret_basic",
|
||||
)
|
||||
|
||||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
async def run_auth_code_client(server_url: str) -> None:
|
||||
"""Authorization code flow (default for auth/* scenarios)."""
|
||||
callback_handler = ConformanceOAuthCallbackHandler()
|
||||
storage = InMemoryTokenStorage()
|
||||
|
||||
# Check for pre-registered client credentials from context
|
||||
context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT")
|
||||
if context_json:
|
||||
try:
|
||||
context = json.loads(context_json)
|
||||
client_id = context.get("client_id")
|
||||
client_secret = context.get("client_secret")
|
||||
if client_id:
|
||||
await storage.set_client_info(
|
||||
OAuthClientInformationFull(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
||||
token_endpoint_auth_method="client_secret_basic" if client_secret else "none",
|
||||
)
|
||||
)
|
||||
logger.debug(f"Pre-loaded client credentials: client_id={client_id}")
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT")
|
||||
|
||||
oauth_auth = OAuthClientProvider(
|
||||
server_url=server_url,
|
||||
client_metadata=OAuthClientMetadata(
|
||||
client_name="conformance-client",
|
||||
redirect_uris=[AnyUrl("http://localhost:3000/callback")],
|
||||
grant_types=["authorization_code", "refresh_token"],
|
||||
response_types=["code"],
|
||||
),
|
||||
storage=storage,
|
||||
redirect_handler=callback_handler.handle_redirect,
|
||||
callback_handler=callback_handler.handle_callback,
|
||||
client_metadata_url="https://conformance-test.local/client-metadata.json",
|
||||
)
|
||||
|
||||
await _run_auth_session(server_url, oauth_auth)
|
||||
|
||||
|
||||
async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
|
||||
"""Common session logic for all OAuth flows."""
|
||||
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
|
||||
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream):
|
||||
async with ClientSession(
|
||||
read_stream, write_stream, elicitation_callback=default_elicitation_callback
|
||||
) as session:
|
||||
await session.initialize()
|
||||
logger.debug("Initialized successfully")
|
||||
|
||||
tools_result = await session.list_tools()
|
||||
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")
|
||||
|
||||
# Call the first available tool (different tests have different tools)
|
||||
if tools_result.tools:
|
||||
tool_name = tools_result.tools[0].name
|
||||
try:
|
||||
result = await session.call_tool(tool_name, {})
|
||||
logger.debug(f"Called {tool_name}, result: {result}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Tool call result/error: {e}")
|
||||
|
||||
logger.debug("Connection closed successfully")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for the conformance client."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
server_url = sys.argv[1]
|
||||
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
|
||||
|
||||
if scenario:
|
||||
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
|
||||
handler = HANDLERS.get(scenario)
|
||||
if handler:
|
||||
asyncio.run(handler(server_url))
|
||||
elif scenario.startswith("auth/"):
|
||||
asyncio.run(run_auth_code_client(server_url))
|
||||
else:
|
||||
print(f"Unknown scenario: {scenario}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
logger.debug(f"Running default auth flow against {server_url}")
|
||||
asyncio.run(run_auth_code_client(server_url))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
PORT="${PORT:-3001}"
|
||||
SERVER_URL="http://localhost:${PORT}/mcp"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR/../../.."
|
||||
|
||||
# Start everything-server
|
||||
uv run --frozen mcp-everything-server --port "$PORT" &
|
||||
SERVER_PID=$!
|
||||
trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT
|
||||
|
||||
# Wait for server to be ready
|
||||
MAX_RETRIES=30
|
||||
RETRY_COUNT=0
|
||||
while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do
|
||||
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||
if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then
|
||||
echo "Server failed to start after ${MAX_RETRIES} retries" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
echo "Server ready at $SERVER_URL"
|
||||
|
||||
# Run conformance tests
|
||||
npx @modelcontextprotocol/conformance@0.1.10 server --url "$SERVER_URL" "$@"
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: monthly
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# Source: https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && !startsWith(github.event.comment.body, '@claude review')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@2f8ba26a219c06cfb0f468eef8d97055fa814f97 # v1.0.53
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # zizmor: ignore[secrets-outside-env]
|
||||
use_commit_signing: true
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
name: Comment on PRs in Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
comment-on-prs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get previous release
|
||||
id: previous_release
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
CURRENT_TAG: ${{ github.event.release.tag_name }}
|
||||
with:
|
||||
script: |
|
||||
const currentTag = process.env.CURRENT_TAG;
|
||||
|
||||
// Get all releases
|
||||
const { data: releases } = await github.rest.repos.listReleases({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
// Find current release index
|
||||
const currentIndex = releases.findIndex(r => r.tag_name === currentTag);
|
||||
|
||||
if (currentIndex === -1) {
|
||||
console.log('Current release not found in list');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get previous release (next in the list since they're sorted by date desc)
|
||||
const previousRelease = releases[currentIndex + 1];
|
||||
|
||||
if (!previousRelease) {
|
||||
console.log('No previous release found, this might be the first release');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`Found previous release: ${previousRelease.tag_name}`);
|
||||
|
||||
return previousRelease.tag_name;
|
||||
|
||||
- name: Get merged PRs between releases
|
||||
id: get_prs
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
CURRENT_TAG: ${{ github.event.release.tag_name }}
|
||||
PREVIOUS_TAG_JSON: ${{ steps.previous_release.outputs.result }}
|
||||
with:
|
||||
script: |
|
||||
const currentTag = process.env.CURRENT_TAG;
|
||||
const previousTag = JSON.parse(process.env.PREVIOUS_TAG_JSON);
|
||||
|
||||
if (!previousTag) {
|
||||
console.log('No previous release found, skipping');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`Finding PRs between ${previousTag} and ${currentTag}`);
|
||||
|
||||
// Get commits between previous and current release
|
||||
const comparison = await github.rest.repos.compareCommits({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
base: previousTag,
|
||||
head: currentTag
|
||||
});
|
||||
|
||||
const commits = comparison.data.commits;
|
||||
console.log(`Found ${commits.length} commits`);
|
||||
|
||||
// Get PRs associated with each commit using GitHub API
|
||||
const prNumbers = new Set();
|
||||
|
||||
for (const commit of commits) {
|
||||
try {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: commit.sha
|
||||
});
|
||||
|
||||
for (const pr of prs) {
|
||||
if (pr.merged_at) {
|
||||
prNumbers.add(pr.number);
|
||||
console.log(`Found merged PR: #${pr.number}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Failed to get PRs for commit ${commit.sha}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${prNumbers.size} merged PRs`);
|
||||
return Array.from(prNumbers);
|
||||
|
||||
- name: Comment on PRs
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
PR_NUMBERS_JSON: ${{ steps.get_prs.outputs.result }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
RELEASE_URL: ${{ github.event.release.html_url }}
|
||||
with:
|
||||
script: |
|
||||
const prNumbers = JSON.parse(process.env.PR_NUMBERS_JSON);
|
||||
const releaseTag = process.env.RELEASE_TAG;
|
||||
const releaseUrl = process.env.RELEASE_URL;
|
||||
|
||||
const comment = `This pull request is included in [${releaseTag}](${releaseUrl})`;
|
||||
|
||||
let commentedCount = 0;
|
||||
|
||||
for (const prNumber of prNumbers) {
|
||||
try {
|
||||
// Check if we've already commented on this PR for this release
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
const alreadyCommented = comments.some(c =>
|
||||
c.user.type === 'Bot' && c.body.includes(releaseTag)
|
||||
);
|
||||
|
||||
if (alreadyCommented) {
|
||||
console.log(`Skipping PR #${prNumber} - already commented for ${releaseTag}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body: comment
|
||||
});
|
||||
commentedCount++;
|
||||
console.log(`Successfully commented on PR #${prNumber}`);
|
||||
} catch (error) {
|
||||
console.error(`Failed to comment on PR #${prNumber}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Commented on ${commentedCount} of ${prNumbers.length} PRs`);
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
name: Conformance Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: conformance-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
server-conformance:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24
|
||||
- run: uv sync --frozen --all-extras --package mcp-everything-server
|
||||
- run: ./.github/actions/conformance/run-server.sh
|
||||
|
||||
client-conformance:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version: 24
|
||||
- run: uv sync --frozen --all-extras --package mcp
|
||||
- run: npx @modelcontextprotocol/conformance@0.1.13 client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- v1.x
|
||||
paths:
|
||||
- docs/**
|
||||
- mkdocs.yml
|
||||
- src/mcp/**
|
||||
- scripts/build-docs.sh
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- .github/workflows/deploy-docs.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-docs
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy-docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
|
||||
- name: Build combined docs (v1.x at /, main at /v2/)
|
||||
run: bash scripts/build-docs.sh site
|
||||
|
||||
- name: Configure Pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main", "v1.x"]
|
||||
tags: ["v*.*.*"]
|
||||
pull_request:
|
||||
branches: ["main", "v1.x"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
uses: ./.github/workflows/shared.yml
|
||||
|
||||
all-green:
|
||||
if: always()
|
||||
needs: [checks]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2
|
||||
with:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
name: Publishing
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release-build:
|
||||
name: Build distribution
|
||||
runs-on: ubuntu-latest
|
||||
needs: [checks]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
enable-cache: false
|
||||
version: 0.9.5
|
||||
|
||||
- name: Set up Python 3.12
|
||||
run: uv python install 3.12
|
||||
|
||||
- name: Build
|
||||
run: uv build
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: release-dists
|
||||
path: dist/
|
||||
|
||||
checks:
|
||||
uses: ./.github/workflows/shared.yml
|
||||
|
||||
pypi-publish:
|
||||
name: Upload release to PyPI
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
needs:
|
||||
- release-build
|
||||
permissions:
|
||||
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
|
||||
|
||||
steps:
|
||||
- name: Retrieve release distributions
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0
|
||||
with:
|
||||
name: release-dists
|
||||
path: dist/
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
name: Shared Checks
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
COLUMNS: 150
|
||||
|
||||
jobs:
|
||||
pre-commit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --all-extras --python 3.10
|
||||
|
||||
- uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
|
||||
with:
|
||||
extra_args: --all-files --verbose
|
||||
env:
|
||||
SKIP: no-commit-to-branch,readme-v1-frozen
|
||||
|
||||
# TODO(Max): Drop this in v2.
|
||||
- name: Check README.md is not modified
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
git fetch --no-tags --depth=1 origin "$BASE_SHA"
|
||||
if git diff --name-only "$BASE_SHA" -- README.md | grep -q .; then
|
||||
echo "::error::README.md is frozen at v1. Edit README.v2.md instead."
|
||||
exit 1
|
||||
fi
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
test:
|
||||
name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 10
|
||||
continue-on-error: true
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
dep-resolution:
|
||||
- name: lowest-direct
|
||||
install-flags: "--upgrade --resolution lowest-direct"
|
||||
- name: locked
|
||||
install-flags: "--frozen"
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
|
||||
- name: Install the project
|
||||
run: uv sync ${{ matrix.dep-resolution.install-flags }} --all-extras --python ${{ matrix.python-version }}
|
||||
|
||||
- name: Run pytest with coverage
|
||||
shell: bash
|
||||
run: |
|
||||
uv run --frozen --no-sync coverage erase
|
||||
uv run --frozen --no-sync coverage run -m pytest -n auto
|
||||
uv run --frozen --no-sync coverage combine
|
||||
uv run --frozen --no-sync coverage report
|
||||
|
||||
- name: Check for unnecessary no cover pragmas
|
||||
if: runner.os != 'Windows'
|
||||
run: uv run --frozen --no-sync strict-no-cover
|
||||
|
||||
readme-snippets:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
enable-cache: true
|
||||
version: 0.9.5
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen --all-extras --python 3.10
|
||||
|
||||
- name: Check README snippets are up to date
|
||||
run: uv run --frozen scripts/update_readme_snippets.py --check --readme README.v2.md
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
name: Weekly Lockfile Update
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Every Thursday at 8:00 UTC
|
||||
- cron: "0 8 * * 4"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-lockfile:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: astral-sh/setup-uv@803947b9bd8e9f986429fa0c5a41c367cd732b41 # v7.2.1
|
||||
with:
|
||||
version: 0.9.5
|
||||
|
||||
- name: Update lockfile
|
||||
run: |
|
||||
echo '## Updated Dependencies' > pr_body.md
|
||||
echo '' >> pr_body.md
|
||||
echo '```' >> pr_body.md
|
||||
uv lock --upgrade 2>&1 | tee -a pr_body.md
|
||||
echo '```' >> pr_body.md
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
|
||||
with:
|
||||
commit-message: "chore: update uv.lock with latest dependencies"
|
||||
sign-commits: true
|
||||
title: "chore: weekly dependency update"
|
||||
body-path: pr_body.md
|
||||
branch: weekly-lockfile-update
|
||||
delete-branch: true
|
||||
add-paths: uv.lock
|
||||
labels: dependencies
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
name: GitHub Actions Security Analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
.DS_Store
|
||||
scratch/
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
/.worktrees/
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
.idea/
|
||||
|
||||
# vscode
|
||||
.vscode/
|
||||
.windsurfrules
|
||||
**/CLAUDE.local.md
|
||||
|
||||
# claude code
|
||||
results/
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
fail_fast: true
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [yaml, json5]
|
||||
|
||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||
rev: v0.45.0
|
||||
hooks:
|
||||
- id: markdownlint
|
||||
args:
|
||||
[
|
||||
"--fix",
|
||||
"--config",
|
||||
"pyproject.toml",
|
||||
"--configPointer",
|
||||
"/tool/markdown/lint",
|
||||
]
|
||||
types: [markdown]
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: ruff-format
|
||||
name: Ruff Format
|
||||
entry: uv run --frozen ruff
|
||||
args: [format]
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
- id: ruff
|
||||
name: Ruff
|
||||
entry: uv run --frozen ruff
|
||||
args: ["check", "--fix", "--exit-non-zero-on-fix"]
|
||||
types: [python]
|
||||
language: system
|
||||
pass_filenames: false
|
||||
exclude: ^README(\.v2)?\.md$
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: uv run --frozen pyright
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
- id: uv-lock-check
|
||||
name: Check uv.lock is up to date
|
||||
entry: uv lock --check
|
||||
language: system
|
||||
files: ^(pyproject\.toml|uv\.lock)$
|
||||
pass_filenames: false
|
||||
# TODO(Max): Drop this in v2.
|
||||
- id: readme-v1-frozen
|
||||
name: README.md is frozen (v1 docs)
|
||||
entry: README.md is frozen at v1. Edit README.v2.md instead.
|
||||
language: fail
|
||||
files: ^README\.md$
|
||||
- id: readme-snippets
|
||||
name: Check README snippets are up to date
|
||||
entry: uv run --frozen python scripts/update_readme_snippets.py --check
|
||||
language: system
|
||||
files: ^(README\.v2\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$
|
||||
pass_filenames: false
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
# Development Guidelines
|
||||
|
||||
## Branching Model
|
||||
|
||||
<!-- TODO: drop this section once v2 ships and main becomes the stable line -->
|
||||
|
||||
- `main` is currently the V2 rework. Breaking changes are expected here — when removing or
|
||||
replacing an API, delete it outright and document the change in
|
||||
`docs/migration.md`. Do not add `@deprecated` shims or backward-compat layers
|
||||
on `main`.
|
||||
- `v1.x` is the release branch for the current stable line. Backport PRs target
|
||||
this branch and use a `[v1.x]` title prefix.
|
||||
- `README.md` is frozen at v1 (a pre-commit hook rejects edits). Edit
|
||||
`README.v2.md` instead.
|
||||
|
||||
## Package Management
|
||||
|
||||
- ONLY use uv, NEVER pip
|
||||
- Installation: `uv add <package>`
|
||||
- Running tools: `uv run --frozen <tool>`. Always pass `--frozen` so uv doesn't
|
||||
rewrite `uv.lock` as a side effect.
|
||||
- Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run
|
||||
against a specific interpreter (CI covers 3.10–3.14).
|
||||
- Upgrading: `uv lock --upgrade-package <package>`
|
||||
- FORBIDDEN: `uv pip install`, `@latest` syntax
|
||||
- Don't raise dependency floors for CVEs alone. The `>=` constraint already
|
||||
lets users upgrade. Only raise a floor when the SDK needs functionality from
|
||||
the newer version, and don't add SDK code to work around a dependency's
|
||||
vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning.
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Type hints required for all code
|
||||
- Public APIs must have docstrings. When a public API raises exceptions a
|
||||
caller would reasonably catch, document them in a `Raises:` section. Don't
|
||||
list exceptions from argument validation or programmer error.
|
||||
- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a
|
||||
symbol there is a deliberate API decision, not a convenience re-export.
|
||||
- IMPORTANT: All imports go at the top of the file — inline imports hide
|
||||
dependencies and obscure circular-import bugs. Only exception: when a
|
||||
top-level import genuinely can't work (lazy-loading optional deps, or
|
||||
tests that re-import a module).
|
||||
|
||||
## Testing
|
||||
|
||||
- Framework: `uv run --frozen pytest`
|
||||
- Async testing: use anyio, not asyncio
|
||||
- Do not use `Test` prefixed classes — write plain top-level `test_*` functions.
|
||||
Legacy files still contain `Test*` classes; do NOT follow that pattern for new
|
||||
tests even when adding to such a file.
|
||||
- IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution;
|
||||
reach for threads only when necessary, and subprocesses only as a last resort.
|
||||
- For end-to-end behavior, an in-memory `Client(server)` is usually the
|
||||
cleanest approach (see `tests/client/test_client.py` for the canonical
|
||||
pattern). For narrower changes, testing the function directly is fine. Use
|
||||
judgment.
|
||||
- Test files mirror the source tree: `src/mcp/client/stdio.py` →
|
||||
`tests/client/test_stdio.py`. Add tests to the existing file for that module.
|
||||
- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead:
|
||||
- Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test
|
||||
- For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()`
|
||||
- Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts)
|
||||
- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs
|
||||
- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail
|
||||
tests. Don't silence warnings from your own code; fix the underlying cause.
|
||||
Scoped `ignore::` entries for upstream libraries are acceptable in
|
||||
`pyproject.toml` with a comment explaining why.
|
||||
|
||||
### Coverage
|
||||
|
||||
CI requires 100% (`fail_under = 100`, `branch = true`).
|
||||
|
||||
- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the
|
||||
default Python. Not identical to CI: CI runs 3.10–3.14 × {ubuntu, windows}
|
||||
× {locked, lowest-direct}, and some branch-coverage quirks only surface on
|
||||
specific matrix entries.
|
||||
- Targeted check while iterating (~4s, deterministic):
|
||||
|
||||
```bash
|
||||
uv run --frozen coverage erase
|
||||
uv run --frozen coverage run -m pytest tests/path/test_foo.py
|
||||
uv run --frozen coverage combine
|
||||
uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0
|
||||
# UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns
|
||||
UV_FROZEN=1 uv run --frozen strict-no-cover
|
||||
```
|
||||
|
||||
Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0`
|
||||
and `--include` scope the report. `strict-no-cover` has no false positives on
|
||||
partial runs — if your new test executes a line marked `# pragma: no cover`,
|
||||
even a single-file run catches it.
|
||||
|
||||
Avoid adding new `# pragma: no cover`, `# type: ignore`, or `# noqa` comments.
|
||||
In tests, use `assert isinstance(x, T)` to narrow types instead of
|
||||
`# type: ignore`. In library code (`src/`), a `# pragma: no cover` needs very
|
||||
good reasoning — it usually means a test is missing. Audit before pushing:
|
||||
|
||||
```bash
|
||||
git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)'
|
||||
```
|
||||
|
||||
What the existing pragmas mean:
|
||||
|
||||
- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` (skipped
|
||||
on Windows runners) fails if it IS executed. When your test starts covering
|
||||
such a line, remove the pragma.
|
||||
- `# pragma: lax no cover` — excluded from coverage but not checked by
|
||||
`strict-no-cover`. Use for lines covered on some platforms/versions but not
|
||||
others.
|
||||
- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the
|
||||
`->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows).
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
When making breaking changes, document them in `docs/migration.md`. Include:
|
||||
|
||||
- What changed
|
||||
- Why it changed
|
||||
- How to migrate existing code
|
||||
|
||||
Search for related sections in the migration guide and group related changes together
|
||||
rather than adding new standalone sections.
|
||||
|
||||
## Formatting & Type Checking
|
||||
|
||||
- Format: `uv run --frozen ruff format .`
|
||||
- Lint: `uv run --frozen ruff check . --fix`
|
||||
- Type check: `uv run --frozen pyright`
|
||||
- Pre-commit runs all of the above plus markdownlint, a `uv.lock` consistency
|
||||
check, and README checks — see `.pre-commit-config.yaml`
|
||||
|
||||
## Exception Handling
|
||||
|
||||
- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions**
|
||||
- Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")`
|
||||
- **Catch specific exceptions** where possible:
|
||||
- File ops: `except (OSError, PermissionError):`
|
||||
- JSON: `except json.JSONDecodeError:`
|
||||
- Network: `except (ConnectionError, TimeoutError):`
|
||||
- **FORBIDDEN** `except Exception:` - unless in top-level handlers
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socio-economic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported to the community leaders responsible for enforcement at
|
||||
<mcp-coc@anthropic.com>.
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
<https://www.contributor-covenant.org/faq>. Translations are available at
|
||||
<https://www.contributor-covenant.org/translations>.
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
# Contributing
|
||||
|
||||
Thank you for your interest in contributing to the MCP Python SDK! This document provides guidelines and instructions for contributing.
|
||||
|
||||
## Before You Start
|
||||
|
||||
We welcome contributions! These guidelines exist to save everyone time, yours included. Following them means your work is more likely to be accepted.
|
||||
|
||||
**All pull requests require a corresponding issue.** Unless your change is trivial (typo, docs tweak, broken link), create an issue first. Every merged feature becomes ongoing maintenance, so we need to agree something is worth doing before reviewing code. PRs without a linked issue will be closed.
|
||||
|
||||
Having an issue doesn't guarantee acceptance. Wait for maintainer feedback or a `ready for work` label before starting. PRs for issues without buy-in may also be closed.
|
||||
|
||||
Use issues to validate your idea before investing time in code. PRs are for execution, not exploration.
|
||||
|
||||
### The SDK is Opinionated
|
||||
|
||||
Not every contribution will be accepted, even with a working implementation. We prioritize maintainability and consistency over adding capabilities. This is at maintainers' discretion.
|
||||
|
||||
### What Needs Discussion
|
||||
|
||||
These always require an issue first:
|
||||
|
||||
- New public APIs or decorators
|
||||
- Architectural changes or refactoring
|
||||
- Changes that touch multiple modules
|
||||
- Features that might require spec changes (these need a [SEP](https://github.com/modelcontextprotocol/modelcontextprotocol) first)
|
||||
|
||||
Bug fixes for clear, reproducible issues are welcome—but still create an issue to track the fix.
|
||||
|
||||
### Finding Issues to Work On
|
||||
|
||||
| Label | For | Description |
|
||||
|-------|-----|-------------|
|
||||
| [`good first issue`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) | Newcomers | Can tackle without deep codebase knowledge |
|
||||
| [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) | Experienced contributors | Maintainers probably won't get to this |
|
||||
| [`ready for work`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22ready+for+work%22) | Maintainers | Triaged and ready for a maintainer to pick up |
|
||||
|
||||
Issues labeled `needs confirmation` or `needs maintainer action` are **not** ready for work—wait for maintainer input first.
|
||||
|
||||
Before starting, comment on the issue so we can assign it to you. This prevents duplicate effort.
|
||||
|
||||
## Development Setup
|
||||
|
||||
1. Make sure you have Python 3.10+ installed
|
||||
2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
3. Fork the repository
|
||||
4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git`
|
||||
5. Install dependencies:
|
||||
|
||||
```bash
|
||||
uv sync --frozen --all-extras --dev
|
||||
```
|
||||
|
||||
6. Set up pre-commit hooks:
|
||||
|
||||
```bash
|
||||
uv tool install pre-commit --with pre-commit-uv --force-reinstall
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. Choose the correct branch for your changes:
|
||||
|
||||
| Change Type | Target Branch | Example |
|
||||
|-------------|---------------|---------|
|
||||
| New features, breaking changes | `main` | New APIs, refactors |
|
||||
| Security fixes for v1 | `v1.x` | Critical patches |
|
||||
| Bug fixes for v1 | `v1.x` | Non-breaking fixes |
|
||||
|
||||
> **Note:** `main` is the v2 development branch. Breaking changes are welcome on `main`. The `v1.x` branch receives only security and critical bug fixes.
|
||||
|
||||
2. Create a new branch from your chosen base branch
|
||||
|
||||
3. Make your changes
|
||||
|
||||
4. Ensure tests pass:
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
5. Run type checking:
|
||||
|
||||
```bash
|
||||
uv run pyright
|
||||
```
|
||||
|
||||
6. Run linting:
|
||||
|
||||
```bash
|
||||
uv run ruff check .
|
||||
uv run ruff format .
|
||||
```
|
||||
|
||||
7. Update README snippets if you modified example code:
|
||||
|
||||
```bash
|
||||
uv run scripts/update_readme_snippets.py
|
||||
```
|
||||
|
||||
8. (Optional) Run pre-commit hooks on all files:
|
||||
|
||||
```bash
|
||||
pre-commit run --all-files
|
||||
```
|
||||
|
||||
9. Submit a pull request to the same branch you branched from
|
||||
|
||||
## Code Style
|
||||
|
||||
- We use `ruff` for linting and formatting
|
||||
- Follow PEP 8 style guidelines
|
||||
- Add type hints to all functions
|
||||
- Include docstrings for public APIs
|
||||
|
||||
## Pull Requests
|
||||
|
||||
By the time you open a PR, the "what" and "why" should already be settled in an issue. This keeps reviews focused on implementation.
|
||||
|
||||
### Scope
|
||||
|
||||
Small PRs get reviewed fast. Large PRs sit in the queue.
|
||||
|
||||
A few dozen lines can be reviewed in minutes. Hundreds of lines across many files takes real effort and things slip through. If your change is big, break it into smaller PRs or get alignment from a maintainer first.
|
||||
|
||||
### What Gets Rejected
|
||||
|
||||
- **No prior discussion**: Features or significant changes without an approved issue
|
||||
- **Scope creep**: Changes that go beyond what was discussed
|
||||
- **Misalignment**: Even well-implemented features may be rejected if they don't fit the SDK's direction
|
||||
- **Overengineering**: Unnecessary complexity for simple problems
|
||||
|
||||
### Checklist
|
||||
|
||||
1. Update documentation as needed
|
||||
2. Add tests for new functionality
|
||||
3. Ensure CI passes
|
||||
4. Address review feedback
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the MIT License.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Anthropic, PBC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
|||
# Release Process
|
||||
|
||||
## Bumping Dependencies
|
||||
|
||||
1. Change dependency version in `pyproject.toml`
|
||||
2. Upgrade lock with `uv lock --resolution lowest-direct`
|
||||
|
||||
## Major or Minor Release
|
||||
|
||||
Create a GitHub release via UI with the tag being `vX.Y.Z` where `X.Y.Z` is the version,
|
||||
and the release title being the same. Then ask someone to review the release.
|
||||
|
||||
The package version will be set automatically from the tag.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# Security Policy
|
||||
|
||||
Thank you for helping keep the Model Context Protocol and its ecosystem secure.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
If you discover a security vulnerability in this repository, please report it through
|
||||
the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability)
|
||||
for this repository.
|
||||
|
||||
Please **do not** report security vulnerabilities through public GitHub issues, discussions,
|
||||
or pull requests.
|
||||
|
||||
## What to Include
|
||||
|
||||
To help us triage and respond quickly, please include:
|
||||
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce the issue
|
||||
- The potential impact
|
||||
- Any suggested fixes (optional)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Authorization
|
||||
|
||||
!!! warning "Under Construction"
|
||||
|
||||
This page is currently being written. Check back soon for complete documentation.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# Concepts
|
||||
|
||||
!!! warning "Under Construction"
|
||||
|
||||
This page is currently being written. Check back soon for complete documentation.
|
||||
|
||||
<!--
|
||||
- Server vs Client
|
||||
- Three primitives (tools, resources, prompts)
|
||||
- Transports (stdio, SSE, streamable HTTP)
|
||||
- Context and sessions
|
||||
- Lifecycle and state
|
||||
-->
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# Experimental Features
|
||||
|
||||
!!! warning "Experimental APIs"
|
||||
|
||||
The features in this section are experimental and may change without notice.
|
||||
They track the evolving MCP specification and are not yet stable.
|
||||
|
||||
This section documents experimental features in the MCP Python SDK. These features
|
||||
implement draft specifications that are still being refined.
|
||||
|
||||
## Available Experimental Features
|
||||
|
||||
### [Tasks](tasks.md)
|
||||
|
||||
Tasks enable asynchronous execution of MCP operations. Instead of waiting for a
|
||||
long-running operation to complete, the server returns a task reference immediately.
|
||||
Clients can then poll for status updates and retrieve results when ready.
|
||||
|
||||
Tasks are useful for:
|
||||
|
||||
- **Long-running computations** that would otherwise block
|
||||
- **Batch operations** that process many items
|
||||
- **Interactive workflows** that require user input (elicitation) or LLM assistance (sampling)
|
||||
|
||||
## Using Experimental APIs
|
||||
|
||||
Experimental features are accessed via the `.experimental` property:
|
||||
|
||||
```python
|
||||
# Server-side: enable task support (auto-registers default handlers)
|
||||
server = Server(name="my-server")
|
||||
server.experimental.enable_tasks()
|
||||
|
||||
# Client-side
|
||||
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})
|
||||
```
|
||||
|
||||
## Providing Feedback
|
||||
|
||||
Since these features are experimental, feedback is especially valuable. If you encounter
|
||||
issues or have suggestions, please open an issue on the
|
||||
[python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues).
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
# Client Task Usage
|
||||
|
||||
!!! warning "Experimental"
|
||||
|
||||
Tasks are an experimental feature. The API may change without notice.
|
||||
|
||||
This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Call a tool as a task and poll for the result:
|
||||
|
||||
```python
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Call tool as task
|
||||
result = await session.experimental.call_tool_as_task(
|
||||
"process_data",
|
||||
{"input": "hello"},
|
||||
ttl=60000,
|
||||
)
|
||||
task_id = result.task.taskId
|
||||
|
||||
# Poll until complete
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
print(f"Status: {status.status} - {status.statusMessage or ''}")
|
||||
|
||||
# Get result
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
print(f"Result: {final.content[0].text}")
|
||||
```
|
||||
|
||||
## Calling Tools as Tasks
|
||||
|
||||
Use `call_tool_as_task()` to invoke a tool with task augmentation:
|
||||
|
||||
```python
|
||||
result = await session.experimental.call_tool_as_task(
|
||||
"my_tool", # Tool name
|
||||
{"arg": "value"}, # Arguments
|
||||
ttl=60000, # Time-to-live in milliseconds
|
||||
meta={"key": "val"}, # Optional metadata
|
||||
)
|
||||
|
||||
task_id = result.task.taskId
|
||||
print(f"Task: {task_id}, Status: {result.task.status}")
|
||||
```
|
||||
|
||||
The response is a `CreateTaskResult` containing:
|
||||
|
||||
- `task.taskId` - Unique identifier for polling
|
||||
- `task.status` - Initial status (usually `"working"`)
|
||||
- `task.pollInterval` - Suggested polling interval (milliseconds)
|
||||
- `task.ttl` - Time-to-live for results
|
||||
- `task.createdAt` - Creation timestamp
|
||||
|
||||
## Polling with poll_task
|
||||
|
||||
The `poll_task()` async iterator polls until the task reaches a terminal state:
|
||||
|
||||
```python
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
print(f"Status: {status.status}")
|
||||
if status.statusMessage:
|
||||
print(f"Progress: {status.statusMessage}")
|
||||
```
|
||||
|
||||
It automatically:
|
||||
|
||||
- Respects the server's suggested `pollInterval`
|
||||
- Stops when status is `completed`, `failed`, or `cancelled`
|
||||
- Yields each status for progress display
|
||||
|
||||
### Handling input_required
|
||||
|
||||
When a task needs user input (elicitation), it transitions to `input_required`. You must call `get_task_result()` to receive and respond to the elicitation:
|
||||
|
||||
```python
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
print(f"Status: {status.status}")
|
||||
|
||||
if status.status == "input_required":
|
||||
# This delivers the elicitation and waits for completion
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
break
|
||||
```
|
||||
|
||||
The elicitation callback (set during session creation) handles the actual user interaction.
|
||||
|
||||
## Elicitation Callbacks
|
||||
|
||||
To handle elicitation requests from the server, provide a callback when creating the session:
|
||||
|
||||
```python
|
||||
from mcp.types import ElicitRequestParams, ElicitResult
|
||||
|
||||
async def handle_elicitation(context, params: ElicitRequestParams) -> ElicitResult:
|
||||
# Display the message to the user
|
||||
print(f"Server asks: {params.message}")
|
||||
|
||||
# Collect user input (this is a simplified example)
|
||||
response = input("Your response (y/n): ")
|
||||
confirmed = response.lower() == "y"
|
||||
|
||||
return ElicitResult(
|
||||
action="accept",
|
||||
content={"confirm": confirmed},
|
||||
)
|
||||
|
||||
async with ClientSession(
|
||||
read,
|
||||
write,
|
||||
elicitation_callback=handle_elicitation,
|
||||
) as session:
|
||||
await session.initialize()
|
||||
# ... call tasks that may require elicitation
|
||||
```
|
||||
|
||||
## Sampling Callbacks
|
||||
|
||||
Similarly, handle sampling requests with a callback:
|
||||
|
||||
```python
|
||||
from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent
|
||||
|
||||
async def handle_sampling(context, params: CreateMessageRequestParams) -> CreateMessageResult:
|
||||
# In a real implementation, call your LLM here
|
||||
prompt = params.messages[-1].content.text if params.messages else ""
|
||||
|
||||
# Return a mock response
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
content=TextContent(type="text", text=f"Response to: {prompt}"),
|
||||
model="my-model",
|
||||
)
|
||||
|
||||
async with ClientSession(
|
||||
read,
|
||||
write,
|
||||
sampling_callback=handle_sampling,
|
||||
) as session:
|
||||
# ...
|
||||
```
|
||||
|
||||
## Retrieving Results
|
||||
|
||||
Once a task completes, retrieve the result:
|
||||
|
||||
```python
|
||||
if status.status == "completed":
|
||||
result = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
for content in result.content:
|
||||
if hasattr(content, "text"):
|
||||
print(content.text)
|
||||
|
||||
elif status.status == "failed":
|
||||
print(f"Task failed: {status.statusMessage}")
|
||||
|
||||
elif status.status == "cancelled":
|
||||
print("Task was cancelled")
|
||||
```
|
||||
|
||||
The result type matches the original request:
|
||||
|
||||
- `tools/call` → `CallToolResult`
|
||||
- `sampling/createMessage` → `CreateMessageResult`
|
||||
- `elicitation/create` → `ElicitResult`
|
||||
|
||||
## Cancellation
|
||||
|
||||
Cancel a running task:
|
||||
|
||||
```python
|
||||
cancel_result = await session.experimental.cancel_task(task_id)
|
||||
print(f"Cancelled, status: {cancel_result.status}")
|
||||
```
|
||||
|
||||
Note: Cancellation is cooperative—the server must check for and handle cancellation.
|
||||
|
||||
## Listing Tasks
|
||||
|
||||
View all tasks on the server:
|
||||
|
||||
```python
|
||||
result = await session.experimental.list_tasks()
|
||||
for task in result.tasks:
|
||||
print(f"{task.taskId}: {task.status}")
|
||||
|
||||
# Handle pagination
|
||||
while result.nextCursor:
|
||||
result = await session.experimental.list_tasks(cursor=result.nextCursor)
|
||||
for task in result.tasks:
|
||||
print(f"{task.taskId}: {task.status}")
|
||||
```
|
||||
|
||||
## Advanced: Client as Task Receiver
|
||||
|
||||
Servers can send task-augmented requests to clients. This is useful when the server needs the client to perform async work (like complex sampling or user interaction).
|
||||
|
||||
### Declaring Client Capabilities
|
||||
|
||||
Register task handlers to declare what task-augmented requests your client accepts:
|
||||
|
||||
```python
|
||||
from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers
|
||||
from mcp.types import (
|
||||
CreateTaskResult, GetTaskResult, GetTaskPayloadResult,
|
||||
TaskMetadata, ElicitRequestParams,
|
||||
)
|
||||
from mcp.shared.experimental.tasks import InMemoryTaskStore
|
||||
|
||||
# Client-side task store
|
||||
client_store = InMemoryTaskStore()
|
||||
|
||||
async def handle_augmented_elicitation(context, params: ElicitRequestParams, task_metadata: TaskMetadata):
|
||||
"""Handle task-augmented elicitation from server."""
|
||||
# Create a task for this elicitation
|
||||
task = await client_store.create_task(task_metadata)
|
||||
|
||||
# Start async work (e.g., show UI, wait for user)
|
||||
async def complete_elicitation():
|
||||
# ... do async work ...
|
||||
result = ElicitResult(action="accept", content={"confirm": True})
|
||||
await client_store.store_result(task.taskId, result)
|
||||
await client_store.update_task(task.taskId, status="completed")
|
||||
|
||||
context.session._task_group.start_soon(complete_elicitation)
|
||||
|
||||
# Return task reference immediately
|
||||
return CreateTaskResult(task=task)
|
||||
|
||||
async def handle_get_task(context, params):
|
||||
"""Handle tasks/get from server."""
|
||||
task = await client_store.get_task(params.taskId)
|
||||
return GetTaskResult(
|
||||
taskId=task.taskId,
|
||||
status=task.status,
|
||||
statusMessage=task.statusMessage,
|
||||
createdAt=task.createdAt,
|
||||
lastUpdatedAt=task.lastUpdatedAt,
|
||||
ttl=task.ttl,
|
||||
pollInterval=100,
|
||||
)
|
||||
|
||||
async def handle_get_task_result(context, params):
|
||||
"""Handle tasks/result from server."""
|
||||
result = await client_store.get_result(params.taskId)
|
||||
return GetTaskPayloadResult.model_validate(result.model_dump())
|
||||
|
||||
task_handlers = ExperimentalTaskHandlers(
|
||||
augmented_elicitation=handle_augmented_elicitation,
|
||||
get_task=handle_get_task,
|
||||
get_task_result=handle_get_task_result,
|
||||
)
|
||||
|
||||
async with ClientSession(
|
||||
read,
|
||||
write,
|
||||
experimental_task_handlers=task_handlers,
|
||||
) as session:
|
||||
# Client now accepts task-augmented elicitation from server
|
||||
await session.initialize()
|
||||
```
|
||||
|
||||
This enables flows where:
|
||||
|
||||
1. Client calls a task-augmented tool
|
||||
2. Server's tool work calls `task.elicit_as_task()`
|
||||
3. Client receives task-augmented elicitation
|
||||
4. Client creates its own task, does async work
|
||||
5. Server polls client's task
|
||||
6. Eventually both tasks complete
|
||||
|
||||
## Complete Example
|
||||
|
||||
A client that handles all task scenarios:
|
||||
|
||||
```python
|
||||
import anyio
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.stdio import stdio_client
|
||||
from mcp.types import CallToolResult, ElicitRequestParams, ElicitResult
|
||||
|
||||
|
||||
async def elicitation_callback(context, params: ElicitRequestParams) -> ElicitResult:
|
||||
print(f"\n[Elicitation] {params.message}")
|
||||
response = input("Confirm? (y/n): ")
|
||||
return ElicitResult(action="accept", content={"confirm": response.lower() == "y"})
|
||||
|
||||
|
||||
async def main():
|
||||
async with stdio_client(command="python", args=["server.py"]) as (read, write):
|
||||
async with ClientSession(
|
||||
read,
|
||||
write,
|
||||
elicitation_callback=elicitation_callback,
|
||||
) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List available tools
|
||||
tools = await session.list_tools()
|
||||
print("Tools:", [t.name for t in tools.tools])
|
||||
|
||||
# Call a task-augmented tool
|
||||
print("\nCalling task tool...")
|
||||
result = await session.experimental.call_tool_as_task(
|
||||
"confirm_action",
|
||||
{"action": "delete files"},
|
||||
)
|
||||
task_id = result.task.taskId
|
||||
print(f"Task created: {task_id}")
|
||||
|
||||
# Poll and handle input_required
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
print(f"Status: {status.status}")
|
||||
|
||||
if status.status == "input_required":
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
print(f"Result: {final.content[0].text}")
|
||||
break
|
||||
|
||||
if status.status == "completed":
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
print(f"Result: {final.content[0].text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
anyio.run(main)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Handle task errors gracefully:
|
||||
|
||||
```python
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
try:
|
||||
result = await session.experimental.call_tool_as_task("my_tool", args)
|
||||
task_id = result.task.taskId
|
||||
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
if status.status == "failed":
|
||||
raise RuntimeError(f"Task failed: {status.statusMessage}")
|
||||
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
|
||||
except MCPError as e:
|
||||
print(f"MCP error: {e.message}")
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Server Implementation](tasks-server.md) - Build task-supporting servers
|
||||
- [Tasks Overview](tasks.md) - Review lifecycle and concepts
|
||||
|
|
@ -0,0 +1,577 @@
|
|||
# Server Task Implementation
|
||||
|
||||
!!! warning "Experimental"
|
||||
|
||||
Tasks are an experimental feature. The API may change without notice.
|
||||
|
||||
This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The simplest way to add task support:
|
||||
|
||||
```python
|
||||
from mcp.server import Server
|
||||
from mcp.server.experimental.task_context import ServerTaskContext
|
||||
from mcp.types import CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED
|
||||
|
||||
server = Server("my-server")
|
||||
server.experimental.enable_tasks() # Registers all task handlers automatically
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="process_data",
|
||||
description="Process data asynchronously",
|
||||
inputSchema={"type": "object", "properties": {"input": {"type": "string"}}},
|
||||
execution=ToolExecution(taskSupport=TASK_REQUIRED),
|
||||
)
|
||||
]
|
||||
|
||||
@server.call_tool()
|
||||
async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult:
|
||||
if name == "process_data":
|
||||
return await handle_process_data(arguments)
|
||||
return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True)
|
||||
|
||||
async def handle_process_data(arguments: dict) -> CreateTaskResult:
|
||||
ctx = server.request_context
|
||||
ctx.experimental.validate_task_mode(TASK_REQUIRED)
|
||||
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Processing...")
|
||||
result = arguments.get("input", "").upper()
|
||||
return CallToolResult(content=[TextContent(type="text", text=result)])
|
||||
|
||||
return await ctx.experimental.run_task(work)
|
||||
```
|
||||
|
||||
That's it. `enable_tasks()` automatically:
|
||||
|
||||
- Creates an in-memory task store
|
||||
- Registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel`
|
||||
- Updates server capabilities
|
||||
|
||||
## Tool Declaration
|
||||
|
||||
Tools declare task support via the `execution.taskSupport` field:
|
||||
|
||||
```python
|
||||
from mcp.types import Tool, ToolExecution, TASK_REQUIRED, TASK_OPTIONAL, TASK_FORBIDDEN
|
||||
|
||||
Tool(
|
||||
name="my_tool",
|
||||
inputSchema={"type": "object"},
|
||||
execution=ToolExecution(taskSupport=TASK_REQUIRED), # or TASK_OPTIONAL, TASK_FORBIDDEN
|
||||
)
|
||||
```
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `TASK_REQUIRED` | Tool **must** be called as a task |
|
||||
| `TASK_OPTIONAL` | Tool supports both sync and task execution |
|
||||
| `TASK_FORBIDDEN` | Tool **cannot** be called as a task (default) |
|
||||
|
||||
Validate the request matches your tool's requirements:
|
||||
|
||||
```python
|
||||
@server.call_tool()
|
||||
async def handle_tool(name: str, arguments: dict):
|
||||
ctx = server.request_context
|
||||
|
||||
if name == "required_task_tool":
|
||||
ctx.experimental.validate_task_mode(TASK_REQUIRED) # Raises if not task mode
|
||||
return await handle_as_task(arguments)
|
||||
|
||||
elif name == "optional_task_tool":
|
||||
if ctx.experimental.is_task:
|
||||
return await handle_as_task(arguments)
|
||||
else:
|
||||
return handle_sync(arguments)
|
||||
```
|
||||
|
||||
## The run_task Pattern
|
||||
|
||||
`run_task()` is the recommended way to execute task work:
|
||||
|
||||
```python
|
||||
async def handle_my_tool(arguments: dict) -> CreateTaskResult:
|
||||
ctx = server.request_context
|
||||
ctx.experimental.validate_task_mode(TASK_REQUIRED)
|
||||
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
# Your work here
|
||||
return CallToolResult(content=[TextContent(type="text", text="Done")])
|
||||
|
||||
return await ctx.experimental.run_task(work)
|
||||
```
|
||||
|
||||
**What `run_task()` does:**
|
||||
|
||||
1. Creates a task in the store
|
||||
2. Spawns your work function in the background
|
||||
3. Returns `CreateTaskResult` immediately
|
||||
4. Auto-completes the task when your function returns
|
||||
5. Auto-fails the task if your function raises
|
||||
|
||||
**The `ServerTaskContext` provides:**
|
||||
|
||||
- `task.task_id` - The task identifier
|
||||
- `task.update_status(message)` - Update progress
|
||||
- `task.complete(result)` - Explicitly complete (usually automatic)
|
||||
- `task.fail(error)` - Explicitly fail
|
||||
- `task.is_cancelled` - Check if cancellation requested
|
||||
|
||||
## Status Updates
|
||||
|
||||
Keep clients informed of progress:
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Starting...")
|
||||
|
||||
for i, item in enumerate(items):
|
||||
await task.update_status(f"Processing {i+1}/{len(items)}")
|
||||
await process_item(item)
|
||||
|
||||
await task.update_status("Finalizing...")
|
||||
return CallToolResult(content=[TextContent(type="text", text="Complete")])
|
||||
```
|
||||
|
||||
Status messages appear in `tasks/get` responses, letting clients show progress to users.
|
||||
|
||||
## Elicitation Within Tasks
|
||||
|
||||
Tasks can request user input via elicitation. This transitions the task to `input_required` status.
|
||||
|
||||
### Form Elicitation
|
||||
|
||||
Collect structured data from the user:
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Waiting for confirmation...")
|
||||
|
||||
result = await task.elicit(
|
||||
message="Delete these files?",
|
||||
requestedSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"confirm": {"type": "boolean"},
|
||||
"reason": {"type": "string"},
|
||||
},
|
||||
"required": ["confirm"],
|
||||
},
|
||||
)
|
||||
|
||||
if result.action == "accept" and result.content.get("confirm"):
|
||||
# User confirmed
|
||||
return CallToolResult(content=[TextContent(type="text", text="Files deleted")])
|
||||
else:
|
||||
# User declined or cancelled
|
||||
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
|
||||
```
|
||||
|
||||
### URL Elicitation
|
||||
|
||||
Direct users to external URLs for OAuth, payments, or other out-of-band flows:
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Waiting for OAuth...")
|
||||
|
||||
result = await task.elicit_url(
|
||||
message="Please authorize with GitHub",
|
||||
url="https://github.com/login/oauth/authorize?client_id=...",
|
||||
elicitation_id="oauth-github-123",
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
# User completed OAuth flow
|
||||
return CallToolResult(content=[TextContent(type="text", text="Connected to GitHub")])
|
||||
else:
|
||||
return CallToolResult(content=[TextContent(type="text", text="OAuth cancelled")])
|
||||
```
|
||||
|
||||
## Sampling Within Tasks
|
||||
|
||||
Tasks can request LLM completions from the client:
|
||||
|
||||
```python
|
||||
from mcp.types import SamplingMessage, TextContent
|
||||
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Generating response...")
|
||||
|
||||
result = await task.create_message(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text="Write a haiku about coding"),
|
||||
)
|
||||
],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
haiku = result.content.text if isinstance(result.content, TextContent) else "Error"
|
||||
return CallToolResult(content=[TextContent(type="text", text=haiku)])
|
||||
```
|
||||
|
||||
Sampling supports additional parameters:
|
||||
|
||||
```python
|
||||
result = await task.create_message(
|
||||
messages=[...],
|
||||
max_tokens=500,
|
||||
system_prompt="You are a helpful assistant",
|
||||
temperature=0.7,
|
||||
stop_sequences=["\n\n"],
|
||||
model_preferences=ModelPreferences(hints=[ModelHint(name="claude-3")]),
|
||||
)
|
||||
```
|
||||
|
||||
## Cancellation Support
|
||||
|
||||
Check for cancellation in long-running work:
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
for i in range(1000):
|
||||
if task.is_cancelled:
|
||||
# Clean up and exit
|
||||
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
|
||||
|
||||
await task.update_status(f"Step {i}/1000")
|
||||
await process_step(i)
|
||||
|
||||
return CallToolResult(content=[TextContent(type="text", text="Complete")])
|
||||
```
|
||||
|
||||
The SDK's default cancel handler updates the task status. Your work function should check `is_cancelled` periodically.
|
||||
|
||||
## Custom Task Store
|
||||
|
||||
For production, implement `TaskStore` with persistent storage:
|
||||
|
||||
```python
|
||||
from mcp.shared.experimental.tasks.store import TaskStore
|
||||
from mcp.types import Task, TaskMetadata, Result
|
||||
|
||||
class RedisTaskStore(TaskStore):
|
||||
def __init__(self, redis_client):
|
||||
self.redis = redis_client
|
||||
|
||||
async def create_task(self, metadata: TaskMetadata, task_id: str | None = None) -> Task:
|
||||
# Create and persist task
|
||||
...
|
||||
|
||||
async def get_task(self, task_id: str) -> Task | None:
|
||||
# Retrieve task from Redis
|
||||
...
|
||||
|
||||
async def update_task(self, task_id: str, status: str | None = None, ...) -> Task:
|
||||
# Update and persist
|
||||
...
|
||||
|
||||
async def store_result(self, task_id: str, result: Result) -> None:
|
||||
# Store result in Redis
|
||||
...
|
||||
|
||||
async def get_result(self, task_id: str) -> Result | None:
|
||||
# Retrieve result
|
||||
...
|
||||
|
||||
# ... implement remaining methods
|
||||
```
|
||||
|
||||
Use your custom store:
|
||||
|
||||
```python
|
||||
store = RedisTaskStore(redis_client)
|
||||
server.experimental.enable_tasks(store=store)
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
A server with multiple task-supporting tools:
|
||||
|
||||
```python
|
||||
from mcp.server import Server
|
||||
from mcp.server.experimental.task_context import ServerTaskContext
|
||||
from mcp.types import (
|
||||
CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution,
|
||||
SamplingMessage, TASK_REQUIRED,
|
||||
)
|
||||
|
||||
server = Server("task-demo")
|
||||
server.experimental.enable_tasks()
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="confirm_action",
|
||||
description="Requires user confirmation",
|
||||
inputSchema={"type": "object", "properties": {"action": {"type": "string"}}},
|
||||
execution=ToolExecution(taskSupport=TASK_REQUIRED),
|
||||
),
|
||||
Tool(
|
||||
name="generate_text",
|
||||
description="Generate text via LLM",
|
||||
inputSchema={"type": "object", "properties": {"prompt": {"type": "string"}}},
|
||||
execution=ToolExecution(taskSupport=TASK_REQUIRED),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def handle_confirm_action(arguments: dict) -> CreateTaskResult:
|
||||
ctx = server.request_context
|
||||
ctx.experimental.validate_task_mode(TASK_REQUIRED)
|
||||
|
||||
action = arguments.get("action", "unknown action")
|
||||
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
result = await task.elicit(
|
||||
message=f"Confirm: {action}?",
|
||||
requestedSchema={
|
||||
"type": "object",
|
||||
"properties": {"confirm": {"type": "boolean"}},
|
||||
"required": ["confirm"],
|
||||
},
|
||||
)
|
||||
|
||||
if result.action == "accept" and result.content.get("confirm"):
|
||||
return CallToolResult(content=[TextContent(type="text", text=f"Executed: {action}")])
|
||||
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
|
||||
|
||||
return await ctx.experimental.run_task(work)
|
||||
|
||||
|
||||
async def handle_generate_text(arguments: dict) -> CreateTaskResult:
|
||||
ctx = server.request_context
|
||||
ctx.experimental.validate_task_mode(TASK_REQUIRED)
|
||||
|
||||
prompt = arguments.get("prompt", "Hello")
|
||||
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Generating...")
|
||||
|
||||
result = await task.create_message(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
|
||||
max_tokens=200,
|
||||
)
|
||||
|
||||
text = result.content.text if isinstance(result.content, TextContent) else "Error"
|
||||
return CallToolResult(content=[TextContent(type="text", text=text)])
|
||||
|
||||
return await ctx.experimental.run_task(work)
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult:
|
||||
if name == "confirm_action":
|
||||
return await handle_confirm_action(arguments)
|
||||
elif name == "generate_text":
|
||||
return await handle_generate_text(arguments)
|
||||
return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True)
|
||||
```
|
||||
|
||||
## Error Handling in Tasks
|
||||
|
||||
Tasks handle errors automatically, but you can also fail explicitly:
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
try:
|
||||
result = await risky_operation()
|
||||
return CallToolResult(content=[TextContent(type="text", text=result)])
|
||||
except PermissionError:
|
||||
await task.fail("Access denied - insufficient permissions")
|
||||
raise
|
||||
except TimeoutError:
|
||||
await task.fail("Operation timed out after 30 seconds")
|
||||
raise
|
||||
```
|
||||
|
||||
When `run_task()` catches an exception, it automatically:
|
||||
|
||||
1. Marks the task as `failed`
|
||||
2. Sets `statusMessage` to the exception message
|
||||
3. Propagates the exception (which is caught by the task group)
|
||||
|
||||
For custom error messages, call `task.fail()` before raising.
|
||||
|
||||
## HTTP Transport Example
|
||||
|
||||
For web applications, use the Streamable HTTP transport:
|
||||
|
||||
```python
|
||||
import uvicorn
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.experimental.task_context import ServerTaskContext
|
||||
from mcp.types import (
|
||||
CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED,
|
||||
)
|
||||
|
||||
|
||||
server = Server("http-task-server")
|
||||
server.experimental.enable_tasks()
|
||||
|
||||
|
||||
@server.list_tools()
|
||||
async def list_tools():
|
||||
return [
|
||||
Tool(
|
||||
name="long_operation",
|
||||
description="A long-running operation",
|
||||
inputSchema={"type": "object", "properties": {"duration": {"type": "number"}}},
|
||||
execution=ToolExecution(taskSupport=TASK_REQUIRED),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def handle_long_operation(arguments: dict) -> CreateTaskResult:
|
||||
ctx = server.request_context
|
||||
ctx.experimental.validate_task_mode(TASK_REQUIRED)
|
||||
|
||||
duration = arguments.get("duration", 5)
|
||||
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
import anyio
|
||||
for i in range(int(duration)):
|
||||
await task.update_status(f"Step {i+1}/{int(duration)}")
|
||||
await anyio.sleep(1)
|
||||
return CallToolResult(content=[TextContent(type="text", text=f"Completed after {duration}s")])
|
||||
|
||||
return await ctx.experimental.run_task(work)
|
||||
|
||||
|
||||
@server.call_tool()
|
||||
async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult:
|
||||
if name == "long_operation":
|
||||
return await handle_long_operation(arguments)
|
||||
return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(server.streamable_http_app(), host="127.0.0.1", port=8000)
|
||||
```
|
||||
|
||||
## Testing Task Servers
|
||||
|
||||
Test task functionality with the SDK's testing utilities:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
import anyio
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_task_tool():
|
||||
server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream(10)
|
||||
client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream(10)
|
||||
|
||||
async def run_server():
|
||||
await server.run(
|
||||
client_to_server_receive,
|
||||
server_to_client_send,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
async def run_client():
|
||||
async with ClientSession(server_to_client_receive, client_to_server_send) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Call the tool as a task
|
||||
result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"})
|
||||
task_id = result.task.taskId
|
||||
assert result.task.status == "working"
|
||||
|
||||
# Poll until complete
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
if status.status in ("completed", "failed"):
|
||||
break
|
||||
|
||||
# Get result
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
assert len(final.content) > 0
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(run_server)
|
||||
tg.start_soon(run_client)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Keep Work Functions Focused
|
||||
|
||||
```python
|
||||
# Good: focused work function
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Validating...")
|
||||
validate_input(arguments)
|
||||
|
||||
await task.update_status("Processing...")
|
||||
result = await process_data(arguments)
|
||||
|
||||
return CallToolResult(content=[TextContent(type="text", text=result)])
|
||||
```
|
||||
|
||||
### Check Cancellation in Loops
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
results = []
|
||||
for item in large_dataset:
|
||||
if task.is_cancelled:
|
||||
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
|
||||
|
||||
results.append(await process(item))
|
||||
|
||||
return CallToolResult(content=[TextContent(type="text", text=str(results))])
|
||||
```
|
||||
|
||||
### Use Meaningful Status Messages
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
await task.update_status("Connecting to database...")
|
||||
db = await connect()
|
||||
|
||||
await task.update_status("Fetching records (0/1000)...")
|
||||
for i, record in enumerate(records):
|
||||
if i % 100 == 0:
|
||||
await task.update_status(f"Processing records ({i}/1000)...")
|
||||
await process(record)
|
||||
|
||||
await task.update_status("Finalizing results...")
|
||||
return CallToolResult(content=[TextContent(type="text", text="Done")])
|
||||
```
|
||||
|
||||
### Handle Elicitation Responses
|
||||
|
||||
```python
|
||||
async def work(task: ServerTaskContext) -> CallToolResult:
|
||||
result = await task.elicit(message="Continue?", requestedSchema={...})
|
||||
|
||||
match result.action:
|
||||
case "accept":
|
||||
# User accepted, process content
|
||||
return await process_accepted(result.content)
|
||||
case "decline":
|
||||
# User explicitly declined
|
||||
return CallToolResult(content=[TextContent(type="text", text="User declined")])
|
||||
case "cancel":
|
||||
# User cancelled the elicitation
|
||||
return CallToolResult(content=[TextContent(type="text", text="Cancelled")])
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Client Usage](tasks-client.md) - Learn how clients interact with task servers
|
||||
- [Tasks Overview](tasks.md) - Review lifecycle and concepts
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
# Tasks
|
||||
|
||||
!!! warning "Experimental"
|
||||
|
||||
Tasks are an experimental feature tracking the draft MCP specification.
|
||||
The API may change without notice.
|
||||
|
||||
Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result.
|
||||
|
||||
## When to Use Tasks
|
||||
|
||||
Tasks are designed for operations that:
|
||||
|
||||
- Take significant time (seconds to minutes)
|
||||
- Need progress updates during execution
|
||||
- Require user input mid-execution (elicitation, sampling)
|
||||
- Should run without blocking the requestor
|
||||
|
||||
Common use cases:
|
||||
|
||||
- Long-running data processing
|
||||
- Multi-step workflows with user confirmation
|
||||
- LLM-powered operations requiring sampling
|
||||
- OAuth flows requiring user browser interaction
|
||||
|
||||
## Task Lifecycle
|
||||
|
||||
```text
|
||||
┌─────────────┐
|
||||
│ working │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────────┐ ┌───────────┐ ┌───────────┐
|
||||
│ completed │ │ failed │ │ cancelled │
|
||||
└────────────┘ └───────────┘ └───────────┘
|
||||
▲
|
||||
│
|
||||
┌────────┴────────┐
|
||||
│ input_required │◄──────┐
|
||||
└────────┬────────┘ │
|
||||
│ │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `working` | Task is being processed |
|
||||
| `input_required` | Receiver needs input from requestor (elicitation/sampling) |
|
||||
| `completed` | Task finished successfully |
|
||||
| `failed` | Task encountered an error |
|
||||
| `cancelled` | Task was cancelled by requestor |
|
||||
|
||||
Terminal states (`completed`, `failed`, `cancelled`) are final—tasks cannot transition out of them.
|
||||
|
||||
## Bidirectional Flow
|
||||
|
||||
Tasks work in both directions:
|
||||
|
||||
**Client → Server** (most common):
|
||||
|
||||
```text
|
||||
Client Server
|
||||
│ │
|
||||
│── tools/call (task) ──────────────>│ Creates task
|
||||
│<── CreateTaskResult ───────────────│
|
||||
│ │
|
||||
│── tasks/get ──────────────────────>│
|
||||
│<── status: working ────────────────│
|
||||
│ │ ... work continues ...
|
||||
│── tasks/get ──────────────────────>│
|
||||
│<── status: completed ──────────────│
|
||||
│ │
|
||||
│── tasks/result ───────────────────>│
|
||||
│<── CallToolResult ─────────────────│
|
||||
```
|
||||
|
||||
**Server → Client** (for elicitation/sampling):
|
||||
|
||||
```text
|
||||
Server Client
|
||||
│ │
|
||||
│── elicitation/create (task) ──────>│ Creates task
|
||||
│<── CreateTaskResult ───────────────│
|
||||
│ │
|
||||
│── tasks/get ──────────────────────>│
|
||||
│<── status: working ────────────────│
|
||||
│ │ ... user interaction ...
|
||||
│── tasks/get ──────────────────────>│
|
||||
│<── status: completed ──────────────│
|
||||
│ │
|
||||
│── tasks/result ───────────────────>│
|
||||
│<── ElicitResult ───────────────────│
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Task Metadata
|
||||
|
||||
When augmenting a request with task execution, include `TaskMetadata`:
|
||||
|
||||
```python
|
||||
from mcp.types import TaskMetadata
|
||||
|
||||
task = TaskMetadata(ttl=60000) # TTL in milliseconds
|
||||
```
|
||||
|
||||
The `ttl` (time-to-live) specifies how long the task and result are retained after completion.
|
||||
|
||||
### Task Store
|
||||
|
||||
Servers persist task state in a `TaskStore`. The SDK provides `InMemoryTaskStore` for development:
|
||||
|
||||
```python
|
||||
from mcp.shared.experimental.tasks import InMemoryTaskStore
|
||||
|
||||
store = InMemoryTaskStore()
|
||||
```
|
||||
|
||||
For production, implement `TaskStore` with a database or distributed cache.
|
||||
|
||||
### Capabilities
|
||||
|
||||
Both servers and clients declare task support through capabilities:
|
||||
|
||||
**Server capabilities:**
|
||||
|
||||
- `tasks.requests.tools.call` - Server accepts task-augmented tool calls
|
||||
|
||||
**Client capabilities:**
|
||||
|
||||
- `tasks.requests.sampling.createMessage` - Client accepts task-augmented sampling
|
||||
- `tasks.requests.elicitation.create` - Client accepts task-augmented elicitation
|
||||
|
||||
The SDK manages these automatically when you enable task support.
|
||||
|
||||
## Quick Example
|
||||
|
||||
**Server** (simplified API):
|
||||
|
||||
```python
|
||||
from mcp.server import Server
|
||||
from mcp.server.experimental.task_context import ServerTaskContext
|
||||
from mcp.types import CallToolResult, TextContent, TASK_REQUIRED
|
||||
|
||||
server = Server("my-server")
|
||||
server.experimental.enable_tasks() # One-line setup
|
||||
|
||||
@server.call_tool()
|
||||
async def handle_tool(name: str, arguments: dict):
|
||||
ctx = server.request_context
|
||||
ctx.experimental.validate_task_mode(TASK_REQUIRED)
|
||||
|
||||
async def work(task: ServerTaskContext):
|
||||
await task.update_status("Processing...")
|
||||
# ... do work ...
|
||||
return CallToolResult(content=[TextContent(type="text", text="Done!")])
|
||||
|
||||
return await ctx.experimental.run_task(work)
|
||||
```
|
||||
|
||||
**Client:**
|
||||
|
||||
```python
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.types import CallToolResult
|
||||
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Call tool as task
|
||||
result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"})
|
||||
task_id = result.task.taskId
|
||||
|
||||
# Poll until done
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
print(f"Status: {status.status}")
|
||||
|
||||
# Get result
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Server Implementation](tasks-server.md) - Build task-supporting servers
|
||||
- [Client Usage](tasks-client.md) - Call and poll tasks from clients
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"""Generate the code reference pages and navigation."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import mkdocs_gen_files
|
||||
|
||||
nav = mkdocs_gen_files.Nav()
|
||||
|
||||
root = Path(__file__).parent.parent.parent
|
||||
src = root / "src"
|
||||
|
||||
for path in sorted(src.rglob("*.py")):
|
||||
module_path = path.relative_to(src).with_suffix("")
|
||||
doc_path = path.relative_to(src).with_suffix(".md")
|
||||
full_doc_path = Path("api", doc_path)
|
||||
|
||||
parts = tuple(module_path.parts)
|
||||
|
||||
if parts[-1] == "__init__":
|
||||
parts = parts[:-1]
|
||||
doc_path = doc_path.with_name("index.md")
|
||||
full_doc_path = full_doc_path.with_name("index.md")
|
||||
elif parts[-1].startswith("_"):
|
||||
continue
|
||||
|
||||
nav[parts] = doc_path.as_posix()
|
||||
|
||||
with mkdocs_gen_files.open(full_doc_path, "w") as fd:
|
||||
ident = ".".join(parts)
|
||||
fd.write(f"::: {ident}")
|
||||
|
||||
mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root))
|
||||
|
||||
with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file:
|
||||
nav_file.writelines(nav.build_literate_nav())
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
# MCP Python SDK
|
||||
|
||||
!!! info "You are viewing the in-development v2 documentation"
|
||||
For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/).
|
||||
|
||||
The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction.
|
||||
|
||||
This Python SDK implements the full MCP specification, making it easy to:
|
||||
|
||||
- **Build MCP servers** that expose resources, prompts, and tools
|
||||
- **Create MCP clients** that can connect to any MCP server
|
||||
- **Use standard transports** like stdio, SSE, and Streamable HTTP
|
||||
|
||||
If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io).
|
||||
|
||||
## Quick Example
|
||||
|
||||
Here's a simple MCP server that exposes a tool, resource, and prompt:
|
||||
|
||||
```python title="server.py"
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
mcp = MCPServer("Test Server", json_response=True)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
||||
|
||||
@mcp.resource("greeting://{name}")
|
||||
def get_greeting(name: str) -> str:
|
||||
"""Get a personalized greeting"""
|
||||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def greet_user(name: str, style: str = "friendly") -> str:
|
||||
"""Generate a greeting prompt"""
|
||||
return f"Write a {style} greeting for someone named {name}."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="streamable-http")
|
||||
```
|
||||
|
||||
Run the server:
|
||||
|
||||
```bash
|
||||
uv run --with mcp server.py
|
||||
```
|
||||
|
||||
Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`:
|
||||
|
||||
```bash
|
||||
npx -y @modelcontextprotocol/inspector
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
<!-- TODO(Marcelo): automatically generate the follow references with a header on each of those files. -->
|
||||
1. **[Install](installation.md)** the MCP SDK
|
||||
2. **[Learn concepts](concepts.md)** - understand the three primitives and architecture
|
||||
3. **[Explore authorization](authorization.md)** - add security to your servers
|
||||
4. **[Use low-level APIs](low-level-server.md)** - for advanced customization
|
||||
|
||||
## API Reference
|
||||
|
||||
Full API documentation is available in the [API Reference](api/mcp/index.md).
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Installation
|
||||
|
||||
The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as:
|
||||
|
||||
=== "pip"
|
||||
|
||||
```bash
|
||||
pip install mcp
|
||||
```
|
||||
=== "uv"
|
||||
|
||||
```bash
|
||||
uv add mcp
|
||||
```
|
||||
|
||||
The following dependencies are automatically installed:
|
||||
|
||||
- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports.
|
||||
- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport.
|
||||
- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/).
|
||||
- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints.
|
||||
- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing.
|
||||
- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint.
|
||||
- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer.
|
||||
- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints.
|
||||
- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation.
|
||||
- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools.
|
||||
|
||||
This package has the following optional groups:
|
||||
|
||||
- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Low-Level Server
|
||||
|
||||
!!! warning "Under Construction"
|
||||
|
||||
This page is currently being written. Check back soon for complete documentation.
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,77 @@
|
|||
# Testing MCP Servers
|
||||
|
||||
The Python SDK provides a `Client` class for testing MCP servers with an in-memory transport.
|
||||
This makes it easy to write tests without network overhead.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
Let's assume you have a simple server with a single tool:
|
||||
|
||||
```python title="server.py"
|
||||
from mcp.server import MCPServer
|
||||
|
||||
app = MCPServer("Calculator")
|
||||
|
||||
@app.tool()
|
||||
def add(a: int, b: int) -> int:
|
||||
"""Add two numbers.""" # (1)!
|
||||
return a + b
|
||||
```
|
||||
|
||||
1. The docstring is automatically added as the description of the tool.
|
||||
|
||||
To run the below test, you'll need to install the following dependencies:
|
||||
|
||||
=== "pip"
|
||||
```bash
|
||||
pip install inline-snapshot pytest
|
||||
```
|
||||
|
||||
=== "uv"
|
||||
```bash
|
||||
uv add inline-snapshot pytest
|
||||
```
|
||||
|
||||
!!! info
|
||||
I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework,
|
||||
so I won't go into details here.
|
||||
|
||||
The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows
|
||||
you to take snapshots of the output of your tests. Which makes it easier to create tests for your
|
||||
server - you don't need to use it, but we are spreading the word for best practices.
|
||||
|
||||
```python title="test_server.py"
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp import Client
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
from server import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def anyio_backend(): # (1)!
|
||||
return "asyncio"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(): # (2)!
|
||||
async with Client(app, raise_exceptions=True) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_call_add_tool(client: Client):
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(type="text", text="3")],
|
||||
structuredContent={"result": 3},
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on).
|
||||
2. The `client` fixture creates a connected client that can be reused across multiple tests.
|
||||
|
||||
There you go! You can now extend your tests to cover more scenarios.
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
# Python SDK Examples
|
||||
|
||||
This folders aims to provide simple examples of using the Python SDK. Please refer to the
|
||||
[servers repository](https://github.com/modelcontextprotocol/servers)
|
||||
for real-world servers.
|
||||
|
|
@ -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,383 @@
|
|||
#!/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 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.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, "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")
|
||||
|
||||
def get_state(self):
|
||||
"""Get the received state parameter."""
|
||||
return self.callback_data["state"]
|
||||
|
||||
|
||||
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() -> tuple[str, str | None]:
|
||||
"""Wait for OAuth callback and return auth code and state."""
|
||||
print("⏳ Waiting for authorization callback...")
|
||||
try:
|
||||
auth_code = callback_server.wait_for_callback(timeout=300)
|
||||
return auth_code, callback_server.get_state()
|
||||
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,43 @@
|
|||
# Simple Task Client
|
||||
|
||||
A minimal MCP client demonstrating polling for task results over streamable HTTP.
|
||||
|
||||
## Running
|
||||
|
||||
First, start the simple-task server in another terminal:
|
||||
|
||||
```bash
|
||||
cd examples/servers/simple-task
|
||||
uv run mcp-simple-task
|
||||
```
|
||||
|
||||
Then run the client:
|
||||
|
||||
```bash
|
||||
cd examples/clients/simple-task-client
|
||||
uv run mcp-simple-task-client
|
||||
```
|
||||
|
||||
Use `--url` to connect to a different server.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Connects to the server via streamable HTTP
|
||||
2. Calls the `long_running_task` tool as a task
|
||||
3. Polls the task status until completion
|
||||
4. Retrieves and prints the result
|
||||
|
||||
## Expected output
|
||||
|
||||
```text
|
||||
Available tools: ['long_running_task']
|
||||
|
||||
Calling tool as a task...
|
||||
Task created: <task-id>
|
||||
Status: working - Starting work...
|
||||
Status: working - Processing step 1...
|
||||
Status: working - Processing step 2...
|
||||
Status: completed -
|
||||
|
||||
Result: Task completed!
|
||||
```
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import sys
|
||||
|
||||
from .main import main
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"""Simple task client demonstrating MCP tasks polling over streamable HTTP."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
|
||||
async def run(url: str) -> None:
|
||||
async with streamable_http_client(url) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List tools
|
||||
tools = await session.list_tools()
|
||||
print(f"Available tools: {[t.name for t in tools.tools]}")
|
||||
|
||||
# Call the tool as a task
|
||||
print("\nCalling tool as a task...")
|
||||
|
||||
result = await session.experimental.call_tool_as_task(
|
||||
"long_running_task",
|
||||
arguments={},
|
||||
ttl=60000,
|
||||
)
|
||||
task_id = result.task.task_id
|
||||
print(f"Task created: {task_id}")
|
||||
|
||||
status = None
|
||||
# Poll until done (respects server's pollInterval hint)
|
||||
async for status in session.experimental.poll_task(task_id):
|
||||
print(f" Status: {status.status} - {status.status_message or ''}")
|
||||
|
||||
# Check final status
|
||||
if status and status.status != "completed":
|
||||
print(f"Task ended with status: {status.status}")
|
||||
return
|
||||
|
||||
# Get the result
|
||||
task_result = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
content = task_result.content[0]
|
||||
if isinstance(content, TextContent):
|
||||
print(f"\nResult: {content.text}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--url", default="http://localhost:8000/mcp", help="Server URL")
|
||||
def main(url: str) -> int:
|
||||
asyncio.run(run(url))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
[project]
|
||||
name = "mcp-simple-task-client"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP client demonstrating task polling"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "tasks", "client"]
|
||||
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.0", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-task-client = "mcp_simple_task_client.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_task_client"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_task_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", "ruff>=0.6.9"]
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# Simple Interactive Task Client
|
||||
|
||||
A minimal MCP client demonstrating responses to interactive tasks (elicitation and sampling).
|
||||
|
||||
## Running
|
||||
|
||||
First, start the interactive task server in another terminal:
|
||||
|
||||
```bash
|
||||
cd examples/servers/simple-task-interactive
|
||||
uv run mcp-simple-task-interactive
|
||||
```
|
||||
|
||||
Then run the client:
|
||||
|
||||
```bash
|
||||
cd examples/clients/simple-task-interactive-client
|
||||
uv run mcp-simple-task-interactive-client
|
||||
```
|
||||
|
||||
Use `--url` to connect to a different server.
|
||||
|
||||
## What it does
|
||||
|
||||
1. Connects to the server via streamable HTTP
|
||||
2. Calls `confirm_delete` - server asks for confirmation, client responds via terminal
|
||||
3. Calls `write_haiku` - server requests LLM completion, client returns a hardcoded haiku
|
||||
|
||||
## Key concepts
|
||||
|
||||
### Elicitation callback
|
||||
|
||||
```python
|
||||
async def elicitation_callback(context, params) -> ElicitResult:
|
||||
# Handle user input request from server
|
||||
return ElicitResult(action="accept", content={"confirm": True})
|
||||
```
|
||||
|
||||
### Sampling callback
|
||||
|
||||
```python
|
||||
async def sampling_callback(context, params) -> CreateMessageResult:
|
||||
# Handle LLM completion request from server
|
||||
return CreateMessageResult(model="...", role="assistant", content=...)
|
||||
```
|
||||
|
||||
### Using call_tool_as_task
|
||||
|
||||
```python
|
||||
# Call a tool as a task (returns immediately with task reference)
|
||||
result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"})
|
||||
task_id = result.task.task_id
|
||||
|
||||
# Get result - this delivers elicitation/sampling requests and blocks until complete
|
||||
final = await session.experimental.get_task_result(task_id, CallToolResult)
|
||||
```
|
||||
|
||||
**Important**: The `get_task_result()` call is what triggers the delivery of elicitation
|
||||
and sampling requests to your callbacks. It blocks until the task completes and returns
|
||||
the final result.
|
||||
|
||||
## Expected output
|
||||
|
||||
```text
|
||||
Available tools: ['confirm_delete', 'write_haiku']
|
||||
|
||||
--- Demo 1: Elicitation ---
|
||||
Calling confirm_delete tool...
|
||||
Task created: <task-id>
|
||||
|
||||
[Elicitation] Server asks: Are you sure you want to delete 'important.txt'?
|
||||
Your response (y/n): y
|
||||
[Elicitation] Responding with: confirm=True
|
||||
Result: Deleted 'important.txt'
|
||||
|
||||
--- Demo 2: Sampling ---
|
||||
Calling write_haiku tool...
|
||||
Task created: <task-id>
|
||||
|
||||
[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves
|
||||
[Sampling] Responding with haiku
|
||||
Result:
|
||||
Haiku:
|
||||
Cherry blossoms fall
|
||||
Softly on the quiet pond
|
||||
Spring whispers goodbye
|
||||
```
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import sys
|
||||
|
||||
from .main import main
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
"""Simple interactive task client demonstrating elicitation and sampling responses.
|
||||
|
||||
This example demonstrates the spec-compliant polling pattern:
|
||||
1. Poll tasks/get watching for status changes
|
||||
2. On input_required, call tasks/result to receive elicitation/sampling requests
|
||||
3. Continue until terminal status, then retrieve final result
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import click
|
||||
from mcp import ClientSession
|
||||
from mcp.client.context import ClientRequestContext
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import (
|
||||
CallToolResult,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
|
||||
async def elicitation_callback(
|
||||
context: ClientRequestContext,
|
||||
params: ElicitRequestParams,
|
||||
) -> ElicitResult:
|
||||
"""Handle elicitation requests from the server."""
|
||||
print(f"\n[Elicitation] Server asks: {params.message}")
|
||||
|
||||
# Simple terminal prompt
|
||||
response = input("Your response (y/n): ").strip().lower()
|
||||
confirmed = response in ("y", "yes", "true", "1")
|
||||
|
||||
print(f"[Elicitation] Responding with: confirm={confirmed}")
|
||||
return ElicitResult(action="accept", content={"confirm": confirmed})
|
||||
|
||||
|
||||
async def sampling_callback(
|
||||
context: ClientRequestContext,
|
||||
params: CreateMessageRequestParams,
|
||||
) -> CreateMessageResult:
|
||||
"""Handle sampling requests from the server."""
|
||||
# Get the prompt from the first message
|
||||
prompt = "unknown"
|
||||
if params.messages:
|
||||
content = params.messages[0].content
|
||||
if isinstance(content, TextContent):
|
||||
prompt = content.text
|
||||
|
||||
print(f"\n[Sampling] Server requests LLM completion for: {prompt}")
|
||||
|
||||
# Return a hardcoded haiku (in real use, call your LLM here)
|
||||
haiku = """Cherry blossoms fall
|
||||
Softly on the quiet pond
|
||||
Spring whispers goodbye"""
|
||||
|
||||
print("[Sampling] Responding with haiku")
|
||||
return CreateMessageResult(
|
||||
model="mock-haiku-model",
|
||||
role="assistant",
|
||||
content=TextContent(type="text", text=haiku),
|
||||
)
|
||||
|
||||
|
||||
def get_text(result: CallToolResult) -> str:
|
||||
"""Extract text from a CallToolResult."""
|
||||
if result.content and isinstance(result.content[0], TextContent):
|
||||
return result.content[0].text
|
||||
return "(no text)"
|
||||
|
||||
|
||||
async def run(url: str) -> None:
|
||||
async with streamable_http_client(url) as (read, write):
|
||||
async with ClientSession(
|
||||
read,
|
||||
write,
|
||||
elicitation_callback=elicitation_callback,
|
||||
sampling_callback=sampling_callback,
|
||||
) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List tools
|
||||
tools = await session.list_tools()
|
||||
print(f"Available tools: {[t.name for t in tools.tools]}")
|
||||
|
||||
# Demo 1: Elicitation (confirm_delete)
|
||||
print("\n--- Demo 1: Elicitation ---")
|
||||
print("Calling confirm_delete tool...")
|
||||
|
||||
elicit_task = await session.experimental.call_tool_as_task("confirm_delete", {"filename": "important.txt"})
|
||||
elicit_task_id = elicit_task.task.task_id
|
||||
print(f"Task created: {elicit_task_id}")
|
||||
|
||||
# Poll until terminal, calling tasks/result on input_required
|
||||
async for status in session.experimental.poll_task(elicit_task_id):
|
||||
print(f"[Poll] Status: {status.status}")
|
||||
if status.status == "input_required":
|
||||
# Server needs input - tasks/result delivers the elicitation request
|
||||
elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult)
|
||||
break
|
||||
else:
|
||||
# poll_task exited due to terminal status
|
||||
elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult)
|
||||
|
||||
print(f"Result: {get_text(elicit_result)}")
|
||||
|
||||
# Demo 2: Sampling (write_haiku)
|
||||
print("\n--- Demo 2: Sampling ---")
|
||||
print("Calling write_haiku tool...")
|
||||
|
||||
sampling_task = await session.experimental.call_tool_as_task("write_haiku", {"topic": "autumn leaves"})
|
||||
sampling_task_id = sampling_task.task.task_id
|
||||
print(f"Task created: {sampling_task_id}")
|
||||
|
||||
# Poll until terminal, calling tasks/result on input_required
|
||||
async for status in session.experimental.poll_task(sampling_task_id):
|
||||
print(f"[Poll] Status: {status.status}")
|
||||
if status.status == "input_required":
|
||||
sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult)
|
||||
break
|
||||
else:
|
||||
sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult)
|
||||
|
||||
print(f"Result:\n{get_text(sampling_result)}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--url", default="http://localhost:8000/mcp", help="Server URL")
|
||||
def main(url: str) -> int:
|
||||
asyncio.run(run(url))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
[project]
|
||||
name = "mcp-simple-task-interactive-client"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP client demonstrating interactive task responses"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "tasks", "client", "elicitation", "sampling"]
|
||||
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.0", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-task-interactive-client = "mcp_simple_task_interactive_client.main:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_task_interactive_client"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_task_interactive_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", "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"]
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"""MCPServer Complex inputs Example
|
||||
|
||||
Demonstrates validation via pydantic with complex models.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
mcp = MCPServer("Shrimp Tank")
|
||||
|
||||
|
||||
class ShrimpTank(BaseModel):
|
||||
class Shrimp(BaseModel):
|
||||
name: Annotated[str, Field(max_length=10)]
|
||||
|
||||
shrimp: list[Shrimp]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def name_shrimp(
|
||||
tank: ShrimpTank,
|
||||
# You can use pydantic Field in function signatures for validation.
|
||||
extra_names: Annotated[list[str], Field(max_length=10)],
|
||||
) -> list[str]:
|
||||
"""List all shrimp names in the tank"""
|
||||
return [shrimp.name for shrimp in tank.shrimp] + extra_names
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"""MCPServer Desktop Example
|
||||
|
||||
A simple example that exposes the desktop directory as a resource.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Demo")
|
||||
|
||||
|
||||
@mcp.resource("dir://desktop")
|
||||
def desktop() -> list[str]:
|
||||
"""List the files in the user's desktop"""
|
||||
desktop = Path.home() / "Desktop"
|
||||
return [str(f) for f in desktop.iterdir()]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def sum(a: int, b: int) -> int:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"""MCPServer Echo Server with direct CallToolResult return"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
mcp = MCPServer("Echo Server")
|
||||
|
||||
|
||||
class EchoResponse(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo(text: str) -> Annotated[CallToolResult, EchoResponse]:
|
||||
"""Echo the input text with structure and metadata"""
|
||||
return CallToolResult(
|
||||
content=[TextContent(type="text", text=text)], structured_content={"text": text}, _meta={"some": "metadata"}
|
||||
)
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""MCPServer Echo Server"""
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Echo Server")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo_tool(text: str) -> str:
|
||||
"""Echo the input text"""
|
||||
return text
|
||||
|
||||
|
||||
@mcp.resource("echo://static")
|
||||
def echo_resource() -> str:
|
||||
return "Echo!"
|
||||
|
||||
|
||||
@mcp.resource("echo://{text}")
|
||||
def echo_template(text: str) -> str:
|
||||
"""Echo the input text"""
|
||||
return f"Echo: {text}"
|
||||
|
||||
|
||||
@mcp.prompt("echo")
|
||||
def echo_prompt(text: str) -> str:
|
||||
return text
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"""MCPServer Icons Demo Server
|
||||
|
||||
Demonstrates using icons with tools, resources, prompts, and implementation.
|
||||
"""
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.mcpserver import Icon, MCPServer
|
||||
|
||||
# Load the icon file and convert to data URI
|
||||
icon_path = Path(__file__).parent / "mcp.png"
|
||||
icon_data = base64.standard_b64encode(icon_path.read_bytes()).decode()
|
||||
icon_data_uri = f"data:image/png;base64,{icon_data}"
|
||||
|
||||
icon_data = Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"])
|
||||
|
||||
# Create server with icons in implementation
|
||||
mcp = MCPServer(
|
||||
"Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data]
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool(icons=[icon_data])
|
||||
def demo_tool(message: str) -> str:
|
||||
"""A demo tool with an icon."""
|
||||
return message
|
||||
|
||||
|
||||
@mcp.resource("demo://readme", icons=[icon_data])
|
||||
def readme_resource() -> str:
|
||||
"""A demo resource with an icon"""
|
||||
return "This resource has an icon"
|
||||
|
||||
|
||||
@mcp.prompt("prompt_with_icon", icons=[icon_data])
|
||||
def prompt_with_icon(text: str) -> str:
|
||||
"""A demo prompt with an icon"""
|
||||
return text
|
||||
|
||||
|
||||
@mcp.tool(
|
||||
icons=[
|
||||
Icon(src=icon_data_uri, mime_type="image/png", sizes=["16x16"]),
|
||||
Icon(src=icon_data_uri, mime_type="image/png", sizes=["32x32"]),
|
||||
Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]),
|
||||
]
|
||||
)
|
||||
def multi_icon_tool(action: str) -> str:
|
||||
"""A tool demonstrating multiple icons."""
|
||||
return "multi_icon_tool"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run the server
|
||||
mcp.run()
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"""MCPServer Echo Server that sends log messages and progress updates to the client"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Echo Server with logging and progress updates")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def echo(text: str, ctx: Context) -> str:
|
||||
"""Echo the input text sending log messages and progress updates during processing."""
|
||||
await ctx.report_progress(progress=0, total=100)
|
||||
await ctx.info("Starting to process echo for input: " + text)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await ctx.info("Halfway through processing echo for input: " + text)
|
||||
await ctx.report_progress(progress=50, total=100)
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await ctx.info("Finished processing echo for input: " + text)
|
||||
await ctx.report_progress(progress=100, total=100)
|
||||
|
||||
# Progress notifications are process asynchronously by the client.
|
||||
# A small delay here helps ensure the last notification is processed by the client.
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return text
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -0,0 +1,324 @@
|
|||
# /// script
|
||||
# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"]
|
||||
# ///
|
||||
|
||||
# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector
|
||||
|
||||
"""Recursive memory system inspired by the human brain's clustering of memories.
|
||||
Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient
|
||||
similarity search.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Self, TypeVar
|
||||
|
||||
import asyncpg
|
||||
import numpy as np
|
||||
from openai import AsyncOpenAI
|
||||
from pgvector.asyncpg import register_vector # Import register_vector
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic_ai import Agent
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
MAX_DEPTH = 5
|
||||
SIMILARITY_THRESHOLD = 0.7
|
||||
DECAY_FACTOR = 0.99
|
||||
REINFORCEMENT_FACTOR = 1.1
|
||||
|
||||
DEFAULT_LLM_MODEL = "openai:gpt-4o"
|
||||
DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small"
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
mcp = MCPServer("memory")
|
||||
|
||||
DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db"
|
||||
# reset memory with rm ~/.mcp/{USER}/memory/*
|
||||
PROFILE_DIR = (Path.home() / ".mcp" / os.environ.get("USER", "anon") / "memory").resolve()
|
||||
PROFILE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
a_array = np.array(a, dtype=np.float64)
|
||||
b_array = np.array(b, dtype=np.float64)
|
||||
return np.dot(a_array, b_array) / (np.linalg.norm(a_array) * np.linalg.norm(b_array))
|
||||
|
||||
|
||||
async def do_ai(
|
||||
user_prompt: str,
|
||||
system_prompt: str,
|
||||
result_type: type[T] | Annotated,
|
||||
deps=None,
|
||||
) -> T:
|
||||
agent = Agent(
|
||||
DEFAULT_LLM_MODEL,
|
||||
system_prompt=system_prompt,
|
||||
result_type=result_type,
|
||||
)
|
||||
result = await agent.run(user_prompt, deps=deps)
|
||||
return result.data
|
||||
|
||||
|
||||
@dataclass
|
||||
class Deps:
|
||||
openai: AsyncOpenAI
|
||||
pool: asyncpg.Pool
|
||||
|
||||
|
||||
async def get_db_pool() -> asyncpg.Pool:
|
||||
async def init(conn):
|
||||
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
|
||||
await register_vector(conn)
|
||||
|
||||
pool = await asyncpg.create_pool(DB_DSN, init=init)
|
||||
return pool
|
||||
|
||||
|
||||
class MemoryNode(BaseModel):
|
||||
id: int | None = None
|
||||
content: str
|
||||
summary: str = ""
|
||||
importance: float = 1.0
|
||||
access_count: int = 0
|
||||
timestamp: float = Field(default_factory=lambda: datetime.now(timezone.utc).timestamp())
|
||||
embedding: list[float]
|
||||
|
||||
@classmethod
|
||||
async def from_content(cls, content: str, deps: Deps):
|
||||
embedding = await get_embedding(content, deps)
|
||||
return cls(content=content, embedding=embedding)
|
||||
|
||||
async def save(self, deps: Deps):
|
||||
async with deps.pool.acquire() as conn:
|
||||
if self.id is None:
|
||||
result = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO memories (content, summary, importance, access_count,
|
||||
timestamp, embedding)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id
|
||||
""",
|
||||
self.content,
|
||||
self.summary,
|
||||
self.importance,
|
||||
self.access_count,
|
||||
self.timestamp,
|
||||
self.embedding,
|
||||
)
|
||||
self.id = result["id"]
|
||||
else:
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE memories
|
||||
SET content = $1, summary = $2, importance = $3,
|
||||
access_count = $4, timestamp = $5, embedding = $6
|
||||
WHERE id = $7
|
||||
""",
|
||||
self.content,
|
||||
self.summary,
|
||||
self.importance,
|
||||
self.access_count,
|
||||
self.timestamp,
|
||||
self.embedding,
|
||||
self.id,
|
||||
)
|
||||
|
||||
async def merge_with(self, other: Self, deps: Deps):
|
||||
self.content = await do_ai(
|
||||
f"{self.content}\n\n{other.content}",
|
||||
"Combine the following two texts into a single, coherent text.",
|
||||
str,
|
||||
deps,
|
||||
)
|
||||
self.importance += other.importance
|
||||
self.access_count += other.access_count
|
||||
self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)]
|
||||
self.summary = await do_ai(self.content, "Summarize the following text concisely.", str, deps)
|
||||
await self.save(deps)
|
||||
# Delete the merged node from the database
|
||||
if other.id is not None:
|
||||
await delete_memory(other.id, deps)
|
||||
|
||||
def get_effective_importance(self):
|
||||
return self.importance * (1 + math.log(self.access_count + 1))
|
||||
|
||||
|
||||
async def get_embedding(text: str, deps: Deps) -> list[float]:
|
||||
embedding_response = await deps.openai.embeddings.create(
|
||||
input=text,
|
||||
model=DEFAULT_EMBEDDING_MODEL,
|
||||
)
|
||||
return embedding_response.data[0].embedding
|
||||
|
||||
|
||||
async def delete_memory(memory_id: int, deps: Deps):
|
||||
async with deps.pool.acquire() as conn:
|
||||
await conn.execute("DELETE FROM memories WHERE id = $1", memory_id)
|
||||
|
||||
|
||||
async def add_memory(content: str, deps: Deps):
|
||||
new_memory = await MemoryNode.from_content(content, deps)
|
||||
await new_memory.save(deps)
|
||||
|
||||
similar_memories = await find_similar_memories(new_memory.embedding, deps)
|
||||
for memory in similar_memories:
|
||||
if memory.id != new_memory.id:
|
||||
await new_memory.merge_with(memory, deps)
|
||||
|
||||
await update_importance(new_memory.embedding, deps)
|
||||
|
||||
await prune_memories(deps)
|
||||
|
||||
return f"Remembered: {content}"
|
||||
|
||||
|
||||
async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]:
|
||||
async with deps.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, content, summary, importance, access_count, timestamp, embedding
|
||||
FROM memories
|
||||
ORDER BY embedding <-> $1
|
||||
LIMIT 5
|
||||
""",
|
||||
embedding,
|
||||
)
|
||||
memories = [
|
||||
MemoryNode(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
summary=row["summary"],
|
||||
importance=row["importance"],
|
||||
access_count=row["access_count"],
|
||||
timestamp=row["timestamp"],
|
||||
embedding=row["embedding"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return memories
|
||||
|
||||
|
||||
async def update_importance(user_embedding: list[float], deps: Deps):
|
||||
async with deps.pool.acquire() as conn:
|
||||
rows = await conn.fetch("SELECT id, importance, access_count, embedding FROM memories")
|
||||
for row in rows:
|
||||
memory_embedding = row["embedding"]
|
||||
similarity = cosine_similarity(user_embedding, memory_embedding)
|
||||
if similarity > SIMILARITY_THRESHOLD:
|
||||
new_importance = row["importance"] * REINFORCEMENT_FACTOR
|
||||
new_access_count = row["access_count"] + 1
|
||||
else:
|
||||
new_importance = row["importance"] * DECAY_FACTOR
|
||||
new_access_count = row["access_count"]
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE memories
|
||||
SET importance = $1, access_count = $2
|
||||
WHERE id = $3
|
||||
""",
|
||||
new_importance,
|
||||
new_access_count,
|
||||
row["id"],
|
||||
)
|
||||
|
||||
|
||||
async def prune_memories(deps: Deps):
|
||||
async with deps.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, importance, access_count
|
||||
FROM memories
|
||||
ORDER BY importance DESC
|
||||
OFFSET $1
|
||||
""",
|
||||
MAX_DEPTH,
|
||||
)
|
||||
for row in rows:
|
||||
await conn.execute("DELETE FROM memories WHERE id = $1", row["id"])
|
||||
|
||||
|
||||
async def display_memory_tree(deps: Deps) -> str:
|
||||
async with deps.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT content, summary, importance, access_count
|
||||
FROM memories
|
||||
ORDER BY importance DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
MAX_DEPTH,
|
||||
)
|
||||
result = ""
|
||||
for row in rows:
|
||||
effective_importance = row["importance"] * (1 + math.log(row["access_count"] + 1))
|
||||
summary = row["summary"] or row["content"]
|
||||
result += f"- {summary} (Importance: {effective_importance:.2f})\n"
|
||||
return result
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def remember(
|
||||
contents: list[str] = Field(description="List of observations or memories to store"),
|
||||
):
|
||||
deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
|
||||
try:
|
||||
return "\n".join(await asyncio.gather(*[add_memory(content, deps) for content in contents]))
|
||||
finally:
|
||||
await deps.pool.close()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def read_profile() -> str:
|
||||
deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool())
|
||||
profile = await display_memory_tree(deps)
|
||||
await deps.pool.close()
|
||||
return profile
|
||||
|
||||
|
||||
async def initialize_database():
|
||||
pool = await asyncpg.create_pool("postgresql://postgres:postgres@localhost:54320/postgres")
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
SELECT pg_terminate_backend(pg_stat_activity.pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE pg_stat_activity.datname = 'memory_db'
|
||||
AND pid <> pg_backend_pid();
|
||||
""")
|
||||
await conn.execute("DROP DATABASE IF EXISTS memory_db;")
|
||||
await conn.execute("CREATE DATABASE memory_db;")
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
pool = await asyncpg.create_pool(DB_DSN)
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;")
|
||||
|
||||
await register_vector(conn)
|
||||
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
importance REAL NOT NULL,
|
||||
access_count INT NOT NULL,
|
||||
timestamp DOUBLE PRECISION NOT NULL,
|
||||
embedding vector(1536) NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories
|
||||
USING hnsw (embedding vector_l2_ops);
|
||||
""")
|
||||
finally:
|
||||
await pool.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(initialize_database())
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"""MCPServer Example showing parameter descriptions"""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Parameter Descriptions Server")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def greet_user(
|
||||
name: str = Field(description="The name of the person to greet"),
|
||||
title: str = Field(description="Optional title like Mr/Ms/Dr", default=""),
|
||||
times: int = Field(description="Number of times to repeat the greeting", default=1),
|
||||
) -> str:
|
||||
"""Greet a user with optional title and repetition"""
|
||||
greeting = f"Hello {title + ' ' if title else ''}{name}!"
|
||||
return "\n".join([greeting] * times)
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
# Create an MCP server
|
||||
mcp = MCPServer("Demo")
|
||||
|
||||
|
||||
# Add an addition tool
|
||||
@mcp.tool()
|
||||
def sum(a: int, b: int) -> int:
|
||||
"""Add two numbers"""
|
||||
return a + b
|
||||
|
||||
|
||||
# Add a dynamic greeting resource
|
||||
@mcp.resource("greeting://{name}")
|
||||
def get_greeting(name: str) -> str:
|
||||
"""Get a personalized greeting"""
|
||||
return f"Hello, {name}!"
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"""MCPServer Screenshot Example
|
||||
|
||||
Give Claude a tool to capture and view screenshots.
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.utilities.types import Image
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Screenshot Demo")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def take_screenshot() -> Image:
|
||||
"""Take a screenshot of the user's screen and return it as an image. Use
|
||||
this tool anytime the user wants you to look at something they're doing.
|
||||
"""
|
||||
import pyautogui
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
# if the file exceeds ~1MB, it will be rejected by Claude
|
||||
screenshot = pyautogui.screenshot()
|
||||
screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True)
|
||||
return Image(data=buffer.getvalue(), format="jpeg")
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"""MCPServer Echo Server"""
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Echo Server")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def echo(text: str) -> str:
|
||||
"""Echo the input text"""
|
||||
return text
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
# /// script
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
"""MCPServer Text Me Server
|
||||
--------------------------------
|
||||
This defines a simple MCPServer server that sends a text message to a phone number via https://surgemsg.com/.
|
||||
|
||||
To run this example, create a `.env` file with the following values:
|
||||
|
||||
SURGE_API_KEY=...
|
||||
SURGE_ACCOUNT_ID=...
|
||||
SURGE_MY_PHONE_NUMBER=...
|
||||
SURGE_MY_FIRST_NAME=...
|
||||
SURGE_MY_LAST_NAME=...
|
||||
|
||||
Visit https://surgemsg.com/ and click "Get Started" to obtain these values.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
import httpx
|
||||
from pydantic import BeforeValidator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
|
||||
class SurgeSettings(BaseSettings):
|
||||
model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="SURGE_", env_file=".env")
|
||||
|
||||
api_key: str
|
||||
account_id: str
|
||||
my_phone_number: Annotated[str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)]
|
||||
my_first_name: str
|
||||
my_last_name: str
|
||||
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Text me")
|
||||
surge_settings = SurgeSettings() # type: ignore
|
||||
|
||||
|
||||
@mcp.tool(name="textme", description="Send a text message to me")
|
||||
def text_me(text_content: str) -> str:
|
||||
"""Send a text message to a phone number via https://surgemsg.com/"""
|
||||
with httpx.Client() as client:
|
||||
response = client.post(
|
||||
"https://api.surgemsg.com/messages",
|
||||
headers={
|
||||
"Authorization": f"Bearer {surge_settings.api_key}",
|
||||
"Surge-Account": surge_settings.account_id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"body": text_content,
|
||||
"conversation": {
|
||||
"contact": {
|
||||
"first_name": surge_settings.my_first_name,
|
||||
"last_name": surge_settings.my_last_name,
|
||||
"phone_number": surge_settings.my_phone_number,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return f"Message sent: {text_content}"
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""Example MCPServer server that uses Unicode characters in various places to help test
|
||||
Unicode handling in tools and inspectors.
|
||||
"""
|
||||
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
mcp = MCPServer()
|
||||
|
||||
|
||||
@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: á é í ó ú ñ 漢字 🎉")
|
||||
def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str:
|
||||
"""A simple tool that demonstrates Unicode handling in:
|
||||
- Tool description (emojis, accents, CJK characters)
|
||||
- Parameter defaults (CJK characters)
|
||||
- Return values (Spanish punctuation, emojis)
|
||||
"""
|
||||
return f"{greeting}, {name}! 👋"
|
||||
|
||||
|
||||
@mcp.tool(description="🎨 Tool that returns a list of emoji categories")
|
||||
def list_emoji_categories() -> list[str]:
|
||||
"""Returns a list of emoji categories with emoji examples."""
|
||||
return [
|
||||
"😀 Smileys & Emotion",
|
||||
"👋 People & Body",
|
||||
"🐶 Animals & Nature",
|
||||
"🍎 Food & Drink",
|
||||
"⚽ Activities",
|
||||
"🌍 Travel & Places",
|
||||
"💡 Objects",
|
||||
"❤️ Symbols",
|
||||
"🚩 Flags",
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool(description="🔤 Tool that returns text in different scripts")
|
||||
def multilingual_hello() -> str:
|
||||
"""Returns hello in different scripts and writing systems."""
|
||||
return "\n".join(
|
||||
[
|
||||
"English: Hello!",
|
||||
"Spanish: ¡Hola!",
|
||||
"French: Bonjour!",
|
||||
"German: Grüß Gott!",
|
||||
"Russian: Привет!",
|
||||
"Greek: Γεια σας!",
|
||||
"Hebrew: !שָׁלוֹם",
|
||||
"Arabic: !مرحبا",
|
||||
"Hindi: नमस्ते!",
|
||||
"Chinese: 你好!",
|
||||
"Japanese: こんにちは!",
|
||||
"Korean: 안녕하세요!",
|
||||
"Thai: สวัสดี!",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
|
|
@ -0,0 +1,224 @@
|
|||
"""MCPServer Weather Example with Structured Output
|
||||
|
||||
Demonstrates how to use structured output with tools to return
|
||||
well-typed, validated data that clients can easily process.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TypedDict
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.client import Client
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
# Create server
|
||||
mcp = MCPServer("Weather Service")
|
||||
|
||||
|
||||
# Example 1: Using a Pydantic model for structured output
|
||||
class WeatherData(BaseModel):
|
||||
"""Structured weather data response"""
|
||||
|
||||
temperature: float = Field(description="Temperature in Celsius")
|
||||
humidity: float = Field(description="Humidity percentage (0-100)")
|
||||
condition: str = Field(description="Weather condition (sunny, cloudy, rainy, etc.)")
|
||||
wind_speed: float = Field(description="Wind speed in km/h")
|
||||
location: str = Field(description="Location name")
|
||||
timestamp: datetime = Field(default_factory=datetime.now, description="Observation time")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_weather(city: str) -> WeatherData:
|
||||
"""Get current weather for a city with full structured data"""
|
||||
# In a real implementation, this would fetch from a weather API
|
||||
return WeatherData(temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3, location=city)
|
||||
|
||||
|
||||
# Example 2: Using TypedDict for a simpler structure
|
||||
class WeatherSummary(TypedDict):
|
||||
"""Simple weather summary"""
|
||||
|
||||
city: str
|
||||
temp_c: float
|
||||
description: str
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_weather_summary(city: str) -> WeatherSummary:
|
||||
"""Get a brief weather summary for a city"""
|
||||
return WeatherSummary(city=city, temp_c=22.5, description="Partly cloudy with light breeze")
|
||||
|
||||
|
||||
# Example 3: Using dict[str, Any] for flexible schemas
|
||||
@mcp.tool()
|
||||
def get_weather_metrics(cities: list[str]) -> dict[str, dict[str, float]]:
|
||||
"""Get weather metrics for multiple cities
|
||||
|
||||
Returns a dictionary mapping city names to their metrics
|
||||
"""
|
||||
# Returns nested dictionaries with weather metrics
|
||||
return {
|
||||
city: {"temperature": 20.0 + i * 2, "humidity": 60.0 + i * 5, "pressure": 1013.0 + i * 0.5}
|
||||
for i, city in enumerate(cities)
|
||||
}
|
||||
|
||||
|
||||
# Example 4: Using dataclass for weather alerts
|
||||
@dataclass
|
||||
class WeatherAlert:
|
||||
"""Weather alert information"""
|
||||
|
||||
severity: str # "low", "medium", "high"
|
||||
title: str
|
||||
description: str
|
||||
affected_areas: list[str]
|
||||
valid_until: datetime
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_weather_alerts(region: str) -> list[WeatherAlert]:
|
||||
"""Get active weather alerts for a region"""
|
||||
# In production, this would fetch real alerts
|
||||
if region.lower() == "california":
|
||||
return [
|
||||
WeatherAlert(
|
||||
severity="high",
|
||||
title="Heat Wave Warning",
|
||||
description="Temperatures expected to exceed 40 degrees",
|
||||
affected_areas=["Los Angeles", "San Diego", "Riverside"],
|
||||
valid_until=datetime(2024, 7, 15, 18, 0),
|
||||
),
|
||||
WeatherAlert(
|
||||
severity="medium",
|
||||
title="Air Quality Advisory",
|
||||
description="Poor air quality due to wildfire smoke",
|
||||
affected_areas=["San Francisco Bay Area"],
|
||||
valid_until=datetime(2024, 7, 14, 12, 0),
|
||||
),
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
# Example 5: Returning primitives with structured output
|
||||
@mcp.tool()
|
||||
def get_temperature(city: str, unit: str = "celsius") -> float:
|
||||
"""Get just the temperature for a city
|
||||
|
||||
When returning primitives as structured output,
|
||||
the result is wrapped in {"result": value}
|
||||
"""
|
||||
base_temp = 22.5
|
||||
if unit.lower() == "fahrenheit":
|
||||
return base_temp * 9 / 5 + 32
|
||||
return base_temp
|
||||
|
||||
|
||||
# Example 6: Weather statistics with nested models
|
||||
class DailyStats(BaseModel):
|
||||
"""Statistics for a single day"""
|
||||
|
||||
high: float
|
||||
low: float
|
||||
mean: float
|
||||
|
||||
|
||||
class WeatherStats(BaseModel):
|
||||
"""Weather statistics over a period"""
|
||||
|
||||
location: str
|
||||
period_days: int
|
||||
temperature: DailyStats
|
||||
humidity: DailyStats
|
||||
precipitation_mm: float = Field(description="Total precipitation in millimeters")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_weather_stats(city: str, days: int = 7) -> WeatherStats:
|
||||
"""Get weather statistics for the past N days"""
|
||||
return WeatherStats(
|
||||
location=city,
|
||||
period_days=days,
|
||||
temperature=DailyStats(high=28.5, low=15.2, mean=21.8),
|
||||
humidity=DailyStats(high=85.0, low=45.0, mean=65.0),
|
||||
precipitation_mm=12.4,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
async def test() -> None:
|
||||
"""Test the tools by calling them through the server as a client would"""
|
||||
print("Testing Weather Service Tools (via MCP protocol)\n")
|
||||
print("=" * 80)
|
||||
|
||||
async with Client(mcp) as client:
|
||||
# Test get_weather
|
||||
result = await client.call_tool("get_weather", {"city": "London"})
|
||||
print("\nWeather in London:")
|
||||
print(json.dumps(result.structured_content, indent=2))
|
||||
|
||||
# Test get_weather_summary
|
||||
result = await client.call_tool("get_weather_summary", {"city": "Paris"})
|
||||
print("\nWeather summary for Paris:")
|
||||
print(json.dumps(result.structured_content, indent=2))
|
||||
|
||||
# Test get_weather_metrics
|
||||
result = await client.call_tool("get_weather_metrics", {"cities": ["Tokyo", "Sydney", "Mumbai"]})
|
||||
print("\nWeather metrics:")
|
||||
print(json.dumps(result.structured_content, indent=2))
|
||||
|
||||
# Test get_weather_alerts
|
||||
result = await client.call_tool("get_weather_alerts", {"region": "California"})
|
||||
print("\nWeather alerts for California:")
|
||||
print(json.dumps(result.structured_content, indent=2))
|
||||
|
||||
# Test get_temperature
|
||||
result = await client.call_tool("get_temperature", {"city": "Berlin", "unit": "fahrenheit"})
|
||||
print("\nTemperature in Berlin:")
|
||||
print(json.dumps(result.structured_content, indent=2))
|
||||
|
||||
# Test get_weather_stats
|
||||
result = await client.call_tool("get_weather_stats", {"city": "Seattle", "days": 30})
|
||||
print("\nWeather stats for Seattle (30 days):")
|
||||
print(json.dumps(result.structured_content, indent=2))
|
||||
|
||||
# Also show the text content for comparison
|
||||
print("\nText content for last result:")
|
||||
for content in result.content:
|
||||
if content.type == "text":
|
||||
print(content.text)
|
||||
|
||||
async def print_schemas() -> None:
|
||||
"""Print all tool schemas"""
|
||||
print("Tool Schemas for Weather Service\n")
|
||||
print("=" * 80)
|
||||
|
||||
tools = await mcp.list_tools()
|
||||
for tool in tools:
|
||||
print(f"\nTool: {tool.name}")
|
||||
print(f"Description: {tool.description}")
|
||||
print("Input Schema:")
|
||||
print(json.dumps(tool.input_schema, indent=2))
|
||||
|
||||
if tool.output_schema:
|
||||
print("Output Schema:")
|
||||
print(json.dumps(tool.output_schema, indent=2))
|
||||
else:
|
||||
print("Output Schema: None (returns unstructured content)")
|
||||
|
||||
print("-" * 80)
|
||||
|
||||
# Check command line arguments
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--schemas":
|
||||
asyncio.run(print_schemas())
|
||||
else:
|
||||
print("Usage:")
|
||||
print(" python weather_structured.py # Run tool tests")
|
||||
print(" python weather_structured.py --schemas # Print tool schemas")
|
||||
print()
|
||||
asyncio.run(test())
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
# MCP Everything Server
|
||||
|
||||
A comprehensive MCP server implementing all protocol features for conformance testing.
|
||||
|
||||
## Overview
|
||||
|
||||
The Everything Server is a reference implementation that demonstrates all features of the Model Context Protocol (MCP). It is designed to be used with the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) to validate MCP client and server implementations.
|
||||
|
||||
## Installation
|
||||
|
||||
From the python-sdk root directory:
|
||||
|
||||
```bash
|
||||
uv sync --frozen
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Running the Server
|
||||
|
||||
Start the server with default settings (port 3001):
|
||||
|
||||
```bash
|
||||
uv run -m mcp_everything_server
|
||||
```
|
||||
|
||||
Or with custom options:
|
||||
|
||||
```bash
|
||||
uv run -m mcp_everything_server --port 3001 --log-level DEBUG
|
||||
```
|
||||
|
||||
The server will be available at: `http://localhost:3001/mcp`
|
||||
|
||||
### Command-Line Options
|
||||
|
||||
- `--port` - Port to listen on (default: 3001)
|
||||
- `--log-level` - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO)
|
||||
|
||||
## Running Conformance Tests
|
||||
|
||||
See the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) for instructions on running conformance tests against this server.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""MCP Everything Server - Comprehensive conformance test server."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
"""CLI entry point for the MCP Everything Server."""
|
||||
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
#!/usr/bin/env python3
|
||||
"""MCP Everything Server - Conformance Test Server
|
||||
|
||||
Server implementing all MCP features for conformance testing based on Conformance Server Specification.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
import click
|
||||
from mcp.server import ServerRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer
|
||||
from mcp.server.mcpserver.prompts.base import UserMessage
|
||||
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
|
||||
from mcp.types import (
|
||||
AudioContent,
|
||||
Completion,
|
||||
CompletionArgument,
|
||||
CompletionContext,
|
||||
EmbeddedResource,
|
||||
EmptyResult,
|
||||
ImageContent,
|
||||
JSONRPCMessage,
|
||||
PromptReference,
|
||||
ResourceTemplateReference,
|
||||
SamplingMessage,
|
||||
SetLevelRequestParams,
|
||||
SubscribeRequestParams,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
UnsubscribeRequestParams,
|
||||
)
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Type aliases for event store
|
||||
StreamId = str
|
||||
EventId = str
|
||||
|
||||
|
||||
class InMemoryEventStore(EventStore):
|
||||
"""Simple in-memory event store for SSE resumability testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._events: list[tuple[StreamId, EventId, JSONRPCMessage | None]] = []
|
||||
self._event_id_counter = 0
|
||||
|
||||
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
|
||||
"""Store an event and return its ID."""
|
||||
self._event_id_counter += 1
|
||||
event_id = str(self._event_id_counter)
|
||||
self._events.append((stream_id, event_id, message))
|
||||
return event_id
|
||||
|
||||
async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None:
|
||||
"""Replay events after the specified ID."""
|
||||
target_stream_id = None
|
||||
for stream_id, event_id, _ in self._events:
|
||||
if event_id == last_event_id:
|
||||
target_stream_id = stream_id
|
||||
break
|
||||
if target_stream_id is None:
|
||||
return None
|
||||
last_event_id_int = int(last_event_id)
|
||||
for stream_id, event_id, message in self._events:
|
||||
if stream_id == target_stream_id and int(event_id) > last_event_id_int:
|
||||
# Skip priming events (None message)
|
||||
if message is not None:
|
||||
await send_callback(EventMessage(message, event_id))
|
||||
return target_stream_id
|
||||
|
||||
|
||||
# Test data
|
||||
TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
|
||||
TEST_AUDIO_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA="
|
||||
|
||||
# Server state
|
||||
resource_subscriptions: set[str] = set()
|
||||
watched_resource_content = "Watched resource content"
|
||||
|
||||
# Create event store for SSE resumability (SEP-1699)
|
||||
event_store = InMemoryEventStore()
|
||||
|
||||
mcp = MCPServer(
|
||||
name="mcp-conformance-test-server",
|
||||
)
|
||||
|
||||
|
||||
# Tools
|
||||
@mcp.tool()
|
||||
def test_simple_text() -> str:
|
||||
"""Tests simple text content response"""
|
||||
return "This is a simple text response for testing."
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def test_image_content() -> list[ImageContent]:
|
||||
"""Tests image content response"""
|
||||
return [ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def test_audio_content() -> list[AudioContent]:
|
||||
"""Tests audio content response"""
|
||||
return [AudioContent(type="audio", data=TEST_AUDIO_BASE64, mime_type="audio/wav")]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def test_embedded_resource() -> list[EmbeddedResource]:
|
||||
"""Tests embedded resource content response"""
|
||||
return [
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="test://embedded-resource",
|
||||
mime_type="text/plain",
|
||||
text="This is an embedded resource content.",
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedResource]:
|
||||
"""Tests response with multiple content types (text, image, resource)"""
|
||||
return [
|
||||
TextContent(type="text", text="Multiple content types test:"),
|
||||
ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png"),
|
||||
EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri="test://mixed-content-resource",
|
||||
mime_type="application/json",
|
||||
text='{"test": "data", "value": 123}',
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_tool_with_logging(ctx: Context) -> str:
|
||||
"""Tests tool that emits log messages during execution"""
|
||||
await ctx.info("Tool execution started")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
await ctx.info("Tool processing data")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
await ctx.info("Tool execution completed")
|
||||
return "Tool with logging executed successfully"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_tool_with_progress(ctx: Context) -> str:
|
||||
"""Tests tool that reports progress notifications"""
|
||||
await ctx.report_progress(progress=0, total=100, message="Completed step 0 of 100")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
await ctx.report_progress(progress=50, total=100, message="Completed step 50 of 100")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
await ctx.report_progress(progress=100, total=100, message="Completed step 100 of 100")
|
||||
|
||||
# Return progress token as string
|
||||
progress_token = (
|
||||
ctx.request_context.meta.get("progress_token") if ctx.request_context and ctx.request_context.meta else 0
|
||||
)
|
||||
return str(progress_token)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_sampling(prompt: str, ctx: Context) -> str:
|
||||
"""Tests server-initiated sampling (LLM completion request)"""
|
||||
try:
|
||||
# Request sampling from client
|
||||
result = await ctx.session.create_message(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
|
||||
max_tokens=100,
|
||||
)
|
||||
|
||||
# Since we're not passing tools param, result.content is single content
|
||||
if result.content.type == "text":
|
||||
model_response = result.content.text
|
||||
else:
|
||||
model_response = "No response"
|
||||
|
||||
return f"LLM response: {model_response}"
|
||||
except Exception as e:
|
||||
return f"Sampling not supported or error: {str(e)}"
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
response: str = Field(description="User's response")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_elicitation(message: str, ctx: Context) -> str:
|
||||
"""Tests server-initiated elicitation (user input request)"""
|
||||
try:
|
||||
# Request user input from client
|
||||
result = await ctx.elicit(message=message, schema=UserResponse)
|
||||
|
||||
# Type-safe discriminated union narrowing using action field
|
||||
if result.action == "accept":
|
||||
content = result.data.model_dump_json()
|
||||
else: # decline or cancel
|
||||
content = "{}"
|
||||
|
||||
return f"User response: action={result.action}, content={content}"
|
||||
except Exception as e:
|
||||
return f"Elicitation not supported or error: {str(e)}"
|
||||
|
||||
|
||||
class SEP1034DefaultsSchema(BaseModel):
|
||||
"""Schema for testing SEP-1034 elicitation with default values for all primitive types"""
|
||||
|
||||
name: str = Field(default="John Doe", description="User name")
|
||||
age: int = Field(default=30, description="User age")
|
||||
score: float = Field(default=95.5, description="User score")
|
||||
status: str = Field(
|
||||
default="active",
|
||||
description="User status",
|
||||
json_schema_extra={"enum": ["active", "inactive", "pending"]},
|
||||
)
|
||||
verified: bool = Field(default=True, description="Verification status")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_elicitation_sep1034_defaults(ctx: Context) -> str:
|
||||
"""Tests elicitation with default values for all primitive types (SEP-1034)"""
|
||||
try:
|
||||
# Request user input with defaults for all primitive types
|
||||
result = await ctx.elicit(message="Please provide user information", schema=SEP1034DefaultsSchema)
|
||||
|
||||
# Type-safe discriminated union narrowing using action field
|
||||
if result.action == "accept":
|
||||
content = result.data.model_dump_json()
|
||||
else: # decline or cancel
|
||||
content = "{}"
|
||||
|
||||
return f"Elicitation result: action={result.action}, content={content}"
|
||||
except Exception as e:
|
||||
return f"Elicitation not supported or error: {str(e)}"
|
||||
|
||||
|
||||
class EnumSchemasTestSchema(BaseModel):
|
||||
"""Schema for testing enum schema variations (SEP-1330)"""
|
||||
|
||||
untitledSingle: str = Field(
|
||||
description="Simple enum without titles", json_schema_extra={"enum": ["active", "inactive", "pending"]}
|
||||
)
|
||||
titledSingle: str = Field(
|
||||
description="Enum with titled options (oneOf)",
|
||||
json_schema_extra={
|
||||
"oneOf": [
|
||||
{"const": "low", "title": "Low Priority"},
|
||||
{"const": "medium", "title": "Medium Priority"},
|
||||
{"const": "high", "title": "High Priority"},
|
||||
]
|
||||
},
|
||||
)
|
||||
untitledMulti: list[str] = Field(
|
||||
description="Multi-select without titles",
|
||||
json_schema_extra={"items": {"type": "string", "enum": ["read", "write", "execute"]}},
|
||||
)
|
||||
titledMulti: list[str] = Field(
|
||||
description="Multi-select with titled options",
|
||||
json_schema_extra={
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{"const": "feature", "title": "New Feature"},
|
||||
{"const": "bug", "title": "Bug Fix"},
|
||||
{"const": "docs", "title": "Documentation"},
|
||||
]
|
||||
}
|
||||
},
|
||||
)
|
||||
legacyEnum: str = Field(
|
||||
description="Legacy enum with enumNames",
|
||||
json_schema_extra={
|
||||
"enum": ["small", "medium", "large"],
|
||||
"enumNames": ["Small Size", "Medium Size", "Large Size"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_elicitation_sep1330_enums(ctx: Context) -> str:
|
||||
"""Tests elicitation with enum schema variations per SEP-1330"""
|
||||
try:
|
||||
result = await ctx.elicit(
|
||||
message="Please select values using different enum schema types", schema=EnumSchemasTestSchema
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
content = result.data.model_dump_json()
|
||||
else:
|
||||
content = "{}"
|
||||
|
||||
return f"Elicitation completed: action={result.action}, content={content}"
|
||||
except Exception as e:
|
||||
return f"Elicitation not supported or error: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def test_error_handling() -> str:
|
||||
"""Tests error response handling"""
|
||||
raise RuntimeError("This tool intentionally returns an error for testing")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_reconnection(ctx: Context) -> str:
|
||||
"""Tests SSE polling by closing stream mid-call (SEP-1699)"""
|
||||
await ctx.info("Before disconnect")
|
||||
|
||||
await ctx.close_sse_stream()
|
||||
|
||||
await asyncio.sleep(0.2) # Wait for client to reconnect
|
||||
|
||||
await ctx.info("After reconnect")
|
||||
return "Reconnection test completed"
|
||||
|
||||
|
||||
# Resources
|
||||
@mcp.resource("test://static-text")
|
||||
def static_text_resource() -> str:
|
||||
"""A static text resource for testing"""
|
||||
return "This is the content of the static text resource."
|
||||
|
||||
|
||||
@mcp.resource("test://static-binary")
|
||||
def static_binary_resource() -> bytes:
|
||||
"""A static binary resource (image) for testing"""
|
||||
return base64.b64decode(TEST_IMAGE_BASE64)
|
||||
|
||||
|
||||
@mcp.resource("test://template/{id}/data")
|
||||
def template_resource(id: str) -> str:
|
||||
"""A resource template with parameter substitution"""
|
||||
return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"})
|
||||
|
||||
|
||||
@mcp.resource("test://watched-resource")
|
||||
def watched_resource() -> str:
|
||||
"""A resource that can be subscribed to for updates"""
|
||||
return watched_resource_content
|
||||
|
||||
|
||||
# Prompts
|
||||
@mcp.prompt()
|
||||
def test_simple_prompt() -> list[UserMessage]:
|
||||
"""A simple prompt without arguments"""
|
||||
return [UserMessage(role="user", content=TextContent(type="text", text="This is a simple prompt for testing."))]
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def test_prompt_with_arguments(arg1: str, arg2: str) -> list[UserMessage]:
|
||||
"""A prompt with required arguments"""
|
||||
return [
|
||||
UserMessage(
|
||||
role="user", content=TextContent(type="text", text=f"Prompt with arguments: arg1='{arg1}', arg2='{arg2}'")
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]:
|
||||
"""A prompt that includes an embedded resource"""
|
||||
return [
|
||||
UserMessage(
|
||||
role="user",
|
||||
content=EmbeddedResource(
|
||||
type="resource",
|
||||
resource=TextResourceContents(
|
||||
uri=resourceUri,
|
||||
mime_type="text/plain",
|
||||
text="Embedded resource content for testing.",
|
||||
),
|
||||
),
|
||||
),
|
||||
UserMessage(role="user", content=TextContent(type="text", text="Please process the embedded resource above.")),
|
||||
]
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
def test_prompt_with_image() -> list[UserMessage]:
|
||||
"""A prompt that includes image content"""
|
||||
return [
|
||||
UserMessage(role="user", content=ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")),
|
||||
UserMessage(role="user", content=TextContent(type="text", text="Please analyze the image above.")),
|
||||
]
|
||||
|
||||
|
||||
# Custom request handlers
|
||||
# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource,
|
||||
# and set_logging_level to avoid accessing protected _lowlevel_server attribute.
|
||||
async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
|
||||
"""Handle logging level changes"""
|
||||
logger.info(f"Log level set to: {params.level}")
|
||||
return EmptyResult()
|
||||
|
||||
|
||||
async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult:
|
||||
"""Handle resource subscription"""
|
||||
resource_subscriptions.add(str(params.uri))
|
||||
logger.info(f"Subscribed to resource: {params.uri}")
|
||||
return EmptyResult()
|
||||
|
||||
|
||||
async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeRequestParams) -> EmptyResult:
|
||||
"""Handle resource unsubscription"""
|
||||
resource_subscriptions.discard(str(params.uri))
|
||||
logger.info(f"Unsubscribed from resource: {params.uri}")
|
||||
return EmptyResult()
|
||||
|
||||
|
||||
mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage]
|
||||
mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage]
|
||||
mcp._lowlevel_server._add_request_handler("resources/unsubscribe", handle_unsubscribe) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
@mcp.completion()
|
||||
async def _handle_completion(
|
||||
ref: PromptReference | ResourceTemplateReference,
|
||||
argument: CompletionArgument,
|
||||
context: CompletionContext | None,
|
||||
) -> Completion:
|
||||
"""Handle completion requests"""
|
||||
# Basic completion support - returns empty array for conformance
|
||||
# Real implementations would provide contextual suggestions
|
||||
return Completion(values=[], total=0, has_more=False)
|
||||
|
||||
|
||||
# CLI
|
||||
@click.command()
|
||||
@click.option("--port", default=3001, help="Port to listen on for HTTP")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
)
|
||||
def main(port: int, log_level: str) -> int:
|
||||
"""Run the MCP Everything Server."""
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger.info(f"Starting MCP Everything Server on port {port}")
|
||||
logger.info(f"Endpoint will be: http://localhost:{port}/mcp")
|
||||
|
||||
mcp.run(
|
||||
transport="streamable-http",
|
||||
port=port,
|
||||
event_store=event_store,
|
||||
retry_interval=100, # 100ms retry interval for SSE polling
|
||||
)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
[project]
|
||||
name = "mcp-everything-server"
|
||||
version = "0.1.0"
|
||||
description = "Comprehensive MCP server implementing all protocol features for conformance testing"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "conformance", "testing"]
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-everything-server = "mcp_everything_server.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_everything_server"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_everything_server"]
|
||||
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"]
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
# MCP OAuth Authentication Demo
|
||||
|
||||
This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification.
|
||||
|
||||
---
|
||||
|
||||
## Running the Servers
|
||||
|
||||
### Step 1: Start Authorization Server
|
||||
|
||||
```bash
|
||||
# Navigate to the simple-auth directory
|
||||
cd examples/servers/simple-auth
|
||||
|
||||
# Start Authorization Server on port 9000
|
||||
uv run mcp-simple-auth-as --port=9000
|
||||
```
|
||||
|
||||
**What it provides:**
|
||||
|
||||
- OAuth 2.0 flows (registration, authorization, token exchange)
|
||||
- Simple credential-based authentication (no external provider needed)
|
||||
- Token introspection endpoint for Resource Servers (`/introspect`)
|
||||
|
||||
---
|
||||
|
||||
### Step 2: Start Resource Server (MCP Server)
|
||||
|
||||
```bash
|
||||
# In another terminal, navigate to the simple-auth directory
|
||||
cd examples/servers/simple-auth
|
||||
|
||||
# Start Resource Server on port 8001, connected to Authorization Server
|
||||
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http
|
||||
|
||||
# With RFC 8707 strict resource validation (recommended for production)
|
||||
uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict
|
||||
|
||||
```
|
||||
|
||||
### Step 3: Test with Client
|
||||
|
||||
```bash
|
||||
cd examples/clients/simple-auth-client
|
||||
# Start client with streamable HTTP
|
||||
MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### RFC 9728 Discovery
|
||||
|
||||
**Client → Resource Server:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/.well-known/oauth-protected-resource
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"resource": "http://localhost:8001",
|
||||
"authorization_servers": ["http://localhost:9000"]
|
||||
}
|
||||
```
|
||||
|
||||
**Client → Authorization Server:**
|
||||
|
||||
```bash
|
||||
curl http://localhost:9000/.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"issuer": "http://localhost:9000",
|
||||
"authorization_endpoint": "http://localhost:9000/authorize",
|
||||
"token_endpoint": "http://localhost:9000/token"
|
||||
}
|
||||
```
|
||||
|
||||
## Legacy MCP Server as Authorization Server (Backwards Compatibility)
|
||||
|
||||
For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth):
|
||||
|
||||
### Running the Legacy Server
|
||||
|
||||
```bash
|
||||
# Start legacy server on port 8000 (the default)
|
||||
cd examples/servers/simple-auth
|
||||
uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http
|
||||
```
|
||||
|
||||
**Differences from the new architecture:**
|
||||
|
||||
- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior)
|
||||
- **No separate RS:** The server handles both authentication and MCP tools
|
||||
- **Local token validation:** Tokens are validated internally without introspection
|
||||
- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource`
|
||||
- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL
|
||||
|
||||
### Testing with Legacy Server
|
||||
|
||||
```bash
|
||||
# Test with client (will automatically fall back to legacy discovery)
|
||||
cd examples/clients/simple-auth-client
|
||||
MCP_SERVER_PORT=8000 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client
|
||||
```
|
||||
|
||||
The client will:
|
||||
|
||||
1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server)
|
||||
2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server`
|
||||
3. Complete authentication with the MCP server acting as its own AS
|
||||
|
||||
This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only.
|
||||
|
||||
## Manual Testing
|
||||
|
||||
### Test Discovery
|
||||
|
||||
```bash
|
||||
# Test Resource Server discovery endpoint (new architecture)
|
||||
curl -v http://localhost:8001/.well-known/oauth-protected-resource
|
||||
|
||||
# Test Authorization Server metadata
|
||||
curl -v http://localhost:9000/.well-known/oauth-authorization-server
|
||||
```
|
||||
|
||||
### Test Token Introspection
|
||||
|
||||
```bash
|
||||
# After getting a token through OAuth flow:
|
||||
curl -X POST http://localhost:9000/introspect \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "token=your_access_token"
|
||||
```
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Simple MCP server with GitHub OAuth authentication."""
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
"""Main entry point for simple MCP server with GitHub OAuth authentication."""
|
||||
|
||||
import sys
|
||||
|
||||
from mcp_simple_auth.server import main
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
"""Authorization Server for MCP Split Demo.
|
||||
|
||||
This server handles OAuth flows, client registration, and token issuance.
|
||||
Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc.
|
||||
|
||||
NOTE: this is a simplified example for demonstration purposes.
|
||||
This is not a production-ready implementation.
|
||||
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
|
||||
import click
|
||||
from pydantic import AnyHttpUrl, BaseModel
|
||||
from starlette.applications import Starlette
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from mcp.server.auth.routes import cors_middleware, create_auth_routes
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||
|
||||
from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthServerSettings(BaseModel):
|
||||
"""Settings for the Authorization Server."""
|
||||
|
||||
# Server settings
|
||||
host: str = "localhost"
|
||||
port: int = 9000
|
||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
||||
auth_callback_path: str = "http://localhost:9000/login/callback"
|
||||
|
||||
|
||||
class SimpleAuthProvider(SimpleOAuthProvider):
|
||||
"""Authorization Server provider with simple demo authentication.
|
||||
|
||||
This provider:
|
||||
1. Issues MCP tokens after simple credential authentication
|
||||
2. Stores token state for introspection by Resource Servers
|
||||
"""
|
||||
|
||||
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
|
||||
super().__init__(auth_settings, auth_callback_path, server_url)
|
||||
|
||||
|
||||
def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette:
|
||||
"""Create the Authorization Server application."""
|
||||
oauth_provider = SimpleAuthProvider(
|
||||
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
|
||||
)
|
||||
|
||||
mcp_auth_settings = AuthSettings(
|
||||
issuer_url=server_settings.server_url,
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True,
|
||||
valid_scopes=[auth_settings.mcp_scope],
|
||||
default_scopes=[auth_settings.mcp_scope],
|
||||
),
|
||||
required_scopes=[auth_settings.mcp_scope],
|
||||
resource_server_url=None,
|
||||
)
|
||||
|
||||
# Create OAuth routes
|
||||
routes = create_auth_routes(
|
||||
provider=oauth_provider,
|
||||
issuer_url=mcp_auth_settings.issuer_url,
|
||||
service_documentation_url=mcp_auth_settings.service_documentation_url,
|
||||
client_registration_options=mcp_auth_settings.client_registration_options,
|
||||
revocation_options=mcp_auth_settings.revocation_options,
|
||||
)
|
||||
|
||||
# Add login page route (GET)
|
||||
async def login_page_handler(request: Request) -> Response:
|
||||
"""Show login form."""
|
||||
state = request.query_params.get("state")
|
||||
if not state:
|
||||
raise HTTPException(400, "Missing state parameter")
|
||||
return await oauth_provider.get_login_page(state)
|
||||
|
||||
routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"]))
|
||||
|
||||
# Add login callback route (POST)
|
||||
async def login_callback_handler(request: Request) -> Response:
|
||||
"""Handle simple authentication callback."""
|
||||
return await oauth_provider.handle_login_callback(request)
|
||||
|
||||
routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"]))
|
||||
|
||||
# Add token introspection endpoint (RFC 7662) for Resource Servers
|
||||
async def introspect_handler(request: Request) -> Response:
|
||||
"""Token introspection endpoint for Resource Servers.
|
||||
|
||||
Resource Servers call this endpoint to validate tokens without
|
||||
needing direct access to token storage.
|
||||
"""
|
||||
form = await request.form()
|
||||
token = form.get("token")
|
||||
if not token or not isinstance(token, str):
|
||||
return JSONResponse({"active": False}, status_code=400)
|
||||
|
||||
# Look up token in provider
|
||||
access_token = await oauth_provider.load_access_token(token)
|
||||
if not access_token:
|
||||
return JSONResponse({"active": False})
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"active": True,
|
||||
"client_id": access_token.client_id,
|
||||
"scope": " ".join(access_token.scopes),
|
||||
"exp": access_token.expires_at,
|
||||
"iat": int(time.time()),
|
||||
"token_type": "Bearer",
|
||||
"aud": access_token.resource, # RFC 8707 audience claim
|
||||
}
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route(
|
||||
"/introspect",
|
||||
endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]),
|
||||
methods=["POST", "OPTIONS"],
|
||||
)
|
||||
)
|
||||
|
||||
return Starlette(routes=routes)
|
||||
|
||||
|
||||
async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings):
|
||||
"""Run the Authorization Server."""
|
||||
auth_server = create_authorization_server(server_settings, auth_settings)
|
||||
|
||||
config = Config(
|
||||
auth_server,
|
||||
host=server_settings.host,
|
||||
port=server_settings.port,
|
||||
log_level="info",
|
||||
)
|
||||
server = Server(config)
|
||||
|
||||
logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}")
|
||||
|
||||
await server.serve()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=9000, help="Port to listen on")
|
||||
def main(port: int) -> int:
|
||||
"""Run the MCP Authorization Server.
|
||||
|
||||
This server handles OAuth flows and can be used by multiple Resource Servers.
|
||||
|
||||
Uses simple hardcoded credentials for demo purposes.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
# Load simple auth settings
|
||||
auth_settings = SimpleAuthSettings()
|
||||
|
||||
# Create server settings
|
||||
host = "localhost"
|
||||
server_url = f"http://{host}:{port}"
|
||||
server_settings = AuthServerSettings(
|
||||
host=host,
|
||||
port=port,
|
||||
server_url=AnyHttpUrl(server_url),
|
||||
auth_callback_path=f"{server_url}/login",
|
||||
)
|
||||
|
||||
asyncio.run(run_server(server_settings, auth_settings))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # type: ignore[call-arg]
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
"""Legacy Combined Authorization Server + Resource Server for MCP.
|
||||
|
||||
This server implements the old spec where MCP servers could act as both AS and RS.
|
||||
Used for backwards compatibility testing with the new split AS/RS architecture.
|
||||
|
||||
NOTE: this is a simplified example for demonstration purposes.
|
||||
This is not a production-ready implementation.
|
||||
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
import click
|
||||
from pydantic import AnyHttpUrl, BaseModel
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||
from mcp.server.mcpserver.server import MCPServer
|
||||
|
||||
from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerSettings(BaseModel):
|
||||
"""Settings for the simple auth MCP server."""
|
||||
|
||||
# Server settings
|
||||
host: str = "localhost"
|
||||
port: int = 8000
|
||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000")
|
||||
auth_callback_path: str = "http://localhost:8000/login/callback"
|
||||
|
||||
|
||||
class LegacySimpleOAuthProvider(SimpleOAuthProvider):
|
||||
"""Simple OAuth provider for legacy MCP server."""
|
||||
|
||||
def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str):
|
||||
super().__init__(auth_settings, auth_callback_path, server_url)
|
||||
|
||||
|
||||
def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> MCPServer:
|
||||
"""Create a simple MCPServer server with simple authentication."""
|
||||
oauth_provider = LegacySimpleOAuthProvider(
|
||||
auth_settings, server_settings.auth_callback_path, str(server_settings.server_url)
|
||||
)
|
||||
|
||||
mcp_auth_settings = AuthSettings(
|
||||
issuer_url=server_settings.server_url,
|
||||
client_registration_options=ClientRegistrationOptions(
|
||||
enabled=True,
|
||||
valid_scopes=[auth_settings.mcp_scope],
|
||||
default_scopes=[auth_settings.mcp_scope],
|
||||
),
|
||||
required_scopes=[auth_settings.mcp_scope],
|
||||
# No resource_server_url parameter in legacy mode
|
||||
resource_server_url=None,
|
||||
)
|
||||
|
||||
app = MCPServer(
|
||||
name="Simple Auth MCP Server",
|
||||
instructions="A simple MCP server with simple credential authentication",
|
||||
auth_server_provider=oauth_provider,
|
||||
debug=True,
|
||||
auth=mcp_auth_settings,
|
||||
)
|
||||
# Store server settings for later use in run()
|
||||
app._server_settings = server_settings # type: ignore[attr-defined]
|
||||
|
||||
@app.custom_route("/login", methods=["GET"])
|
||||
async def login_page_handler(request: Request) -> Response:
|
||||
"""Show login form."""
|
||||
state = request.query_params.get("state")
|
||||
if not state:
|
||||
raise HTTPException(400, "Missing state parameter")
|
||||
return await oauth_provider.get_login_page(state)
|
||||
|
||||
@app.custom_route("/login/callback", methods=["POST"])
|
||||
async def login_callback_handler(request: Request) -> Response:
|
||||
"""Handle simple authentication callback."""
|
||||
return await oauth_provider.handle_login_callback(request)
|
||||
|
||||
@app.tool()
|
||||
async def get_time() -> dict[str, Any]:
|
||||
"""Get the current server time.
|
||||
|
||||
This tool demonstrates that system information can be protected
|
||||
by OAuth authentication. User must be authenticated to access it.
|
||||
"""
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
return {
|
||||
"current_time": now.isoformat(),
|
||||
"timezone": "UTC", # Simplified for demo
|
||||
"timestamp": now.timestamp(),
|
||||
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8000, help="Port to listen on")
|
||||
@click.option(
|
||||
"--transport",
|
||||
default="streamable-http",
|
||||
type=click.Choice(["sse", "streamable-http"]),
|
||||
help="Transport protocol to use ('sse' or 'streamable-http')",
|
||||
)
|
||||
def main(port: int, transport: Literal["sse", "streamable-http"]) -> int:
|
||||
"""Run the simple auth MCP server."""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
auth_settings = SimpleAuthSettings()
|
||||
# Create server settings
|
||||
host = "localhost"
|
||||
server_url = f"http://{host}:{port}"
|
||||
server_settings = ServerSettings(
|
||||
host=host,
|
||||
port=port,
|
||||
server_url=AnyHttpUrl(server_url),
|
||||
auth_callback_path=f"{server_url}/login",
|
||||
)
|
||||
|
||||
mcp_server = create_simple_mcp_server(server_settings, auth_settings)
|
||||
logger.info(f"🚀 MCP Legacy Server running on {server_url}")
|
||||
mcp_server.run(transport=transport, host=host, port=port)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # type: ignore[call-arg]
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
"""MCP Resource Server with Token Introspection.
|
||||
|
||||
This server validates tokens via Authorization Server introspection and serves MCP resources.
|
||||
Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation.
|
||||
|
||||
NOTE: this is a simplified example for demonstration purposes.
|
||||
This is not a production-ready implementation.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
import click
|
||||
from pydantic import AnyHttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.mcpserver.server import MCPServer
|
||||
|
||||
from .token_verifier import IntrospectionTokenVerifier
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResourceServerSettings(BaseSettings):
|
||||
"""Settings for the MCP Resource Server."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_")
|
||||
|
||||
# Server settings
|
||||
host: str = "localhost"
|
||||
port: int = 8001
|
||||
server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp")
|
||||
|
||||
# Authorization Server settings
|
||||
auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000")
|
||||
auth_server_introspection_endpoint: str = "http://localhost:9000/introspect"
|
||||
# No user endpoint needed - we get user data from token introspection
|
||||
|
||||
# MCP settings
|
||||
mcp_scope: str = "user"
|
||||
|
||||
# RFC 8707 resource validation
|
||||
oauth_strict: bool = False
|
||||
|
||||
|
||||
def create_resource_server(settings: ResourceServerSettings) -> MCPServer:
|
||||
"""Create MCP Resource Server with token introspection.
|
||||
|
||||
This server:
|
||||
1. Provides protected resource metadata (RFC 9728)
|
||||
2. Validates tokens via Authorization Server introspection
|
||||
3. Serves MCP tools and resources
|
||||
"""
|
||||
# Create token verifier for introspection with RFC 8707 resource validation
|
||||
token_verifier = IntrospectionTokenVerifier(
|
||||
introspection_endpoint=settings.auth_server_introspection_endpoint,
|
||||
server_url=str(settings.server_url),
|
||||
validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set
|
||||
)
|
||||
|
||||
# Create MCPServer server as a Resource Server
|
||||
app = MCPServer(
|
||||
name="MCP Resource Server",
|
||||
instructions="Resource Server that validates tokens via Authorization Server introspection",
|
||||
debug=True,
|
||||
# Auth configuration for RS mode
|
||||
token_verifier=token_verifier,
|
||||
auth=AuthSettings(
|
||||
issuer_url=settings.auth_server_url,
|
||||
required_scopes=[settings.mcp_scope],
|
||||
resource_server_url=settings.server_url,
|
||||
),
|
||||
)
|
||||
# Store settings for later use in run()
|
||||
app._resource_server_settings = settings # type: ignore[attr-defined]
|
||||
|
||||
@app.tool()
|
||||
async def get_time() -> dict[str, Any]:
|
||||
"""Get the current server time.
|
||||
|
||||
This tool demonstrates that system information can be protected
|
||||
by OAuth authentication. User must be authenticated to access it.
|
||||
"""
|
||||
|
||||
now = datetime.datetime.now()
|
||||
|
||||
return {
|
||||
"current_time": now.isoformat(),
|
||||
"timezone": "UTC", # Simplified for demo
|
||||
"timestamp": now.timestamp(),
|
||||
"formatted": now.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8001, help="Port to listen on")
|
||||
@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL")
|
||||
@click.option(
|
||||
"--transport",
|
||||
default="streamable-http",
|
||||
type=click.Choice(["sse", "streamable-http"]),
|
||||
help="Transport protocol to use ('sse' or 'streamable-http')",
|
||||
)
|
||||
@click.option(
|
||||
"--oauth-strict",
|
||||
is_flag=True,
|
||||
help="Enable RFC 8707 resource validation",
|
||||
)
|
||||
def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int:
|
||||
"""Run the MCP Resource Server.
|
||||
|
||||
This server:
|
||||
- Provides RFC 9728 Protected Resource Metadata
|
||||
- Validates tokens via Authorization Server introspection
|
||||
- Serves MCP tools requiring authentication
|
||||
|
||||
Must be used with a running Authorization Server.
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
try:
|
||||
# Parse auth server URL
|
||||
auth_server_url = AnyHttpUrl(auth_server)
|
||||
|
||||
# Create settings
|
||||
host = "localhost"
|
||||
server_url = f"http://{host}:{port}/mcp"
|
||||
settings = ResourceServerSettings(
|
||||
host=host,
|
||||
port=port,
|
||||
server_url=AnyHttpUrl(server_url),
|
||||
auth_server_url=auth_server_url,
|
||||
auth_server_introspection_endpoint=f"{auth_server}/introspect",
|
||||
oauth_strict=oauth_strict,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.error(f"Configuration error: {e}")
|
||||
logger.error("Make sure to provide a valid Authorization Server URL")
|
||||
return 1
|
||||
|
||||
try:
|
||||
mcp_server = create_resource_server(settings)
|
||||
|
||||
logger.info(f"🚀 MCP Resource Server running on {settings.server_url}")
|
||||
logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}")
|
||||
|
||||
# Run the server - this should block and keep running
|
||||
mcp_server.run(transport=transport, host=host, port=port)
|
||||
logger.info("Server stopped")
|
||||
return 0
|
||||
except Exception:
|
||||
logger.exception("Server error")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # type: ignore[call-arg]
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
"""Simple OAuth provider for MCP servers.
|
||||
|
||||
This module contains a basic OAuth implementation using hardcoded user credentials
|
||||
for demonstration purposes. No external authentication provider is required.
|
||||
|
||||
NOTE: this is a simplified example for demonstration purposes.
|
||||
This is not a production-ready implementation.
|
||||
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
|
||||
|
||||
|
||||
class SimpleAuthSettings(BaseSettings):
|
||||
"""Simple OAuth settings for demo purposes."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="MCP_")
|
||||
|
||||
# Demo user credentials
|
||||
demo_username: str = "demo_user"
|
||||
demo_password: str = "demo_password"
|
||||
|
||||
# MCP OAuth scope
|
||||
mcp_scope: str = "user"
|
||||
|
||||
|
||||
class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
|
||||
"""Simple OAuth provider for demo purposes.
|
||||
|
||||
This provider handles the OAuth flow by:
|
||||
1. Providing a simple login form for demo credentials
|
||||
2. Issuing MCP tokens after successful authentication
|
||||
3. Maintaining token state for introspection
|
||||
"""
|
||||
|
||||
def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str):
|
||||
self.settings = settings
|
||||
self.auth_callback_url = auth_callback_url
|
||||
self.server_url = server_url
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.auth_codes: dict[str, AuthorizationCode] = {}
|
||||
self.tokens: dict[str, AccessToken] = {}
|
||||
self.state_mapping: dict[str, dict[str, str | None]] = {}
|
||||
# Store authenticated user information
|
||||
self.user_data: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
"""Get OAuth client information."""
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull):
|
||||
"""Register a new OAuth client."""
|
||||
if not client_info.client_id:
|
||||
raise ValueError("No client_id provided")
|
||||
self.clients[client_info.client_id] = client_info
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
"""Generate an authorization URL for simple login flow."""
|
||||
state = params.state or secrets.token_hex(16)
|
||||
|
||||
# Store state mapping for callback
|
||||
self.state_mapping[state] = {
|
||||
"redirect_uri": str(params.redirect_uri),
|
||||
"code_challenge": params.code_challenge,
|
||||
"redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly),
|
||||
"client_id": client.client_id,
|
||||
"resource": params.resource, # RFC 8707
|
||||
}
|
||||
|
||||
# Build simple login URL that points to login page
|
||||
auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}"
|
||||
|
||||
return auth_url
|
||||
|
||||
async def get_login_page(self, state: str) -> HTMLResponse:
|
||||
"""Generate login page HTML for the given state."""
|
||||
if not state:
|
||||
raise HTTPException(400, "Missing state parameter")
|
||||
|
||||
# Create simple login form HTML
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MCP Demo Authentication</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; max-width: 500px; margin: 0 auto; padding: 20px; }}
|
||||
.form-group {{ margin-bottom: 15px; }}
|
||||
input {{ width: 100%; padding: 8px; margin-top: 5px; }}
|
||||
button {{ background-color: #4CAF50; color: white; padding: 10px 15px; border: none; cursor: pointer; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h2>MCP Demo Authentication</h2>
|
||||
<p>This is a simplified authentication demo. Use the demo credentials below:</p>
|
||||
<p><strong>Username:</strong> demo_user<br>
|
||||
<strong>Password:</strong> demo_password</p>
|
||||
|
||||
<form action="{self.server_url.rstrip("/")}/login/callback" method="post">
|
||||
<input type="hidden" name="state" value="{state}">
|
||||
<div class="form-group">
|
||||
<label>Username:</label>
|
||||
<input type="text" name="username" value="demo_user" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password:</label>
|
||||
<input type="password" name="password" value="demo_password" required>
|
||||
</div>
|
||||
<button type="submit">Sign In</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
async def handle_login_callback(self, request: Request) -> Response:
|
||||
"""Handle login form submission callback."""
|
||||
form = await request.form()
|
||||
username = form.get("username")
|
||||
password = form.get("password")
|
||||
state = form.get("state")
|
||||
|
||||
if not username or not password or not state:
|
||||
raise HTTPException(400, "Missing username, password, or state parameter")
|
||||
|
||||
# Ensure we have strings, not UploadFile objects
|
||||
if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str):
|
||||
raise HTTPException(400, "Invalid parameter types")
|
||||
|
||||
redirect_uri = await self.handle_simple_callback(username, password, state)
|
||||
return RedirectResponse(url=redirect_uri, status_code=302)
|
||||
|
||||
async def handle_simple_callback(self, username: str, password: str, state: str) -> str:
|
||||
"""Handle simple authentication callback and return redirect URI."""
|
||||
state_data = self.state_mapping.get(state)
|
||||
if not state_data:
|
||||
raise HTTPException(400, "Invalid state parameter")
|
||||
|
||||
redirect_uri = state_data["redirect_uri"]
|
||||
code_challenge = state_data["code_challenge"]
|
||||
redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True"
|
||||
client_id = state_data["client_id"]
|
||||
resource = state_data.get("resource") # RFC 8707
|
||||
|
||||
# These are required values from our own state mapping
|
||||
assert redirect_uri is not None
|
||||
assert code_challenge is not None
|
||||
assert client_id is not None
|
||||
|
||||
# Validate demo credentials
|
||||
if username != self.settings.demo_username or password != self.settings.demo_password:
|
||||
raise HTTPException(401, "Invalid credentials")
|
||||
|
||||
# Create MCP authorization code
|
||||
new_code = f"mcp_{secrets.token_hex(16)}"
|
||||
auth_code = AuthorizationCode(
|
||||
code=new_code,
|
||||
client_id=client_id,
|
||||
redirect_uri=AnyHttpUrl(redirect_uri),
|
||||
redirect_uri_provided_explicitly=redirect_uri_provided_explicitly,
|
||||
expires_at=time.time() + 300,
|
||||
scopes=[self.settings.mcp_scope],
|
||||
code_challenge=code_challenge,
|
||||
resource=resource, # RFC 8707
|
||||
)
|
||||
self.auth_codes[new_code] = auth_code
|
||||
|
||||
# Store user data
|
||||
self.user_data[username] = {
|
||||
"username": username,
|
||||
"user_id": f"user_{secrets.token_hex(8)}",
|
||||
"authenticated_at": time.time(),
|
||||
}
|
||||
|
||||
del self.state_mapping[state]
|
||||
return construct_redirect_uri(redirect_uri, code=new_code, state=state)
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
"""Load an authorization code."""
|
||||
return self.auth_codes.get(authorization_code)
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
"""Exchange authorization code for tokens."""
|
||||
if authorization_code.code not in self.auth_codes:
|
||||
raise ValueError("Invalid authorization code")
|
||||
if not client.client_id:
|
||||
raise ValueError("No client_id provided")
|
||||
|
||||
# Generate MCP access token
|
||||
mcp_token = f"mcp_{secrets.token_hex(32)}"
|
||||
|
||||
# Store MCP token
|
||||
self.tokens[mcp_token] = AccessToken(
|
||||
token=mcp_token,
|
||||
client_id=client.client_id,
|
||||
scopes=authorization_code.scopes,
|
||||
expires_at=int(time.time()) + 3600,
|
||||
resource=authorization_code.resource, # RFC 8707
|
||||
)
|
||||
|
||||
# Store user data mapping for this token
|
||||
self.user_data[mcp_token] = {
|
||||
"username": self.settings.demo_username,
|
||||
"user_id": f"user_{secrets.token_hex(8)}",
|
||||
"authenticated_at": time.time(),
|
||||
}
|
||||
|
||||
del self.auth_codes[authorization_code.code]
|
||||
|
||||
return OAuthToken(
|
||||
access_token=mcp_token,
|
||||
token_type="Bearer",
|
||||
expires_in=3600,
|
||||
scope=" ".join(authorization_code.scopes),
|
||||
)
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
"""Load and validate an access token."""
|
||||
access_token = self.tokens.get(token)
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
# Check if expired
|
||||
if access_token.expires_at and access_token.expires_at < time.time():
|
||||
del self.tokens[token]
|
||||
return None
|
||||
|
||||
return access_token
|
||||
|
||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||
"""Load a refresh token - not supported in this example."""
|
||||
return None
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self,
|
||||
client: OAuthClientInformationFull,
|
||||
refresh_token: RefreshToken,
|
||||
scopes: list[str],
|
||||
) -> OAuthToken:
|
||||
"""Exchange refresh token - not supported in this example."""
|
||||
raise NotImplementedError("Refresh tokens not supported")
|
||||
|
||||
# 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."""
|
||||
if token in self.tokens:
|
||||
del self.tokens[token]
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662)."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IntrospectionTokenVerifier(TokenVerifier):
|
||||
"""Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662).
|
||||
|
||||
This is a simple example implementation for demonstration purposes.
|
||||
Production implementations should consider:
|
||||
- Connection pooling and reuse
|
||||
- More sophisticated error handling
|
||||
- Rate limiting and retry logic
|
||||
- Comprehensive configuration options
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
introspection_endpoint: str,
|
||||
server_url: str,
|
||||
validate_resource: bool = False,
|
||||
):
|
||||
self.introspection_endpoint = introspection_endpoint
|
||||
self.server_url = server_url
|
||||
self.validate_resource = validate_resource
|
||||
self.resource_url = resource_url_from_server_url(server_url)
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
"""Verify token via introspection endpoint."""
|
||||
import httpx
|
||||
|
||||
# Validate URL to prevent SSRF attacks
|
||||
if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")):
|
||||
logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}")
|
||||
return None
|
||||
|
||||
# Configure secure HTTP client
|
||||
timeout = httpx.Timeout(10.0, connect=5.0)
|
||||
limits = httpx.Limits(max_connections=10, max_keepalive_connections=5)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=timeout,
|
||||
limits=limits,
|
||||
verify=True, # Enforce SSL verification
|
||||
) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
self.introspection_endpoint,
|
||||
data={"token": token},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.debug(f"Token introspection returned status {response.status_code}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
if not data.get("active", False):
|
||||
return None
|
||||
|
||||
# RFC 8707 resource validation (only when --oauth-strict is set)
|
||||
if self.validate_resource and not self._validate_resource(data):
|
||||
logger.warning(f"Token resource validation failed. Expected: {self.resource_url}")
|
||||
return None
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=data.get("client_id", "unknown"),
|
||||
scopes=data.get("scope", "").split() if data.get("scope") else [],
|
||||
expires_at=data.get("exp"),
|
||||
resource=data.get("aud"), # Include resource in token
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Token introspection failed: {e}")
|
||||
return None
|
||||
|
||||
def _validate_resource(self, token_data: dict[str, Any]) -> bool:
|
||||
"""Validate token was issued for this resource server."""
|
||||
if not self.server_url or not self.resource_url:
|
||||
return False # Fail if strict validation requested but URLs missing
|
||||
|
||||
# Check 'aud' claim first (standard JWT audience)
|
||||
aud: list[str] | str | None = token_data.get("aud")
|
||||
if isinstance(aud, list):
|
||||
for audience in aud:
|
||||
if self._is_valid_resource(audience):
|
||||
return True
|
||||
return False
|
||||
elif aud:
|
||||
return self._is_valid_resource(aud)
|
||||
|
||||
# No resource binding - invalid per RFC 8707
|
||||
return False
|
||||
|
||||
def _is_valid_resource(self, resource: str) -> bool:
|
||||
"""Check if resource matches this server using hierarchical matching."""
|
||||
if not self.resource_url:
|
||||
return False
|
||||
|
||||
return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
[project]
|
||||
name = "mcp-simple-auth"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP server demonstrating OAuth authentication"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
license = { text = "MIT" }
|
||||
dependencies = [
|
||||
"anyio>=4.5",
|
||||
"click>=8.2.0",
|
||||
"httpx>=0.27",
|
||||
"mcp",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.5.2",
|
||||
"sse-starlette>=1.6.1",
|
||||
"uvicorn>=0.23.1; sys_platform != 'emscripten'",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-auth-rs = "mcp_simple_auth.server:main"
|
||||
mcp-simple-auth-as = "mcp_simple_auth.auth_server:main"
|
||||
mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_auth"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"]
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# MCP Simple Pagination
|
||||
|
||||
A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server using either stdio (default) or Streamable HTTP transport:
|
||||
|
||||
```bash
|
||||
# Using stdio transport (default)
|
||||
uv run mcp-simple-pagination
|
||||
|
||||
# Using Streamable HTTP transport on custom port
|
||||
uv run mcp-simple-pagination --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
The server exposes:
|
||||
|
||||
- 25 tools (paginated, 5 per page)
|
||||
- 30 resources (paginated, 10 per page)
|
||||
- 20 prompts (paginated, 7 per page)
|
||||
|
||||
Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page.
|
||||
|
||||
## Example
|
||||
|
||||
Using the MCP client, you can retrieve paginated items like this using the STDIO transport:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from mcp.client.session import ClientSession
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
|
||||
|
||||
async def main():
|
||||
async with stdio_client(
|
||||
StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Get first page of tools
|
||||
tools_page1 = await session.list_tools()
|
||||
print(f"First page: {len(tools_page1.tools)} tools")
|
||||
print(f"Next cursor: {tools_page1.nextCursor}")
|
||||
|
||||
# Get second page using cursor
|
||||
if tools_page1.nextCursor:
|
||||
tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor)
|
||||
print(f"Second page: {len(tools_page2.tools)} tools")
|
||||
|
||||
# Similarly for resources
|
||||
resources_page1 = await session.list_resources()
|
||||
print(f"First page: {len(resources_page1.resources)} resources")
|
||||
|
||||
# And for prompts
|
||||
prompts_page1 = await session.list_prompts()
|
||||
print(f"First page: {len(prompts_page1.prompts)} prompts")
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Pagination Details
|
||||
|
||||
The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use:
|
||||
|
||||
- Database offsets or row IDs
|
||||
- Timestamps for time-based pagination
|
||||
- Opaque tokens encoding pagination state
|
||||
|
||||
The pagination implementation demonstrates:
|
||||
|
||||
- Handling `None` cursor for the first page
|
||||
- Returning `nextCursor` when more data exists
|
||||
- Gracefully handling invalid cursors
|
||||
- Different page sizes for different resource types
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue