diff --git a/README.md b/README.md index 2637887..b7d5f39 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # Host remote MCP servers built with official MCP SDKs on Azure Functions (early preview) -This repo contains instructions and sample for running MCP server built with the Python MCP SDK on Azure Functions. The repo uses the weather sample server to demonstrate how this can be done. You can clone to run and test the server locally, follow by easy deploy with `azd up` to have it in the cloud in a few minutes. +This repo contains instructions and sample for running MCP server built with the Python MCP SDK on Azure Functions. The repo includes two sample servers: + +1. **Weather Server** (`weather.py`): Demonstrates basic MCP tools for getting weather forecasts and alerts +2. **Get User Server** (`get_user.py`): Demonstrates On-Behalf-Of (OBO) flow for calling Microsoft Graph API to retrieve logged-in user information + +You can clone to run and test the servers locally, then easily deploy with `azd up` to have them in the cloud in a few minutes. **Watch the video overview** @@ -165,6 +170,52 @@ In the debug output from Visual Studio Code, you see a series of requests and re Other than Visual Studio Code, agents in Azure AI Foundry can also connect to Function-hosted MCP servers that are configured with Easy Auth. Docs coming soon. +## Demonstrating On-Behalf-Of (OBO) Flow + +The `get_user.py` server demonstrates how to implement the On-Behalf-Of (OBO) flow to call Microsoft Graph API on behalf of the authenticated user. This pattern is useful when your MCP tools need to access user-specific data from Microsoft Graph or other protected APIs. + +### How the OBO Flow Works + +1. **User Authentication**: Azure App Service authentication validates the user and forwards the bearer token in the `Authorization` header +2. **Token Extraction**: The MCP tool extracts the bearer token from the request headers +3. **Managed Identity Assertion**: A Managed Identity credential obtains an assertion token for token exchange +4. **Token Exchange**: `OnBehalfOfCredential` exchanges the user's token for a Microsoft Graph access token +5. **API Call**: The tool calls Microsoft Graph's `/me` endpoint with the exchanged token +6. **Response**: User information is returned with sensitive fields masked for security + +### Using the Get User Tool + +To use the `get_user.py` server: + +1. Update `host.json` to point to `get_user.py` instead of `weather.py`: + ```json + { + "version": "2.0", + "configurationProfile": "mcp-custom-handler", + "customHandler": { + "description": { + "defaultExecutablePath": "python", + "arguments": ["get_user.py"] + }, + "http": { + "DefaultAuthorizationLevel": "anonymous" + }, + "port": "8000" + } + } + ``` + +2. Deploy the application following the deployment steps above + +3. The infrastructure is already configured to support OBO flow with: + - Entra app registration with Microsoft Graph permissions + - Federated identity credential for managed identity + - Required environment variables for token exchange + +4. Once deployed, use the `get_current_user` tool to retrieve the logged-in user's information from Microsoft Graph + +**Note**: This tool requires the infrastructure to be deployed to Azure as it relies on Azure App Service authentication and Managed Identity. It will not work in local development without additional configuration. + ## Clean up resources When you're done working with your server, you can use this command to delete the resources created on Azure and avoid incurring any further costs: diff --git a/get_user.py b/get_user.py new file mode 100644 index 0000000..31dc53f --- /dev/null +++ b/get_user.py @@ -0,0 +1,186 @@ +import json +import os +import sys +from typing import Any + +import httpx +from azure.identity import ManagedIdentityCredential, OnBehalfOfCredential +from mcp.server import Server +from mcp.server.streamable_http import StreamableHTTPServerTransport +from mcp.types import Tool, TextContent, CallToolResult + +# Create an MCP server +server = Server("get-user") + +# Store request headers for access in tool handlers +_request_headers = {} + + +@server.list_tools() +async def handle_list_tools() -> list[Tool]: + """List available tools.""" + return [ + Tool( + name="get_current_user", + description="Get current logged-in user information from Microsoft Graph using Azure App Service authentication headers and On-Behalf-Of flow", + inputSchema={ + "type": "object", + "properties": {}, + "required": [] + }, + ) + ] + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any]) -> CallToolResult: + """Handle tool execution.""" + if name != "get_current_user": + raise ValueError(f"Unknown tool: {name}") + + try: + global _request_headers + + if not _request_headers: + result = { + "authenticated": False, + "message": "No authentication headers found. This tool requires Azure App Service authentication." + } + return CallToolResult(content=[TextContent(type="text", text=json.dumps(result, indent=2))]) + + # Get the auth token from Authorization header and remove the "Bearer " prefix + auth_header = _request_headers.get("authorization", "") + if not auth_header or not auth_header.startswith("Bearer "): + result = { + "authenticated": False, + "message": "No bearer token found in authorization header." + } + return CallToolResult(content=[TextContent(type="text", text=json.dumps(result, indent=2))]) + + auth_token = auth_header.split(" ", 1)[1] + + # Get configuration from environment variables + token_exchange_audience = os.environ.get("TokenExchangeAudience", "api://AzureADTokenExchange") + public_token_exchange_scope = f"{token_exchange_audience}/.default" + federated_credential_client_id = os.environ.get("OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID") + client_id = os.environ.get("WEBSITE_AUTH_CLIENT_ID") + tenant_id = os.environ.get("WEBSITE_AUTH_AAD_ALLOWED_TENANTS") + + if not all([federated_credential_client_id, client_id, tenant_id]): + result = { + "authenticated": False, + "message": "Missing required environment variables for OBO flow. Ensure OVERRIDE_USE_MI_FIC_ASSERTION_CLIENTID, WEBSITE_AUTH_CLIENT_ID, and WEBSITE_AUTH_AAD_ALLOWED_TENANTS are set." + } + return CallToolResult(content=[TextContent(type="text", text=json.dumps(result, indent=2))]) + + # Create Managed Identity credential + managed_identity_credential = ManagedIdentityCredential(client_id=federated_credential_client_id) + + # Get assertion token for OBO flow + assertion_token_result = managed_identity_credential.get_token(public_token_exchange_scope) + assertion_token = assertion_token_result.token + + # Create OBO credential + obo_credential = OnBehalfOfCredential( + tenant_id=tenant_id, + client_id=client_id, + user_assertion=auth_token, + client_credential=assertion_token + ) + + # Get token for Microsoft Graph + graph_token_result = obo_credential.get_token("https://graph.microsoft.com/.default") + graph_token = graph_token_result.token + + # Call Microsoft Graph API to get user information + async with httpx.AsyncClient() as client: + graph_response = await client.get( + "https://graph.microsoft.com/v1.0/me", + headers={ + "Authorization": f"Bearer {graph_token}" + } + ) + graph_response.raise_for_status() + graph_data = graph_response.json() + + # Mask sensitive information (for demo purposes) + masked_user_data = dict(graph_data) + if "businessPhones" in masked_user_data: + masked_user_data["businessPhones"] = ["[MASKED]" for _ in masked_user_data["businessPhones"]] + if "id" in masked_user_data: + masked_user_data["id"] = "[MASKED]" + + result = { + "authenticated": True, + "user": masked_user_data, + "message": "Successfully retrieved user information from Microsoft Graph" + } + + return CallToolResult(content=[TextContent(type="text", text=json.dumps(result, indent=2))]) + + except Exception as e: + import traceback + error_details = traceback.format_exc() + hostname = os.environ.get("WEBSITE_HOSTNAME", "your-function-app-hostname") + + error_result = { + "authenticated": False, + "message": f"Error during token exchange and Graph API call. You're logged in but might need to grant consent to the application. Open a browser to the following link to consent: https://{hostname}/.auth/login/aad?post_login_redirect_uri=https://{hostname}/", + "error": str(e), + "details": error_details + } + + return CallToolResult(content=[TextContent(type="text", text=json.dumps(error_result, indent=2))]) + + +async def main(): + """Run the MCP server using streamable HTTP transport.""" + from starlette.applications import Starlette + from starlette.requests import Request + from starlette.responses import Response + from starlette.routing import Route + import uvicorn + + async def handle_mcp(request: Request) -> Response: + """Handle MCP requests.""" + global _request_headers + # Store request headers so tools can access them + _request_headers = dict(request.headers) + + # Create transport for this request + transport = StreamableHTTPServerTransport() + await server.connect(transport) + + # Get the request body + body = await request.body() + + # Handle the MCP request + result = await transport.handle_request(body.decode() if body else "") + + return Response( + content=result, + media_type="application/json" + ) + + # Create Starlette app + app = Starlette( + routes=[ + Route("/mcp", handle_mcp, methods=["POST"]), + ] + ) + + # Run with uvicorn + port = int(os.environ.get("FUNCTIONS_CUSTOMHANDLER_PORT", "8000")) + print(f"Starting MCP get_user server on port {port}...") + config = uvicorn.Config(app, host="0.0.0.0", port=port, log_level="info") + server_instance = uvicorn.Server(config) + await server_instance.serve() + + +if __name__ == "__main__": + import asyncio + try: + asyncio.run(main()) + except Exception as e: + print(f"Error while running MCP server: {e}", file=sys.stderr) + sys.exit(1) diff --git a/pyproject.toml b/pyproject.toml index 7f248d0..2a4d32a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,4 +7,7 @@ requires-python = ">=3.10" dependencies = [ "httpx>=0.28.1", "mcp[cli]>=1.14.1", + "azure-identity>=1.19.0", + "starlette>=0.49.1", + "uvicorn>=0.34.0", ] \ No newline at end of file