Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
6 changes: 5 additions & 1 deletion rest/python/client/flower_shop/simple_happy_path_client.py
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
156 changes: 156 additions & 0 deletions rest/python/server/routes/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# 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