mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-18 19:44:19 +02:00
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com> Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com> Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com> Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: nightcityblade <nightcityblade@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Bill Easton <strawgate@users.noreply.github.com> Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com> Co-authored-by: Eric Robinson <ericrobinson@indeed.com> Co-authored-by: Martim Santos <martimfasantos@gmail.com> Co-authored-by: d 🔹 <liusway405@gmail.com> Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com> Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com> Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com> Co-authored-by: nightcityblade <jackchen@haloailabs.com> Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com> Co-authored-by: nate nowack <thrast36@gmail.com> Co-authored-by: Bill Easton <williamseaston@gmail.com> Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com> Co-authored-by: Rushabh Doshi <radoshi@gmail.com> Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp> Co-authored-by: Jeremy Simon <simonjer805@gmail.com> Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com> Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com> Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com> Fix auto-close MRE script posting comment without closing (#3386) Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407) Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413) Fix transform arg collisions with passthrough params (#3431) Fix get_* returning None when latest version is disabled (#3439) Fix get_* returning None when latest version is disabled (#3421) Fix server lifespan overlap teardown (#3415) Fix $ref output schema object detection regression (#3420) resolved annotations (#3429) Fix async partial callables rejected by iscoroutinefunction (#3438) Fix async partial callables rejected by iscoroutinefunction (#3423) fix: add version to components (#3458) fix: use intent-based flag for OIDC scope patch in load_access_token (#3465) Fixes #3461 fix: normalize Google scope shorthands and surface valid_scopes (#3477) fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481) fix: shield lifespan teardown from cancellation (#3480) fix: forward custom_route endpoints from mounted servers (#3462) fix updates _get_additional_http_routes() to traverse providers, Fixes #3457 fix: remove hardcoded version from CLI help text (#3456) fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468) fix: task test teardown hanging 5s per test (#3499) Closes #3498 fix: validate workspace path is a directory before cursor install (#3440) Fixes #3426 fix: handle re.error from malformed URI templates in build_regex (#3501) fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503) fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502) fix warnings and timeouts (#3504) close upgrade check issue when build passes (#3505) Closes #3484 fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507) fix: prevent path traversal in skill download (#3493) fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492) fix: remove unrelated transform and http.py changes from PR scope fix: remove forced follow_redirects from httpx_client_factory calls (#3496) fix: stop passing follow_redirects to httpx_client_factory fix: restore follow_redirects=True for custom httpx client factories Closes #3509 fix: CSRF double-submit cookie check in consent flow (#3519) fix: validate server names in install commands (#3522) fix: use raw strings for regex in pytest.raises match (#3523) fix: reject refresh tokens used as Bearer access tokens (#3524) fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495) fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540) fix: filter warnings by message in KEY_PREFIX test (#3549) fix: suppress output schema for ToolResult subclass annotations (#3548) fix: increase sleep duration in proxy cache tests (#3567) fix: store absolute token expiry to prevent stale expires_in on reload (#3572) fix: preserve tool properties named 'title' during schema compression (#3582) Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589) Fix app tool routing: visibility check and middleware propagation (#3591) Fix query parameter serialization to respect OpenAPI explode/style settings (#3595) Fix dev apps form: union types, textarea support, JSON parsing (#3597) fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603) fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609) fix(docs): correct misleading stateless_http header (#3622) fix: filesystem provider import machinery (#3626) Closes #3625 (issues 2, 3, 6) fix: recover StdioTransport after subprocess exits (#3630) fix(server): preserve mounted tool task metadata (#3632) fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649) fix imports, add PrefabAppConfig (#3650) fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651) Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652) closed by default (#3657) Fix prompt caching middleware missing wrap/unwrap round-trip (#3666) fix: serialize object query params per OpenAPI style/explode rules (#3662) Fixes #2857 fix: HTTP request headers not accessible in background task workers (#3631) fix: restore HTTP headers in worker execution path for background tasks (#3681) fix: strip discriminator after dereferencing schemas (#3682) fix: remove stale ty:ignore directives for ty 0.0.26 (#3684) Fix docs gaps in app provider pages (#3690) fix: dev apps log panel UX improvements (#3698) fix dev server empty string args (#3700)
377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""FastMCP conformance test server.
|
|
|
|
Registers the exact tools, resources, and prompts expected by the
|
|
MCP conformance test suite (https://github.com/modelcontextprotocol/conformance).
|
|
"""
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import sys
|
|
from enum import Enum as PyEnum
|
|
|
|
import mcp.types
|
|
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
|
from pydantic import AnyUrl, BaseModel, Field
|
|
|
|
from fastmcp import FastMCP
|
|
from fastmcp.exceptions import ToolError
|
|
from fastmcp.prompts import Message
|
|
from fastmcp.server.context import Context
|
|
from fastmcp.tools.function_tool import FunctionTool
|
|
from fastmcp.utilities.types import Audio, Image
|
|
|
|
# Minimal 1x1 red PNG for image tests (89 bytes)
|
|
_1X1_PNG = base64.b64decode(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
|
|
"nGP4z8BQDwAEgAF/pooBPQAAAABJRU5ErkJggg=="
|
|
)
|
|
|
|
# Minimal valid WAV: 16-bit mono PCM, 44100 Hz, single silent sample
|
|
_SILENT_WAV = (
|
|
b"RIFF"
|
|
+ (38).to_bytes(4, "little")
|
|
+ b"WAVEfmt "
|
|
+ (16).to_bytes(4, "little")
|
|
+ (1).to_bytes(2, "little") # PCM
|
|
+ (1).to_bytes(2, "little") # mono
|
|
+ (44100).to_bytes(4, "little") # sample rate
|
|
+ (88200).to_bytes(4, "little") # byte rate
|
|
+ (2).to_bytes(2, "little") # block align
|
|
+ (16).to_bytes(2, "little") # bits per sample
|
|
+ b"data"
|
|
+ (2).to_bytes(4, "little")
|
|
+ (0).to_bytes(2, "little") # one silent sample
|
|
)
|
|
|
|
server = FastMCP("conformance-test-server", dereference_schemas=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@server.tool(name="test_simple_text")
|
|
async def test_simple_text() -> str:
|
|
"""A simple text tool for conformance testing."""
|
|
return "This is a simple text response for testing."
|
|
|
|
|
|
@server.tool(name="test_image_content")
|
|
async def test_image_content() -> Image:
|
|
"""Returns a PNG image."""
|
|
return Image(data=_1X1_PNG, format="png")
|
|
|
|
|
|
@server.tool(name="test_audio_content")
|
|
async def test_audio_content() -> Audio:
|
|
"""Returns WAV audio."""
|
|
return Audio(data=_SILENT_WAV, format="wav")
|
|
|
|
|
|
@server.tool(name="test_embedded_resource")
|
|
async def test_embedded_resource() -> list:
|
|
"""Returns an embedded resource."""
|
|
return [
|
|
EmbeddedResource(
|
|
type="resource",
|
|
resource=mcp.types.TextResourceContents(
|
|
uri=AnyUrl("test://embedded-resource"),
|
|
mimeType="text/plain",
|
|
text="This is an embedded resource content.",
|
|
),
|
|
)
|
|
]
|
|
|
|
|
|
@server.tool(name="test_multiple_content_types")
|
|
async def test_multiple_content_types() -> list:
|
|
"""Returns mixed text, image, and resource content."""
|
|
return [
|
|
TextContent(type="text", text="This is a text part of the response."),
|
|
ImageContent(
|
|
type="image",
|
|
data=base64.b64encode(_1X1_PNG).decode(),
|
|
mimeType="image/png",
|
|
),
|
|
EmbeddedResource(
|
|
type="resource",
|
|
resource=mcp.types.TextResourceContents(
|
|
uri=AnyUrl("test://mixed-content-resource"),
|
|
mimeType="application/json",
|
|
text='{"test":"data","value":123}',
|
|
),
|
|
),
|
|
]
|
|
|
|
|
|
@server.tool(name="test_error_handling")
|
|
async def test_error_handling() -> str:
|
|
"""Always returns an error."""
|
|
raise ToolError("This tool intentionally returns an error for testing")
|
|
|
|
|
|
@server.tool(name="test_tool_with_logging")
|
|
async def test_tool_with_logging(ctx: Context) -> str:
|
|
"""Sends log notifications during execution."""
|
|
await ctx.info("Tool execution started")
|
|
await asyncio.sleep(0.05)
|
|
await ctx.info("Tool processing data")
|
|
await asyncio.sleep(0.05)
|
|
await ctx.info("Tool execution completed")
|
|
return "Logging test complete."
|
|
|
|
|
|
@server.tool(name="test_tool_with_progress")
|
|
async def test_tool_with_progress(ctx: Context) -> str:
|
|
"""Reports progress notifications."""
|
|
await ctx.report_progress(0, 100)
|
|
await asyncio.sleep(0.05)
|
|
await ctx.report_progress(50, 100)
|
|
await asyncio.sleep(0.05)
|
|
await ctx.report_progress(100, 100)
|
|
return "Progress test complete."
|
|
|
|
|
|
@server.tool(name="test_sampling")
|
|
async def test_sampling(prompt: str, ctx: Context) -> str:
|
|
"""Requests LLM sampling via the client."""
|
|
result = await ctx.sample(
|
|
messages=[prompt],
|
|
result_type=str,
|
|
)
|
|
return f"Sampling result: {result}"
|
|
|
|
|
|
class _UserInfo(BaseModel):
|
|
username: str
|
|
email: str
|
|
|
|
|
|
@server.tool(name="test_elicitation")
|
|
async def test_elicitation(message: str, ctx: Context) -> str:
|
|
"""Requests user input via elicitation."""
|
|
result = await ctx.elicit(message, _UserInfo)
|
|
return f"Elicitation result: {result}"
|
|
|
|
|
|
class _UserStatus(str, PyEnum):
|
|
active = "active"
|
|
inactive = "inactive"
|
|
pending = "pending"
|
|
|
|
|
|
class _DefaultsForm(BaseModel):
|
|
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: _UserStatus = Field(default=_UserStatus.active, description="User status")
|
|
verified: bool = Field(default=True, description="Verification status")
|
|
|
|
|
|
@server.tool(name="test_elicitation_sep1034_defaults")
|
|
async def test_elicitation_sep1034_defaults(ctx: Context) -> str:
|
|
"""Tests elicitation with default values per SEP-1034."""
|
|
result = await ctx.elicit(
|
|
"Please review and update the form fields with defaults",
|
|
_DefaultsForm,
|
|
)
|
|
return f"Elicitation completed: {result}"
|
|
|
|
|
|
@server.tool(name="test_elicitation_sep1330_enums")
|
|
async def test_elicitation_sep1330_enums(ctx: Context) -> str:
|
|
"""Tests elicitation with enum schema improvements per SEP-1330."""
|
|
result = await ctx.session.elicit(
|
|
message="Please select options from the enum fields",
|
|
requestedSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"untitledSingle": {
|
|
"type": "string",
|
|
"description": "Select one option",
|
|
"enum": ["option1", "option2", "option3"],
|
|
},
|
|
"titledSingle": {
|
|
"type": "string",
|
|
"description": "Select one option with titles",
|
|
"oneOf": [
|
|
{"const": "value1", "title": "First Option"},
|
|
{"const": "value2", "title": "Second Option"},
|
|
{"const": "value3", "title": "Third Option"},
|
|
],
|
|
},
|
|
"legacyEnum": {
|
|
"type": "string",
|
|
"description": "Select one option (legacy)",
|
|
"enum": ["opt1", "opt2", "opt3"],
|
|
"enumNames": [
|
|
"Option One",
|
|
"Option Two",
|
|
"Option Three",
|
|
],
|
|
},
|
|
"untitledMulti": {
|
|
"type": "array",
|
|
"description": "Select multiple options",
|
|
"minItems": 1,
|
|
"maxItems": 3,
|
|
"items": {
|
|
"type": "string",
|
|
"enum": ["option1", "option2", "option3"],
|
|
},
|
|
},
|
|
"titledMulti": {
|
|
"type": "array",
|
|
"description": "Select multiple options with titles",
|
|
"minItems": 1,
|
|
"maxItems": 3,
|
|
"items": {
|
|
"anyOf": [
|
|
{"const": "value1", "title": "First Choice"},
|
|
{"const": "value2", "title": "Second Choice"},
|
|
{"const": "value3", "title": "Third Choice"},
|
|
]
|
|
},
|
|
},
|
|
},
|
|
"required": [],
|
|
},
|
|
related_request_id=ctx.request_id,
|
|
)
|
|
return f"Elicitation completed: action={result.action}, content={json.dumps(result.content or {})}"
|
|
|
|
|
|
async def _json_schema_2020_12_fn(
|
|
name: str | None = None,
|
|
address: dict | None = None,
|
|
) -> str:
|
|
"""Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)."""
|
|
return f"JSON Schema 2020-12 tool called with: name={name}, address={address}"
|
|
|
|
|
|
server.add_tool(
|
|
FunctionTool(
|
|
fn=_json_schema_2020_12_fn,
|
|
name="json_schema_2020_12_tool",
|
|
description="Tool with JSON Schema 2020-12 features for conformance testing (SEP-1613)",
|
|
parameters={
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"$defs": {
|
|
"address": {
|
|
"type": "object",
|
|
"properties": {
|
|
"street": {"type": "string"},
|
|
"city": {"type": "string"},
|
|
},
|
|
}
|
|
},
|
|
"properties": {
|
|
"name": {"type": "string"},
|
|
"address": {"$ref": "#/$defs/address"},
|
|
},
|
|
"additionalProperties": False,
|
|
},
|
|
)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Resources
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@server.resource(
|
|
"test://static-text",
|
|
name="Static text resource",
|
|
mime_type="text/plain",
|
|
)
|
|
async def static_text_resource() -> str:
|
|
"""Returns static text content."""
|
|
return "This is the content of the static text resource."
|
|
|
|
|
|
@server.resource(
|
|
"test://static-binary",
|
|
name="Static binary resource",
|
|
mime_type="image/png",
|
|
)
|
|
async def static_binary_resource() -> bytes:
|
|
"""Returns a binary PNG image."""
|
|
return _1X1_PNG
|
|
|
|
|
|
@server.resource(
|
|
"test://template/{id}/data",
|
|
name="Template resource",
|
|
mime_type="application/json",
|
|
)
|
|
async def template_resource(id: str) -> str:
|
|
"""Returns JSON data with the template parameter substituted."""
|
|
return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"})
|
|
|
|
|
|
@server.resource(
|
|
"test://watched-resource",
|
|
name="Watched resource",
|
|
mime_type="text/plain",
|
|
)
|
|
async def watched_resource() -> str:
|
|
"""A resource that supports subscriptions."""
|
|
return "Watched resource content."
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Prompts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@server.prompt(name="test_simple_prompt")
|
|
async def test_simple_prompt() -> str:
|
|
"""A simple prompt for conformance testing."""
|
|
return "This is a simple prompt for testing."
|
|
|
|
|
|
@server.prompt(name="test_prompt_with_arguments")
|
|
async def test_prompt_with_arguments(arg1: str, arg2: str) -> str:
|
|
"""A prompt that accepts arguments."""
|
|
return f"Prompt with arguments: arg1='{arg1}', arg2='{arg2}'"
|
|
|
|
|
|
@server.prompt(name="test_prompt_with_embedded_resource")
|
|
async def test_prompt_with_embedded_resource(resourceUri: str) -> list:
|
|
"""A prompt that returns an embedded resource."""
|
|
return [
|
|
Message(
|
|
EmbeddedResource(
|
|
type="resource",
|
|
resource=mcp.types.TextResourceContents(
|
|
uri=AnyUrl(resourceUri),
|
|
mimeType="text/plain",
|
|
text=f"Content of resource {resourceUri}",
|
|
),
|
|
)
|
|
),
|
|
]
|
|
|
|
|
|
@server.prompt(name="test_prompt_with_image")
|
|
async def test_prompt_with_image() -> list:
|
|
"""A prompt that returns an image."""
|
|
return [
|
|
Message(
|
|
ImageContent(
|
|
type="image",
|
|
data=base64.b64encode(_1X1_PNG).decode(),
|
|
mimeType="image/png",
|
|
)
|
|
),
|
|
Message("Please analyze the image above."),
|
|
]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
|
|
server.run(transport="streamable-http", host="127.0.0.1", port=port)
|