diff --git a/labs/mcp-a2a-agents/deploy-a2a-infra-assests.ipynb b/labs/mcp-a2a-agents/deploy-a2a-infra-assests.ipynb index 630f8842..d4fd0662 100644 --- a/labs/mcp-a2a-agents/deploy-a2a-infra-assests.ipynb +++ b/labs/mcp-a2a-agents/deploy-a2a-infra-assests.ipynb @@ -269,8 +269,8 @@ " print(f\" Input Schema: {tool.inputSchema}\")\n", "\n", "try:\n", - " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/weather\"))\n", - " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/oncall\"))\n", + " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/weather/mcp\"))\n", + " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/oncall/mcp\"))\n", "finally:\n", " print(f\"βœ… Connection closed\")\n" ] @@ -300,13 +300,13 @@ "\n", "weather_plugin = MCPStreamableHttpPlugin(\n", " name=\"Weather\",\n", - " url=f\"{apim_resource_gateway_url}/weather\",\n", + " url=f\"{apim_resource_gateway_url}/weather/mcp\",\n", " description=\"Weather Plugin\",\n", " )\n", "\n", "oncall_plugin = MCPStreamableHttpPlugin(\n", " name=\"OnCall\",\n", - " url=f\"{apim_resource_gateway_url}/oncall\",\n", + " url=f\"{apim_resource_gateway_url}/oncall/mcp\",\n", " description=\"OnCall Plugin\",\n", ")\n", "\n", diff --git a/labs/mcp-a2a-agents/mcp-agent-as-a2a-server.ipynb b/labs/mcp-a2a-agents/mcp-agent-as-a2a-server.ipynb index 5c23e270..a2ddb169 100644 --- a/labs/mcp-a2a-agents/mcp-agent-as-a2a-server.ipynb +++ b/labs/mcp-a2a-agents/mcp-agent-as-a2a-server.ipynb @@ -178,7 +178,7 @@ "outputs": [], "source": [ "title=\"Weather\"\n", - "mcp_url = \"/weather\"\n", + "mcp_url = \"/weather/mcp\"\n", "\n", "utils.run(f'az containerapp secret set -n {a2a_weather_containerapp_resource_name} -g {resource_group_name} --secrets apimsubscriptionkey={apim_subscription_key}', \"Weather A2A Server secret updated\", \"Weather A2A Server secret update failed\")\n", "utils.run(f'az containerapp update -n {a2a_weather_containerapp_resource_name} -g {resource_group_name} --image \"{container_registry_name}.azurecr.io/{a2a_sk_server_image}:v0.{build}\" --set-env-vars TITLE={title} MCP_URL={mcp_url} A2A_URL={apim_resource_gateway_url}/weather-agent-a2a APIM_GATEWAY_URL={apim_resource_gateway_url} OPENAI_API_VERSION={openai_api_version} OPENAI_DEPLOYMENT_NAME={openai_deployment_name} APIM_SUBSCRIPTION_KEY=secretref:apimsubscriptionkey', \n", @@ -201,7 +201,7 @@ "outputs": [], "source": [ "title=\"Oncall\"\n", - "mcp_url = \"/oncall\"\n", + "mcp_url = \"/oncall/mcp\"\n", "\n", "utils.run(f'az containerapp secret set -n {a2a_oncall_containerapp_resource_name} -g {resource_group_name} --secrets apimsubscriptionkey={apim_subscription_key}', f\"{title} A2A Server secret updated\", f\"{title} A2A Server secret update failed\")\n", "utils.run(f'az containerapp update -n {a2a_oncall_containerapp_resource_name} -g {resource_group_name} --image \"{container_registry_name}.azurecr.io/{a2a_ag_server_image}:v0.{build}\" --set-env-vars TITLE={title} MCP_URL={mcp_url} A2A_URL={apim_resource_gateway_url}/oncall-agent-a2a APIM_GATEWAY_URL={apim_resource_gateway_url} OPENAI_API_VERSION={openai_api_version} OPENAI_DEPLOYMENT_NAME={openai_deployment_name} APIM_SUBSCRIPTION_KEY=secretref:apimsubscriptionkey', \n", diff --git a/labs/mcp-a2a-agents/mcp-agent-as-mcp-server.ipynb b/labs/mcp-a2a-agents/mcp-agent-as-mcp-server.ipynb index 00c660f0..d97c7341 100644 --- a/labs/mcp-a2a-agents/mcp-agent-as-mcp-server.ipynb +++ b/labs/mcp-a2a-agents/mcp-agent-as-mcp-server.ipynb @@ -133,14 +133,14 @@ "outputs": [], "source": [ "title=\"Weather\"\n", - "mcp_url = \"/weather\"\n", + "mcp_url = \"/weather/mcp\"\n", "\n", "utils.run(f'az containerapp secret set -n {a2a_weather_containerapp_resource_name} -g {resource_group_name} --secrets apimsubscriptionkey={apim_subscription_key}', \"Weather A2A Server secret updated\", \"Weather A2A Server secret update failed\")\n", "utils.run(f'az containerapp update -n {a2a_weather_containerapp_resource_name} -g {resource_group_name} --image \"{container_registry_name}.azurecr.io/{mcp_sk_server_image}:v0.{build}\" --set-env-vars TITLE={title} MCP_URL={mcp_url} APIM_GATEWAY_URL={apim_resource_gateway_url} OPENAI_API_VERSION={openai_api_version} OPENAI_DEPLOYMENT_NAME={openai_deployment_name} APIM_SUBSCRIPTION_KEY=secretref:apimsubscriptionkey', \n", " \"Weather MCP SK Server with MCP deployment succeeded\", \"Weather MCP SK Server with MCP deployment failed\")\n", "\n", "title=\"Oncall\"\n", - "mcp_url = \"/oncall\"\n", + "mcp_url = \"/oncall/mcp\"\n", "\n", "utils.run(f'az containerapp secret set -n {a2a_oncall_containerapp_resource_name} -g {resource_group_name} --secrets apimsubscriptionkey={apim_subscription_key}', \"Oncall A2A Server secret updated\", \"Oncall A2A Server secret update failed\")\n", "utils.run(f'az containerapp update -n {a2a_oncall_containerapp_resource_name} -g {resource_group_name} --image \"{container_registry_name}.azurecr.io/{mcp_sk_server_image}:v0.{build}\" --set-env-vars TITLE={title} MCP_URL={mcp_url} APIM_GATEWAY_URL={apim_resource_gateway_url} OPENAI_API_VERSION={openai_api_version} OPENAI_DEPLOYMENT_NAME={openai_deployment_name} APIM_SUBSCRIPTION_KEY=secretref:apimsubscriptionkey', \n", diff --git a/labs/model-context-protocol/main.bicep b/labs/model-context-protocol/main.bicep index 18ddc2a3..754230a5 100644 --- a/labs/model-context-protocol/main.bicep +++ b/labs/model-context-protocol/main.bicep @@ -10,11 +10,11 @@ param inferenceAPIType string = 'AzureOpenAI' param inferenceAPIPath string = 'inference' // Path to the inference API in the APIM service param foundryProjectName string = 'default' - -param githubAPIPath string = 'github' -param weatherAPIPath string = 'weather' -param oncallAPIPath string = 'oncall' -param servicenowAPIPath string = 'servicenow' +param gitHubAuthorizationProviderName string = 'github' +param githubPath string = 'github' +param weatherPath string = 'weather' +param oncallPath string = 'oncall' +param servicenowPath string = 'servicenow' param serviceNowInstanceName string = '' // ------------------ @@ -188,7 +188,7 @@ resource gitHubMCPServerContainerApp 'Microsoft.App/containerApps@2023-11-02-pre env: [ { name: 'APIM_GATEWAY_URL' - value: '${apimService.properties.gatewayUrl}/${githubAPIPath}' + value: '${apimService.properties.gatewayUrl}/${githubPath}/api' } { name: 'AZURE_CLIENT_ID' @@ -210,9 +210,13 @@ resource gitHubMCPServerContainerApp 'Microsoft.App/containerApps@2023-11-02-pre name: 'APIM_SERVICE_NAME' value: apimService.name } + { + name: 'AUTHORIZATION_PROVIDER_ID' + value: gitHubAuthorizationProviderName + } { name: 'POST_LOGIN_REDIRECT_URL' - value: 'http://www.bing.com' + value: 'http://www.github.com' } { name: 'APIM_IDENTITY_OBJECT_ID' @@ -352,7 +356,7 @@ resource servicenowMCPServerContainerApp 'Microsoft.App/containerApps@2023-11-02 env: [ { name: 'APIM_GATEWAY_URL' - value: '${apimService.properties.gatewayUrl}/${servicenowAPIPath}' + value: '${apimService.properties.gatewayUrl}/${servicenowPath}/api' } { name: 'AZURE_CLIENT_ID' @@ -397,43 +401,62 @@ resource servicenowMCPServerContainerApp 'Microsoft.App/containerApps@2023-11-02 } } -module githubAPIModule '../../modules/apim-streamable-mcp/api.bicep' = { +module githubAPIModule './src/github/apim-api/api.bicep' = { name: 'githubAPIModule' params: { apimServiceName: apimService.name - MCPPath: githubAPIPath - MCPServiceURL: 'https://${gitHubMCPServerContainerApp.properties.configuration.ingress.fqdn}/${githubAPIPath}' + APIPath: githubPath + APIServiceURL: 'https://api.github.com' + authorizationProviderName: gitHubAuthorizationProviderName } } -module weatherAPIModule '../../modules/apim-streamable-mcp/api.bicep' = { - name: 'weatherAPIModule' +module githubMCPModule '../../modules/apim-streamable-mcp/api.bicep' = { + name: 'githubMCPModule' params: { apimServiceName: apimService.name - MCPPath: weatherAPIPath - MCPServiceURL: 'https://${weatherMCPServerContainerApp.properties.configuration.ingress.fqdn}/${weatherAPIPath}' + MCPPath: githubPath + MCPServiceURL: 'https://${gitHubMCPServerContainerApp.properties.configuration.ingress.fqdn}' } } -module oncallAPIModule '../../modules/apim-streamable-mcp/api.bicep' = { - name: 'oncallAPIModule' +module weatherMCPModule '../../modules/apim-streamable-mcp/api.bicep' = { + name: 'weatherMCPModule' params: { apimServiceName: apimService.name - MCPPath: oncallAPIPath - MCPServiceURL: 'https://${oncallMCPServerContainerApp.properties.configuration.ingress.fqdn}/${oncallAPIPath}' + MCPPath: weatherPath + MCPServiceURL: 'https://${weatherMCPServerContainerApp.properties.configuration.ingress.fqdn}' } } -module serviceNowAPIModule 'src/servicenow/apim-api/api.bicep' = if(length(serviceNowInstanceName) > 0) { +module oncallMCPModule '../../modules/apim-streamable-mcp/api.bicep' = { + name: 'oncallMCPModule' + params: { + apimServiceName: apimService.name + MCPPath: oncallPath + MCPServiceURL: 'https://${oncallMCPServerContainerApp.properties.configuration.ingress.fqdn}' + } +} + +module servicenowAPIModule './src/servicenow/apim-api/api.bicep' = if(length(serviceNowInstanceName) > 0) { name: 'servicenowAPIModule' params: { apimServiceName: apimService.name - APIPath: servicenowAPIPath - APIServiceURL: 'https://${servicenowMCPServerContainerApp.properties.configuration.ingress.fqdn}/${servicenowAPIPath}' + APIPath: servicenowPath + APIServiceURL: 'https://api.servicenow.com' serviceNowInstanceName: serviceNowInstanceName } } +module serviceNowMCPModule '../../modules/apim-streamable-mcp/api.bicep' = if(length(serviceNowInstanceName) > 0) { + name: 'servicenowMCPModule' + params: { + apimServiceName: apimService.name + MCPPath: servicenowPath + MCPServiceURL: 'https://${servicenowMCPServerContainerApp.properties.configuration.ingress.fqdn}/${servicenowPath}/mcp' + } +} + var apimContributorRoleDefinitionID = resourceId('Microsoft.Authorization/roleDefinitions', '312a565d-c81f-4fd8-895a-4e21e48d571c') resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { scope: apimService diff --git a/labs/model-context-protocol/model-context-protocol.ipynb b/labs/model-context-protocol/model-context-protocol.ipynb index ba864150..7bdd6dec 100644 --- a/labs/model-context-protocol/model-context-protocol.ipynb +++ b/labs/model-context-protocol/model-context-protocol.ipynb @@ -82,17 +82,6 @@ "utils.print_ok('Notebook initialized')" ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Ensure you have the latest version of the Azure AI Projects package\n", - "# This is required for the MCP client to work properly with the Azure AI Foundry service if you want to run the AI Agent Service demo\n", - "%pip install azure-ai-projects==1.0.0b12 agent-framework" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -244,22 +233,7 @@ "\n", "### πŸ§ͺ Test the connection to the MCP servers and List Tools\n", "\n", - "πŸ’‘ To integrate MCP servers in VS Code, use the MCP server URL `../mcp ` for configuration in GitHub Copilot Agent Mode\n", - "\n", - "If the notebook is run again, the JWT validation that gets applied to the policies later on must first be removed. Otherwise, the next calls below will fail as auth is not yet expected to be in place." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "policy_xml_file = \"src/github/apim-api/no-auth-client-policy.xml\"\n", - "\n", - "with open(policy_xml_file, 'r') as file:\n", - " policy_xml = file.read()\n", - " utils.update_api_operation_policy(subscription_id, resource_group_name, apim_resource_name, \"github-mcp-tools\", \"mcp\", policy_xml)" + "πŸ’‘ To integrate MCP servers in VS Code, use the MCP server URL `../mcp ` for configuration in GitHub Copilot Agent Mode" ] }, { @@ -270,7 +244,6 @@ "source": [ "import os, json, asyncio, time, requests\n", "from mcp import ClientSession\n", - "from mcp.client.sse import sse_client\n", "from mcp.client.streamable_http import streamablehttp_client\n", "import nest_asyncio\n", "nest_asyncio.apply()\n", @@ -304,9 +277,9 @@ " print(f\" Input Schema: {tool.inputSchema}\")\n", "\n", "try: \n", - " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/weather\"))\n", - " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/oncall\"))\n", - " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/github\"))\n", + " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/weather/mcp\"))\n", + " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/oncall/mcp\"))\n", + " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/github/mcp\"))\n", "\n", "finally:\n", " print(f\"βœ… Connection closed\")\n" @@ -322,7 +295,7 @@ "#### Execute the following steps:\n", "1. Execute `npx @modelcontextprotocol/inspector` in a terminal\n", "2. Open the provided URL in a browser\n", - "3. Set the transport type as SSE\n", + "3. Set the transport type as Streamable HTTP\n", "4. Provide the MCP server url and click connect\n", "5. Select the \"Tools\" tab to see and run the available tools" ] @@ -429,8 +402,8 @@ " if streams is not None:\n", " await streams_ctx.__aexit__(None, None, None)\n", "\n", - "asyncio.run(run_completion_with_tools(f\"{apim_resource_gateway_url}/weather\", \"What's the current weather in Lisbon?\"))\n", - "asyncio.run(run_completion_with_tools(f\"{apim_resource_gateway_url}/oncall\", \"Who's oncall in CET today?\"))\n" + "asyncio.run(run_completion_with_tools(f\"{apim_resource_gateway_url}/weather/mcp\", \"What's the current weather in Lisbon?\"))\n", + "asyncio.run(run_completion_with_tools(f\"{apim_resource_gateway_url}/oncall/mcp\", \"Who's oncall in CET today?\"))\n" ] }, { @@ -460,7 +433,7 @@ " async with (\n", " MCPStreamableHTTPTool(\n", " name=\"weather\",\n", - " url=f\"{apim_resource_gateway_url}/weather\",\n", + " url=f\"{apim_resource_gateway_url}/weather/mcp\",\n", " headers={\"Authorization\": \"Bearer token\"},\n", " description=\"Weather Plugin\",\n", " )\n", @@ -517,7 +490,7 @@ "# MCP tool definition\n", "mcp_tool = McpTool(\n", " server_label=\"weather\",\n", - " server_url=f\"{apim_resource_gateway_url}/weather\",\n", + " server_url=f\"{apim_resource_gateway_url}/weather/mcp\",\n", " #allowed_tools=[], # Optional initial allow‑list\n", ")\n", "\n", @@ -586,8 +559,19 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "\n", - "### πŸ§ͺ Execute a [Semantic Kernel Agent using MCP Tools](https://devblogs.microsoft.com/semantic-kernel/integrating-model-context-protocol-tools-with-semantic-kernel-a-step-by-step-guide/) via Azure API Management" + "\n", + "### 5️⃣ Create a GitHub OAuth app and configure the credential provider\n", + "\n", + "#### Step 1 - [Register the application in GitHub](https://learn.microsoft.com/en-us/azure/api-management/credentials-how-to-github#step-1-register-an-application-in-github)\n", + "\n", + "πŸ‘‰ Use the Authorization callback URL that is provided below \n", + "πŸ‘‰ Copy the Client ID and Client secret\n", + "\n", + "#### Step 2 - [Configure the credential provider in API Management](https://learn.microsoft.com/en-us/azure/api-management/credentials-how-to-github#step-2-configure-a-credential-provider-in-api-management)\n", + "\n", + "πŸ‘‰ You just need to update the Client ID and Client secret on the existing `github` credential manager provider or create a new one of type 'GitHub'\n", + "πŸ‘‰ Set the scopes as `user repo`\n", + "πŸ‘‰ Disregard the remaining steps outlined in the documentation\n" ] }, { @@ -596,53 +580,15 @@ "metadata": {}, "outputs": [], "source": [ - "import asyncio\n", - "from semantic_kernel.agents import ChatCompletionAgent, ChatHistoryAgentThread\n", - "from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion\n", - "from semantic_kernel.connectors.mcp import MCPStreamableHttpPlugin\n", - "\n", - "user_input = \"What's the current weather in Lisbon?\"\n", - "\n", - "async def main():\n", - " # 1. Create the agent\n", - " async with MCPStreamableHttpPlugin(\n", - " name=\"Weather\",\n", - " url=f\"{apim_resource_gateway_url}/weather\",\n", - " description=\"Weather Plugin\",\n", - " ) as weather_plugin:\n", - " agent = ChatCompletionAgent(\n", - " service=AzureChatCompletion(\n", - " endpoint=f\"{apim_resource_gateway_url}/{inference_api_path}\",\n", - " api_key=api_key,\n", - " api_version=inference_api_version, \n", - " deployment_name=models_config[0]['name'] # Use the first model from the models_config\n", - " ),\n", - " name=\"IssueAgent\",\n", - " instructions=\"Answer questions about the Weather.\",\n", - " plugins=[weather_plugin],\n", - " )\n", - "\n", - " thread: ChatHistoryAgentThread | None = None\n", - "\n", - " print(f\"# User: {user_input}\")\n", - " # 2. Invoke the agent for a response\n", - " response = await agent.get_response(messages=user_input, thread=thread)\n", - " print(f\"# {response.name}: {response} \")\n", - " thread = response.thread # type: ignore\n", - "\n", - " # 3. Cleanup: Clear the thread\n", - " await thread.delete() if thread else None\n", - "\n", - "if __name__ == \"__main__\":\n", - " asyncio.run(main())" + "print(f\"Authorization callback URL: https://authorization-manager.consent.azure-apim.net/redirect/apim/{apim_resource_name}\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "\n", - "### πŸ§ͺ Execute an [AutoGen Agent using MCP Tools](https://microsoft.github.io/autogen/stable/reference/python/autogen_ext.tools.mcp.html) via Azure API Management" + "\n", + "### πŸ§ͺ Execute an [LangChain Agent](https://docs.langchain.com/oss/python/langchain/agents) via Azure API Management" ] }, { @@ -651,60 +597,82 @@ "metadata": {}, "outputs": [], "source": [ + "from langchain_mcp_adapters.tools import load_mcp_tools\n", + "from langchain.agents import create_agent\n", + "from langchain_openai import AzureChatOpenAI\n", + "from langgraph.checkpoint.memory import MemorySaver\n", + "from mcp import ClientSession\n", + "from mcp.client.streamable_http import streamablehttp_client\n", "import asyncio\n", - "from autogen_ext.models.openai import AzureOpenAIChatCompletionClient\n", - "from autogen_ext.tools.mcp import mcp_server_tools, StreamableHttpServerParams\n", - "from autogen_agentchat.agents import AssistantAgent\n", - "from autogen_agentchat.ui import Console\n", - "from autogen_core import CancellationToken\n", - "\n", - "async def run_agent(url, prompt) -> None:\n", - " # Create server params for the remote MCP service\n", - " server_params = StreamableHttpServerParams(\n", - " url=url,\n", - " headers={\"Content-Type\": \"application/json\"},\n", - " timeout=30, # Connection timeout in seconds\n", - " )\n", - "\n", - " # Get all available tools\n", - " tools = await mcp_server_tools(server_params)\n", - "\n", - " # Create an agent that can use the translation tool\n", - " model_client = AzureOpenAIChatCompletionClient(azure_deployment=models_config[0]['name'], model=models_config[0]['name'],\n", - " azure_endpoint=f\"{apim_resource_gateway_url}/{inference_api_path}\",\n", - " api_key=api_key,\n", - " api_version=inference_api_version\n", - " )\n", - " agent = AssistantAgent(\n", - " name=\"weather\",\n", - " model_client=model_client,\n", - " reflect_on_tool_use=True,\n", - " tools=tools, # type: ignore\n", - " system_message=\"You are a helpful assistant.\",\n", - " )\n", - " await Console(\n", - " agent.run_stream(task=prompt)\n", - " )\n", - "\n", - "asyncio.run(run_agent(f\"{apim_resource_gateway_url}/weather\", \"What's the weather in Lisbon, Cairo and London?\"))\n" + "\n", + "mcp_url = f\"{apim_resource_gateway_url}/github/mcp\"\n", + "\n", + "model = AzureChatOpenAI(\n", + " azure_endpoint=f\"{apim_resource_gateway_url}/{inference_api_path}\",\n", + " api_key=api_key,\n", + " api_version=inference_api_version,\n", + " model=models_config[0]['name']\n", + ")\n", + "\n", + "memory = MemorySaver()\n", + "\n", + "# Use a persistent session to maintain the Mcp-Session-Id across tool calls\n", + "# The session is automatically cleared when the context manager exits (sends DELETE with Mcp-Session-Id)\n", + "async with streamablehttp_client(mcp_url) as (read_stream, write_stream, get_session_id):\n", + " async with ClientSession(read_stream, write_stream) as session:\n", + " await session.initialize()\n", + " print(f\"MCP session started: {get_session_id()}\")\n", + " \n", + " tools = await load_mcp_tools(session)\n", + " agent = create_agent(model, tools, checkpointer=memory)\n", + "\n", + " config = {\"configurable\": {\"thread_id\": \"1\"}}\n", + "\n", + " response = await agent.ainvoke(\n", + " {\"messages\": [{\"role\": \"system\", \"content\": \"You are a helpful assistant that retrieves GitHub issues. When authorizing always provide the URL\"},\n", + " {\"role\": \"user\", \"content\": \"Please list all the issues assigned to me in the GitHub repository ai-gateway-toolkit under the username vieiraae\"}]},\n", + " config=config\n", + " )\n", + "\n", + " print(response[\"messages\"][-1].content)\n", + "\n", + " # Poll until the user completes the authorization flow\n", + " max_retries = 30\n", + " for attempt in range(1, max_retries + 1):\n", + " await asyncio.sleep(5)\n", + " print(f\"\\n⏳ Checking authorization status (attempt {attempt}/{max_retries})...\")\n", + "\n", + " response = await agent.ainvoke(\n", + " {\"messages\": [{\"role\": \"user\", \"content\": \"I'm authorized! Please retry the original request.\"}]},\n", + " config=config\n", + " )\n", + "\n", + " last_message = response[\"messages\"][-1].content.lower()\n", + " if \"authorize\" not in last_message and \"login\" not in last_message and \"authentication\" not in last_message:\n", + " print(response[\"messages\"][-1].content)\n", + " break\n", + "\n", + " print(\"πŸ” Not yet authorized, waiting...\")\n", + " else:\n", + " print(\"❌ Authorization timed out after maximum retries\")\n", + "\n", + "print(\"\\n\\nπŸ—‘οΈ MCP session cleared\")" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "\n", - "### 5️⃣ Create a GitHub OAuth app and configure the credential provider\n", - "\n", - "#### Step 1 - [Register the application in GitHub](https://learn.microsoft.com/en-us/azure/api-management/credentials-how-to-github#step-1-register-an-application-in-github)\n", - "\n", - "πŸ‘‰ Use the Authorization callback URL that is provided below \n", - "πŸ‘‰ Copy the Client ID and Client secret\n", - "\n", - "#### Step 2 - [Configure the credential provider in API Management](https://learn.microsoft.com/en-us/azure/api-management/credentials-how-to-github#step-2-configure-a-credential-provider-in-api-management)\n", + "\n", + "### πŸ§ͺ Run the GitHub MCP Server with VS Code to retrieve GitHub Issues\n", "\n", - "πŸ‘‰ You just need to update the Client ID and Client secret on the existing `github` credential manager provider \n", - "πŸ‘‰ Disregard the remaining steps outlined in the documentation\n" + "1. [Configure the GitHub MCP Server in VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) \n", + "2. Type in the chat the following prompt: `Please list all the issues assigned to me in the GitHub repository {your-repo-name} under the organization {your-org-name}`\n", + "3. The agent will suggest running the `authorize_github` tool.\n", + "4. Once the user accepts to run the tool, the agent will call the `authorize_github` and provide an URL to proceed with the authentication and authorization on GitHub.\n", + "5. After the user confirms that it's done, the agent will suggest running the `get_user` tool.\n", + "6. Once the user accepts to run the `get_user` tool, the agent will call the tool, return user information as context and suggest running the `get_issues` tool.\n", + "7. Once the user accepts to run the `get_issues` tool, the agent will provide the list of issues from the given repo." ] }, { @@ -726,31 +694,6 @@ "πŸ‘‰ You just need to update the Client ID and Client secret on the existing `servicenonw` credential manager provider " ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "print(f\"Authorization callback URL: https://authorization-manager.consent.azure-apim.net/redirect/apim/{apim_resource_name}\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "### πŸ§ͺ Run the GitHub MCP Server with VS Code to retrieve GitHub Issues\n", - "\n", - "1. [Configure the GitHub MCP Server in VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers#_add-an-mcp-server) \n", - "2. Type in the chat the following prompt: `Please list all the issues assigned to me in the GitHub repository {your-repo-name} under the organization {your-org-name}`\n", - "3. The agent will suggest running the `authorize_github` tool.\n", - "4. Once the user accepts to run the tool, the agent will call the `authorize_github` and provide an URL to proceed with the authentication and authorization on GitHub.\n", - "5. After the user confirms that it's done, the agent will suggest running the `get_user` tool.\n", - "6. Once the user accepts to run the `get_user` tool, the agent will call the tool, return user information as context and suggest running the `get_issues` tool.\n", - "7. Once the user accepts to run the `get_issues` tool, the agent will provide the list of issues from the given repo." - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -776,7 +719,7 @@ "\n", "### πŸ” (Optional) Implement [authorization policies](src/github/apim-api/auth-client-policy.xml) on MCP endpoints\n", "\n", - "πŸ‘‰ To ensure the enforcement of valid security tokens, we apply the `validate-jwt` policy to the `/sse` and `/messages` endpoints. The following code snippet demonstrates the application of this policy to GitHub API operations for token validation:" + "πŸ‘‰ To ensure the enforcement of valid security tokens, we apply the `validate-jwt` to the mcp server:" ] }, { @@ -785,12 +728,11 @@ "metadata": {}, "outputs": [], "source": [ - "policy_xml_file = \"src/github/apim-api/auth-client-policy.xml\"\n", + "policy_xml_file = \"../../modules/apim-streamable-mcp/auth-client-policy.xml\"\n", "\n", "with open(policy_xml_file, 'r') as file:\n", " policy_xml = file.read()\n", - " utils.update_api_operation_policy(subscription_id, resource_group_name, apim_resource_name, \"github-mcp\", \"sse\", policy_xml)\n", - " utils.update_api_operation_policy(subscription_id, resource_group_name, apim_resource_name, \"github-mcp\", \"messages\", policy_xml)" + " utils.update_api_policy(subscription_id, resource_group_name, apim_resource_name, \"github-mcp-server\", policy_xml)\n" ] }, { @@ -809,12 +751,12 @@ "source": [ "# Unauthenticated call should fail with 401 Unauthorized\n", "import requests\n", - "utils.print_info(\"Calling sse endpoint WITHOUT authorization...\")\n", - "response = requests.get(f\"{apim_resource_gateway_url}/github/sse\", headers={\"Content-Type\": \"application/json\"})\n", + "utils.print_info(\"Calling the MCP server WITHOUT authorization...\")\n", + "response = requests.get(f\"{apim_resource_gateway_url}/github/mcp\", headers={\"Content-Type\": \"application/json\"})\n", "if response.status_code == 401:\n", " utils.print_ok(\"Received 401 Unauthorized as expected\")\n", "elif response.status_code == 200:\n", - " utils.print_error(\"Call succeeded. Double check that validate-jwt policy has been deployed to sse endpoint\")\n", + " utils.print_error(\"Call succeeded. Double check that validate-jwt policy has been deployed to MCP Server\")\n", "else:\n", " utils.print_error(f\"Unexpected status code: {response.status_code}\")\n" ] @@ -835,11 +777,11 @@ "source": [ "import requests\n", "# Authenticated call should succeed\n", - "utils.print_info(\"Calling sse endpoint WITH authorization...\")\n", + "utils.print_info(\"Calling the MCP server WITH authorization...\")\n", "output = utils.run(\"az account get-access-token --resource \\\"https://azure-api.net/authorization-manager\\\"\")\n", "if output.success and output.json_data:\n", " access_token = output.json_data.get('accessToken')\n", - " response = requests.get(f\"{apim_resource_gateway_url}/github/sse\", stream=True,\n", + " response = requests.get(f\"{apim_resource_gateway_url}/github/mcp\", stream=True,\n", " headers={\"Content-Type\": \"application/json\", \"Authorization\": \"Bearer \" + str(access_token)})\n", " if response.status_code == 200:\n", " utils.print_ok(\"Received status code 200 as expected\")\n", @@ -862,7 +804,7 @@ ], "metadata": { "kernelspec": { - "display_name": "myenv", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -876,7 +818,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.5" + "version": "3.12.10" } }, "nbformat": 4, diff --git a/labs/model-context-protocol/src/github/apim-api/api.bicep b/labs/model-context-protocol/src/github/apim-api/api.bicep index a6230f5b..4ae609cd 100644 --- a/labs/model-context-protocol/src/github/apim-api/api.bicep +++ b/labs/model-context-protocol/src/github/apim-api/api.bicep @@ -1,41 +1,24 @@ param apimServiceName string -param MCPServiceURL string -param MCPPath string = 'github' +param APIPath string = 'github' +param APIServiceURL string = 'https://api.github.com' +param authorizationProviderName string = 'github' resource apim 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = { name: apimServiceName } -resource mcpBackend 'Microsoft.ApiManagement/service/backends@2024-06-01-preview' ={ +resource api 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = { parent: apim - name: '${MCPPath}-mcp-backend' + name: 'github-api' properties: { - protocol: 'http' - url: MCPServiceURL - tls: { - validateCertificateChain: true - validateCertificateName: true - } - type: 'Single' - } -} - -resource mcp 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = { - parent: apim - name: '${MCPPath}-mcp-tools' - properties: { - displayName: '${MCPPath} MCP Tools' - type: 'mcp' + displayName: 'GitHub API' subscriptionRequired: false - backendId: mcpBackend.name - path: MCPPath + serviceUrl: APIServiceURL + path: '${APIPath}/api' protocols: [ 'https' ] - mcpProperties:{ - transportType: 'streamable' - } authenticationSettings: { oAuth2AuthenticationSettings: [] openidAuthenticationSettings: [] @@ -45,11 +28,30 @@ resource mcp 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = { query: 'subscription-key' } isCurrent: true + format: 'openapi+json' + value: loadTextContent('openapi.json') + } +} + +resource apiInsights 'Microsoft.ApiManagement/service/apis/diagnostics@2022-08-01' = { + name: 'applicationinsights' + parent: api + properties: { + alwaysLog: 'allErrors' + httpCorrelationProtocol: 'W3C' + logClientIp: true + loggerId: resourceId(resourceGroup().name, 'Microsoft.ApiManagement/service/loggers', apimServiceName, 'appinsights-logger') + metrics: true + verbosity: 'verbose' + sampling: { + samplingType: 'fixed' + percentage: 100 + } } } resource apiPolicy 'Microsoft.ApiManagement/service/apis/policies@2021-12-01-preview' = { - parent: mcp + parent: api name: 'policy' properties: { value: loadTextContent('policy.xml') @@ -57,13 +59,12 @@ resource apiPolicy 'Microsoft.ApiManagement/service/apis/policies@2021-12-01-pre } } - - +/* resource authorizationProvider 'Microsoft.ApiManagement/service/authorizationProviders@2024-06-01-preview' = { parent: apim - name: 'github' + name: authorizationProviderName properties: { - displayName: 'github' + displayName: authorizationProviderName identityProvider: 'github' oauth2: { redirectUrl: 'https://authorization-manager.consent.azure-apim.net/redirect/apim/${apim.name}' @@ -76,37 +77,5 @@ resource authorizationProvider 'Microsoft.ApiManagement/service/authorizationPro } } } +*/ -resource userOperation 'Microsoft.ApiManagement/service/apis/operations@2024-06-01-preview' existing = { - parent: mcp - name: 'user' -} - -resource userOperationPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2024-06-01-preview' = { - parent: userOperation - name: 'policy' - properties: { - value: loadTextContent('operation-policy.xml') - format: 'rawxml' - } - dependsOn: [ - apim - ] -} - -resource issuesOperation 'Microsoft.ApiManagement/service/apis/operations@2024-06-01-preview' existing = { - parent: mcp - name: 'issues' -} - -resource issuesOperationPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2024-06-01-preview' = { - parent: issuesOperation - name: 'policy' - properties: { - value: loadTextContent('operation-policy.xml') - format: 'rawxml' - } - dependsOn: [ - apim - ] -} diff --git a/labs/model-context-protocol/src/github/apim-api/no-auth-client-policy.xml b/labs/model-context-protocol/src/github/apim-api/no-auth-client-policy.xml deleted file mode 100644 index ce18a374..00000000 --- a/labs/model-context-protocol/src/github/apim-api/no-auth-client-policy.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/labs/model-context-protocol/src/github/apim-api/openapi.json b/labs/model-context-protocol/src/github/apim-api/openapi.json index ffa7b00f..3e44739d 100644 --- a/labs/model-context-protocol/src/github/apim-api/openapi.json +++ b/labs/model-context-protocol/src/github/apim-api/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.1", "info": { - "title": "GitHub MCP", + "title": "GitHub API", "description": "", "version": "1.0" }, @@ -9,50 +9,6 @@ "url": "https://apim-rocks.azure-api.net/github" }], "paths": { - "/messages/": { - "post": { - "tags": ["MCP"], - "summary": "messages", - "description": "messages", - "operationId": "messages", - "parameters": [{ - "name": "Content-Type", - "in": "header", - "required": true, - "schema": { - "enum": [""], - "type": "" - } - }], - "responses": { - "200": { - "description": "null" - } - } - } - }, - "/sse": { - "get": { - "tags": ["MCP"], - "summary": "sse", - "description": "sse", - "operationId": "sse", - "parameters": [{ - "name": "Content-Type", - "in": "header", - "required": true, - "schema": { - "enum": [""], - "type": "" - } - }], - "responses": { - "200": { - "description": "null" - } - } - } - }, "/user": { "get": { "tags": ["API"], diff --git a/labs/model-context-protocol/src/github/apim-api/operation-policy.xml b/labs/model-context-protocol/src/github/apim-api/operation-policy.xml deleted file mode 100644 index e8f0c103..00000000 --- a/labs/model-context-protocol/src/github/apim-api/operation-policy.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - @("Bearer " + ((Authorization)context.Variables.GetValueOrDefault("auth-context"))?.AccessToken) - - - - application/vnd.github+json - - - 2022-11-28 - - - API Management - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/labs/model-context-protocol/src/github/apim-api/policy.xml b/labs/model-context-protocol/src/github/apim-api/policy.xml index ce18a374..43823455 100644 --- a/labs/model-context-protocol/src/github/apim-api/policy.xml +++ b/labs/model-context-protocol/src/github/apim-api/policy.xml @@ -1,14 +1,38 @@ + + + + + + @("Bearer " + ((Authorization)context.Variables.GetValueOrDefault("auth-context"))?.AccessToken) + + + + application/vnd.github+json + + + 2022-11-28 + + + API Management + + + + - + \ No newline at end of file diff --git a/labs/model-context-protocol/src/servicenow/apim-api/api.bicep b/labs/model-context-protocol/src/servicenow/apim-api/api.bicep index e59eff42..be1ad0b4 100644 --- a/labs/model-context-protocol/src/servicenow/apim-api/api.bicep +++ b/labs/model-context-protocol/src/servicenow/apim-api/api.bicep @@ -1,32 +1,23 @@ param apimServiceName string -param APIServiceURL string -param APIPath string = 'servicenow' param serviceNowInstanceName string +param APIServiceURL string = 'https://${serviceNowInstanceName}.service-now.com' +param APIPath string = 'servicenow' resource apim 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = { name: apimServiceName -} - -resource servicenowBackend 'Microsoft.ApiManagement/service/backends@2024-06-01-preview' = { - name: 'servicenow-backend' - parent: apim - properties: { - description: 'Backend for ServiceNow API' - url: 'https://${serviceNowInstanceName}.service-now.com' - protocol: 'http' - } -} - +} resource api 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = { parent: apim - name: 'servicenow-mcp' + name: 'servicenow-api' properties: { - displayName: 'ServiceNow MCP' + displayName: 'ServiceNow API' apiRevision: '1' subscriptionRequired: false + type: 'http' + apiType: 'http' serviceUrl: APIServiceURL - path: APIPath + path: '${APIPath}/api' protocols: [ 'https' ] diff --git a/labs/model-context-protocol/src/servicenow/apim-api/openapi.json b/labs/model-context-protocol/src/servicenow/apim-api/openapi.json index 1463a05f..f5252767 100644 --- a/labs/model-context-protocol/src/servicenow/apim-api/openapi.json +++ b/labs/model-context-protocol/src/servicenow/apim-api/openapi.json @@ -1,7 +1,7 @@ { "openapi": "3.0.1", "info": { - "title": "ServiceNow MCP", + "title": "ServiceNow API", "description": "", "version": "1.0" }, @@ -573,62 +573,6 @@ } } } - }, - "/messages/": { - "post": { - "tags": [ - "MCP" - ], - "summary": "messages", - "description": "messages", - "operationId": "messages", - "parameters": [ - { - "name": "Content-Type", - "in": "header", - "required": true, - "schema": { - "enum": [ - "" - ], - "type": "" - } - } - ], - "responses": { - "200": { - "description": "null" - } - } - } - }, - "/sse": { - "get": { - "tags": [ - "MCP" - ], - "summary": "sse", - "description": "sse", - "operationId": "sse", - "parameters": [ - { - "name": "Content-Type", - "in": "header", - "required": true, - "schema": { - "enum": [ - "" - ], - "type": "" - } - } - ], - "responses": { - "200": { - "description": "null" - } - } - } } }, "components": { diff --git a/labs/model-context-protocol/src/servicenow/mcp-server/mcp-server.py b/labs/model-context-protocol/src/servicenow/mcp-server/mcp-server.py index 92eaa5dc..2f4c2330 100644 --- a/labs/model-context-protocol/src/servicenow/mcp-server/mcp-server.py +++ b/labs/model-context-protocol/src/servicenow/mcp-server/mcp-server.py @@ -12,6 +12,8 @@ from azure.mgmt.apimanagement.models import AuthorizationContract, AuthorizationAccessPolicyContract, AuthorizationLoginRequestContract +### DEPRECATED IMPLEMENTATION - SEE shared/mcp-servers/github/http/mcp_server.py for reference implementation of credential management and authorization flow using APIM. This file is being kept for reference for the ServiceNow implementation. + # Initialize FastMCP server for ServiceNow API mcp = FastMCP("ServiceNow") diff --git a/labs/realtime-mcp-agents/realtime-mcp-agents.ipynb b/labs/realtime-mcp-agents/realtime-mcp-agents.ipynb index e0479b47..2ae9032c 100644 --- a/labs/realtime-mcp-agents/realtime-mcp-agents.ipynb +++ b/labs/realtime-mcp-agents/realtime-mcp-agents.ipynb @@ -274,8 +274,8 @@ " print(f\" Input Schema: {tool.inputSchema}\")\n", "\n", "try: \n", - " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/weather\"))\n", - " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/spotify_mcp\"))\n", + " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/weather/mcp\"))\n", + " asyncio.run(list_tools(f\"{apim_resource_gateway_url}/spotify_mcp/mcp\"))\n", "\n", "finally:\n", " print(f\"βœ… Connection closed\")\n" @@ -331,7 +331,7 @@ "\n", "weather_plugin = MCPStreamableHttpPlugin(\n", " name=\"Weather\",\n", - " url=f\"{apim_resource_gateway_url}/weather\",\n", + " url=f\"{apim_resource_gateway_url}/weather/mcp\",\n", " description=\"Weather Plugin\",\n", ")\n", " \n", @@ -417,8 +417,8 @@ "\n", "class OpenAIHandler(AsyncStreamHandler):\n", " mcp_config = {\n", - " 'spotify': f\"{apim_resource_gateway_url}/spotify_mcp\",\n", - " 'weather': f\"{apim_resource_gateway_url}/weather\"\n", + " 'spotify': f\"{apim_resource_gateway_url}/spotify_mcp/mcp\",\n", + " 'weather': f\"{apim_resource_gateway_url}/weather/mcp\"\n", " }\n", " mcp_servers = {}\n", " tool_server_map = {}\n", @@ -659,85 +659,6 @@ "✨ Type in the chat the following prompt: `Create a ServiceNow incident for each GitHub issue`. To combine GitHub and ServiceNow MCP Servers." ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "### πŸ” (Optional) Implement [authorization policies](src/github/apim-api/auth-client-policy.xml) on MCP endpoints\n", - "\n", - "πŸ‘‰ To ensure the enforcement of valid security tokens, we apply the `validate-jwt` policy to the `/sse` and `/messages` endpoints. The following code snippet demonstrates the application of this policy to GitHub API operations for token validation:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "policy_xml_file = \"src/github/apim-api/auth-client-policy.xml\"\n", - "\n", - "with open(policy_xml_file, 'r') as file:\n", - " policy_xml = file.read()\n", - " utils.update_api_operation_policy(subscription_id, resource_group_name, apim_resource_name, \"github-mcp\", \"sse\", policy_xml)\n", - " utils.update_api_operation_policy(subscription_id, resource_group_name, apim_resource_name, \"github-mcp\", \"messages\", policy_xml)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "### πŸ§ͺ Test the authorization **WITHOUT** a valid token" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Unauthenticated call should fail with 401 Unauthorized\n", - "import requests\n", - "utils.print_info(\"Calling sse endpoint WITHOUT authorization...\")\n", - "response = requests.get(f\"{apim_resource_gateway_url}/github/sse\", headers={\"Content-Type\": \"application/json\"})\n", - "if response.status_code == 401:\n", - " utils.print_ok(\"Received 401 Unauthorized as expected\")\n", - "elif response.status_code == 200:\n", - " utils.print_error(\"Call succeeded. Double check that validate-jwt policy has been deployed to sse endpoint\")\n", - "else:\n", - " utils.print_error(f\"Unexpected status code: {response.status_code}\")\n" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "### πŸ§ͺ Test the authorization **WITH** a valid token" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import requests\n", - "# Authenticated call should succeed\n", - "utils.print_info(\"Calling sse endpoint WITH authorization...\")\n", - "output = utils.run(\"az account get-access-token --resource \\\"https://azure-api.net/authorization-manager\\\"\")\n", - "if output.success and output.json_data:\n", - " access_token = output.json_data.get('accessToken')\n", - " response = requests.get(f\"{apim_resource_gateway_url}/github/sse\", stream=True,\n", - " headers={\"Content-Type\": \"application/json\", \"Authorization\": \"Bearer \" + str(access_token)})\n", - " if response.status_code == 200:\n", - " utils.print_ok(\"Received status code 200 as expected\")\n", - " else:\n", - " utils.print_error(f\"Unexpected status code: {response.status_code}\")\n", - "\n" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -776,7 +697,7 @@ ], "metadata": { "kernelspec": { - "display_name": "py3.13", + "display_name": ".venv", "language": "python", "name": "python3" }, @@ -790,7 +711,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.9" + "version": "3.12.10" } }, "nbformat": 4, diff --git a/modules/apim-streamable-mcp/api.bicep b/modules/apim-streamable-mcp/api.bicep index 724f18b9..bc6713c8 100644 --- a/modules/apim-streamable-mcp/api.bicep +++ b/modules/apim-streamable-mcp/api.bicep @@ -22,13 +22,14 @@ resource mcpBackend 'Microsoft.ApiManagement/service/backends@2024-06-01-preview resource mcp 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = { parent: apim - name: '${MCPPath}-mcp-tools' + name: '${MCPPath}-mcp-server' properties: { - displayName: '${MCPPath} MCP Tools' + displayName: '${MCPPath} MCP Server' + description: '${MCPPath} MCP Server' type: 'mcp' subscriptionRequired: false backendId: mcpBackend.name - path: MCPPath + path: '${MCPPath}/mcp' protocols: [ 'https' ] @@ -56,5 +57,21 @@ resource APIPolicy 'Microsoft.ApiManagement/service/apis/policies@2021-12-01-pre } } +resource mcpInsights 'Microsoft.ApiManagement/service/apis/diagnostics@2022-08-01' = { + name: 'applicationinsights' + parent: mcp + properties: { + alwaysLog: 'allErrors' + httpCorrelationProtocol: 'W3C' + logClientIp: true + loggerId: resourceId(resourceGroup().name, 'Microsoft.ApiManagement/service/loggers', apimServiceName, 'appinsights-logger') + metrics: true + verbosity: 'verbose' + sampling: { + samplingType: 'fixed' + percentage: 100 + } + } +} diff --git a/labs/model-context-protocol/src/github/apim-api/auth-client-policy.xml b/modules/apim-streamable-mcp/auth-client-policy.xml similarity index 100% rename from labs/model-context-protocol/src/github/apim-api/auth-client-policy.xml rename to modules/apim-streamable-mcp/auth-client-policy.xml diff --git a/shared/mcp-servers/github/http/Dockerfile b/shared/mcp-servers/github/http/Dockerfile index 7bb5aa2a..89e69830 100644 --- a/shared/mcp-servers/github/http/Dockerfile +++ b/shared/mcp-servers/github/http/Dockerfile @@ -11,4 +11,4 @@ COPY . . EXPOSE 8080 -CMD ["uvicorn", "mcp_server:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["python", "mcp_server.py", "--host", "0.0.0.0", "--port", "8080"] diff --git a/shared/mcp-servers/github/http/credential_manager.py b/shared/mcp-servers/github/http/credential_manager.py new file mode 100644 index 00000000..0d155c3a --- /dev/null +++ b/shared/mcp-servers/github/http/credential_manager.py @@ -0,0 +1,152 @@ +import uuid +from azure.identity import DefaultAzureCredential +from azure.mgmt.apimanagement import ApiManagementClient +from azure.mgmt.apimanagement.models import ( + AuthorizationContract, + AuthorizationAccessPolicyContract, + AuthorizationLoginRequestContract, +) + + +class CredentialManager: + """Manages OAuth credentials for MCP sessions using Azure API Management authorization providers.""" + + def __init__( + self, + tenant_id: str, + subscription_id: str, + resource_group_name: str, + service_name: str, + apim_identity_object_id: str, + post_login_redirect_url: str, + authorization_provider_id: str, + authorization_type: str = "OAuth2", + oauth2_grant_type: str = "AuthorizationCode", + ): + self.tenant_id = tenant_id + self.subscription_id = subscription_id + self.resource_group_name = resource_group_name + self.service_name = service_name + self.apim_identity_object_id = apim_identity_object_id + self.post_login_redirect_url = post_login_redirect_url + self.authorization_provider_id = authorization_provider_id + self.authorization_type = authorization_type + self.oauth2_grant_type = oauth2_grant_type + self._client = ApiManagementClient( + credential=DefaultAzureCredential(), + subscription_id=subscription_id, + ) + + def _get_authorization_id(self, session_id: str) -> str: + """Build the authorization id from the provider and session.""" + return f"{self.authorization_provider_id.lower()}-{session_id}" + + def is_authorized(self, session_id: str) -> bool: + """Check if the session already has a connected authorization. + + Args: + session_id: The MCP session identifier. + + Returns: + True if the authorization status is 'Connected', False otherwise. + """ + return self._get_authorization_status(session_id) == "Connected" + + def _get_authorization_status(self, session_id: str) -> str | None: + """Get the current authorization status for a session. + + Args: + session_id: The MCP session identifier. + + Returns: + The status string ('Connected', 'Error', etc.) or None if not found. + """ + authorization_id = self._get_authorization_id(session_id) + try: + response = self._client.authorization.get( + resource_group_name=self.resource_group_name, + service_name=self.service_name, + authorization_provider_id=self.authorization_provider_id, + authorization_id=authorization_id, + ) + return response.status + except Exception: + return None + + def get_login_url(self, session_id: str) -> str: + """Create an authorization and return the login URL for the user. + + If the session is already authorized, returns a message indicating so. + If the authorization already exists (e.g. login pending), skips creation + and returns the login link directly to avoid duplicate access policy errors. + + Args: + session_id: The MCP session identifier. + + Returns: + The login URL string, or a message if already authorized. + """ + authorization_id = self._get_authorization_id(session_id) + + status = self._get_authorization_status(session_id) + + if status == "Connected": + return "Connection already authorized." + + # Only create authorization and access policy if no authorization exists yet + if status is None: + # Create authorization + self._client.authorization.create_or_update( + resource_group_name=self.resource_group_name, + service_name=self.service_name, + authorization_provider_id=self.authorization_provider_id, + authorization_id=authorization_id, + parameters=AuthorizationContract( + authorization_type=self.authorization_type, + o_auth2_grant_type=self.oauth2_grant_type, + ), + ) + + # Create access policy for the APIM managed identity + self._client.authorization_access_policy.create_or_update( + resource_group_name=self.resource_group_name, + service_name=self.service_name, + authorization_provider_id=self.authorization_provider_id, + authorization_id=authorization_id, + authorization_access_policy_id=str(uuid.uuid4())[:33], + parameters=AuthorizationAccessPolicyContract( + tenant_id=self.tenant_id, + object_id=self.apim_identity_object_id, + ), + ) + + # Get login link (works for both new and existing-but-pending authorizations) + response = self._client.authorization_login_links.post( + resource_group_name=self.resource_group_name, + service_name=self.service_name, + authorization_provider_id=self.authorization_provider_id, + authorization_id=authorization_id, + parameters=AuthorizationLoginRequestContract( + post_login_redirect_url=self.post_login_redirect_url, + ), + ) + + return response.login_link + + def delete_authorization(self, session_id: str) -> None: + """Delete the authorization for a session (cleanup). + + Args: + session_id: The MCP session identifier. + """ + authorization_id = self._get_authorization_id(session_id) + try: + self._client.authorization.delete( + resource_group_name=self.resource_group_name, + service_name=self.service_name, + authorization_provider_id=self.authorization_provider_id, + authorization_id=authorization_id, + if_match="*", + ) + except Exception: + pass diff --git a/shared/mcp-servers/github/http/mcp_server.py b/shared/mcp-servers/github/http/mcp_server.py index b4445fee..19d6fbd4 100644 --- a/shared/mcp-servers/github/http/mcp_server.py +++ b/shared/mcp-servers/github/http/mcp_server.py @@ -1,212 +1,102 @@ -import random -import uvicorn -import httpx, os, uuid -from typing import Any +import httpx +import os +from fastmcp import FastMCP, Context +from credential_manager import CredentialManager -# Support either the standalone 'fastmcp' package or the 'mcp' package layout. -try: - from fastmcp import FastMCP, Context # pip install fastmcp -except ModuleNotFoundError: # fall back to the layout you used originally - from mcp.server.fastmcp import FastMCP, Context # pip install mcp - -from starlette.applications import Starlette -from starlette.routing import Mount - -from azure.identity import DefaultAzureCredential -from azure.mgmt.apimanagement import ApiManagementClient -from azure.mgmt.apimanagement.models import AuthorizationContract, AuthorizationAccessPolicyContract, AuthorizationLoginRequestContract - -mcp = FastMCP("Github") - -# Environment variables APIM_GATEWAY_URL = str(os.getenv("APIM_GATEWAY_URL")) -SUBSCRIPTION_ID = str(os.getenv("SUBSCRIPTION_ID")) -RESOURCE_GROUP_NAME = str(os.getenv("RESOURCE_GROUP_NAME")) -APIM_SERVICE_NAME = str(os.getenv("APIM_SERVICE_NAME")) -AZURE_TENANT_ID = str(os.getenv("AZURE_TENANT_ID")) -AZURE_CLIENT_ID = str(os.getenv("AZURE_CLIENT_ID")) -POST_LOGIN_REDIRECT_URL = str(os.getenv("POST_LOGIN_REDIRECT_URL")) -APIM_IDENTITY_OBJECT_ID = str(os.getenv("APIM_IDENTITY_OBJECT_ID")) -idp = "github" -@mcp.tool() -async def authorize_github(ctx: Context) -> str: - """Validate Credential Manager connection exists and is connected. - - Args: - idp: The identity provider to authorize - Returns: - 401: Login URL for the user to authorize the connection - 200: Connection authorized - """ - print("Authorizing connection...") - print(f"AZURE_TENANT_ID: {AZURE_TENANT_ID}") - print(f"APIM Gateway URL: {APIM_GATEWAY_URL}") - - session_id = str(id(ctx.session)) - provider_id = idp.lower() - authorization_id = f"{provider_id}-{session_id}" - - print(f"SessionId: {session_id}") - - print("Creating API Management client...") - client = ApiManagementClient( - credential=DefaultAzureCredential(), - subscription_id=SUBSCRIPTION_ID, - ) +mcp = FastMCP("GitHub") - try: - response = client.authorization.get( - resource_group_name=RESOURCE_GROUP_NAME, - service_name=APIM_SERVICE_NAME, - authorization_provider_id=idp, - authorization_id=authorization_id, - ) - if response.status == "Connected": - print("GitHub authorization is already connected.") - return "Connection authorized." - except Exception as e: - print(f"Failed to get authorization") - - print("Getting authorization provider...") - response = client.authorization_provider.get( - resource_group_name=RESOURCE_GROUP_NAME, - service_name=APIM_SERVICE_NAME, - authorization_provider_id=idp, - ) +credential_manager = CredentialManager( + tenant_id=str(os.getenv("AZURE_TENANT_ID")), + subscription_id=str(os.getenv("SUBSCRIPTION_ID")), + resource_group_name=str(os.getenv("RESOURCE_GROUP_NAME")), + service_name=str(os.getenv("APIM_SERVICE_NAME")), + apim_identity_object_id=str(os.getenv("APIM_IDENTITY_OBJECT_ID")), + post_login_redirect_url=str(os.getenv("POST_LOGIN_REDIRECT_URL")), + authorization_provider_id=str(os.getenv("AUTHORIZATION_PROVIDER_ID")), +) - authContract: AuthorizationContract = AuthorizationContract( - authorization_type="OAuth2", - o_auth2_grant_type="AuthorizationCode" - ) - print("Creating or updating authorization...") - response = client.authorization.create_or_update( - resource_group_name=RESOURCE_GROUP_NAME, - service_name=APIM_SERVICE_NAME, - authorization_provider_id=idp, - authorization_id=authorization_id, - parameters=authContract - ) +def _get_session_id(ctx: Context) -> str: + """Extract the session id from the MCP context.""" + return str(id(ctx.session)) - authPolicyContract: AuthorizationAccessPolicyContract = AuthorizationAccessPolicyContract( - tenant_id=AZURE_TENANT_ID, - object_id=APIM_IDENTITY_OBJECT_ID - ) - print("Creating or updating authorization access policy...") - response = client.authorization_access_policy.create_or_update( - resource_group_name=RESOURCE_GROUP_NAME, - service_name=APIM_SERVICE_NAME, - authorization_provider_id=idp, - authorization_id=authorization_id, - authorization_access_policy_id=str(uuid.uuid4())[:33], - parameters=authPolicyContract - ) +def _get_github_headers(session_id: str) -> dict: + """Build headers for GitHub API calls via APIM.""" + authorization_id = credential_manager._get_authorization_id(session_id) + return { + "Content-Type": "application/json", + "authorizationId": authorization_id, + "providerId": credential_manager.authorization_provider_id, + } - authPolicyContract: AuthorizationAccessPolicyContract = AuthorizationAccessPolicyContract( - tenant_id=AZURE_TENANT_ID, - object_id=AZURE_CLIENT_ID - ) - print("Creating or updating authorization access policy...") - response = client.authorization_access_policy.create_or_update( - resource_group_name=RESOURCE_GROUP_NAME, - service_name=APIM_SERVICE_NAME, - authorization_provider_id=idp, - authorization_id=authorization_id, - authorization_access_policy_id=str(uuid.uuid4())[:33], - parameters=authPolicyContract - ) +async def _ensure_authorized(session_id: str) -> str | None: + """Check authorization and return login URL if not yet authorized.""" + if not credential_manager.is_authorized(session_id): + login_url = credential_manager.get_login_url(session_id) + return f"Please authorize by opening this link: {login_url}" + return None - authLoginRequestContract: AuthorizationLoginRequestContract = AuthorizationLoginRequestContract( - post_login_redirect_url=POST_LOGIN_REDIRECT_URL - ) - - print("Getting authorization link...") - response = client.authorization_login_links.post( - resource_group_name=RESOURCE_GROUP_NAME, - service_name=APIM_SERVICE_NAME, - authorization_provider_id=idp, - authorization_id=authorization_id, - parameters=authLoginRequestContract - ) - print("Login URL: ", response.login_link) - return f"Please authorize by opening this link: {response.login_link}" @mcp.tool() async def get_user(ctx: Context) -> str: """Get user associated with GitHub access token. Returns: - GitHub user information - """ - print("Getting user info...") - - session_id = str(id(ctx.session)) - provider_id = idp.lower() - authorization_id = f"{provider_id}-{session_id}" - - print(f"SessionId: {session_id}") - - githubUserUrl = f"{APIM_GATEWAY_URL}/user" - githubHeaders = { - "Content-Type": "application/json", - "authorizationId": authorization_id, - "providerId": provider_id - } + GitHub user information if the connection is authorized, otherwise a message with the login URL. + """ + session_id = _get_session_id(ctx) + print(f"Getting user info... SessionId: {session_id}") + + auth_message = await _ensure_authorized(session_id) + if auth_message: + return auth_message - githubResponse = httpx.get(githubUserUrl, headers=githubHeaders) - if (githubResponse.status_code == 200): - user = githubResponse.json() - return f"User: {user}" + response = httpx.get( + f"{APIM_GATEWAY_URL}/user", + headers=_get_github_headers(session_id), + ) + if response.status_code == 200: + return f"User: {response.json()}" else: - return f"Unable to get user info. Status code: {githubResponse.status_code}, Response: {githubResponse.text}" - + return f"Unable to get user info. Status code: {response.status_code}, Response: {response.text}" + + @mcp.tool() async def get_issues(ctx: Context, username: str, repo: str) -> str: """Get all issues for the specified repository for the authenticated user. - + Args: username: The GitHub username repo: The repository name - + Returns: - A list of issues + A list of issues if the connection is authorized, otherwise a message with the login URL. """ - print("Getting the list of issues...") + session_id = _get_session_id(ctx) + print(f"Getting the list of issues... SessionId: {session_id}") - session_id = str(id(ctx.session)) - provider_id = idp.lower() - authorization_id = f"{provider_id}-{session_id}" - - print(f"SessionId: {session_id}") + auth_message = await _ensure_authorized(session_id) + if auth_message: + return auth_message - githubIssuesUrl = f"{APIM_GATEWAY_URL}/repos/{username}/{repo}/issues" - githubHeaders = { - "Content-Type": "application/json", - "authorizationId": authorization_id, - "providerId": provider_id - } - - githubResponse = httpx.get(githubIssuesUrl, headers=githubHeaders) - if (githubResponse.status_code == 200): - issues = githubResponse.json() - return f"Issues: {issues}" + response = httpx.get( + f"{APIM_GATEWAY_URL}/repos/{username}/{repo}/issues", + headers=_get_github_headers(session_id), + ) + if response.status_code == 200: + return f"Issues: {response.json()}" else: - return f"Unable to get issues. Status code: {githubResponse.status_code}, Response: {githubResponse.text}" + return f"Unable to get issues. Status code: {response.status_code}, Response: {response.text}" -# Expose an ASGI app that speaks Streamable HTTP at /mcp/ -mcp_asgi = mcp.http_app() -app = Starlette( - routes=[Mount("/github", app=mcp_asgi)], # MCP will be at /weather/mcp/ - lifespan=mcp_asgi.lifespan, -) if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser(description="Run MCP Streamable-HTTP server") + parser = argparse.ArgumentParser(description=f"Run {mcp.name} MCP Streamable-HTTP server") parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") parser.add_argument("--port", type=int, default=8080, help="Port to listen on") args = parser.parse_args() - uvicorn.run(app, host=args.host, port=args.port) + mcp.run(transport="http", path=f"/mcp", port=args.port, host=args.host) diff --git a/shared/mcp-servers/github/http/requirements.txt b/shared/mcp-servers/github/http/requirements.txt index 95a0c876..81b4456e 100644 --- a/shared/mcp-servers/github/http/requirements.txt +++ b/shared/mcp-servers/github/http/requirements.txt @@ -9,10 +9,8 @@ httpx==0.28.1 httpx-sse==0.4.0 idna==3.10 sniffio==1.3.1 -sse-starlette==2.2.1 -starlette==0.46.0 -typing_extensions==4.12.2 -uvicorn==0.34.0 -azure.identity==1.21.0 -azure-mgmt-apimanagement==4.0.1 -fastmcp==2.12.4 \ No newline at end of file +typing_extensions>=4.12.2 +uvicorn>=0.34.0 +azure.identity>=1.21.0 +azure-mgmt-apimanagement>=4.0.1 +fastmcp>=3.0,<4 \ No newline at end of file diff --git a/shared/mcp-servers/oncall/http/Dockerfile b/shared/mcp-servers/oncall/http/Dockerfile index 7bb5aa2a..89e69830 100644 --- a/shared/mcp-servers/oncall/http/Dockerfile +++ b/shared/mcp-servers/oncall/http/Dockerfile @@ -11,4 +11,4 @@ COPY . . EXPOSE 8080 -CMD ["uvicorn", "mcp_server:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["python", "mcp_server.py", "--host", "0.0.0.0", "--port", "8080"] diff --git a/shared/mcp-servers/oncall/http/mcp_server.py b/shared/mcp-servers/oncall/http/mcp_server.py index a009ccbc..9790ed1d 100644 --- a/shared/mcp-servers/oncall/http/mcp_server.py +++ b/shared/mcp-servers/oncall/http/mcp_server.py @@ -1,16 +1,7 @@ import random -import uvicorn - -# Support either the standalone 'fastmcp' package or the 'mcp' package layout. -try: - from fastmcp import FastMCP, Context # pip install fastmcp -except ModuleNotFoundError: # fall back to the layout you used originally - from mcp.server.fastmcp import FastMCP, Context # pip install mcp - -from starlette.applications import Starlette -from starlette.routing import Mount - - +import httpx +import os +from fastmcp import FastMCP, Context mcp = FastMCP("Oncall") @@ -33,17 +24,10 @@ async def get_oncall_list(ctx: Context) -> str: return str(oncall_list) -# Expose an ASGI app that speaks Streamable HTTP at /mcp/ -mcp_asgi = mcp.http_app() -app = Starlette( - routes=[Mount("/oncall", app=mcp_asgi)], # MCP will be at /weather/mcp/ - lifespan=mcp_asgi.lifespan, -) - if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser(description="Run MCP Streamable-HTTP server") + parser = argparse.ArgumentParser(description=f"Run {mcp.name} MCP Streamable-HTTP server") parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") parser.add_argument("--port", type=int, default=8080, help="Port to listen on") args = parser.parse_args() - uvicorn.run(app, host=args.host, port=args.port) + mcp.run(transport="http", path=f"/mcp", port=args.port, host=args.host) diff --git a/shared/mcp-servers/oncall/http/requirements.txt b/shared/mcp-servers/oncall/http/requirements.txt index 95a0c876..81b4456e 100644 --- a/shared/mcp-servers/oncall/http/requirements.txt +++ b/shared/mcp-servers/oncall/http/requirements.txt @@ -9,10 +9,8 @@ httpx==0.28.1 httpx-sse==0.4.0 idna==3.10 sniffio==1.3.1 -sse-starlette==2.2.1 -starlette==0.46.0 -typing_extensions==4.12.2 -uvicorn==0.34.0 -azure.identity==1.21.0 -azure-mgmt-apimanagement==4.0.1 -fastmcp==2.12.4 \ No newline at end of file +typing_extensions>=4.12.2 +uvicorn>=0.34.0 +azure.identity>=1.21.0 +azure-mgmt-apimanagement>=4.0.1 +fastmcp>=3.0,<4 \ No newline at end of file diff --git a/shared/mcp-servers/weather/http/Dockerfile b/shared/mcp-servers/weather/http/Dockerfile index 7bb5aa2a..89e69830 100644 --- a/shared/mcp-servers/weather/http/Dockerfile +++ b/shared/mcp-servers/weather/http/Dockerfile @@ -11,4 +11,4 @@ COPY . . EXPOSE 8080 -CMD ["uvicorn", "mcp_server:app", "--host", "0.0.0.0", "--port", "8080"] +CMD ["python", "mcp_server.py", "--host", "0.0.0.0", "--port", "8080"] diff --git a/shared/mcp-servers/weather/http/mcp_server.py b/shared/mcp-servers/weather/http/mcp_server.py index c5555e14..d4d09ea3 100644 --- a/shared/mcp-servers/weather/http/mcp_server.py +++ b/shared/mcp-servers/weather/http/mcp_server.py @@ -1,16 +1,7 @@ import random -import uvicorn - -# Support either the standalone 'fastmcp' package or the 'mcp' package layout. -try: - from fastmcp import FastMCP, Context # pip install fastmcp -except ModuleNotFoundError: # fall back to the layout you used originally - from mcp.server.fastmcp import FastMCP, Context # pip install mcp - -from starlette.applications import Starlette -from starlette.routing import Mount - - +import httpx +import os +from fastmcp import FastMCP, Context mcp = FastMCP("Weather") @@ -41,17 +32,10 @@ async def get_weather(ctx: Context, city: str) -> str: } return str(weather_info) -# Expose an ASGI app that speaks Streamable HTTP at /mcp/ -mcp_asgi = mcp.http_app() -app = Starlette( - routes=[Mount("/weather", app=mcp_asgi)], # MCP will be at /weather/mcp/ - lifespan=mcp_asgi.lifespan, -) - if __name__ == "__main__": import argparse - parser = argparse.ArgumentParser(description="Run MCP Streamable-HTTP server") + parser = argparse.ArgumentParser(description=f"Run {mcp.name} MCP Streamable-HTTP server") parser.add_argument("--host", default="0.0.0.0", help="Host to bind to") parser.add_argument("--port", type=int, default=8080, help="Port to listen on") args = parser.parse_args() - uvicorn.run(app, host=args.host, port=args.port) + mcp.run(transport="http", path=f"/mcp", port=args.port, host=args.host) diff --git a/shared/mcp-servers/weather/http/requirements.txt b/shared/mcp-servers/weather/http/requirements.txt index 95a0c876..81b4456e 100644 --- a/shared/mcp-servers/weather/http/requirements.txt +++ b/shared/mcp-servers/weather/http/requirements.txt @@ -9,10 +9,8 @@ httpx==0.28.1 httpx-sse==0.4.0 idna==3.10 sniffio==1.3.1 -sse-starlette==2.2.1 -starlette==0.46.0 -typing_extensions==4.12.2 -uvicorn==0.34.0 -azure.identity==1.21.0 -azure-mgmt-apimanagement==4.0.1 -fastmcp==2.12.4 \ No newline at end of file +typing_extensions>=4.12.2 +uvicorn>=0.34.0 +azure.identity>=1.21.0 +azure-mgmt-apimanagement>=4.0.1 +fastmcp>=3.0,<4 \ No newline at end of file