Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,12 @@ def main() -> None:

discovery_data = response.json()

# Discovery profiles are wrapped in a top-level `ucp` object per the UCP
# discovery schema; fall back to the root for flat/legacy profiles.
ucp_data = discovery_data.get("ucp", discovery_data)

supported_handlers = []
for handlers in discovery_data.get("payment_handlers", {}).values():
for handlers in ucp_data.get("payment_handlers", {}).values():
supported_handlers.extend(handlers)

logger.info(
Expand Down
10 changes: 6 additions & 4 deletions rest/python/server/routes/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,12 @@ async def get_merchant_profile(request: Request):
).replace("{{SHOP_ID}}", SHOP_ID)

profile_data = json.loads(profile_json)
if "ucp" in profile_data:
profile_data = profile_data["ucp"]

if "payment_handlers" not in profile_data:
profile_data["payment_handlers"] = []
# Preserve the spec-required top-level `ucp` wrapper. The UCP discovery
# profile schema (discovery/profile.json) defines `$defs.base.required =
# ["ucp"]`, so the served body MUST be `{"ucp": {...}}`. Default
# payment_handlers INSIDE the ucp object rather than at the document root.
ucp = profile_data.setdefault("ucp", {})
ucp.setdefault("payment_handlers", [])

return profile_data
160 changes: 160 additions & 0 deletions rest/python/server/routes/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# Copyright 2026 UCP Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Minimal MCP (JSON-RPC 2.0) transport for the UCP shopping service.

The discovery profile advertises an ``mcp`` transport at ``<endpoint>/mcp``, but
the server otherwise only implements REST, so spec-conformant MCP clients fail
discovery with an HTTP 404 on that endpoint.

This module adds the minimum needed for capability discovery to succeed over
MCP: it answers ``initialize`` and ``tools/list`` (advertising the shopping
methods from the UCP shopping OpenRPC). Executing tools (``tools/call``) by
bridging to the REST checkout handlers is intentionally left as a follow-up.
"""

from typing import Any

import config
from fastapi import APIRouter
from fastapi import Request
from fastapi import Response
from fastapi.responses import JSONResponse

router = APIRouter()

# MCP protocol revision this endpoint speaks.
MCP_PROTOCOL_VERSION = "2025-06-18"

_OBJECT = {"type": "object"}
_STRING = {"type": "string"}

# Shopping methods exposed over MCP, mirroring the UCP shopping OpenRPC
# (https://ucp.dev/2026-01-23/services/shopping/openrpc.json).
_TOOLS: list[dict[str, Any]] = [
{
"name": "create_checkout",
"description": "Create a checkout.",
"inputSchema": {
"type": "object",
"properties": {"meta": _OBJECT, "checkout": _OBJECT},
"required": ["meta", "checkout"],
},
},
{
"name": "get_checkout",
"description": "Get a checkout by id.",
"inputSchema": {
"type": "object",
"properties": {"meta": _OBJECT, "id": _STRING},
"required": ["meta", "id"],
},
},
{
"name": "update_checkout",
"description": "Update a checkout.",
"inputSchema": {
"type": "object",
"properties": {"meta": _OBJECT, "id": _STRING, "checkout": _OBJECT},
"required": ["meta", "id", "checkout"],
},
},
{
"name": "complete_checkout",
"description": "Complete a checkout and place the order.",
"inputSchema": {
"type": "object",
"properties": {
"meta": _OBJECT,
"id": _STRING,
"checkout": _OBJECT,
"idempotency_key": _STRING,
},
"required": ["meta", "id", "checkout", "idempotency_key"],
},
},
{
"name": "cancel_checkout",
"description": "Cancel a checkout.",
"inputSchema": {
"type": "object",
"properties": {"meta": _OBJECT, "id": _STRING},
"required": ["meta", "id"],
},
},
]


def _result(request_id: Any, result: Any) -> JSONResponse:
return JSONResponse({"jsonrpc": "2.0", "id": request_id, "result": result})


def _error(request_id: Any, code: int, message: str) -> JSONResponse:
return JSONResponse(
{
"jsonrpc": "2.0",
"id": request_id,
"error": {"code": code, "message": message},
}
)


@router.post("/mcp")
async def mcp_endpoint(request: Request) -> Response:
"""Minimal MCP (JSON-RPC 2.0) endpoint for capability discovery.

Answers ``initialize`` and ``tools/list`` so MCP clients can complete
discovery. ``tools/call`` is not yet bridged to the REST checkout handlers.
"""
try:
payload = await request.json()
except Exception:
return _error(None, -32700, "Parse error")

method = payload.get("method")
request_id = payload.get("id")

# JSON-RPC notifications (no id), e.g. notifications/initialized.
if (
request_id is None
and isinstance(method, str)
and method.startswith("notifications/")
):
return Response(status_code=202)

if method == "initialize":
return _result(
request_id,
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {"tools": {}},
"serverInfo": {
"name": "ucp-shopping-sample",
"version": config.get_server_version(),
},
},
)

if method == "tools/list":
return _result(request_id, {"tools": _TOOLS})

if method == "tools/call":
return _error(
request_id,
-32000,
"tools/call is not yet implemented on this sample's MCP endpoint; use the"
" REST transport for checkout operations.",
)

return _error(request_id, -32601, f"Method not found: {method}")
2 changes: 2 additions & 0 deletions rest/python/server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from fastapi.responses import JSONResponse
import generated_routes.ucp_routes
from routes.discovery import router as discovery_router
from routes.mcp import router as mcp_router
from routes.order import router as order_router
import routes.ucp_implementation
import uvicorn
Expand Down Expand Up @@ -59,6 +60,7 @@ async def ucp_exception_handler(request: Request, exc: UcpError):
app.include_router(generated_routes.ucp_routes.router)
app.include_router(order_router)
app.include_router(discovery_router)
app.include_router(mcp_router)


def main(argv: Sequence[str]) -> None:
Expand Down
Loading