chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,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,787 @@
|
||||
#!/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
|
||||
from typing import Annotated, Any
|
||||
|
||||
import click
|
||||
from mcp.server import ServerRequestContext
|
||||
from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity
|
||||
from mcp.server.mcpserver.prompts.base import Prompt, UserMessage
|
||||
from mcp.server.streamable_http import EventCallback, EventMessage, EventStore
|
||||
from mcp.shared.exceptions import MCPError
|
||||
from mcp_types import (
|
||||
AudioContent,
|
||||
Completion,
|
||||
CompletionArgument,
|
||||
CompletionContext,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
ElicitResult,
|
||||
EmbeddedResource,
|
||||
EmptyResult,
|
||||
ImageContent,
|
||||
InputRequest,
|
||||
InputRequiredResult,
|
||||
JSONRPCMessage,
|
||||
ListRootsRequest,
|
||||
ListRootsResult,
|
||||
PromptReference,
|
||||
ResourceTemplateReference,
|
||||
SamplingMessage,
|
||||
SetLevelRequestParams,
|
||||
SubscribeRequestParams,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
UnsubscribeRequestParams,
|
||||
)
|
||||
from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
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()
|
||||
|
||||
# Fixed fixture key (RequestStateSecurity requires at least 32 bytes); a real deployment would load a shared secret.
|
||||
_REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key"
|
||||
|
||||
mcp = MCPServer(
|
||||
name="mcp-conformance-test-server",
|
||||
request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]),
|
||||
)
|
||||
|
||||
|
||||
# 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") # pyright: ignore[reportDeprecated]
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
await ctx.info("Tool processing data") # pyright: ignore[reportDeprecated]
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
await ctx.info("Tool execution completed") # pyright: ignore[reportDeprecated]
|
||||
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. Without related_request_id the request goes
|
||||
# to the standalone GET stream and is silently dropped if it is not open yet.
|
||||
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
|
||||
max_tokens=100,
|
||||
related_request_id=ctx.request_id,
|
||||
)
|
||||
|
||||
# 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()
|
||||
def test_x_mcp_header(
|
||||
region: Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Mirrored into the Mcp-Param-Region header",
|
||||
json_schema_extra={"x-mcp-header": "Region"},
|
||||
),
|
||||
] = "<none>",
|
||||
) -> str:
|
||||
"""Tests SEP-2243 Mcp-Param-* server-side validation.
|
||||
|
||||
Arms the http-custom-header-server-validation conformance scenario, which
|
||||
skips when no tool with an `x-mcp-header` annotation is found.
|
||||
"""
|
||||
return f"region={region}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_missing_capability(ctx: Context) -> str:
|
||||
"""Tests that a handler-raised MISSING_REQUIRED_CLIENT_CAPABILITY surfaces as a top-level JSON-RPC error.
|
||||
|
||||
Requires the client to declare the ``sampling`` capability. When absent, raises
|
||||
`MCPError` (which the tool dispatch re-raises rather than wrapping in
|
||||
``CallToolResult.isError``) so the conformance harness observes a protocol-level
|
||||
error response with ``data.requiredCapabilities``.
|
||||
"""
|
||||
client_params = ctx.session.client_params
|
||||
sampling_declared = client_params is not None and client_params.capabilities.sampling is not None
|
||||
if not sampling_declared:
|
||||
raise MCPError(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message="This tool requires the client 'sampling' capability",
|
||||
data={"requiredCapabilities": ["sampling"]},
|
||||
)
|
||||
return "Client declared sampling capability; proceeding."
|
||||
|
||||
|
||||
# SEP-2322 InputRequiredResult fixtures (multi-round-trip / ephemeral workflow)
|
||||
|
||||
NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}
|
||||
|
||||
|
||||
def _name_elicitation(message: str = "What is your name?") -> ElicitRequest:
|
||||
return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=NAME_SCHEMA))
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_elicitation(ctx: Context) -> str | InputRequiredResult:
|
||||
"""Tests InputRequiredResult with a single elicitation request"""
|
||||
responses = ctx.input_responses
|
||||
if responses and "user_name" in responses:
|
||||
answer = responses["user_name"]
|
||||
name = answer.content.get("name", "stranger") if isinstance(answer, ElicitResult) and answer.content else "?"
|
||||
return f"Hello, {name}!"
|
||||
return InputRequiredResult(input_requests={"user_name": _name_elicitation()})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_sampling(ctx: Context) -> str | InputRequiredResult:
|
||||
"""Tests InputRequiredResult with a single sampling request"""
|
||||
responses = ctx.input_responses
|
||||
if responses and "capital_question" in responses:
|
||||
answer = responses["capital_question"]
|
||||
text = answer.content.text if isinstance(answer, CreateMessageResult) and answer.content.type == "text" else "?"
|
||||
return f"Model said: {text}"
|
||||
return InputRequiredResult(
|
||||
input_requests={
|
||||
"capital_question": CreateMessageRequest(
|
||||
params=CreateMessageRequestParams(
|
||||
messages=[
|
||||
SamplingMessage(
|
||||
role="user", content=TextContent(type="text", text="What is the capital of France?")
|
||||
)
|
||||
],
|
||||
max_tokens=100,
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_list_roots(ctx: Context) -> str | InputRequiredResult:
|
||||
"""Tests InputRequiredResult with a single roots/list request"""
|
||||
responses = ctx.input_responses
|
||||
if responses and "client_roots" in responses:
|
||||
answer = responses["client_roots"]
|
||||
count = len(answer.roots) if isinstance(answer, ListRootsResult) else 0
|
||||
return f"Client exposed {count} root(s)."
|
||||
return InputRequiredResult(input_requests={"client_roots": ListRootsRequest()})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_request_state(ctx: Context) -> str | InputRequiredResult:
|
||||
"""Tests requestState round-tripping in the InputRequiredResult flow"""
|
||||
responses = ctx.input_responses
|
||||
if responses and "confirm" in responses and ctx.request_state == "request-state-nonce":
|
||||
return "state-ok: confirmation received"
|
||||
confirm = ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Please confirm",
|
||||
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
|
||||
)
|
||||
)
|
||||
return InputRequiredResult(input_requests={"confirm": confirm}, request_state="request-state-nonce")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_multiple_inputs(ctx: Context) -> str | InputRequiredResult:
|
||||
"""Tests InputRequiredResult carrying elicitation, sampling and roots requests together"""
|
||||
responses = ctx.input_responses
|
||||
if responses and {"user_name", "greeting", "client_roots"} <= responses.keys():
|
||||
return "All inputs received."
|
||||
return InputRequiredResult(
|
||||
input_requests={
|
||||
"user_name": _name_elicitation(),
|
||||
"greeting": CreateMessageRequest(
|
||||
params=CreateMessageRequestParams(
|
||||
messages=[
|
||||
SamplingMessage(role="user", content=TextContent(type="text", text="Generate a greeting"))
|
||||
],
|
||||
max_tokens=50,
|
||||
)
|
||||
),
|
||||
"client_roots": ListRootsRequest(),
|
||||
},
|
||||
request_state="multiple-inputs",
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_multi_round(ctx: Context) -> str | InputRequiredResult:
|
||||
"""Tests a three-round InputRequiredResult flow with evolving requestState"""
|
||||
state = json.loads(ctx.request_state) if ctx.request_state else {"round": 0}
|
||||
responses = ctx.input_responses or {}
|
||||
|
||||
if state["round"] == 0:
|
||||
return InputRequiredResult(
|
||||
input_requests={"step1": _name_elicitation("Step 1: What is your name?")},
|
||||
request_state=json.dumps({"round": 1}),
|
||||
)
|
||||
|
||||
if state["round"] == 1 and "step1" in responses:
|
||||
step1 = responses["step1"]
|
||||
name = step1.content.get("name") if isinstance(step1, ElicitResult) and step1.content else None
|
||||
color_schema = {"type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"]}
|
||||
return InputRequiredResult(
|
||||
input_requests={
|
||||
"step2": ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Step 2: What is your favorite color?", requested_schema=color_schema
|
||||
)
|
||||
)
|
||||
},
|
||||
request_state=json.dumps({"round": 2, "name": name}),
|
||||
)
|
||||
|
||||
if state["round"] == 2 and "step2" in responses:
|
||||
step2 = responses["step2"]
|
||||
color = step2.content.get("color") if isinstance(step2, ElicitResult) and step2.content else None
|
||||
return f"{state.get('name')} likes {color}."
|
||||
|
||||
# Missing or out-of-order response: re-request from the start.
|
||||
return InputRequiredResult(
|
||||
input_requests={"step1": _name_elicitation("Step 1: What is your name?")},
|
||||
request_state=json.dumps({"round": 1}),
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult:
|
||||
"""Tests that the server rejects a tampered requestState echo.
|
||||
|
||||
The handler stays plaintext; tamper rejection happens in the SDK's request-state boundary.
|
||||
"""
|
||||
if ctx.request_state is None:
|
||||
confirm = ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="Please confirm",
|
||||
requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]},
|
||||
)
|
||||
)
|
||||
return InputRequiredResult(input_requests={"confirm": confirm}, request_state="round-1")
|
||||
return f"state-ok: {ctx.request_state}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_input_required_result_capabilities(ctx: Context) -> InputRequiredResult:
|
||||
"""Tests that inputRequests only include methods the client declared support for"""
|
||||
caps = ctx.client_capabilities
|
||||
requests: dict[str, InputRequest] = {}
|
||||
if caps is None or caps.sampling is not None:
|
||||
requests["sample"] = CreateMessageRequest(
|
||||
params=CreateMessageRequestParams(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Say hello"))],
|
||||
max_tokens=50,
|
||||
)
|
||||
)
|
||||
if caps is None or caps.elicitation is not None:
|
||||
requests["ask"] = _name_elicitation()
|
||||
return InputRequiredResult(input_requests=requests, request_state="capability-gated")
|
||||
|
||||
|
||||
# SEP-1613 / SEP-2106 JSON Schema 2020-12 fixture: a tool whose inputSchema carries
|
||||
# the full set of 2020-12 keywords the conformance scenario asserts on.
|
||||
|
||||
JSON_SCHEMA_2020_12_INPUT_SCHEMA: dict[str, Any] = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"$defs": {
|
||||
"address": {
|
||||
"$anchor": "addressDef",
|
||||
"type": "object",
|
||||
"properties": {"street": {"type": "string"}, "city": {"type": "string"}},
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"address": {"$ref": "#/$defs/address"},
|
||||
"contactMethod": {"type": "string", "enum": ["phone", "email"]},
|
||||
"phone": {"type": "string"},
|
||||
"email": {"type": "string"},
|
||||
},
|
||||
"allOf": [{"anyOf": [{"required": ["phone"]}, {"required": ["email"]}]}],
|
||||
"if": {"properties": {"contactMethod": {"const": "phone"}}, "required": ["contactMethod"]},
|
||||
"then": {"required": ["phone"]},
|
||||
"else": {"required": ["email"]},
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool(name="json_schema_2020_12_tool")
|
||||
def json_schema_2020_12_tool() -> str:
|
||||
"""Tests JSON Schema 2020-12 keyword preservation in tools/list (inputSchema installed below)."""
|
||||
return "json_schema_2020_12_tool"
|
||||
|
||||
|
||||
# TODO(felix): replace with a public input_schema= override once MCPServer.tool() grows one.
|
||||
mcp._tool_manager._tools["json_schema_2020_12_tool"].parameters = ( # pyright: ignore[reportPrivateUsage]
|
||||
JSON_SCHEMA_2020_12_INPUT_SCHEMA
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_reconnection(ctx: Context) -> str:
|
||||
"""Tests SSE polling by closing stream mid-call (SEP-1699)"""
|
||||
await ctx.info("Before disconnect") # pyright: ignore[reportDeprecated]
|
||||
|
||||
await ctx.close_sse_stream()
|
||||
|
||||
await asyncio.sleep(0.2) # Wait for client to reconnect
|
||||
|
||||
await ctx.info("After reconnect") # pyright: ignore[reportDeprecated]
|
||||
return "Reconnection test completed"
|
||||
|
||||
|
||||
def _dynamic_tool() -> str:
|
||||
"""A tool registered and removed by test_trigger_tool_change."""
|
||||
return "dynamic"
|
||||
|
||||
|
||||
def _dynamic_prompt() -> str:
|
||||
"""A prompt registered and removed by test_trigger_prompt_change."""
|
||||
return "dynamic"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_trigger_tool_change(ctx: Context) -> str:
|
||||
"""Mutates the tool list and announces it to subscriptions/listen streams (SEP-2575)"""
|
||||
mcp.add_tool(_dynamic_tool, name="test_dynamic_tool")
|
||||
mcp.remove_tool("test_dynamic_tool")
|
||||
await ctx.notify_tools_changed()
|
||||
return "tool list changed"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def test_trigger_prompt_change(ctx: Context) -> str:
|
||||
"""Mutates the prompt list and announces it to subscriptions/listen streams (SEP-2575)"""
|
||||
mcp.add_prompt(Prompt.from_function(_dynamic_prompt, name="test_dynamic_prompt", description="dynamic"))
|
||||
mcp.remove_prompt("test_dynamic_prompt")
|
||||
await ctx.notify_prompts_changed()
|
||||
return "prompt list changed"
|
||||
|
||||
|
||||
# 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.")),
|
||||
]
|
||||
|
||||
|
||||
@mcp.prompt()
|
||||
async def test_input_required_result_prompt(ctx: Context) -> list[UserMessage] | InputRequiredResult:
|
||||
"""Tests InputRequiredResult from prompts/get (SEP-2322 non-tool request)"""
|
||||
responses = ctx.input_responses
|
||||
if responses and "user_context" in responses:
|
||||
answer = responses["user_context"]
|
||||
text = answer.content.get("context", "?") if isinstance(answer, ElicitResult) and answer.content else "?"
|
||||
return [UserMessage(role="user", content=TextContent(type="text", text=f"Use the following context: {text}"))]
|
||||
return InputRequiredResult(
|
||||
input_requests={
|
||||
"user_context": ElicitRequest(
|
||||
params=ElicitRequestFormParams(
|
||||
message="What context should the prompt use?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"context": {"type": "string"}},
|
||||
"required": ["context"],
|
||||
},
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# 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( # pyright: ignore[reportPrivateUsage]
|
||||
"logging/setLevel", SetLevelRequestParams, handle_set_logging_level
|
||||
)
|
||||
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
|
||||
"resources/subscribe", SubscribeRequestParams, handle_subscribe
|
||||
)
|
||||
mcp._lowlevel_server.add_request_handler( # pyright: ignore[reportPrivateUsage]
|
||||
"resources/unsubscribe", UnsubscribeRequestParams, handle_unsubscribe
|
||||
)
|
||||
|
||||
|
||||
@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,185 @@
|
||||
"""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
|
||||
"sub": access_token.subject, # RFC 7662 subject
|
||||
"iss": str(server_settings.server_url),
|
||||
}
|
||||
)
|
||||
|
||||
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,272 @@
|
||||
"""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
|
||||
subject=username,
|
||||
)
|
||||
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
|
||||
subject=authorization_code.subject,
|
||||
)
|
||||
|
||||
# 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,108 @@
|
||||
"""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
|
||||
subject=data.get("sub"), # RFC 7662 subject (resource owner)
|
||||
claims=data,
|
||||
)
|
||||
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
|
||||
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .server import main
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Simple MCP server demonstrating pagination for tools, resources, and prompts.
|
||||
|
||||
This example shows how to implement pagination with the low-level server API
|
||||
to handle large lists of items that need to be split across multiple pages.
|
||||
"""
|
||||
|
||||
from typing import TypeVar
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import mcp_types as types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# Sample data - in real scenarios, this might come from a database
|
||||
SAMPLE_TOOLS = [
|
||||
types.Tool(
|
||||
name=f"tool_{i}",
|
||||
title=f"Tool {i}",
|
||||
description=f"This is sample tool number {i}",
|
||||
input_schema={"type": "object", "properties": {"input": {"type": "string"}}},
|
||||
)
|
||||
for i in range(1, 26) # 25 tools total
|
||||
]
|
||||
|
||||
SAMPLE_RESOURCES = [
|
||||
types.Resource(
|
||||
uri=f"file:///path/to/resource_{i}.txt",
|
||||
name=f"resource_{i}",
|
||||
description=f"This is sample resource number {i}",
|
||||
)
|
||||
for i in range(1, 31) # 30 resources total
|
||||
]
|
||||
|
||||
SAMPLE_PROMPTS = [
|
||||
types.Prompt(
|
||||
name=f"prompt_{i}",
|
||||
description=f"This is sample prompt number {i}",
|
||||
arguments=[
|
||||
types.PromptArgument(name="arg1", description="First argument", required=True),
|
||||
],
|
||||
)
|
||||
for i in range(1, 21) # 20 prompts total
|
||||
]
|
||||
|
||||
|
||||
def _paginate(cursor: str | None, items: list[T], page_size: int) -> tuple[list[T], str | None]:
|
||||
"""Helper to paginate a list of items given a cursor."""
|
||||
if cursor is not None:
|
||||
try:
|
||||
start_idx = int(cursor)
|
||||
except (ValueError, TypeError):
|
||||
return [], None
|
||||
else:
|
||||
start_idx = 0
|
||||
|
||||
page = items[start_idx : start_idx + page_size]
|
||||
next_cursor = str(start_idx + page_size) if start_idx + page_size < len(items) else None
|
||||
return page, next_cursor
|
||||
|
||||
|
||||
# Paginated list_tools - returns 5 tools per page
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
cursor = params.cursor if params is not None else None
|
||||
page, next_cursor = _paginate(cursor, SAMPLE_TOOLS, page_size=5)
|
||||
return types.ListToolsResult(tools=page, next_cursor=next_cursor)
|
||||
|
||||
|
||||
# Paginated list_resources - returns 10 resources per page
|
||||
async def handle_list_resources(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListResourcesResult:
|
||||
cursor = params.cursor if params is not None else None
|
||||
page, next_cursor = _paginate(cursor, SAMPLE_RESOURCES, page_size=10)
|
||||
return types.ListResourcesResult(resources=page, next_cursor=next_cursor)
|
||||
|
||||
|
||||
# Paginated list_prompts - returns 7 prompts per page
|
||||
async def handle_list_prompts(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListPromptsResult:
|
||||
cursor = params.cursor if params is not None else None
|
||||
page, next_cursor = _paginate(cursor, SAMPLE_PROMPTS, page_size=7)
|
||||
return types.ListPromptsResult(prompts=page, next_cursor=next_cursor)
|
||||
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
# Find the tool in our sample data
|
||||
tool = next((t for t in SAMPLE_TOOLS if t.name == params.name), None)
|
||||
if not tool:
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
|
||||
return types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=f"Called tool '{params.name}' with arguments: {params.arguments}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_read_resource(
|
||||
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
resource = next((r for r in SAMPLE_RESOURCES if r.uri == str(params.uri)), None)
|
||||
if not resource:
|
||||
raise ValueError(f"Unknown resource: {params.uri}")
|
||||
|
||||
return types.ReadResourceResult(
|
||||
contents=[
|
||||
types.TextResourceContents(
|
||||
uri=str(params.uri),
|
||||
text=f"Content of {resource.name}: This is sample content for the resource.",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult:
|
||||
prompt = next((p for p in SAMPLE_PROMPTS if p.name == params.name), None)
|
||||
if not prompt:
|
||||
raise ValueError(f"Unknown prompt: {params.name}")
|
||||
|
||||
message_text = f"This is the prompt '{params.name}'"
|
||||
if params.arguments:
|
||||
message_text += f" with arguments: {params.arguments}"
|
||||
|
||||
return types.GetPromptResult(
|
||||
description=prompt.description,
|
||||
messages=[
|
||||
types.PromptMessage(
|
||||
role="user",
|
||||
content=types.TextContent(type="text", text=message_text),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8000, help="Port to listen on for HTTP")
|
||||
@click.option(
|
||||
"--transport",
|
||||
type=click.Choice(["stdio", "streamable-http"]),
|
||||
default="stdio",
|
||||
help="Transport type",
|
||||
)
|
||||
def main(port: int, transport: str) -> int:
|
||||
app = Server(
|
||||
"mcp-simple-pagination",
|
||||
on_list_tools=handle_list_tools,
|
||||
on_list_resources=handle_list_resources,
|
||||
on_list_prompts=handle_list_prompts,
|
||||
on_call_tool=handle_call_tool,
|
||||
on_read_resource=handle_read_resource,
|
||||
on_get_prompt=handle_get_prompt,
|
||||
)
|
||||
|
||||
if transport == "streamable-http":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port)
|
||||
else:
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def arun():
|
||||
async with stdio_server() as streams:
|
||||
await app.run(streams[0], streams[1], app.create_initialization_options())
|
||||
|
||||
anyio.run(arun)
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "mcp-simple-pagination"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP server demonstrating pagination for tools, resources, and prompts"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "pagination", "cursor"]
|
||||
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 = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-pagination = "mcp_simple_pagination.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_pagination"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_pagination"]
|
||||
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,55 @@
|
||||
# MCP Simple Prompt
|
||||
|
||||
A simple MCP server that exposes a customizable prompt template with optional context and topic parameters.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server using either stdio (default) or Streamable HTTP transport:
|
||||
|
||||
```bash
|
||||
# Using stdio transport (default)
|
||||
uv run mcp-simple-prompt
|
||||
|
||||
# Using Streamable HTTP transport on custom port
|
||||
uv run mcp-simple-prompt --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
The server exposes a prompt named "simple" that accepts two optional arguments:
|
||||
|
||||
- `context`: Additional context to consider
|
||||
- `topic`: Specific topic to focus on
|
||||
|
||||
## Example
|
||||
|
||||
Using the MCP client, you can retrieve the prompt 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-prompt"])
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List available prompts
|
||||
prompts = await session.list_prompts()
|
||||
print(prompts)
|
||||
|
||||
# Get the prompt with arguments
|
||||
prompt = await session.get_prompt(
|
||||
"simple",
|
||||
{
|
||||
"context": "User is a software developer",
|
||||
"topic": "Python async programming",
|
||||
},
|
||||
)
|
||||
print(prompt)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .server import main
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
@@ -0,0 +1,98 @@
|
||||
import anyio
|
||||
import click
|
||||
import mcp_types as types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
|
||||
def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]:
|
||||
"""Create the messages for the prompt."""
|
||||
messages: list[types.PromptMessage] = []
|
||||
|
||||
# Add context if provided
|
||||
if context:
|
||||
messages.append(
|
||||
types.PromptMessage(
|
||||
role="user",
|
||||
content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"),
|
||||
)
|
||||
)
|
||||
|
||||
# Add the main prompt
|
||||
prompt = "Please help me with "
|
||||
if topic:
|
||||
prompt += f"the following topic: {topic}"
|
||||
else:
|
||||
prompt += "whatever questions I may have."
|
||||
|
||||
messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt)))
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def handle_list_prompts(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListPromptsResult:
|
||||
return types.ListPromptsResult(
|
||||
prompts=[
|
||||
types.Prompt(
|
||||
name="simple",
|
||||
title="Simple Assistant Prompt",
|
||||
description="A simple prompt that can take optional context and topic arguments",
|
||||
arguments=[
|
||||
types.PromptArgument(
|
||||
name="context",
|
||||
description="Additional context to consider",
|
||||
required=False,
|
||||
),
|
||||
types.PromptArgument(
|
||||
name="topic",
|
||||
description="Specific topic to focus on",
|
||||
required=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult:
|
||||
if params.name != "simple":
|
||||
raise ValueError(f"Unknown prompt: {params.name}")
|
||||
|
||||
arguments = params.arguments or {}
|
||||
|
||||
return types.GetPromptResult(
|
||||
messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")),
|
||||
description="A simple prompt with optional context and topic arguments",
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8000, help="Port to listen on for HTTP")
|
||||
@click.option(
|
||||
"--transport",
|
||||
type=click.Choice(["stdio", "streamable-http"]),
|
||||
default="stdio",
|
||||
help="Transport type",
|
||||
)
|
||||
def main(port: int, transport: str) -> int:
|
||||
app = Server(
|
||||
"mcp-simple-prompt",
|
||||
on_list_prompts=handle_list_prompts,
|
||||
on_get_prompt=handle_get_prompt,
|
||||
)
|
||||
|
||||
if transport == "streamable-http":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port)
|
||||
else:
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def arun():
|
||||
async with stdio_server() as streams:
|
||||
await app.run(streams[0], streams[1], app.create_initialization_options())
|
||||
|
||||
anyio.run(arun)
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "mcp-simple-prompt"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP server exposing a customizable prompt"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "web", "fetch"]
|
||||
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 = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-prompt = "mcp_simple_prompt.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_prompt"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_prompt"]
|
||||
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,48 @@
|
||||
# MCP Simple Resource
|
||||
|
||||
A simple MCP server that exposes sample text files as resources.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server using either stdio (default) or Streamable HTTP transport:
|
||||
|
||||
```bash
|
||||
# Using stdio transport (default)
|
||||
uv run mcp-simple-resource
|
||||
|
||||
# Using Streamable HTTP transport on custom port
|
||||
uv run mcp-simple-resource --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
The server exposes some basic text file resources that can be read by clients.
|
||||
|
||||
## Example
|
||||
|
||||
Using the MCP client, you can retrieve resources like this using the STDIO transport:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from pydantic import AnyUrl
|
||||
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-resource"])
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List available resources
|
||||
resources = await session.list_resources()
|
||||
print(resources)
|
||||
|
||||
# Get a specific resource
|
||||
resource = await session.read_resource(AnyUrl("file:///greeting.txt"))
|
||||
print(resource)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .server import main
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
@@ -0,0 +1,91 @@
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import mcp_types as types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
SAMPLE_RESOURCES = {
|
||||
"greeting": {
|
||||
"content": "Hello! This is a sample text resource.",
|
||||
"title": "Welcome Message",
|
||||
},
|
||||
"help": {
|
||||
"content": "This server provides a few sample text resources for testing.",
|
||||
"title": "Help Documentation",
|
||||
},
|
||||
"about": {
|
||||
"content": "This is the simple-resource MCP server implementation.",
|
||||
"title": "About This Server",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def handle_list_resources(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListResourcesResult:
|
||||
return types.ListResourcesResult(
|
||||
resources=[
|
||||
types.Resource(
|
||||
uri=f"file:///{name}.txt",
|
||||
name=name,
|
||||
title=SAMPLE_RESOURCES[name]["title"],
|
||||
description=f"A sample text resource named {name}",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
for name in SAMPLE_RESOURCES.keys()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_read_resource(
|
||||
ctx: ServerRequestContext, params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
parsed = urlparse(str(params.uri))
|
||||
if not parsed.path:
|
||||
raise ValueError(f"Invalid resource path: {params.uri}")
|
||||
name = parsed.path.replace(".txt", "").lstrip("/")
|
||||
|
||||
if name not in SAMPLE_RESOURCES:
|
||||
raise ValueError(f"Unknown resource: {params.uri}")
|
||||
|
||||
return types.ReadResourceResult(
|
||||
contents=[
|
||||
types.TextResourceContents(
|
||||
uri=str(params.uri),
|
||||
text=SAMPLE_RESOURCES[name]["content"],
|
||||
mime_type="text/plain",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8000, help="Port to listen on for HTTP")
|
||||
@click.option(
|
||||
"--transport",
|
||||
type=click.Choice(["stdio", "streamable-http"]),
|
||||
default="stdio",
|
||||
help="Transport type",
|
||||
)
|
||||
def main(port: int, transport: str) -> int:
|
||||
app = Server(
|
||||
"mcp-simple-resource",
|
||||
on_list_resources=handle_list_resources,
|
||||
on_read_resource=handle_read_resource,
|
||||
)
|
||||
|
||||
if transport == "streamable-http":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port)
|
||||
else:
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def arun():
|
||||
async with stdio_server() as streams:
|
||||
await app.run(streams[0], streams[1], app.create_initialization_options())
|
||||
|
||||
anyio.run(arun)
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "mcp-simple-resource"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP server exposing sample text resources"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "web", "fetch"]
|
||||
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 = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-resource = "mcp_simple_resource.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_resource"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_resource"]
|
||||
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,38 @@
|
||||
# MCP Simple StreamableHttp Stateless Server Example
|
||||
|
||||
A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance.
|
||||
|
||||
## Features
|
||||
|
||||
- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None)
|
||||
- Each request creates a new ephemeral connection
|
||||
- No session state maintained between requests
|
||||
- Suitable for deployment in multi-node environments
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server:
|
||||
|
||||
```bash
|
||||
# Using default port 3000
|
||||
uv run mcp-simple-streamablehttp-stateless
|
||||
|
||||
# Using custom port
|
||||
uv run mcp-simple-streamablehttp-stateless --port 3000
|
||||
|
||||
# Custom logging level
|
||||
uv run mcp-simple-streamablehttp-stateless --log-level DEBUG
|
||||
|
||||
# Enable JSON responses instead of SSE streams
|
||||
uv run mcp-simple-streamablehttp-stateless --json-response
|
||||
```
|
||||
|
||||
The server exposes a tool named "start-notification-stream" that accepts three arguments:
|
||||
|
||||
- `interval`: Time between notifications in seconds (e.g., 1.0)
|
||||
- `count`: Number of notifications to send (e.g., 5)
|
||||
- `caller`: Identifier string for the caller
|
||||
|
||||
## Client
|
||||
|
||||
You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing.
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Click will handle CLI arguments
|
||||
import sys
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import logging
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import mcp_types as types
|
||||
import uvicorn
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="start-notification-stream",
|
||||
description=("Sends a stream of notifications with configurable count and interval"),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"required": ["interval", "count", "caller"],
|
||||
"properties": {
|
||||
"interval": {
|
||||
"type": "number",
|
||||
"description": "Interval between notifications in seconds",
|
||||
},
|
||||
"count": {
|
||||
"type": "number",
|
||||
"description": "Number of notifications to send",
|
||||
},
|
||||
"caller": {
|
||||
"type": "string",
|
||||
"description": ("Identifier of the caller to include in notifications"),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
arguments = params.arguments or {}
|
||||
interval = arguments.get("interval", 1.0)
|
||||
count = arguments.get("count", 5)
|
||||
caller = arguments.get("caller", "unknown")
|
||||
|
||||
# Send the specified number of notifications with the given interval
|
||||
for i in range(count):
|
||||
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
|
||||
level="info",
|
||||
data=f"Notification {i + 1}/{count} from caller: {caller}",
|
||||
logger="notification_stream",
|
||||
related_request_id=ctx.request_id,
|
||||
)
|
||||
if i < count - 1: # Don't wait after the last notification
|
||||
await anyio.sleep(interval)
|
||||
|
||||
return types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=3000, help="Port to listen on for HTTP")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
)
|
||||
@click.option(
|
||||
"--json-response",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable JSON responses instead of SSE streams",
|
||||
)
|
||||
def main(
|
||||
port: int,
|
||||
log_level: str,
|
||||
json_response: bool,
|
||||
) -> None:
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
app = Server(
|
||||
"mcp-streamable-http-stateless-demo",
|
||||
on_list_tools=handle_list_tools,
|
||||
on_call_tool=handle_call_tool,
|
||||
)
|
||||
|
||||
starlette_app = app.streamable_http_app(
|
||||
stateless_http=True,
|
||||
json_response=json_response,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
# Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header
|
||||
# for browser-based clients (ensures 500 errors get proper CORS headers)
|
||||
starlette_app = CORSMiddleware(
|
||||
starlette_app,
|
||||
allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default
|
||||
allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods
|
||||
expose_headers=["Mcp-Session-Id"],
|
||||
)
|
||||
|
||||
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
|
||||
@@ -0,0 +1,36 @@
|
||||
[project]
|
||||
name = "mcp-simple-streamablehttp-stateless"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP server exposing a StreamableHttp transport in stateless mode"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"]
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_streamablehttp_stateless"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_streamablehttp_stateless"]
|
||||
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,51 @@
|
||||
# MCP Simple StreamableHttp Server Example
|
||||
|
||||
A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming.
|
||||
|
||||
## Features
|
||||
|
||||
- Uses the StreamableHTTP transport for server-client communication
|
||||
- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint
|
||||
- Ability to send multiple notifications over time to the client
|
||||
- Resumability support via InMemoryEventStore
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server on the default or custom port:
|
||||
|
||||
```bash
|
||||
|
||||
# Using custom port
|
||||
uv run mcp-simple-streamablehttp --port 3000
|
||||
|
||||
# Custom logging level
|
||||
uv run mcp-simple-streamablehttp --log-level DEBUG
|
||||
|
||||
# Enable JSON responses instead of SSE streams
|
||||
uv run mcp-simple-streamablehttp --json-response
|
||||
```
|
||||
|
||||
The server exposes a tool named "start-notification-stream" that accepts three arguments:
|
||||
|
||||
- `interval`: Time between notifications in seconds (e.g., 1.0)
|
||||
- `count`: Number of notifications to send (e.g., 5)
|
||||
- `caller`: Identifier string for the caller
|
||||
|
||||
## Resumability Support
|
||||
|
||||
This server includes resumability support through the InMemoryEventStore. This enables clients to:
|
||||
|
||||
- Reconnect to the server after a disconnection
|
||||
- Resume event streaming from where they left off using the Last-Event-ID header
|
||||
|
||||
The server will:
|
||||
|
||||
- Generate unique event IDs for each SSE message
|
||||
- Store events in memory for later replay
|
||||
- Replay missed events when a client reconnects with a Last-Event-ID header
|
||||
|
||||
Note: The InMemoryEventStore is designed for demonstration purposes only. For production use, consider implementing a persistent storage solution.
|
||||
|
||||
## Client
|
||||
|
||||
You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use [Inspector](https://github.com/modelcontextprotocol/inspector)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""In-memory event store for demonstrating resumability functionality.
|
||||
|
||||
This is a simple implementation intended for examples and testing,
|
||||
not for production use where a persistent storage solution would be more appropriate.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
|
||||
from mcp_types import JSONRPCMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventEntry:
|
||||
"""Represents an event entry in the event store."""
|
||||
|
||||
event_id: EventId
|
||||
stream_id: StreamId
|
||||
message: JSONRPCMessage | None
|
||||
|
||||
|
||||
class InMemoryEventStore(EventStore):
|
||||
"""Simple in-memory implementation of the EventStore interface for resumability.
|
||||
This is primarily intended for examples and testing, not for production use
|
||||
where a persistent storage solution would be more appropriate.
|
||||
|
||||
This implementation keeps only the last N events per stream for memory efficiency.
|
||||
"""
|
||||
|
||||
def __init__(self, max_events_per_stream: int = 100):
|
||||
"""Initialize the event store.
|
||||
|
||||
Args:
|
||||
max_events_per_stream: Maximum number of events to keep per stream
|
||||
"""
|
||||
self.max_events_per_stream = max_events_per_stream
|
||||
# for maintaining last N events per stream
|
||||
self.streams: dict[StreamId, deque[EventEntry]] = {}
|
||||
# event_id -> EventEntry for quick lookup
|
||||
self.event_index: dict[EventId, EventEntry] = {}
|
||||
|
||||
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
|
||||
"""Stores an event with a generated event ID."""
|
||||
event_id = str(uuid4())
|
||||
event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message)
|
||||
|
||||
# Get or create deque for this stream
|
||||
if stream_id not in self.streams:
|
||||
self.streams[stream_id] = deque(maxlen=self.max_events_per_stream)
|
||||
|
||||
# If deque is full, the oldest event will be automatically removed
|
||||
# We need to remove it from the event_index as well
|
||||
if len(self.streams[stream_id]) == self.max_events_per_stream:
|
||||
oldest_event = self.streams[stream_id][0]
|
||||
self.event_index.pop(oldest_event.event_id, None)
|
||||
|
||||
# Add new event
|
||||
self.streams[stream_id].append(event_entry)
|
||||
self.event_index[event_id] = event_entry
|
||||
|
||||
return event_id
|
||||
|
||||
async def replay_events_after(
|
||||
self,
|
||||
last_event_id: EventId,
|
||||
send_callback: EventCallback,
|
||||
) -> StreamId | None:
|
||||
"""Replays events that occurred after the specified event ID."""
|
||||
if last_event_id not in self.event_index:
|
||||
logger.warning(f"Event ID {last_event_id} not found in store")
|
||||
return None
|
||||
|
||||
# Get the stream and find events after the last one
|
||||
last_event = self.event_index[last_event_id]
|
||||
stream_id = last_event.stream_id
|
||||
stream_events = self.streams.get(last_event.stream_id, deque())
|
||||
|
||||
# Events in deque are already in chronological order
|
||||
found_last = False
|
||||
for event in stream_events:
|
||||
if found_last:
|
||||
# Skip priming events (None message)
|
||||
if event.message is not None:
|
||||
await send_callback(EventMessage(event.message, event.event_id))
|
||||
elif event.event_id == last_event_id:
|
||||
found_last = True
|
||||
|
||||
return stream_id
|
||||
@@ -0,0 +1,142 @@
|
||||
import logging
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import mcp_types as types
|
||||
import uvicorn
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from .event_store import InMemoryEventStore
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="start-notification-stream",
|
||||
description="Sends a stream of notifications with configurable count and interval",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"required": ["interval", "count", "caller"],
|
||||
"properties": {
|
||||
"interval": {
|
||||
"type": "number",
|
||||
"description": "Interval between notifications in seconds",
|
||||
},
|
||||
"count": {
|
||||
"type": "number",
|
||||
"description": "Number of notifications to send",
|
||||
},
|
||||
"caller": {
|
||||
"type": "string",
|
||||
"description": "Identifier of the caller to include in notifications",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
arguments = params.arguments or {}
|
||||
interval = arguments.get("interval", 1.0)
|
||||
count = arguments.get("count", 5)
|
||||
caller = arguments.get("caller", "unknown")
|
||||
|
||||
# Send the specified number of notifications with the given interval
|
||||
for i in range(count):
|
||||
# Include more detailed message for resumability demonstration
|
||||
notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected"
|
||||
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
|
||||
level="info",
|
||||
data=notification_msg,
|
||||
logger="notification_stream",
|
||||
# Associates this notification with the original request
|
||||
# Ensures notifications are sent to the correct response stream
|
||||
# Without this, notifications will either go to:
|
||||
# - a standalone SSE stream (if GET request is supported)
|
||||
# - nowhere (if GET request isn't supported)
|
||||
related_request_id=ctx.request_id,
|
||||
)
|
||||
logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}")
|
||||
if i < count - 1: # Don't wait after the last notification
|
||||
await anyio.sleep(interval)
|
||||
|
||||
# This will send a resource notification through standalone SSE
|
||||
# established by GET request
|
||||
await ctx.session.send_resource_updated(uri="http:///test_resource")
|
||||
return types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=3000, help="Port to listen on for HTTP")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)",
|
||||
)
|
||||
@click.option(
|
||||
"--json-response",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Enable JSON responses instead of SSE streams",
|
||||
)
|
||||
def main(
|
||||
port: int,
|
||||
log_level: str,
|
||||
json_response: bool,
|
||||
) -> int:
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
app = Server(
|
||||
"mcp-streamable-http-demo",
|
||||
on_list_tools=handle_list_tools,
|
||||
on_call_tool=handle_call_tool,
|
||||
)
|
||||
|
||||
# Create event store for resumability
|
||||
# The InMemoryEventStore enables resumability support for StreamableHTTP transport.
|
||||
# It stores SSE events with unique IDs, allowing clients to:
|
||||
# 1. Receive event IDs for each SSE message
|
||||
# 2. Resume streams by sending Last-Event-ID in GET requests
|
||||
# 3. Replay missed events after reconnection
|
||||
# Note: This in-memory implementation is for demonstration ONLY.
|
||||
# For production, use a persistent storage solution.
|
||||
event_store = InMemoryEventStore()
|
||||
|
||||
starlette_app = app.streamable_http_app(
|
||||
event_store=event_store,
|
||||
json_response=json_response,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
# Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header
|
||||
# for browser-based clients (ensures 500 errors get proper CORS headers)
|
||||
starlette_app = CORSMiddleware(
|
||||
starlette_app,
|
||||
allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default
|
||||
allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods
|
||||
expose_headers=["Mcp-Session-Id"],
|
||||
)
|
||||
|
||||
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,36 @@
|
||||
[project]
|
||||
name = "mcp-simple-streamablehttp"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP server exposing a StreamableHttp transport for testing"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"]
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_streamablehttp"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_streamablehttp"]
|
||||
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,48 @@
|
||||
|
||||
A simple MCP server that exposes a website fetching tool.
|
||||
|
||||
## Usage
|
||||
|
||||
Start the server using either stdio (default) or Streamable HTTP transport:
|
||||
|
||||
```bash
|
||||
# Using stdio transport (default)
|
||||
uv run mcp-simple-tool
|
||||
|
||||
# Using Streamable HTTP transport on custom port
|
||||
uv run mcp-simple-tool --transport streamable-http --port 8000
|
||||
```
|
||||
|
||||
The server exposes a tool named "fetch" that accepts one required argument:
|
||||
|
||||
- `url`: The URL of the website to fetch
|
||||
|
||||
## Example
|
||||
|
||||
Using the MCP client, you can use the tool 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-tool"])
|
||||
) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List available tools
|
||||
tools = await session.list_tools()
|
||||
print(tools)
|
||||
|
||||
# Call the fetch tool
|
||||
result = await session.call_tool("fetch", {"url": "https://example.com"})
|
||||
print(result)
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
import sys
|
||||
|
||||
from .server import main
|
||||
|
||||
sys.exit(main()) # type: ignore[call-arg]
|
||||
@@ -0,0 +1,80 @@
|
||||
import anyio
|
||||
import click
|
||||
import mcp_types as types
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.shared._httpx_utils import create_mcp_http_client
|
||||
|
||||
|
||||
async def fetch_website(
|
||||
url: str,
|
||||
) -> list[types.ContentBlock]:
|
||||
headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"}
|
||||
async with create_mcp_http_client(headers=headers) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return [types.TextContent(type="text", text=response.text)]
|
||||
|
||||
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="fetch",
|
||||
title="Website Fetcher",
|
||||
description="Fetches a website and returns its content",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"required": ["url"],
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "URL to fetch",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
if params.name != "fetch":
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
arguments = params.arguments or {}
|
||||
if "url" not in arguments:
|
||||
raise ValueError("Missing required argument 'url'")
|
||||
content = await fetch_website(arguments["url"])
|
||||
return types.CallToolResult(content=content)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=8000, help="Port to listen on for HTTP")
|
||||
@click.option(
|
||||
"--transport",
|
||||
type=click.Choice(["stdio", "streamable-http"]),
|
||||
default="stdio",
|
||||
help="Transport type",
|
||||
)
|
||||
def main(port: int, transport: str) -> int:
|
||||
app = Server(
|
||||
"mcp-website-fetcher",
|
||||
on_list_tools=handle_list_tools,
|
||||
on_call_tool=handle_call_tool,
|
||||
)
|
||||
|
||||
if transport == "streamable-http":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port)
|
||||
else:
|
||||
from mcp.server.stdio import stdio_server
|
||||
|
||||
async def arun():
|
||||
async with stdio_server() as streams:
|
||||
await app.run(streams[0], streams[1], app.create_initialization_options())
|
||||
|
||||
anyio.run(arun)
|
||||
|
||||
return 0
|
||||
@@ -0,0 +1,43 @@
|
||||
[project]
|
||||
name = "mcp-simple-tool"
|
||||
version = "0.1.0"
|
||||
description = "A simple MCP server exposing a website fetching tool"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "llm", "automation", "web", "fetch"]
|
||||
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 = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-simple-tool = "mcp_simple_tool.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_simple_tool"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_simple_tool"]
|
||||
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,36 @@
|
||||
# MCP SSE Polling Demo Server
|
||||
|
||||
Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699).
|
||||
|
||||
## Features
|
||||
|
||||
- Priming events (automatic with EventStore)
|
||||
- Server-initiated stream close via `close_sse_stream()` callback
|
||||
- Client auto-reconnect with Last-Event-ID
|
||||
- Progress notifications during long-running tasks
|
||||
- Configurable retry interval
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Start server on default port
|
||||
uv run mcp-sse-polling-demo --port 3000
|
||||
|
||||
# Custom retry interval (milliseconds)
|
||||
uv run mcp-sse-polling-demo --port 3000 --retry-interval 100
|
||||
```
|
||||
|
||||
## Tool: process_batch
|
||||
|
||||
Processes items with periodic checkpoints that trigger SSE stream closes:
|
||||
|
||||
- `items`: Number of items to process (1-100, default: 10)
|
||||
- `checkpoint_every`: Close stream after this many items (1-20, default: 3)
|
||||
|
||||
## Client
|
||||
|
||||
Use the companion `mcp-sse-polling-client` to test:
|
||||
|
||||
```bash
|
||||
uv run mcp-sse-polling-client --url http://localhost:3000/mcp
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks."""
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point for the SSE Polling Demo server."""
|
||||
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
"""In-memory event store for demonstrating resumability functionality.
|
||||
|
||||
This is a simple implementation intended for examples and testing,
|
||||
not for production use where a persistent storage solution would be more appropriate.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from uuid import uuid4
|
||||
|
||||
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
|
||||
from mcp_types import JSONRPCMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EventEntry:
|
||||
"""Represents an event entry in the event store."""
|
||||
|
||||
event_id: EventId
|
||||
stream_id: StreamId
|
||||
message: JSONRPCMessage | None # None for priming events
|
||||
|
||||
|
||||
class InMemoryEventStore(EventStore):
|
||||
"""Simple in-memory implementation of the EventStore interface for resumability.
|
||||
This is primarily intended for examples and testing, not for production use
|
||||
where a persistent storage solution would be more appropriate.
|
||||
|
||||
This implementation keeps only the last N events per stream for memory efficiency.
|
||||
"""
|
||||
|
||||
def __init__(self, max_events_per_stream: int = 100):
|
||||
"""Initialize the event store.
|
||||
|
||||
Args:
|
||||
max_events_per_stream: Maximum number of events to keep per stream
|
||||
"""
|
||||
self.max_events_per_stream = max_events_per_stream
|
||||
# for maintaining last N events per stream
|
||||
self.streams: dict[StreamId, deque[EventEntry]] = {}
|
||||
# event_id -> EventEntry for quick lookup
|
||||
self.event_index: dict[EventId, EventEntry] = {}
|
||||
|
||||
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
|
||||
"""Stores an event with a generated event ID.
|
||||
|
||||
Args:
|
||||
stream_id: ID of the stream the event belongs to
|
||||
message: The message to store, or None for priming events
|
||||
"""
|
||||
event_id = str(uuid4())
|
||||
event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message)
|
||||
|
||||
# Get or create deque for this stream
|
||||
if stream_id not in self.streams:
|
||||
self.streams[stream_id] = deque(maxlen=self.max_events_per_stream)
|
||||
|
||||
# If deque is full, the oldest event will be automatically removed
|
||||
# We need to remove it from the event_index as well
|
||||
if len(self.streams[stream_id]) == self.max_events_per_stream:
|
||||
oldest_event = self.streams[stream_id][0]
|
||||
self.event_index.pop(oldest_event.event_id, None)
|
||||
|
||||
# Add new event
|
||||
self.streams[stream_id].append(event_entry)
|
||||
self.event_index[event_id] = event_entry
|
||||
|
||||
return event_id
|
||||
|
||||
async def replay_events_after(
|
||||
self,
|
||||
last_event_id: EventId,
|
||||
send_callback: EventCallback,
|
||||
) -> StreamId | None:
|
||||
"""Replays events that occurred after the specified event ID."""
|
||||
if last_event_id not in self.event_index:
|
||||
logger.warning(f"Event ID {last_event_id} not found in store")
|
||||
return None
|
||||
|
||||
# Get the stream and find events after the last one
|
||||
last_event = self.event_index[last_event_id]
|
||||
stream_id = last_event.stream_id
|
||||
stream_events = self.streams.get(last_event.stream_id, deque())
|
||||
|
||||
# Events in deque are already in chronological order
|
||||
found_last = False
|
||||
for event in stream_events:
|
||||
if found_last:
|
||||
# Skip priming events (None messages) during replay
|
||||
if event.message is not None:
|
||||
await send_callback(EventMessage(event.message, event.event_id))
|
||||
elif event.event_id == last_event_id:
|
||||
found_last = True
|
||||
|
||||
return stream_id
|
||||
@@ -0,0 +1,160 @@
|
||||
"""SSE Polling Demo Server
|
||||
|
||||
Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks.
|
||||
|
||||
Features demonstrated:
|
||||
- Priming events (automatic with EventStore)
|
||||
- Server-initiated stream close via close_sse_stream callback
|
||||
- Client auto-reconnect with Last-Event-ID
|
||||
- Progress notifications during long-running tasks
|
||||
|
||||
Run with:
|
||||
uv run mcp-sse-polling-demo --port 3000
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import anyio
|
||||
import click
|
||||
import mcp_types as types
|
||||
import uvicorn
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
from .event_store import InMemoryEventStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
"""List available tools."""
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="process_batch",
|
||||
description=(
|
||||
"Process a batch of items with periodic checkpoints. "
|
||||
"Demonstrates SSE polling where server closes stream periodically."
|
||||
),
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"description": "Number of items to process (1-100)",
|
||||
"default": 10,
|
||||
},
|
||||
"checkpoint_every": {
|
||||
"type": "integer",
|
||||
"description": "Close stream after this many items (1-20)",
|
||||
"default": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
"""Handle tool calls."""
|
||||
arguments = params.arguments or {}
|
||||
|
||||
if params.name == "process_batch":
|
||||
items = arguments.get("items", 10)
|
||||
checkpoint_every = arguments.get("checkpoint_every", 3)
|
||||
|
||||
if items < 1 or items > 100:
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Error: items must be between 1 and 100")]
|
||||
)
|
||||
if checkpoint_every < 1 or checkpoint_every > 20:
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")]
|
||||
)
|
||||
|
||||
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
|
||||
level="info",
|
||||
data=f"Starting batch processing of {items} items...",
|
||||
logger="process_batch",
|
||||
related_request_id=ctx.request_id,
|
||||
)
|
||||
|
||||
for i in range(1, items + 1):
|
||||
# Simulate work
|
||||
await anyio.sleep(0.5)
|
||||
|
||||
# Report progress
|
||||
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
|
||||
level="info",
|
||||
data=f"[{i}/{items}] Processing item {i}",
|
||||
logger="process_batch",
|
||||
related_request_id=ctx.request_id,
|
||||
)
|
||||
|
||||
# Checkpoint: close stream to trigger client reconnect
|
||||
if i % checkpoint_every == 0 and i < items:
|
||||
await ctx.session.send_log_message( # pyright: ignore[reportDeprecated]
|
||||
level="info",
|
||||
data=f"Checkpoint at item {i} - closing SSE stream for polling",
|
||||
logger="process_batch",
|
||||
related_request_id=ctx.request_id,
|
||||
)
|
||||
if ctx.close_sse_stream:
|
||||
logger.info(f"Closing SSE stream at checkpoint {i}")
|
||||
await ctx.close_sse_stream()
|
||||
# Wait for client to reconnect (must be > retry_interval of 100ms)
|
||||
await anyio.sleep(0.2)
|
||||
|
||||
return types.CallToolResult(
|
||||
content=[
|
||||
types.TextContent(
|
||||
type="text",
|
||||
text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
return types.CallToolResult(content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")])
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--port", default=3000, help="Port to listen on")
|
||||
@click.option(
|
||||
"--log-level",
|
||||
default="INFO",
|
||||
help="Logging level (DEBUG, INFO, WARNING, ERROR)",
|
||||
)
|
||||
@click.option(
|
||||
"--retry-interval",
|
||||
default=100,
|
||||
help="SSE retry interval in milliseconds (sent to client)",
|
||||
)
|
||||
def main(port: int, log_level: str, retry_interval: int) -> int:
|
||||
"""Run the SSE Polling Demo server."""
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
app = Server(
|
||||
"sse-polling-demo",
|
||||
on_list_tools=handle_list_tools,
|
||||
on_call_tool=handle_call_tool,
|
||||
)
|
||||
|
||||
starlette_app = app.streamable_http_app(
|
||||
event_store=InMemoryEventStore(),
|
||||
retry_interval=retry_interval,
|
||||
debug=True,
|
||||
)
|
||||
|
||||
logger.info(f"SSE Polling Demo server starting on port {port}")
|
||||
logger.info("Try: POST /mcp with tools/call for 'process_batch'")
|
||||
uvicorn.run(starlette_app, host="127.0.0.1", port=port)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
[project]
|
||||
name = "mcp-sse-polling-demo"
|
||||
version = "0.1.0"
|
||||
description = "Demo server showing SSE polling with close_sse_stream for long-running tasks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }]
|
||||
keywords = ["mcp", "sse", "polling", "streamable", "http"]
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"]
|
||||
|
||||
[project.scripts]
|
||||
mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["mcp_sse_polling_demo"]
|
||||
|
||||
[tool.pyright]
|
||||
include = ["mcp_sse_polling_demo"]
|
||||
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 @@
|
||||
"""Example of structured output with low-level MCP server."""
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Example low-level MCP server demonstrating structured output support.
|
||||
|
||||
This example shows how to use the low-level server API to return
|
||||
structured data from tools.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
import mcp.server.stdio
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
|
||||
async def handle_list_tools(
|
||||
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
"""List available tools with their schemas."""
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="get_weather",
|
||||
description="Get weather information (simulated)",
|
||||
input_schema={
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string", "description": "City name"}},
|
||||
"required": ["city"],
|
||||
},
|
||||
output_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"temperature": {"type": "number"},
|
||||
"conditions": {"type": "string"},
|
||||
"humidity": {"type": "integer", "minimum": 0, "maximum": 100},
|
||||
"wind_speed": {"type": "number"},
|
||||
"timestamp": {"type": "string", "format": "date-time"},
|
||||
},
|
||||
"required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
"""Handle tool call with structured output."""
|
||||
|
||||
if params.name == "get_weather":
|
||||
# Simulate weather data (in production, call a real weather API)
|
||||
weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"]
|
||||
|
||||
weather_data = {
|
||||
"temperature": round(random.uniform(0, 35), 1),
|
||||
"conditions": random.choice(weather_conditions),
|
||||
"humidity": random.randint(30, 90),
|
||||
"wind_speed": round(random.uniform(0, 30), 1),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))],
|
||||
structured_content=weather_data,
|
||||
)
|
||||
|
||||
raise ValueError(f"Unknown tool: {params.name}")
|
||||
|
||||
|
||||
server = Server(
|
||||
"structured-output-lowlevel-example",
|
||||
on_list_tools=handle_list_tools,
|
||||
on_call_tool=handle_call_tool,
|
||||
)
|
||||
|
||||
|
||||
async def run():
|
||||
"""Run the low-level server using stdio transport."""
|
||||
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
|
||||
await server.run(
|
||||
read_stream,
|
||||
write_stream,
|
||||
server.create_initialization_options(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(run())
|
||||
@@ -0,0 +1,6 @@
|
||||
[project]
|
||||
name = "mcp-structured-output-lowlevel"
|
||||
version = "0.1.0"
|
||||
description = "Example of structured output with low-level MCP server"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["mcp"]
|
||||
Reference in New Issue
Block a user