"""Self-contained MCP server demonstrating JSON Schema 2020-12 tool inputs, enabled by SEP-2106 (tool inputSchema/outputSchema conform to JSON Schema 2020-12). Each tool in the shared ``schemas.json`` exercises a different 2020-12 keyword that was not permitted in a tool input before SEP-2106 (enum varieties, top-level oneOf, discriminated unions, $ref/$defs, if/then/else, prefixItems tuples). This is the Python sibling of the TypeScript ``demo-server-tool-inputs`` server; the two read the SAME single-source-of-truth ``../schemas.json`` and advertise each raw JSON Schema verbatim in tools/list. The MCP Python SDK's low-level ``Server`` passes ``input_schema`` through untouched (no Pydantic remodeling) and does NOT auto-validate tool-call inputs, so — exactly as the TS server needs an explicit ``CfWorkerJsonSchemaValidator({ draft: "2020-12" })`` — this server runs each tool's arguments through a per-tool ``jsonschema.Draft202012Validator``. jsonschema>=4.20 defaults to the 2020-12 dialect, so ``prefixItems`` / ``items: false`` are honored (a valid ``[x, y]`` tuple is accepted, a 3-element one rejected). SEP-2106: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2106-json-schema-2020-12.md Run over stdio: uv run python server.py (then point MCP Inspector at it) The MCP Python SDK low-level ``Server`` API (constructor-based ``on_list_tools`` / ``on_call_tool`` handlers, ``types.Tool``, ``streamable_http_app``) was confirmed against mcp 2.0.0b1 (modelcontextprotocol/python-sdk). In 2.0 beta the wire types moved out of ``mcp.types`` into the standalone ``mcp_types`` package, so this module does ``import mcp_types as types`` (the SDK's own idiom); every ``types.X`` reference is otherwise unchanged from the 2.0 alpha. """ from __future__ import annotations import json import os from pathlib import Path from typing import Any import anyio import mcp_types as types from jsonschema import Draft202012Validator from mcp.server import Server, ServerRequestContext # schemas.json is the single source of truth, one level up at the topic root: # demo-server-py/ -> tool-schemas/. Loaded verbatim, mirroring the TS server's # demo-tools.ts relative-path load, so the two consumers can never drift. # SCHEMAS_PATH overrides the location so the Docker/HF-Space image can point at a # vendored copy (the build context can't reach ../schemas.json at runtime); the # default keeps stdio + selftest working unchanged from the repo tree. _SCHEMAS_PATH = Path(os.environ.get("SCHEMAS_PATH") or Path(__file__).resolve().parent.parent / "schemas.json") DEMO_TOOLS: list[dict[str, Any]] = json.loads(_SCHEMAS_PATH.read_text(encoding="utf-8"))["tools"] # One compiled 2020-12 validator per tool, keyed by tool name. _VALIDATORS: dict[str, Draft202012Validator] = {t["name"]: Draft202012Validator(t["jsonSchema"]) for t in DEMO_TOOLS} # Validators for each tool's outputSchema (the result/structuredContent shape). _OUTPUT_VALIDATORS: dict[str, Draft202012Validator] = { t["name"]: Draft202012Validator(t["outputSchema"]) for t in DEMO_TOOLS if t.get("outputSchema") } # A canned `structuredContent` per tool that CONFORMS to that tool's outputSchema — # so the server demonstrates a real 2020-12 outputSchema round-trip (advertise + # return + validate). selftest asserts each validates against its outputSchema. _OUTPUTS: dict[str, Any] = { "get-enum-selections": {"result": "applied", "appliedCount": 3}, "lookup-record": {"found": True, "name": "Acme Corp"}, "create-payment": {"receiptId": "rcpt_001", "outcome": {"status": "settled", "settledAt": "2026-06-25T12:00:00Z"}}, "create-shipment": { "shipmentId": "shp_001", "origin": {"city": "San Francisco", "eta": "2026-06-26"}, "destination": {"city": "London", "eta": "2026-06-28"}, }, "register-address": {"status": "active", "validUntil": "2027-06-25"}, "plot-point": {"plotted": [3, 7], "ok": True}, } def _echo(name: str, arguments: dict[str, Any] | None) -> types.CallToolResult: """Shared handler: echo the validated arguments AND return a 2020-12-conforming ``structuredContent`` for the tool's outputSchema. Mirrors the TS server. """ entries = list((arguments or {}).items()) if entries: text = "You provided:\n" + "\n".join(f"- {k}: {json.dumps(v)}" for k, v in entries) else: text = "No arguments provided." return types.CallToolResult( content=[types.TextContent(type="text", text=text)], structured_content=_OUTPUTS.get(name), ) def _validation_error(name: str, errors: list[Any]) -> types.CallToolResult: """Message-rich isError result listing every failing 2020-12 keyword.""" lines = [f"Invalid arguments for '{name}' (JSON Schema 2020-12 validation failed):"] for err in errors: location = "/".join(str(p) for p in err.absolute_path) or "(root)" lines.append(f"- {location}: failed keyword '{err.validator}' — {err.message}") return types.CallToolResult( content=[types.TextContent(type="text", text="\n".join(lines))], is_error=True, ) async def handle_list_tools( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None ) -> types.ListToolsResult: return types.ListToolsResult( tools=[ types.Tool( name=tool["name"], title=tool["title"], description=tool["description"], # Advertised VERBATIM — the raw 2020-12 dicts from schemas.json. input_schema=tool["jsonSchema"], output_schema=tool.get("outputSchema"), annotations=types.ToolAnnotations( read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False, ), ) for tool in DEMO_TOOLS ] ) async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: validator = _VALIDATORS.get(params.name) if validator is None: raise ValueError(f"Unknown tool: {params.name}") arguments = params.arguments or {} # The low-level server does NOT validate tool inputs, so do it explicitly # against the tool's own 2020-12 schema (parity with the TS server). errors = sorted(validator.iter_errors(arguments), key=lambda e: list(e.absolute_path)) if errors: return _validation_error(params.name, errors) return _echo(params.name, arguments) def create_server() -> Server[Any]: """Build the demo server (factory so it can be mounted over HTTP too).""" return Server( "tool-input-schemas-demo", version="0.1.0", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool, ) async def _amain() -> None: from mcp.server.stdio import stdio_server server = create_server() async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, server.create_initialization_options()) def main() -> None: anyio.run(_amain) if __name__ == "__main__": main()