Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"vscode": {
"extensions": [
"ms-toolsai.jupyter",
"ms-python.python"
"ms-python.python",
"ms-azuretools.vscode-bicep"
]
}
}
Expand Down
18 changes: 15 additions & 3 deletions labs/mcp-prm-oauth/clean-up-resources.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,25 @@
"deployment_name = os.path.basename(os.path.dirname(globals()['__vsc_ipynb_file__']))\n",
"resource_group = f\"lab-{deployment_name}\"\n",
"\n",
"utils.cleanup_resources(deployment_name, resource_group_name=resource_group)"
"utils.cleanup_resources(deployment_name, resource_group_name=resource_group)\n",
"\n",
"# The lab also creates an Entra ID app registration for the MCP endpoint (when you don't bring your own `mcpClientId`). It lives in Microsoft Entra ID, not in the resource group, so it must be deleted separately.\n",
"\n",
"app_registration_name = f\"lab-{deployment_name}-app\"\n",
"\n",
"print(f\"Searching for app with display name {app_registration_name}...\")\n",
"output = utils.run(f\"az ad app list --filter \\\"displayName eq '{app_registration_name}'\\\"\", \"Retrieved app registration\", \"Failed to get the app registration\")\n",
"if output.success and output.json_data:\n",
" client_id = output.json_data[0]['appId']\n",
" output = utils.run(f\"az ad app delete --id {client_id}\", \"Deleted app registration\", \"Failed to delete the app registration\")\n",
"else:\n",
" print(f\"No app registration found with display name {app_registration_name}. Nothing to delete.\")"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "myenv",
"display_name": "ai-gateway (3.12.13.final.0)",
"language": "python",
"name": "python3"
},
Expand All @@ -42,7 +54,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.5"
"version": "3.12.-1"
}
},
"nbformat": 4,
Expand Down
18 changes: 10 additions & 8 deletions labs/mcp-prm-oauth/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ param tags object = {}

param location string = resourceGroup().location

param mcpPrmPath string = 'mcp'
param mcpApiPath string = 'mcp'

// ------------------
// VARIABLES
Expand Down Expand Up @@ -60,7 +60,7 @@ module appInsightsModule '../../modules/monitor/v1/appinsights.bicep' = {
}

// 3. API Management
module apimModule '../../modules/apim/v2/apim.bicep' = {
module apimModule '../../modules/apim/v4/apim.bicep' = {
name: 'apimModule'
params: {
apimSku: apimSku
Expand All @@ -71,7 +71,7 @@ module apimModule '../../modules/apim/v2/apim.bicep' = {
}
}

resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = {
resource apimService 'Microsoft.ApiManagement/service@2025-09-01-preview' existing = {
name: 'apim-${resourceSuffix}'
dependsOn: [
apimModule
Expand Down Expand Up @@ -214,7 +214,7 @@ resource mcpServerContainerApp 'Microsoft.App/containerApps@2023-11-02-preview'
]
}

// 9. Entra ID Application Registration for MCP
// 9. Entra ID Application Registration for MCP endpoint
// NOTE: The Microsoft Graph Bicep extension is experimental and may not be available in all environments.
// If the deployment fails due to Graph extension issues, you have two options:
// 1. Create the Entra App manually and provide the mcpClientId parameter
Expand All @@ -223,6 +223,7 @@ resource mcpServerContainerApp 'Microsoft.App/containerApps@2023-11-02-preview'
// To create manually:
// - Create an App Registration in Entra ID
// - Add API permission: user_impersonate (delegated)
// - Add ApplicationID URI: https://<apimfqdn>/${mcpApiPath}/mcp
// - Add redirect URI: https://{containerAppFQDN}/auth/callback
// - Create a federated credential trusting the managed identity
// - Pass the App ID as mcpClientId parameter
Expand All @@ -232,8 +233,9 @@ module mcpEntraAppModule 'src/bicep/identity/mcp-entra-app.bicep' = if (empty(mc
mcpAppUniqueName: mcpEntraAppName
mcpAppDisplayName: mcpEntraAppName
tenantId: subscription().tenantId
userAssignedIdentityPrincipleId: managedIdentityModule.outputs.identityPrincipalId
webAppName: mcpServerContainerApp.name
userAssignedIdentityPrincipleId: managedIdentityModule.outputs.identityPrincipalId
applicationIdUri: '${apimModule.outputs.gatewayUrl}/${mcpApiPath}/mcp'
}
}

Expand All @@ -245,7 +247,7 @@ module mcpApiModule 'src/bicep/apim-mcp/mcp-api.bicep' = {
webAppName: mcpServerContainerApp.name
mcpAppId: !empty(mcpClientId) ? mcpClientId : (mcpEntraAppModule.?outputs.mcpAppId ?? '')
mcpAppTenantId: subscription().tenantId
mcpApiPath: mcpPrmPath
mcpApiPath: mcpApiPath
}
}

Expand Down Expand Up @@ -297,5 +299,5 @@ output mcpServerURL string = 'https://${mcpServerContainerApp.properties.configu
// MCP Configuration
output mcpAppId string = !empty(mcpClientId) ? mcpClientId : (mcpEntraAppModule.?outputs.mcpAppId ?? 'Not created - provide mcpClientId parameter')
output mcpAppTenantId string = subscription().tenantId
output mcpApiEndpoint string = '${apimModule.outputs.gatewayUrl}/${mcpPrmPath}'
output mcpPrmEndpoint string = '${apimModule.outputs.gatewayUrl}/${mcpPrmPath}/.well-known/oauth-protected-resource'
output mcpApiEndpoint string = '${apimModule.outputs.gatewayUrl}/${mcpApiPath}/mcp'
output mcpPrmEndpoint string = '${apimModule.outputs.gatewayUrl}/.well-known/oauth-protected-resource/${mcpApiPath}/mcp'
28 changes: 15 additions & 13 deletions labs/mcp-prm-oauth/mcp-prm-oauth.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"deployment_name = os.path.basename(os.path.dirname(globals()['__vsc_ipynb_file__']))\n",
"resource_group_name = f\"lab-{deployment_name}\" # change the name to match your naming style\n",
"resource_group_location = \"ukwest\"\n",
"resource_suffix = ''.join(random.choices(string.ascii_letters + string.digits, k=8)).lower()\n",
"\n",
"aiservices_config = []\n",
"\n",
Expand All @@ -71,8 +72,7 @@
"inference_api_version = \"2025-03-01-preview\"\n",
"foundry_project_name = deployment_name\n",
"\n",
"# Generate a unique name for the app registration\n",
"app_registration_name = f\"lab-{deployment_name}-{''.join(random.choices(string.ascii_letters + string.digits, k=8)).lower()}-app\"\n",
"app_registration_name = f\"lab-{deployment_name}-{resource_suffix}-app\"\n",
"\n",
"build = 0\n",
"prm_mcp_server_image = \"mcp-prm-server\"\n",
Expand All @@ -83,8 +83,9 @@
"encryption_iv = base64.b64encode(os.urandom(16)).decode('utf-8')\n",
"encryption_key = base64.b64encode(os.urandom(16)).decode('utf-8')\n",
"oauth_scopes = 'openid https://graph.microsoft.com/.default'\n",
"mcpApiPath = \"profile\" # path to the MCP API in the APIM service\n",
"\n",
"utils.print_ok('Notebook initialized')"
"utils.print_ok('Notebook initialized')\n"
]
},
{
Expand Down Expand Up @@ -142,13 +143,13 @@
" \"mcpEntraAppName\": { \"value\": app_registration_name },\n",
" \"apimSku\": { \"value\": apim_sku },\n",
" \"aiServicesConfig\": { \"value\": aiservices_config },\n",
" #\"mcpPrmPath\": { \"value\": mcpPrmPath }, ## Add a path you would like your MCP server to exposed as ##\n",
" \"modelsConfig\": { \"value\": models_config },\n",
" \"apimSubscriptionsConfig\": { \"value\": apim_subscriptions_config },\n",
" \"inferenceAPIPath\": { \"value\": inference_api_path },\n",
" \"inferenceAPIType\": { \"value\": inference_api_type },\n",
" \"foundryProjectName\": { \"value\": foundry_project_name },\n",
" \"oauthScopes\": { \"value\": oauth_scopes },\n",
" \"mcpApiPath\": { \"value\": mcpApiPath }, ## Add a path you would like your MCP server to exposed as ##\n",
" \"encryptionIV\": { \"value\": encryption_iv },\n",
" \"encryptionKey\": { \"value\": encryption_key },\n",
" }\n",
Expand All @@ -160,7 +161,7 @@
"\n",
"# Run the deployment\n",
"output = utils.run(f\"az deployment group create --name {deployment_name} --resource-group {resource_group_name} --template-file main.bicep --parameters params.json\",\n",
" f\"Deployment '{deployment_name}' succeeded\", f\"Deployment '{deployment_name}' failed\")"
" f\"Deployment '{deployment_name}' succeeded\", f\"Deployment '{deployment_name}' failed\")\n"
]
},
{
Expand Down Expand Up @@ -255,7 +256,7 @@
"# Unauthenticated call should fail with 401 Unauthorized\n",
"import requests\n",
"\n",
"mcp_server_url = f\"{apim_resource_gateway_url}/mcp\"\n",
"mcp_server_url = mcp_api_endpoint # e.g. https://<apim-gateway-host>/<mcpApiPath>/mcp\n",
"utils.print_info(\"Calling sse endpoint WITHOUT authorization...\")\n",
"utils.print_message(f\"MCP Server Url : {mcp_server_url}\")\n",
"response = requests.post(mcp_server_url, headers={\"Content-Type\": \"application/json\"})\n",
Expand Down Expand Up @@ -299,13 +300,14 @@
"1. Execute `npx @modelcontextprotocol/inspector` in a terminal\n",
"2. Access the provided URL in a browser (it should open automatically).\n",
"3. Set the transport type as `Streamable HTTP`\n",
"4. Provide the MCP server URL\n",
"4. Provide the MCP server URL (the `mcp_api_endpoint` value, e.g. `https://<apim-gateway-host>/<mcpApiPath>/mcp`)\n",
"5. Click in the `Open Auth Settings` button\n",
"6. Click on `Quick OAuth Flow`\n",
"7. You’ll see a sign-in screen or an “Application Access Request” screen asking for your consent to use your signed-in account. After reviewing the request, click \"Allow\" to proceed.\n",
"8. After being redirected back to the MCP Inspector, scroll down to the `Authentication Complete` step. Expand the `Access Tokens` section and copy the `access_token` value.\n",
"9. Expand the Authentication section on the left and paste the `access_token` into the Bearer Token parameter.\n",
"10. Click on \"Connect\" and verify that the Weather Tool is functioning properly."
"6. In the auth settings, set the `Client ID` to your MCP app registration's `client_id` and set the `Scope` to `<mcp_api_endpoint>/user_impersonate` (e.g. `https://<apim-gateway-host>/<mcpApiPath>/mcp/user_impersonate` — the same value advertised in the `scopes_supported` field of the Protected Resource Metadata)\n",
"7. Click on `Quick OAuth Flow`\n",
"8. You’ll see a sign-in screen or an “Application Access Request” screen asking for your consent to use your signed-in account. After reviewing the request, click \"Allow\" to proceed.\n",
"9. After being redirected back to the MCP Inspector, scroll down to the `Authentication Complete` step. Expand the `Access Tokens` section and copy the `access_token` value.\n",
"10. Expand the Authentication section on the left and paste the `access_token` into the Bearer Token parameter.\n",
"11. Click on \"Connect\" and verify that the Weather Tool is functioning properly.\n"
]
},
{
Expand All @@ -322,7 +324,7 @@
],
"metadata": {
"kernelspec": {
"display_name": "pip2uv",
"display_name": "ai-gateway (3.12.13.final.0)",
"language": "python",
"name": "python3"
},
Expand Down
71 changes: 23 additions & 48 deletions labs/mcp-prm-oauth/src/bicep/apim-mcp/mcp-api.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ param mcpAppTenantId string
param mcpApiPath string = 'mcp'

// Get reference to the existing APIM service
resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' existing = {
resource apimService 'Microsoft.ApiManagement/service@2025-09-01-preview' existing = {
name: apimServiceName
}

resource dynamicDiscovery 'Microsoft.ApiManagement/service/apis@2023-05-01-preview' existing = {
resource dynamicDiscovery 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' existing = {
parent: apimService
name: 'mcp-prm-dynamic-discovery'
}
Expand Down Expand Up @@ -71,36 +71,38 @@ resource mcpApiPathNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-
}
}

// Create mcp backend pointing to the Container App
resource mcpBackend 'Microsoft.ApiManagement/service/backends@2024-06-01-preview' ={
resource mcpBackendServerUrl 'Microsoft.ApiManagement/service/backends@2025-09-01-preview' = {
parent: apimService
name: '${webAppName}-mcp-backend'
properties: {
url: 'https://${containerApp.properties.configuration.ingress.fqdn}'
protocol: 'http'
url: 'https://${containerApp.properties.configuration.ingress.fqdn}/mcp'
tls: {
validateCertificateChain: true
validateCertificateName: true
}
type: 'Single'
}
}
}

// Create the MCP API definition in APIM
resource mcpApi 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = {
// Create the MCP Passthrough definition in APIM
resource mcpApi 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = {
parent: apimService
name: '${webAppName}-mcp-tools'
name: '${webAppName}-mcp-server'
properties: {
displayName: '${webAppName} MCP Tools'
displayName: '${webAppName} MCP Server'
type: 'mcp'
subscriptionRequired: false
backendId: mcpBackend.name
path: '/${mcpApiPath}'
protocols: [
'https'
]
mcpProperties:{
transportType: 'streamable'
backendId: mcpBackendServerUrl.name
description: 'MCP API for ${webAppName} retrieving authenticated account Graph info'
path: mcpApiPath
protocols: ['https']
mcpProperties: {
endpoints : {
mcp: {
uriTemplate: '/mcp'
}
}
}
authenticationSettings: {
oAuth2AuthenticationSettings: []
Expand All @@ -111,7 +113,7 @@ resource mcpApi 'Microsoft.ApiManagement/service/apis@2024-06-01-preview' = {
}

// Apply policy at the API level for all operations
resource mcpApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2024-06-01-preview' = {
resource mcpApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2025-09-01-preview' = {
parent: mcpApi
name: 'policy'
properties: {
Expand All @@ -125,47 +127,20 @@ resource mcpApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2024-06-01-
]
}

// Create the PRM (Protected Resource Metadata) endpoint within MCP server
resource mcpPrmOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = {
parent: mcpApi
name: 'mcp-prm-operation'
properties: {
displayName: 'Protected Resource Metadata'
method: 'GET'
urlTemplate: '/.well-known/oauth-protected-resource'
description: 'Protected Resource Metadata endpoint (RFC 9728)'
}
}

// Apply specific policy for the PRM endpoint (anonymous access)
resource mcpPrmOperationPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2023-05-01-preview' = {
parent: mcpPrmOperation
name: 'policy'
properties: {
format: 'rawxml'
value: loadTextContent('mcp-prm.policy.xml')
}
dependsOn: [
APIMGatewayURLNamedValue
mcpTenantIdNamedValue
mcpClientIdNamedValue
]
}

// Create the PRM (Protected Resource Metadata in the global discovery) endpoint - RFC 9728
resource mcpPrmDiscoveryOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = {
resource mcpPrmDiscoveryOperation 'Microsoft.ApiManagement/service/apis/operations@2025-09-01-preview' = {
parent: dynamicDiscovery
name: 'mcp-prm-discovery-operation'
properties: {
displayName: 'Protected Resource Metadata'
method: 'GET'
urlTemplate: '/${mcpApiPath}'
urlTemplate: '/${mcpApiPath}/mcp'
description: 'Protected Resource Metadata endpoint (RFC 9728)'
}
}

// Apply specific policy for the PRM endpoint (anonymous access)
resource mcpPrmGlobalPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2023-05-01-preview' = {
resource mcpPrmGlobalPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2025-09-01-preview' = {
parent: mcpPrmDiscoveryOperation
name: 'policy'
properties: {
Expand Down
17 changes: 9 additions & 8 deletions labs/mcp-prm-oauth/src/bicep/apim-mcp/mcp-api.policy.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
<policies>
<inbound>
<base />
<!-- Validate Azure AD JWT Token -->
<validate-azure-ad-token tenant-id="{{McpTenantId}}" failed-validation-httpcode="401" failed-validation-error-message="Unauthorized">
<audiences>
<audience>{{McpClientId}}</audience>
</audiences>
</validate-azure-ad-token>
<!-- Validate Azure AD JWT Token -->

<validate-azure-ad-token tenant-id="{{McpTenantId}}" failed-validation-httpcode="401" failed-validation-error-message="Unauthorized">
<audiences>
<audience>{{McpClientId}}</audience>
<audience>{{APIMGatewayURL}}/{{McpApiPath}}</audience>
</audiences>
</validate-azure-ad-token>
</inbound>
<backend>
<base />
Expand All @@ -26,11 +28,10 @@
<return-response>
<set-status code="401" reason="Unauthorized" />
<set-header name="WWW-Authenticate" exists-action="override">
<value>Bearer error="invalid_token", resource_metadata="{{APIMGatewayURL}}/.well-known/oauth-protected-resource"</value>
<value>Bearer error="invalid_token", resource_metadata="{{APIMGatewayURL}}/.well-known/oauth-protected-resource/{{McpApiPath}}/mcp/"</value>
</set-header>
</return-response>
</when>
</choose>
<!-- Handle authentication/authorization errors -->
</on-error>
</policies>
4 changes: 2 additions & 2 deletions labs/mcp-prm-oauth/src/bicep/apim-mcp/mcp-prm.policy.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@
</set-header>
<set-body>@{
return JsonConvert.SerializeObject(new {
resource = "{{APIMGatewayURL}}/{{McpApiPath}}",
resource = "{{APIMGatewayURL}}/{{McpApiPath}}/mcp",
authorization_servers = new[] {
$"https://login.microsoftonline.com/{{McpTenantId}}/v2.0"
},
bearer_methods_supported = new[] {
"header"
},
scopes_supported = new[] {
"{{McpClientId}}/user_impersonate"
"{{APIMGatewayURL}}/{{McpApiPath}}/mcp/user_impersonate"
}
});
}</set-body>
Expand Down
Loading
Loading