diff --git a/README.md b/README.md index 28848d9..f0ddd58 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ The `.vscode/launch.json` provides a debug configuration to attach to an MCP ser 3. Press `Cmd+Shift+D` to open Run and Debug 4. Select "Attach to MCP Server (stdio)" configuration 5. Press `F5` or the play button to start the debugger -6. Select the expenses-mcp-debug server in GitHub Copilot Chat tools +6. Select the "expenses-mcp-debug" server in GitHub Copilot Chat tools 7. Use GitHub Copilot Chat to trigger the MCP tools 8. Debugger pauses at breakpoints @@ -191,7 +191,6 @@ You can use the [.NET Aspire Dashboard](https://learn.microsoft.com/dotnet/aspir uv run servers/basic_mcp_http.py ``` - 4. View the dashboard at: http://localhost:18888 --- @@ -288,6 +287,58 @@ You can try the [Azure pricing calculator](https://azure.com/e/3987c81282c84410b ⚠️ To avoid unnecessary costs, remember to take down your app if it's no longer in use, either by deleting the resource group in the Portal or running `azd down`. +### Use deployed MCP server with GitHub Copilot + +The URL of the deployed MCP server is available in the azd environment variable `MCP_SERVER_URL`, and is written to the `.env` file created after deployment. + +1. To avoid conflicts, stop the MCP servers from `mcp.json` and disable the expense MCP servers in GitHub Copilot Chat tools. +2. Select "MCP: Add Server" from the VS Code Command Palette +3. Select "HTTP" as the server type +4. Enter the URL of the MCP server, based on the `MCP_SERVER_URL` environment variable. +5. Enable the MCP server in GitHub Copilot Chat tools and test it with an expense tracking query: + + ```text + Log expense for 75 dollars of office supplies on my visa last Friday + ``` + +### Running the server locally + +After deployment sets up the required Azure resources (Cosmos DB, Application Insights), you can also run the MCP server locally against those resources: + +```bash +# Run the MCP server +cd servers && uvicorn deployed_mcp:app --host 0.0.0.0 --port 8000 +``` + +### Viewing traces in Azure Application Insights + +By default, OpenTelemetry tracing is enabled for the deployed MCP server, sending traces to Azure Application Insights. + +1. Open the Azure Portal and navigate to the Application Insights resource created during deployment (named `-appinsights`). +2. In Application Insights, go to "Transaction Search" to view traces from the MCP server +3. You can filter and analyze traces to monitor performance and diagnose issues. + +### Viewing traces in Logfire + +You can also view OpenTelemetry traces in [Logfire](https://logfire.io/) by configuring the MCP server to send traces there. + +1. Create a Logfire account and get your write token from the Logfire dashboard. + +2. Set the azd environment variables to enable Logfire: + + ```bash + azd env set OPENTELEMETRY_PLATFORM logfire + azd env set LOGFIRE_TOKEN + ``` + +3. Provision and deploy: + + ```bash + azd up + ``` + +4. Open the Logfire dashboard to view traces from the MCP server. + --- ## Deploy to Azure with private networking @@ -461,7 +512,7 @@ The following environment variables are automatically set by the deployment hook These are then written to `.env` by the postprovision hook for local development. -### Testing locally +### Testing the Entra OAuth server locally After deployment, you can test locally with OAuth enabled: diff --git a/agents/agentframework_http.py b/agents/agentframework_http.py index b0b734c..ba36463 100644 --- a/agents/agentframework_http.py +++ b/agents/agentframework_http.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import asyncio import logging import os @@ -13,24 +11,18 @@ from rich import print from rich.logging import RichHandler -try: - from keycloak_auth import get_auth_headers -except ImportError: - from agents.keycloak_auth import get_auth_headers - # Configure logging logging.basicConfig(level=logging.WARNING, format="%(message)s", datefmt="[%X]", handlers=[RichHandler()]) logger = logging.getLogger("agentframework_mcp_http") +logger.setLevel(logging.INFO) -# Load environment variables -load_dotenv(override=True) - -# Constants +# Configure constants and client based on environment RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" -MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp/") -# Optional: Keycloak authentication (set KEYCLOAK_REALM_URL to enable) -KEYCLOAK_REALM_URL = os.getenv("KEYCLOAK_REALM_URL") +if not RUNNING_IN_PRODUCTION: + load_dotenv(override=True) + +MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000/mcp/") # Configure chat client based on API_HOST API_HOST = os.getenv("API_HOST", "github") @@ -61,24 +53,9 @@ # --- Main Agent Logic --- - - async def http_mcp_example() -> None: - """ - Demonstrate MCP integration with the Expenses MCP server. - - If KEYCLOAK_REALM_URL is set, authenticates via OAuth (DCR + client credentials). - Otherwise, connects without authentication. - """ - # Get auth headers if Keycloak is configured - headers = await get_auth_headers(KEYCLOAK_REALM_URL, client_name_prefix="agentframework") - if headers: - logger.info(f"🔐 Auth enabled - connecting to {MCP_SERVER_URL} with Bearer token") - else: - logger.info(f"📡 No auth - connecting to {MCP_SERVER_URL}") - async with ( - MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL, headers=headers) as mcp_server, + MCPStreamableHTTPTool(name="Expenses MCP Server", url=MCP_SERVER_URL) as mcp_server, ChatAgent( chat_client=client, name="Expenses Agent", diff --git a/agents/agentframework_learn.py b/agents/agentframework_learn.py index 3ea3026..29827fd 100644 --- a/agents/agentframework_learn.py +++ b/agents/agentframework_learn.py @@ -58,11 +58,7 @@ async def http_mcp_example() -> None: using the Microsoft Learn MCP server. """ async with ( - MCPStreamableHTTPTool( - name="Microsoft Learn MCP", - url=LEARN_MCP_URL, - headers={"Authorization": "Bearer your-token"}, - ) as mcp_server, + MCPStreamableHTTPTool(name="Microsoft Learn MCP", url=LEARN_MCP_URL) as mcp_server, ChatAgent( chat_client=client, name="DocsAgent", diff --git a/agents/langchainv1_github.py b/agents/langchainv1_github.py index 42c5837..3e14b07 100644 --- a/agents/langchainv1_github.py +++ b/agents/langchainv1_github.py @@ -90,7 +90,7 @@ async def main(): prompt="You help users research GitHub repositories. Search and analyze information.", ) - query = "Find popular Python MCP server repositories" + query = "Find 5 popular Python MCP server repositories and describe in a bulleted list." rprint(f"[bold]Query:[/bold] {query}\n") try: diff --git a/infra/main.bicep b/infra/main.bicep index 30a1d3d..285217a 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -37,8 +37,16 @@ param keycloakExists bool = false // This does not need a default value, as azd will prompt the user to select a location param openAiResourceLocation string -@description('Flag to enable or disable monitoring resources') -param useMonitoring bool = true +@description('OpenTelemetry platform for monitoring: appinsights, logfire, or none') +@allowed([ + 'appinsights' + 'logfire' + 'none' +]) +param openTelemetryPlatform string = 'appinsights' + +// Derived boolean for App Insights resource creation +var useAppInsights = openTelemetryPlatform == 'appinsights' @description('Flag to enable or disable the virtual network feature') param useVnet bool = false @@ -80,6 +88,10 @@ param entraProxyClientId string = '' @description('Azure/Entra ID app registration client secret for OAuth Proxy - required when mcpAuthProvider is entra_proxy') param entraProxyClientSecret string = '' +@secure() +@description('Logfire token used by the server container as a secret') +param logfireToken string = '' + // Derived booleans for backward compatibility in bicep modules var useKeycloak = mcpAuthProvider == 'keycloak' var useEntraProxy = mcpAuthProvider == 'entra_proxy' @@ -119,7 +131,7 @@ module openAi 'br/public:avm/res/cognitive-services/account:0.7.2' = { bypass: 'AzureServices' } sku: 'S0' - diagnosticSettings: useMonitoring + diagnosticSettings: useAppInsights ? [ { name: 'customSetting' @@ -196,7 +208,7 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.6.1' = { } } -module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.7.0' = if (useMonitoring) { +module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0.7.0' = if (useAppInsights) { name: 'loganalytics' scope: resourceGroup params: { @@ -212,7 +224,7 @@ module logAnalyticsWorkspace 'br/public:avm/res/operational-insights/workspace:0 } // Application Insights for telemetry -module applicationInsights 'br/public:avm/res/insights/component:0.4.2' = if (useMonitoring) { +module applicationInsights 'br/public:avm/res/insights/component:0.4.2' = if (useAppInsights) { name: 'applicationinsights' scope: resourceGroup params: { @@ -411,7 +423,7 @@ module openAiPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = } // Log Analytics Private DNS Zone -module logAnalyticsPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useMonitoring) { +module logAnalyticsPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useAppInsights) { name: 'log-analytics-dns-zone' scope: resourceGroup params: { @@ -427,7 +439,7 @@ module logAnalyticsPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0. } // Additional Log Analytics Private DNS Zone for query endpoint -module logAnalyticsQueryPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useMonitoring) { +module logAnalyticsQueryPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useAppInsights) { name: 'log-analytics-query-dns-zone' scope: resourceGroup params: { @@ -443,7 +455,7 @@ module logAnalyticsQueryPrivateDnsZone 'br/public:avm/res/network/private-dns-zo } // Additional Log Analytics Private DNS Zone for agent service -module logAnalyticsAgentPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useMonitoring) { +module logAnalyticsAgentPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useAppInsights) { name: 'log-analytics-agent-dns-zone' scope: resourceGroup params: { @@ -459,7 +471,7 @@ module logAnalyticsAgentPrivateDnsZone 'br/public:avm/res/network/private-dns-zo } // Azure Monitor Private DNS Zone -module monitorPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useMonitoring) { +module monitorPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useAppInsights) { name: 'monitor-dns-zone' scope: resourceGroup params: { @@ -475,7 +487,7 @@ module monitorPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' } // Storage Blob Private DNS Zone for Log Analytics solution packs -module blobPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useMonitoring) { +module blobPrivateDnsZone 'br/public:avm/res/network/private-dns-zone:0.7.1' = if (useVnet && useAppInsights) { name: 'blob-dns-zone' scope: resourceGroup params: { @@ -599,7 +611,7 @@ module privateEndpoint 'br/public:avm/res/network/private-endpoint:0.11.0' = if } // Azure Monitor Private Link Scope -module monitorPrivateLinkScope 'br/public:avm/res/insights/private-link-scope:0.7.1' = if (useVnet && useMonitoring) { +module monitorPrivateLinkScope 'br/public:avm/res/insights/private-link-scope:0.7.1' = if (useVnet && useAppInsights) { name: 'monitor-private-link-scope' scope: resourceGroup params: { @@ -654,7 +666,7 @@ module containerApps 'core/host/container-apps.bicep' = { tags: tags containerAppsEnvironmentName: '${prefix}-containerapps-env' containerRegistryName: '${take(replace(prefix, '-', ''), 42)}registry' - logAnalyticsWorkspaceName: useMonitoring ? logAnalyticsWorkspace!.outputs.name : '' + logAnalyticsWorkspaceName: useAppInsights ? logAnalyticsWorkspace!.outputs.name : '' // Reference the virtual network only if useVnet is true subnetResourceId: useVnet ? virtualNetwork!.outputs.subnetResourceIds[0] : '' vnetName: useVnet ? virtualNetwork!.outputs.name : '' @@ -746,7 +758,8 @@ module server 'server.bicep' = { cosmosDbContainer: cosmosDbContainerName cosmosDbUserContainer: cosmosDbUserContainerName cosmosDbOAuthContainer: cosmosDbOAuthContainerName - applicationInsightsConnectionString: useMonitoring ? applicationInsights!.outputs.connectionString : '' + applicationInsightsConnectionString: useAppInsights ? applicationInsights!.outputs.connectionString : '' + openTelemetryPlatform: openTelemetryPlatform exists: serverExists // Keycloak authentication configuration (only when enabled) keycloakRealmUrl: useKeycloak ? '${keycloak!.outputs.uri}/realms/${keycloakRealmName}' : '' @@ -759,6 +772,7 @@ module server 'server.bicep' = { entraProxyBaseUrl: useEntraProxy ? entraProxyMcpServerBaseUrl : '' tenantId: useEntraProxy ? tenant().tenantId : '' mcpAuthProvider: mcpAuthProvider + logfireToken: logfireToken } } @@ -897,7 +911,7 @@ output AZURE_COSMOSDB_USER_CONTAINER string = cosmosDbUserContainerName output AZURE_COSMOSDB_OAUTH_CONTAINER string = cosmosDbOAuthContainerName // We typically do not output sensitive values, but App Insights connection strings are not considered highly sensitive -output APPLICATIONINSIGHTS_CONNECTION_STRING string = useMonitoring ? applicationInsights!.outputs.connectionString : '' +output APPLICATIONINSIGHTS_CONNECTION_STRING string = useAppInsights ? applicationInsights!.outputs.connectionString : '' // Entry selection for MCP server (auth-enabled when Keycloak or FastMCP auth is used) // Use server module's computed entry selection (checks URLs/clientId) @@ -918,3 +932,6 @@ output KEYCLOAK_TOKEN_ISSUER string = useKeycloak ? '${keycloakMcpServerBaseUrl} // Auth provider for env scripts output MCP_AUTH_PROVIDER string = mcpAuthProvider + +// OpenTelemetry platform for env scripts +output OPENTELEMETRY_PLATFORM string = openTelemetryPlatform diff --git a/infra/main.parameters.json b/infra/main.parameters.json index a95fffb..e7d274f 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -11,8 +11,8 @@ "principalId": { "value": "${AZURE_PRINCIPAL_ID}" }, - "useMonitoring": { - "value": "${USE_MONITORING=true}" + "openTelemetryPlatform": { + "value": "${OPENTELEMETRY_PLATFORM=appinsights}" }, "useVnet": { "value": "${USE_VNET=false}" @@ -50,12 +50,14 @@ "keycloakMcpServerAudience": { "value": "${KEYCLOAK_MCP_SERVER_AUDIENCE=mcp-server}" }, - "entraProxyClientId": { "value": "${ENTRA_PROXY_AZURE_CLIENT_ID}" }, "entraProxyClientSecret": { "value": "${ENTRA_PROXY_AZURE_CLIENT_SECRET}" + }, + "logfireToken": { + "value": "${LOGFIRE_TOKEN}" } } } diff --git a/infra/server.bicep b/infra/server.bicep index 18df801..d78d590 100644 --- a/infra/server.bicep +++ b/infra/server.bicep @@ -15,6 +15,12 @@ param cosmosDbContainer string param cosmosDbUserContainer string param cosmosDbOAuthContainer string param applicationInsightsConnectionString string = '' +@allowed([ + 'appinsights' + 'logfire' + 'none' +]) +param openTelemetryPlatform string = 'appinsights' param keycloakRealmUrl string = '' param keycloakTokenIssuer string = '' param keycloakMcpServerAudience string = 'mcp-server' @@ -24,6 +30,8 @@ param entraProxyClientId string = '' param entraProxyClientSecret string = '' param entraProxyBaseUrl string = '' param tenantId string = '' +@secure() +param logfireToken string = '' @allowed([ 'none' 'keycloak' @@ -84,8 +92,20 @@ var baseEnv = [ name: 'MCP_ENTRY' value: mcpEntry } + { + name: 'OPENTELEMETRY_PLATFORM' + value: openTelemetryPlatform + } ] +// Logfire environment variables (only added when configured) +var logfireEnv = !empty(logfireToken) ? [ + { + name: 'LOGFIRE_TOKEN' + secretRef: 'logfire-token' + } +] : [] + // Keycloak authentication environment variables (only added when configured) var keycloakEnv = !empty(keycloakRealmUrl) ? [ { @@ -134,6 +154,14 @@ var entraProxySecrets = !empty(entraProxyClientSecret) ? [ } ] : [] +// Secret for Logfire token +var logfireSecrets = !empty(logfireToken) ? [ + { + name: 'logfire-token' + value: logfireToken + } +] : [] + resource serverIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: identityName @@ -151,8 +179,8 @@ module app 'core/host/container-app-upsert.bicep' = { containerAppsEnvironmentName: containerAppsEnvironmentName containerRegistryName: containerRegistryName ingressEnabled: true - env: concat(baseEnv, keycloakEnv, entraProxyEnv) - secrets: entraProxySecrets + env: concat(baseEnv, keycloakEnv, entraProxyEnv, logfireEnv) + secrets: concat(entraProxySecrets, logfireSecrets) targetPort: 8000 probes: [ { diff --git a/infra/write_env.ps1 b/infra/write_env.ps1 index f87eae1..0455edd 100644 --- a/infra/write_env.ps1 +++ b/infra/write_env.ps1 @@ -40,7 +40,9 @@ Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_CONTAINER=$(azd env get- Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_USER_CONTAINER=$(azd env get-value AZURE_COSMOSDB_USER_CONTAINER)" Add-Content -Path $ENV_FILE_PATH -Value "AZURE_COSMOSDB_OAUTH_CONTAINER=$(azd env get-value AZURE_COSMOSDB_OAUTH_CONTAINER)" Add-Content -Path $ENV_FILE_PATH -Value "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)" +Write-EnvIfSet LOGFIRE_TOKEN Write-Env MCP_AUTH_PROVIDER +Write-Env OPENTELEMETRY_PLATFORM # Keycloak-related env vars (only if KEYCLOAK_REALM_URL is set) $KEYCLOAK_REALM_URL = Get-AzdValue KEYCLOAK_REALM_URL diff --git a/infra/write_env.sh b/infra/write_env.sh index 2382b36..fa7627c 100755 --- a/infra/write_env.sh +++ b/infra/write_env.sh @@ -46,7 +46,9 @@ echo "AZURE_COSMOSDB_CONTAINER=$(azd env get-value AZURE_COSMOSDB_CONTAINER)" >> echo "AZURE_COSMOSDB_USER_CONTAINER=$(azd env get-value AZURE_COSMOSDB_USER_CONTAINER)" >> "$ENV_FILE_PATH" echo "AZURE_COSMOSDB_OAUTH_CONTAINER=$(azd env get-value AZURE_COSMOSDB_OAUTH_CONTAINER)" >> "$ENV_FILE_PATH" echo "APPLICATIONINSIGHTS_CONNECTION_STRING=$(azd env get-value APPLICATIONINSIGHTS_CONNECTION_STRING)" >> "$ENV_FILE_PATH" +write_env_if_set LOGFIRE_TOKEN write_env MCP_AUTH_PROVIDER +write_env OPENTELEMETRY_PLATFORM # Keycloak-related env vars (only if KEYCLOAK_REALM_URL is set) KEYCLOAK_REALM_URL=$(get_azd_value KEYCLOAK_REALM_URL) diff --git a/servers/auth_mcp.py b/servers/auth_mcp.py index f35bdab..fed2fe8 100644 --- a/servers/auth_mcp.py +++ b/servers/auth_mcp.py @@ -52,11 +52,12 @@ # Configure Azure SDK OpenTelemetry to use OTEL settings.tracing_implementation = "opentelemetry" -# Configure OpenTelemetry exporters (App Insights or Logfire) -if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): +# Configure OpenTelemetry exporters based on OPENTELEMETRY_PLATFORM env var +opentelemetry_platform = os.getenv("OPENTELEMETRY_PLATFORM", "none").lower() +if opentelemetry_platform == "appinsights" and os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): logger.info("Setting up Azure Monitor instrumentation") configure_azure_monitor() -elif os.getenv("LOGFIRE_PROJECT_NAME"): +elif opentelemetry_platform == "logfire" and os.getenv("LOGFIRE_TOKEN"): logger.info("Setting up Logfire instrumentation") logfire.configure(service_name="expenses-mcp", send_to_logfire=True) @@ -224,9 +225,9 @@ async def get_user_expenses(ctx: Context): if not expenses_data: return "No expenses found." - csv_content = f"Expense data ({len(expenses_data)} entries):\n\n" + expense_summary = f"Expense data ({len(expenses_data)} entries):\n\n" for expense in expenses_data: - csv_content += ( + expense_summary += ( f"Date: {expense.get('date', 'N/A')}, " f"Amount: ${expense.get('amount', 0)}, " f"Category: {expense.get('category', 'N/A')}, " @@ -234,7 +235,7 @@ async def get_user_expenses(ctx: Context): f"Payment: {expense.get('payment_method', 'N/A')}\n" ) - return csv_content + return expense_summary except Exception as e: logger.error(f"Error reading expenses: {str(e)}") diff --git a/servers/basic_mcp_http.py b/servers/basic_mcp_http.py index b214d2d..205531b 100644 --- a/servers/basic_mcp_http.py +++ b/servers/basic_mcp_http.py @@ -14,8 +14,9 @@ load_dotenv(override=True) -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s") +logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(message)s") logger = logging.getLogger("ExpensesMCP") +logger.setLevel(logging.INFO) middleware: list[Middleware] = [] if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): @@ -141,4 +142,4 @@ def analyze_spending_prompt( if __name__ == "__main__": logger.info("MCP Expenses server starting (HTTP mode on port 8000)") - mcp.run(transport="http", host="0.0.0.0", port=8000) + mcp.run(transport="streamable-http", host="0.0.0.0", port=8000) diff --git a/servers/deployed_mcp.py b/servers/deployed_mcp.py index 1281a7b..bebecd3 100644 --- a/servers/deployed_mcp.py +++ b/servers/deployed_mcp.py @@ -17,10 +17,7 @@ from opentelemetry.instrumentation.starlette import StarletteInstrumentor from starlette.responses import JSONResponse -try: - from opentelemetry_middleware import OpenTelemetryMiddleware -except ImportError: - from servers.opentelemetry_middleware import OpenTelemetryMiddleware +from opentelemetry_middleware import OpenTelemetryMiddleware RUNNING_IN_PRODUCTION = os.getenv("RUNNING_IN_PRODUCTION", "false").lower() == "true" @@ -31,13 +28,14 @@ logger = logging.getLogger("ExpensesMCP") logger.setLevel(logging.INFO) -# Configure OpenTelemetry tracing, either via Azure Monitor or Logfire +# Configure OpenTelemetry tracing based on OPENTELEMETRY_PLATFORM env var # We don't support both at the same time due to potential conflicts with tracer providers settings.tracing_implementation = "opentelemetry" # Ensure Azure SDK always uses OpenTelemetry tracing -if os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): +opentelemetry_platform = os.getenv("OPENTELEMETRY_PLATFORM", "none").lower() +if opentelemetry_platform == "appinsights" and os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"): logger.info("Setting up Azure Monitor instrumentation") configure_azure_monitor() -elif os.getenv("LOGFIRE_PROJECT_NAME"): +elif opentelemetry_platform == "logfire" and os.getenv("LOGFIRE_TOKEN"): logger.info("Setting up Logfire instrumentation") logfire.configure(service_name="expenses-mcp", send_to_logfire=True) @@ -115,24 +113,24 @@ async def add_expense( return f"Error: Unable to add expense - {str(e)}" -@mcp.resource("resource://expenses") +@mcp.tool async def get_expenses_data(): - """Get raw expense data from Cosmos DB.""" + """Get raw expense data from Cosmos DB as CSV text.""" logger.info("Expenses data accessed") try: query = "SELECT * FROM c ORDER BY c.date DESC" expenses_data = [] - async for item in cosmos_container.query_items(query=query, enable_cross_partition_query=True): + async for item in cosmos_container.query_items(query=query): expenses_data.append(item) if not expenses_data: return "No expenses found." - csv_content = f"Expense data ({len(expenses_data)} entries):\n\n" + expense_summary = f"Expense data ({len(expenses_data)} entries):\n\n" for expense in expenses_data: - csv_content += ( + expense_summary += ( f"Date: {expense.get('date', 'N/A')}, " f"Amount: ${expense.get('amount', 0)}, " f"Category: {expense.get('category', 'N/A')}, " @@ -140,7 +138,7 @@ async def get_expenses_data(): f"Payment: {expense.get('payment_method', 'N/A')}\n" ) - return csv_content + return expense_summary except Exception as e: logger.error(f"Error reading expenses: {str(e)}") @@ -194,4 +192,6 @@ async def health_check(_request): # ASGI application for uvicorn app = mcp.http_app() + +# Instrument the Starlette app with OpenTelemetry StarletteInstrumentor.instrument_app(app) diff --git a/servers/opentelemetry_middleware.py b/servers/opentelemetry_middleware.py index 64ad299..c8451d6 100644 --- a/servers/opentelemetry_middleware.py +++ b/servers/opentelemetry_middleware.py @@ -1,5 +1,7 @@ +import json import logging import os +from typing import Any from fastmcp.server.middleware import Middleware, MiddlewareContext from opentelemetry import metrics, trace @@ -15,6 +17,7 @@ from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.trace import Status, StatusCode +from opentelemetry.util.types import AttributeValue def configure_aspire_dashboard(service_name: str = "expenses-mcp"): @@ -62,21 +65,46 @@ class OpenTelemetryMiddleware(Middleware): def __init__(self, tracer_name: str): self.tracer = trace.get_tracer(tracer_name) - async def on_call_tool(self, context: MiddlewareContext, call_next): - """Create a span for each tool call with detailed attributes.""" - tool_name = context.message.name + def _span_name(self, method_name: str, target: str | None) -> str: + if target: + return f"{method_name} {target}" + return method_name + + def _safe_json_str(self, value: Any) -> str | None: + """Best-effort JSON serialization. + + `gen_ai.tool.call.arguments` is semconv opt-in and may be sensitive. + The OTEL Python SDK span attribute type system doesn't support + arbitrary nested objects, so we encode as a JSON string. + """ + if value is None: + return None + try: + return json.dumps(value, ensure_ascii=False, default=str) + except Exception: + return str(value) - with self.tracer.start_as_current_span( - f"tool.{tool_name}", - attributes={ - "mcp.method": context.method, - "mcp.source": context.source, - "mcp.tool.name": tool_name, - # If arguments are sensitive, consider omitting or sanitizing them - # If arguments are long/nested, consider adding a size or depth limit - "mcp.tool.arguments": str(context.message.arguments), - }, - ) as span: + async def on_call_tool(self, context: MiddlewareContext, call_next): + """Create a span for each tool call following MCP semantic conventions.""" + # MCP semconv: span name is "{mcp.method.name} {target}" where target matches gen_ai.tool.name. + method_name = str(getattr(context, "method", "")) or "tools/call" + tool_name = str(getattr(context.message, "name", "")) or "unknown" + span_name = self._span_name(method_name=method_name, target=tool_name) + + attributes: dict[str, AttributeValue] = { + "mcp.method.name": method_name, + # PR #2083 aligns tool/prompt naming with GenAI attributes. + "gen_ai.tool.name": tool_name, + "gen_ai.operation.name": "execute_tool", + } + + # Opt-in sensitive attribute (kept for backwards compatibility with prior behavior, + # but now recorded under the semconv key). + tool_args_json = self._safe_json_str(getattr(context.message, "arguments", None)) + if tool_args_json is not None: + attributes["gen_ai.tool.call.arguments"] = tool_args_json + + with self.tracer.start_as_current_span(span_name, attributes=attributes) as span: try: result = await call_next(context) span.set_attribute("mcp.tool.success", True) @@ -93,11 +121,13 @@ async def on_read_resource(self, context: MiddlewareContext, call_next): """Create a span for each resource read.""" resource_uri = str(getattr(context.message, "uri", "unknown")) + method_name = str(getattr(context, "method", "")) or "resources/read" + span_name = self._span_name(method_name=method_name, target=resource_uri if resource_uri != "unknown" else None) + with self.tracer.start_as_current_span( - f"resource.{resource_uri}", + span_name, attributes={ - "mcp.method": context.method, - "mcp.source": context.source, + "mcp.method.name": method_name, "mcp.resource.uri": resource_uri, }, ) as span: @@ -117,12 +147,14 @@ async def on_get_prompt(self, context: MiddlewareContext, call_next): """Create a span for each prompt retrieval.""" prompt_name = getattr(context.message, "name", "unknown") + method_name = str(getattr(context, "method", "")) or "prompts/get" + span_name = self._span_name(method_name=method_name, target=prompt_name if prompt_name != "unknown" else None) + with self.tracer.start_as_current_span( - f"prompt.{prompt_name}", + span_name, attributes={ - "mcp.method": context.method, - "mcp.source": context.source, - "mcp.prompt.name": prompt_name, + "mcp.method.name": method_name, + "gen_ai.prompt.name": str(prompt_name), }, ) as span: try: