diff --git a/README.md b/README.md index 3e39aee..5658eed 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ The MCP servers in this demo highlight how each tool can light up widgets by com - `pizzaz_server_python/` – Python MCP server that returns the Pizzaz widgets. - `solar-system_server_python/` – Python MCP server for the 3D solar system widget. - `build-all.mts` – Vite build orchestrator that produces hashed bundles for every widget entrypoint. +- `authenticated_server_python/` – Python MCP server that demonstrates authenticated tool calls. ## Prerequisites @@ -80,6 +81,7 @@ The repository ships several demo MCP servers that highlight different widget bu - **Pizzaz (Node & Python)** – pizza-inspired collection of tools and components - **Solar system (Python)** – 3D solar system viewer +- **Authenticated (Python)** – pizza carousel tool that requires OAuth ### Pizzaz Node server @@ -97,6 +99,15 @@ pip install -r pizzaz_server_python/requirements.txt uvicorn pizzaz_server_python.main:app --port 8000 ``` +### Authenticated Python server + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r authenticated_server_python/requirements.txt +python authenticated_server_python/main.py +``` + ### Solar system Python server ```bash diff --git a/authenticated_server_python/README.md b/authenticated_server_python/README.md new file mode 100644 index 0000000..a90caa5 --- /dev/null +++ b/authenticated_server_python/README.md @@ -0,0 +1,72 @@ +# Authenticated MCP server (Python) + +This example shows how to build an authenticated app with the OpenAI Apps SDK. +It demonstrates triggering the ChatGPT authentication UI by responding with MCP +authorization metadata and follows the same OAuth flow described in the MCP +authorization spec: https://modelcontextprotocol.io/docs/tutorials/security/authorization#the-authorization-flow:-step-by-step. +The Apps SDK auth guide covers how the UI is triggered: https://developers.openai.com/apps-sdk/build/auth#triggering-authentication-ui. + +The server exposes two OAuth-protected tools: the `pizza-carousel` widget and +`see_past_orders` (returns a `pizzaz-list` widget with past-order data). If a +request is missing a token, the server returns an `mcp/www_authenticate` hint +(backed by `WWW-Authenticate`) plus `/.well-known/oauth-protected-resource` +metadata so ChatGPT knows which authorization server to use. With a valid +token, the tools return widget markup or structured results. + +## Configuring the authorization server (AUth0) + +> The scaffold expects OAuth 2.1 bearer tokens issued by Auth0. Substitute your own IdP if you prefer, but keep the same environment variable names. + +1. **Create an API** + - Auth0 Dashboard → *Applications* → *APIs* → *Create API* + - Name it (e.g., `mcp-python-server`) + - Identifier → `https://your-domain.example.com/mcp` (add this to your `JWT_AUDIENCES` environment variable) + - (JWT) Profile → Auth0 + +2. **Enable a default audience for your tenant** (per [this community post](https://community.auth0.com/t/rfc-8707-implementation-audience-vs-resource/188990/4)) so that Auth0 issues an unencrypted RS256 JWT. + - Tenant settings > Default Audience > Add the API identifier you created in step 1. + +3. **Enable Dynamic Client Registration** + - Go to Dashboard > Settings > Advanced and enable the [OIDC Dynamic Application Registration](https://auth0.com/docs/get-started/applications/dynamic-client-registration?tenant=openai-mcpkit-trial%40prod-us-5&locale=en-us). + +4. **Add a social connection to the tenant** for example Google oauth2 to provide a social login mechanism for uers. + - Authentication > Social > google-oauth2 > Advanced > Promote Connection to Domain Level + +5. **Update your environment variables** + - `AUTH0_ISSUER`: your tenant domain (e.g., `https://dev-your-tenant.us.auth0.com/`) + - `JWT_AUDIENCES`: API identifider created in step 1 (e.g. `https://your-domain.example.com/mcp`) + + +## Prerequisites + +- Python 3.10+ +- A virtual environment (recommended) + +## Installation + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +## Running the server + +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 +``` + +The server listens on `http://127.0.0.1:8000` and exposes the standard MCP +endpoint at `GET /mcp`. + +The pizza carousel tool echoes the optional `searchTerm` argument as a topping +and returns structured content plus widget markup. Unauthenticated calls return +the MCP auth hint so the Apps SDK can start the OAuth flow. + +## Customization + +- Update `AUTHORIZATION_SERVER_URL` (and the resource URL in `main.py`) to point + to your OAuth provider. +- Adjust the `WWW-Authenticate` construction or scopes to match your security + model. +- Rebuild the widget assets (`pnpm run build`) if you change the UI. diff --git a/authenticated_server_python/main.py b/authenticated_server_python/main.py new file mode 100644 index 0000000..1172f67 --- /dev/null +++ b/authenticated_server_python/main.py @@ -0,0 +1,511 @@ +"""MCP server for an authenticated app implemented with the Python FastMCP helper.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any, Dict, List +from urllib.parse import urlparse + +import mcp.types as types +from mcp.server.fastmcp import FastMCP +from mcp.shared.auth import ProtectedResourceMetadata +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + + +@dataclass(frozen=True) +class PizzazWidget: + identifier: str + title: str + template_uri: str + invoking: str + invoked: str + html: str + response_text: str + + +ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" + + +@lru_cache(maxsize=None) +def _load_widget_html(component_name: str) -> str: + html_path = ASSETS_DIR / f"{component_name}.html" + if html_path.exists(): + return html_path.read_text(encoding="utf8") + + fallback_candidates = sorted(ASSETS_DIR.glob(f"{component_name}-*.html")) + if fallback_candidates: + return fallback_candidates[-1].read_text(encoding="utf8") + + raise FileNotFoundError( + f'Widget HTML for "{component_name}" not found in {ASSETS_DIR}. ' + "Run `pnpm run build` to generate the assets before starting the server." + ) + + +CAROUSEL_WIDGET = PizzazWidget( + identifier="pizza-carousel", + title="Show pizza spots", + template_uri="ui://widget/pizza-carousel.html", + invoking="Carousel some spots", + invoked="Served a fresh carousel", + html=_load_widget_html("pizzaz-carousel"), + response_text="Rendered a pizza carousel!", +) + +PAST_ORDERS_WIDGET = PizzazWidget( + identifier="pizzaz-list", + title="Past orders", + template_uri="ui://widget/pizzaz-list.html", + invoking="Fetching your recent orders", + invoked="Served recent orders", + html=_load_widget_html("pizzaz-list"), + response_text="Rendered past orders list!", +) + + +MIME_TYPE = "text/html+skybridge" + +SEARCH_TOOL_NAME = CAROUSEL_WIDGET.identifier +PAST_ORDERS_TOOL_NAME = "see_past_orders" + +SEARCH_TOOL_SCHEMA: Dict[str, Any] = { + "type": "object", + "title": "Search terms", + "properties": { + "searchTerm": { + "type": "string", + "description": "Optional text to echo back in the response.", + }, + }, + "required": [], + "additionalProperties": False, +} + +PAST_ORDERS_TOOL_SCHEMA: Dict[str, Any] = { + "type": "object", + "title": "Past orders", + "properties": { + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "description": "Optional max number of past orders to return.", + } + }, + "required": [], + "additionalProperties": False, +} + +PAST_ORDERS_DATA = [ + { + "orderId": "pz-4931", + "items": ["Classic Margherita", "Garlic knots"], + "status": "delivered", + "total": "$18.50", + }, + { + "orderId": "pz-4810", + "items": ["Pepperoni Feast"], + "status": "delivered", + "total": "$15.00", + }, + { + "orderId": "pz-4799", + "items": ["Veggie Garden", "Caesar salad"], + "status": "refunded", + "total": "$22.40", + }, + { + "orderId": "pz-4750", + "items": ["Spicy Hawaiian"], + "status": "delivered", + "total": "$17.25", + }, +] + +AUTHORIZATION_SERVER_URL = "https://dev-65wmmp5d56ev40iy.us.auth0.com/" +RESOURCE_SERVER_URL = "https://945c890ee720.ngrok-free.app/mcp" + +print("AUTHORIZATION_SERVER_URL", AUTHORIZATION_SERVER_URL) +print("RESOURCE_SERVER_URL", RESOURCE_SERVER_URL) +RESOURCE_SCOPES = [] + +_parsed_resource_url = urlparse(str(RESOURCE_SERVER_URL)) +_resource_path = ( + _parsed_resource_url.path if _parsed_resource_url.path not in ("", "/") else "" +) +PROTECTED_RESOURCE_METADATA_PATH = ( + f"/.well-known/oauth-protected-resource{_resource_path}" +) +PROTECTED_RESOURCE_METADATA_URL = f"{_parsed_resource_url.scheme}://{_parsed_resource_url.netloc}{PROTECTED_RESOURCE_METADATA_PATH}" + +print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) +PROTECTED_RESOURCE_METADATA = ProtectedResourceMetadata( + resource=RESOURCE_SERVER_URL, + authorization_servers=[AUTHORIZATION_SERVER_URL], + scopes_supported=RESOURCE_SCOPES, +) + +# Tool-level securitySchemes inform ChatGPT when OAuth is required for a call. +MIXED_TOOL_SECURITY_SCHEMES = [ + {"type": "noauth"}, + { + "type": "oauth2", + "scopes": RESOURCE_SCOPES, + }, +] + +OAUTH_ONLY_SECURITY_SCHEMES = [ + { + "type": "oauth2", + "scopes": RESOURCE_SCOPES, + } +] + + +mcp = FastMCP( + name="authenticated-server-python", + stateless_http=True, +) + + +def _resource_metadata_url() -> str | None: + auth_config = getattr(mcp.settings, "auth", None) + if auth_config and auth_config.resource_server_url: + parsed = urlparse(str(auth_config.resource_server_url)) + resource_path = parsed.path if parsed.path not in ("", "/") else "" + return f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}" + print("PROTECTED_RESOURCE_METADATA_URL", PROTECTED_RESOURCE_METADATA_URL) + return PROTECTED_RESOURCE_METADATA_URL + + +def _build_www_authenticate_value(error: str, description: str) -> str: + safe_error = error.replace('"', r"\"") + safe_description = description.replace('"', r"\"") + parts = [ + f'error="{safe_error}"error_description="{safe_description}"', + ] + resource_metadata = _resource_metadata_url() + if resource_metadata: + parts.append(f'resource_metadata="{resource_metadata}"') + return f"Bearer {', '.join(parts)}" + + +def _oauth_error_result( + user_message: str, + *, + error: str = "invalid_request", + description: str | None = None, +) -> types.ServerResult: + description_text = description or user_message + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=user_message, + ) + ], + _meta={ + "mcp/www_authenticate": [ + _build_www_authenticate_value(error, description_text) + ] + }, + isError=True, + ) + ) + + +def _get_bearer_token_from_request() -> str | None: + try: + request_context = mcp._mcp_server.request_context + except LookupError: + return None + + request = getattr(request_context, "request", None) + if request is None: + return None + + header_value: Any = None + headers = getattr(request, "headers", None) + if headers is not None: + try: + header_value = headers.get("authorization") + if header_value is None: + header_value = headers.get("Authorization") + except Exception: + header_value = None + + if header_value is None: + # Attempt to read from ASGI scope headers if available + scope = getattr(request, "scope", None) + scope_headers = scope.get("headers") if isinstance(scope, dict) else None + if scope_headers: + for key, value in scope_headers: + decoded_key = ( + key.decode("latin-1") + if isinstance(key, (bytes, bytearray)) + else str(key) + ).lower() + if decoded_key == "authorization": + header_value = ( + value.decode("latin-1") + if isinstance(value, (bytes, bytearray)) + else str(value) + ) + break + + if header_value is None and isinstance(request, dict): + # Fall back to dictionary-like request contexts + raw_value = request.get("authorization") or request.get("Authorization") + header_value = raw_value + + if header_value is None: + return None + + if isinstance(header_value, (bytes, bytearray)): + header_value = header_value.decode("latin-1") + + header_value = header_value.strip() + if not header_value.lower().startswith("bearer "): + return None + + token = header_value[7:].strip() + return token or None + + +@mcp.custom_route(PROTECTED_RESOURCE_METADATA_PATH, methods=["GET", "OPTIONS"]) +async def protected_resource_metadata(request: Request) -> Response: + """Expose RFC 9728 metadata so clients can find the Auth0 authorization server.""" + if request.method == "OPTIONS": + return Response(status_code=204) + return JSONResponse(PROTECTED_RESOURCE_METADATA.model_dump(mode="json")) + + +def _resource_description(widget: PizzazWidget) -> str: + return f"{widget.title} widget markup" + + +def _tool_meta( + widget: PizzazWidget, + security_schemes: List[Dict[str, Any]] | None = None, +) -> Dict[str, Any]: + meta = { + "openai/outputTemplate": widget.template_uri, + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetAccessible": True, + "openai/resultCanProduceWidget": True, + } + if security_schemes is not None: + meta["securitySchemes"] = deepcopy(security_schemes) + return meta + + +def _tool_invocation_meta(widget: PizzazWidget) -> Dict[str, Any]: + return { + "openai/toolInvocation/invoking": widget.invoking, + "openai/toolInvocation/invoked": widget.invoked, + "openai/widgetSessionId": "ren-test-session-id", + } + + +def _tool_error(message: str) -> types.ServerResult: + return types.ServerResult( + types.CallToolResult( + content=[types.TextContent(type="text", text=message)], + isError=True, + ) + ) + + +@mcp._mcp_server.list_tools() +async def _list_tools() -> List[types.Tool]: + tool_meta = _tool_meta(CAROUSEL_WIDGET, MIXED_TOOL_SECURITY_SCHEMES) + past_orders_meta = _tool_meta(PAST_ORDERS_WIDGET, OAUTH_ONLY_SECURITY_SCHEMES) + return [ + types.Tool( + name=SEARCH_TOOL_NAME, + title=CAROUSEL_WIDGET.title, + description="Echo the provided search terms.", + inputSchema=SEARCH_TOOL_SCHEMA, + _meta=tool_meta, + securitySchemes=list(MIXED_TOOL_SECURITY_SCHEMES), + # To disable the approval prompt for the tools + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + ), + types.Tool( + name=PAST_ORDERS_TOOL_NAME, + title="See past orders", + description="Return a list of past pizza orders (OAuth required).", + inputSchema=PAST_ORDERS_TOOL_SCHEMA, + _meta=past_orders_meta, + securitySchemes=list(OAUTH_ONLY_SECURITY_SCHEMES), + annotations={ + "destructiveHint": False, + "openWorldHint": False, + "readOnlyHint": True, + }, + ), + ] + + +@mcp._mcp_server.list_resources() +async def _list_resources() -> List[types.Resource]: + return [ + types.Resource( + name=CAROUSEL_WIDGET.title, + title=CAROUSEL_WIDGET.title, + uri=CAROUSEL_WIDGET.template_uri, + description=_resource_description(CAROUSEL_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(CAROUSEL_WIDGET), + ), + types.Resource( + name=PAST_ORDERS_WIDGET.title, + title=PAST_ORDERS_WIDGET.title, + uri=PAST_ORDERS_WIDGET.template_uri, + description=_resource_description(PAST_ORDERS_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(PAST_ORDERS_WIDGET), + ), + ] + + +@mcp._mcp_server.list_resource_templates() +async def _list_resource_templates() -> List[types.ResourceTemplate]: + return [ + types.ResourceTemplate( + name=CAROUSEL_WIDGET.title, + title=CAROUSEL_WIDGET.title, + uriTemplate=CAROUSEL_WIDGET.template_uri, + description=_resource_description(CAROUSEL_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(CAROUSEL_WIDGET), + ), + types.ResourceTemplate( + name=PAST_ORDERS_WIDGET.title, + title=PAST_ORDERS_WIDGET.title, + uriTemplate=PAST_ORDERS_WIDGET.template_uri, + description=_resource_description(PAST_ORDERS_WIDGET), + mimeType=MIME_TYPE, + _meta=_tool_meta(PAST_ORDERS_WIDGET), + ), + ] + + +async def _handle_read_resource(req: types.ReadResourceRequest) -> types.ServerResult: + requested_uri = str(req.params.uri) + if requested_uri not in { + CAROUSEL_WIDGET.template_uri, + PAST_ORDERS_WIDGET.template_uri, + }: + return types.ServerResult( + types.ReadResourceResult( + contents=[], + _meta={"error": f"Unknown resource: {req.params.uri}"}, + ) + ) + + widget = ( + CAROUSEL_WIDGET + if requested_uri == CAROUSEL_WIDGET.template_uri + else PAST_ORDERS_WIDGET + ) + + contents = [ + types.TextResourceContents( + uri=widget.template_uri, + mimeType=MIME_TYPE, + text=widget.html, + _meta=_tool_meta(widget), + ) + ] + + return types.ServerResult(types.ReadResourceResult(contents=contents)) + + +async def _call_tool_request(req: types.CallToolRequest) -> types.ServerResult: + tool_name = req.params.name + + arguments = req.params.arguments or {} + + if tool_name == SEARCH_TOOL_NAME: + meta = _tool_invocation_meta(CAROUSEL_WIDGET) + topping = str(arguments.get("searchTerm", "")).strip() + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text="Rendered a pizza carousel!", + ) + ], + structuredContent={"pizzaTopping": topping}, + _meta=meta, + ) + ) + + if tool_name == PAST_ORDERS_TOOL_NAME: + if not _get_bearer_token_from_request(): + return _oauth_error_result( + "Authentication required: no access token provided.", + description="No access token was provided", + ) + + meta = _tool_invocation_meta(PAST_ORDERS_WIDGET) + limit = arguments.get("limit") + try: + parsed_limit = int(limit) if limit is not None else len(PAST_ORDERS_DATA) + except Exception: + parsed_limit = len(PAST_ORDERS_DATA) + parsed_limit = max(1, min(parsed_limit, len(PAST_ORDERS_DATA))) + orders = PAST_ORDERS_DATA[:parsed_limit] + return types.ServerResult( + types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=PAST_ORDERS_WIDGET.response_text, + ) + ], + structuredContent={"orders": orders}, + _meta=meta, + ) + ) + + return _tool_error(f"Unknown tool: {req.params.name}") + + +mcp._mcp_server.request_handlers[types.CallToolRequest] = _call_tool_request +mcp._mcp_server.request_handlers[types.ReadResourceRequest] = _handle_read_resource + + +app = mcp.streamable_http_app() + +try: + from starlette.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=False, + ) +except Exception: + pass + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("main:app", host="0.0.0.0", port=8000) diff --git a/authenticated_server_python/requirements.txt b/authenticated_server_python/requirements.txt new file mode 100644 index 0000000..0af5ea9 --- /dev/null +++ b/authenticated_server_python/requirements.txt @@ -0,0 +1,4 @@ +fastmcp>=0.1.0 +mcp>=0.1.0 +pydantic>=2.6.0 +uvicorn>=0.30.0