diff --git a/docs-taxonomy.yml b/docs-taxonomy.yml index c3cc6abf..0f3ce8e7 100644 --- a/docs-taxonomy.yml +++ b/docs-taxonomy.yml @@ -28,10 +28,14 @@ services: application-development: Application Development azure-application-gateway: Azure Application Gateway azure-ai-search: Azure AI Search + azure-api-management: Azure API Management + azure-app-service: Azure App Service azure-architecture: Azure Architecture azure-automation: Azure Automation + azure-container-apps: Azure Container Apps azure-cosmos-db: Azure Cosmos DB azure-database-for-mysql: Azure Database for MySQL + azure-functions: Azure Functions azure-hdinsight: Azure HDInsight azure-kubernetes-service: Azure Kubernetes Service azure-load-testing: Azure Load Testing @@ -39,6 +43,7 @@ services: azure-monitor: Azure Monitor azure-openai: Azure OpenAI azure-storage: Azure Storage + microsoft-entra-id: Microsoft Entra ID microsoft-foundry: Microsoft Foundry technologies: @@ -53,6 +58,7 @@ technologies: java: Java kafka: Apache Kafka kubernetes: Kubernetes + mcp: Model Context Protocol mysql: MySQL nodejs: Node.js open-telemetry: OpenTelemetry @@ -116,3 +122,7 @@ official_source_hosts: - kubernetes.io - www.cncf.io - cncf.io + - github.com + - modelcontextprotocol.io + - py.sdk.modelcontextprotocol.io + - prices.azure.com diff --git a/docs/services/azure-architecture/mcp-configuration/authentication/index.md b/docs/services/azure-architecture/mcp-configuration/authentication/index.md new file mode 100644 index 00000000..26502dd4 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/authentication/index.md @@ -0,0 +1,294 @@ +--- +title: Azure MCP 인증 상세 참고 — Entra 앱, scope와 OBO +description: Azure MCP 통합 가이드에서 사용하는 Entra app registration, client ACL, access token과 OBO 설정을 상세히 설명합니다. +document_type: guide +topic_order: 1 +redirect_from: + - guides/azure-api-management/mcp-entra-private-access/index.md +services: [azure-architecture, azure-api-management, azure-container-apps, microsoft-foundry, microsoft-entra-id] +technologies: [mcp, azure-cli, bicep, python] +tags: [build, secure, ai-agents, identity] +status: current +verification_status: needs-review +sources_checked_at: 2026-09-13 +official_sources: + - title: Create and manage a toolbox in Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox + - title: How toolbox authentication works in Microsoft Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/tool-authentication + - title: Acquire tokens + url: https://learn.microsoft.com/entra/msal/python/getting-started/acquiring-tokens + - title: Network isolation for a toolbox in Microsoft Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox-network-isolation + - title: Azure Developer CLI reference + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/reference + - title: Remote builds support with Azure Container Registry + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/remote-builds + - title: Azure Developer CLI schema reference + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-schema + - title: Customize your Azure Developer CLI workflows using command and event hooks + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-extensibility + - title: Microsoft identity platform and OAuth 2.0 On-Behalf-Of flow + url: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow + - title: Microsoft identity platform and OAuth 2.0 authorization code flow + url: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow + - title: Secure applications and APIs by validating claims + url: https://learn.microsoft.com/entra/identity-platform/claims-validation + - title: Secure a Model Context Protocol (MCP) server with Microsoft Entra ID + url: https://learn.microsoft.com/entra/agent-id/secure-mcp-server-with-entra-id + - title: preAuthorizedApplication resource type + url: https://learn.microsoft.com/graph/api/resources/preauthorizedapplication?view=graph-rest-1.0 + - title: Validate Microsoft Entra token + url: https://learn.microsoft.com/azure/api-management/validate-azure-ad-token-policy + - title: Validate JWT + url: https://learn.microsoft.com/azure/api-management/validate-jwt-policy + - title: Access token claims reference + url: https://learn.microsoft.com/entra/identity-platform/access-token-claims-reference + - title: Secure access to MCP servers in API Management + url: https://learn.microsoft.com/azure/api-management/secure-mcp-servers + - title: Authenticate with managed identity + url: https://learn.microsoft.com/azure/api-management/authentication-managed-identity-policy + - title: Configure credential manager - user-delegated access to backend API + url: https://learn.microsoft.com/azure/api-management/credentials-how-to-user-delegated + - title: Deploy Azure MCP Server with on-behalf-of authentication + url: https://learn.microsoft.com/azure/developer/azure-mcp-server/how-to/deploy-remote-mcp-server-on-behalf-of + - title: Networking in an Azure Container Apps environment + url: https://learn.microsoft.com/azure/container-apps/networking + - title: Use a virtual network to secure inbound or outbound traffic for Azure API Management + url: https://learn.microsoft.com/azure/api-management/virtual-network-concepts + - title: Enable authentication and authorization in Azure Container Apps with Microsoft Entra ID + url: https://learn.microsoft.com/azure/container-apps/authentication-entra + - title: Microsoft.App containerApps/authConfigs + url: https://learn.microsoft.com/azure/templates/microsoft.app/2025-01-01/containerapps/authconfigs + - title: Configure built-in MCP server authorization (Preview) + url: https://learn.microsoft.com/azure/app-service/configure-authentication-mcp +last_verified: 2026-09-13 +review_cycle_days: 90 +applies_to: + - Azure public cloud + - Azure MCP Server 2.0.5 + - MCP Python SDK 2.2.0 + - APIM Developer internal 구성 +related_cases: + - ../validation/index.md +--- + +# Azure MCP 인증 상세 참고 — Entra 앱, scope와 OBO + +APIM 또는 직접 호스팅한 MCP API의 **Entra 앱 등록·scope·client 제한·OBO**를 구성할 때 사용하는 참고 자료입니다. 솔루션 선택은 [대표 가이드](../index.md), 실행 명령은 [sample walkthrough](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md)를 사용합니다. + +| 할 일 | 읽을 부분 | 완료 기준 | +|---|---|---| +| 내부 MCP에 접속 | 1–2장 | 필요한 권한·route·DNS·HTTPS 접속 준비 | +| Entra 앱과 권한 구성 | 3장 | API와 client 앱, scope·consent·허용 client 설정 | +| 보호된 도구와 OBO 호출 | 4–6장 | 정상 사용자 호출 성공, 잘못된 token·부족한 권한 거부 | +| 로그인·호출 오류 진단 | 마지막 진단 표·[실행 사례](../validation/index.md#oauth) | 실패 지점을 network·token·scope·backend로 구분 | + +## 구성 요소와 인증 흐름 + +| 인증 요소 | 제공·처리 주체 | +|---|---| +| PRM (RFC 9728) | MCP Server/APIM이 보호 resource·authorization server·scope 안내 | +| AS metadata / OIDC discovery | Entra ID가 authorization·token·JWKS endpoint 안내 | +| Authorization Code + PKCE | Client/MSAL과 Entra ID 사이에서 수행. MSAL interactive flow가 PKCE 처리 | +| JWT·권한 검사 | APIM/MCP Server가 issuer·audience·유효 시간·scope/role 검사 | + +[![Client가 APIM의 PRM을 읽고 Entra와 OAuth 및 PKCE를 수행한 뒤 token으로 MCP를 호출하는 인증 흐름](../images/mcp-oauth-obo.svg)](../images/mcp-oauth-obo.svg) + +v2 token을 사용하는 이 구성의 Entra issuer는 `https://login.microsoftonline.com//v2.0`입니다. PRM은 MCP Server/APIM이 제공하며, MSAL Python의 [`acquire_token_interactive`](https://learn.microsoft.com/entra/msal/python/getting-started/acquiring-tokens#acquire-token-interactive)는 PKCE를 자동 적용합니다. + +Python MCP와 공식 Azure MCP는 internal Container Apps 환경에서 실행합니다. APIM의 REST-to-MCP와 기존 MCP 프록시는 서로 다른 API이며, Python MCP의 OBO 도구 호출과도 별도 경로입니다. 구성도와 호스팅 대안은 [MCP 인증·호스팅 비교](../hosting-reference/index.md)에 있습니다. + +## 1. azd 배포와 권한 준비 + +[샘플 소스](https://github.com/hellices/devguidesample/tree/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab)의 `azure.yaml`은 `mcp`를 Container Apps service로 선언합니다. `project: .`의 Python 앱을 `python-app` module로 배포하며, `infra/main.bicep`에서 공통 Azure 리소스를 구성합니다. `docker.remoteBuild: true`로 ACR에 container image build를 요청합니다. + +배포 전 다음을 준비합니다. + +- Azure 리소스 생성과 필요한 role assignment를 수행할 권한. +- Entra 앱 등록·서비스 주체·federated identity credential(FIC)을 구성할 권한. Azure RBAC 역할과 Entra 디렉터리 권한은 별도로 부여합니다. +- MCP API의 delegated scope와 ARM delegated permission에 필요한 사용자 또는 관리자 consent. +- 사용할 로컬 클라이언트의 client ID, redirect URI, 접속할 tenant와 API client ID. +- 개발 PC에서 Azure VNet으로 연결할 VPN/ExpressRoute와 DNS 구성, 또는 실습에서 안내하는 개발용 접속 방법. + +로그인·Entra 앱 준비·preview·`azd up`의 실행 순서는 [sample walkthrough](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md)에 있습니다. Sample의 `postprovision` hook은 서버 앱의 FIC가 생성된 managed identity를 신뢰하도록 구성합니다. 설정 형식은 [azure.yaml reference](https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-schema), build 방식은 [ACR remote build](https://learn.microsoft.com/azure/developer/azure-developer-cli/remote-builds)를 참고할 수 있습니다. + +## 2. 로컬 PC에서 private endpoint와 내부 MCP에 접속하기 + +private endpoint는 VNet의 private IP로 접속합니다. 개발 PC가 회사 네트워크에 있다면 **그 IP까지 연결되는 VPN/ExpressRoute 경로와 private DNS 이름 해석**이 필요합니다. Entra 로그인은 이 네트워크 연결을 만들지 않습니다. 네트워크 연결 후 MCP에 보낸 HTTP 요청은 별도로 access token 검사를 받습니다. + +샘플의 APIM Developer와 Container Apps는 private endpoint 대신 **internal VNet 구성**을 사용합니다. 두 방식 모두 개발 PC에서 private IP로 연결할 수 있어야 합니다. + +1. 개발 PC에서 Azure VNet까지의 경로를 준비합니다. +2. APIM과 Container Apps의 hostname이 해당 환경의 private IP로 해석되도록 DNS를 구성합니다. +3. 원래 HTTPS hostname으로 요청합니다. IP 주소를 URL에 넣거나 TLS 인증서 검사를 끄는 방법은 사용하지 않습니다. +4. 네트워크 연결 후 access token 없이 요청하면 인증 오류를, 올바른 token으로 요청하면 MCP 응답을 받는지 확인합니다. + +Container Apps의 internal 환경에서 앱 ingress `external: true`는 **같은 Container Apps 환경 밖의 VNet에서도 앱에 접근할 수 있음**을 뜻합니다. 환경 자체에 public endpoint가 생기는 것은 아닙니다. APIM Standard v2를 선택한다면 inbound private endpoint와 outbound VNet integration을 각각 구성해야 합니다. 자세한 차이는 [Container Apps networking](https://learn.microsoft.com/azure/container-apps/networking)과 [APIM VNet 구성](https://learn.microsoft.com/azure/api-management/virtual-network-concepts)에 정리되어 있습니다. + +## 3. MCP API와 로컬 클라이언트의 앱 등록 + +### API client ID와 scope + +샘플은 두 API 앱 등록을 사용합니다. + +| API | 요청할 scope | access token의 `scp` | +|---|---|---| +| Python MCP와 APIM | `api:///Mcp.Access` | `Mcp.Access` | +| 공식 Azure MCP 2.0.5 | 서버의 OAuth metadata에 표시된 `Mcp.Tools.ReadWrite` scope URI | `Mcp.Tools.ReadWrite` | + +API 앱 등록의 `api.requestedAccessTokenVersion`은 `2`로 설정합니다. v2 access token의 **`aud`는 호출 대상 API의 client ID(GUID)**입니다. 로컬 클라이언트의 client ID나 scope URI와는 다른 값입니다. + +| 값 | 의미 | +|---|---| +| OAuth 요청의 `client_id` | Token을 요청하는 **client 앱** | +| v2 token의 `aud` | Token을 받을 **MCP API 앱**의 client ID | +| v2 token의 `azp` | Token을 사용하는 **client 앱**의 client ID | +| `scp` | `Mcp.Access` 같은 permission 이름. 전체 scope URI가 아님 | + +[공개 claim 정의](https://learn.microsoft.com/entra/identity-platform/access-token-claims-reference#payload-claims)를 기준으로 비교합니다. v1 token에서는 `aud`가 resource URI일 수도 있으므로 v1·v2의 값과 issuer를 섞지 않습니다. + +Azure MCP의 `--read-only`는 제공할 도구를 제한하는 옵션입니다. 이를 사용해도 incoming scope는 `Mcp.Tools.ReadWrite`입니다. 실제 Azure 리소스 접근은 사용자의 RBAC에도 제한됩니다. + +### 로컬 클라이언트와 consent + +대화형 MCP 클라이언트는 사전에 등록한 public client와 authorization code + PKCE를 사용합니다. 등록한 redirect URI와 클라이언트 설정이 일치해야 합니다. public client에는 client secret을 저장하지 않습니다. + +Entra ID의 dynamic client registration(DCR)을 전제로 설정하는 대신, 사용할 IDE나 MCP 클라이언트의 client ID를 준비합니다. 특정 클라이언트를 사전 승인할 때는 API 앱의 `preAuthorizedApplications`에 client의 `appId`와 승인할 `delegatedPermissionIds`를 지정합니다. 구체적인 설정은 [sample의 identity 구성](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/scripts/identity.py)에 있습니다. + +`delegatedPermissionIds`는 scope 이름이 아니라 **해당 scope의 GUID**입니다. 이 사전 승인은 지정한 API permission에만 적용되며, ARM OBO에 필요한 permission과 consent는 별도로 구성합니다. 필드 정의는 [Microsoft Graph의 preAuthorizedApplication](https://learn.microsoft.com/graph/api/resources/preauthorizedapplication?view=graph-rest-1.0)를 따릅니다. + +### 허용할 클라이언트 제한 + +`preAuthorizedApplications`는 consent 설정이며, 등록되지 않은 클라이언트의 HTTP 요청을 차단하는 ACL이 아닙니다. 샘플은 다음 위치에서 허용 client ID를 검사합니다. + +- **Python MCP:** API 코드에서 access token의 `azp`와 client allowlist 비교. +- **APIM:** `validate-azure-ad-token`의 `client-application-ids`. +- **공식 Azure MCP:** Container Apps `authConfigs`의 `identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications`. + +공식 Azure MCP 앞의 Container Apps authentication과 Azure MCP 자체의 tenant·audience·scope 검사를 함께 적용합니다. tenant와 scope가 맞아도 허용하지 않은 클라이언트의 token은 거부하도록 구성합니다. + +### Foundry Toolbox에 연결할 때 + +Toolbox consumer endpoint는 자체 MCP API의 `Mcp.Access` token이 아니라 **`https://ai.azure.com/.default` access token과 Foundry project 권한**을 사용합니다. Developer에게는 project의 Foundry User 역할이 필요하며, agent·end-user identity는 사용 방식에 맞는 권한을 별도로 준비합니다. + +Toolbox가 backend MCP에 연결할 때는 그 connection에 구성한 OAuth·credential·identity 설정을 사용합니다. 이 인증을 자체 MCP server의 OBO와 혼동하지 않습니다. GitHub는 GitHub 인증, Learn은 익명 호출, Azure MCP는 대상 API에 맞는 token이 필요합니다. + +| Connection 방식 | 사용하는 권한 | +|---|---| +| `oauth2` | OAuth authorization을 완료한 사용자의 credential | +| `user-entra-token` | 이를 지원하는 Microsoft 서비스용 사용자 Entra token | +| `project-managed-identity` / `agentic-identity` | Project 또는 agent의 서비스 권한 | +| `custom-keys` / `none` | 저장된 key/header 또는 익명 호출 | + +Toolbox에서 관리하는 사용자 token을 뒤의 gateway가 서비스 token으로 덮어쓰지 않도록 credential 소유 위치를 정합니다. [인증 방식별 동작](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/tool-authentication)에 맞춰 사용자·서비스 권한을 선택합니다. + +Private Toolbox는 Foundry project의 network isolation을 따릅니다. Consumer의 project private endpoint 접속과 delegated subnet에서 backend까지의 연결은 [공식 network 구성](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox-network-isolation)을 적용합니다. Toolset과 client 연결은 [Toolbox sample](https://github.com/hellices/devguidesample/tree/main/docs/services/azure-architecture/mcp-configuration/samples/toolbox)을 사용합니다. + +## 4. access token으로 MCP 호출 + +실습에서는 APIM의 REST-to-MCP에 `curl`로 HTTP 요청을 보냅니다. 공식 Azure MCP·Python MCP와 APIM을 통한 Learn MCP 연결에는 **MCP Inspector 2.5.0 CLI**를 사용합니다. Inspector는 도구 목록과 호출 결과를 터미널에 표시하는 MCP 클라이언트입니다. 샘플은 Node.js 22.19 이상을 사용합니다. + +MCP API는 도구 실행이나 OBO 교환 전에 access token을 검사합니다. + +| 검사 항목 | 기대 값 | +|---|---| +| 서명 | 신뢰하는 Entra issuer의 공개 키와 허용한 알고리즘 | +| `iss`, `tid` | 구성한 issuer와 tenant | +| `aud` | 호출 대상 MCP API의 client ID | +| `exp`, `nbf` | 현재 유효한 token | +| `scp` | API가 요구하는 delegated scope | +| `azp` | 허용한 로컬 클라이언트의 client ID | + +App-only token에도 서비스 주체의 `oid`가 들어갈 수 있습니다. `oid`가 있다는 이유만으로 사용자 token으로 처리하지 않고 delegated scope와 사용자 권한을 확인합니다. JWT 내용을 decode하는 것만으로는 서명 검사가 수행되지 않습니다. + +APIM에서는 `validate-azure-ad-token`의 `audiences`, `client-application-ids`, `required-claims`로 대상 API, 허용 client, scope를 검사할 수 있습니다. 배포에 사용하는 실제 정책은 [sample의 APIM 구성](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/apim.bicep)에 있습니다. + +| 정책 확인 | 기준 | +|---|---| +| PRM 경로 | Token 없이 metadata를 조회할 수 있도록 보호된 tool 경로와 구분 | +| Scope 검사 | `required-claims`는 `validate-azure-ad-token` 또는 [`validate-jwt`](https://learn.microsoft.com/azure/api-management/validate-jwt-policy)의 자식 요소. 독립적인 inbound 정책이 아님 | +| 여러 scope | `scp`의 공백 구분 값에서 필요한 scope를 검사하도록 `separator=" "`와 match 조건 설정 | +| 인증 실패 | 보호된 MCP 요청의 401 challenge에서 PRM 위치 안내 | + +실습에서는 access token 없이 접속한 경우와 정상 token으로 접속한 경우를 차례로 비교합니다. `tools/list`와 `tools/call` 모두 인증 정책의 적용 대상입니다. 이전 MCP revision의 `initialize` 요청을 사용하는 클라이언트도 같은 정책을 적용받습니다. + +## 5. OBO로 사용자의 resource group 조회 + +Python MCP의 resource group 도구와 공식 Azure MCP는 사용자 token을 받아 ARM용 access token을 발급받습니다. + +```text +로컬 MCP 클라이언트 → MCP API + MCP용 access token: aud = 중간 MCP API의 client ID + +MCP API → Entra token endpoint + 서버 앱의 자격 증명 + MCP용 access token을 user assertion으로 제출 + +MCP API → ARM + OBO로 발급받은 ARM용 access token으로 요청 + ARM이 사용자의 Azure RBAC에 따라 리소스 접근 허용 +``` + +1. 중간 MCP API 앱에 ARM의 delegated permission을 추가하고 필요한 consent를 받습니다. +2. 리소스를 조회할 사용자에게 대상 범위의 Azure RBAC 역할을 부여합니다. +3. MCP API를 대상으로 발급한 access token으로 resource group 도구를 호출합니다. +4. 도구 응답에서 요청한 resource group과 반환된 내용을 확인합니다. + +OBO의 user assertion은 **OBO를 요청하는 중간 API를 audience로 가진 사용자 access token**이어야 합니다. ARM용 token을 MCP API에 보내거나 app-only token을 user assertion으로 넣는 방식은 사용할 수 없습니다. 자세한 조건은 [Entra OBO flow](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow)에 있습니다. + +### Managed identity와 FIC의 역할 + +공식 Azure MCP의 `UseOnBehalfOf` 구성에서는 managed identity와 FIC로 **서버 앱을 confidential client로 인증**합니다. ARM에 보낼 token은 이후 OBO로 발급되며, 사용자 권한을 사용합니다. managed identity의 Azure 역할을 사용해 ARM을 호출하는 방식과 다릅니다. + +Python 예제는 서버 측 client secret으로 confidential client를 인증합니다. 이 secret은 서버에서만 사용하며 로컬 MCP 클라이언트에 전달하지 않습니다. APIM의 `authentication-managed-identity` 정책은 APIM의 managed identity 권한으로 backend에 접근하는 기능으로, 사용자 OBO를 대신하지 않습니다. + +## 6. REST inventory와 Learn MCP 호출 + +APIM에서는 다음 두 경로를 따로 사용합니다. + +| 경로 | 호출 흐름 | Backend 인증 | +|---|---|---| +| REST-to-MCP | MCP tool → 등록한 REST operation → inventory API | 샘플의 가상 inventory 데이터는 사용자 token 없이 조회 | +| 기존 MCP 프록시 | MCP 요청 → 기존 Learn MCP 서버 | Learn은 익명 호출이므로 Entra `Authorization` header 제거 | + +실습에서 REST inventory를 직접 조회한 뒤 같은 데이터를 MCP tool로 조회합니다. Learn 경로에서는 도구 목록을 읽고 문서 검색 도구를 호출합니다. 업무용 REST API로 교체할 때는 그 API에 필요한 인증을 별도로 설정합니다. + +GitHub MCP를 추가하는 경우에도 GitHub OAuth 또는 PAT가 필요합니다. APIM [credential manager](https://learn.microsoft.com/azure/api-management/credentials-how-to-user-delegated)는 GitHub 같은 외부 서비스의 OAuth 연결을 관리하는 기능이며, Entra access token을 GitHub token으로 바꾸는 OBO 기능은 아닙니다. + +## 인증 구성의 완료 기준 + +| 대상 | 확인할 것 | 완료 기준 | +|---|---|---| +| PRM | Resource·Entra issuer·scope와 challenge URL | Metadata는 token 없이 조회되고 보호된 tool은 무인증 요청을 거부 | +| Client 로그인 | Client ID·redirect URI·scope·consent, Authorization Code + PKCE | 선택한 client가 API용 token을 취득하고 MCP에 연결 | +| Token·권한 | Issuer·audience·유효 시간·scope/role | 정상 token 허용, 잘못된 audience·부족한 권한 거부 | +| Toolbox | Foundry credential·project 권한·tool connection | 기대한 도구 목록과 선택한 도구 호출 확인 | +| 사용자 위임 | 사용자 connection 또는 OBO와 downstream 권한 | 원본 서비스의 사용자 권한 범위로 실행 | +| 도구 결과 | HTTP 응답과 MCP error·`isError`·업무 결과 | 목록 조회뿐 아니라 실제 도구 결과까지 확인 | + +단계별 명령과 관측값은 [인증 확인 절차](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/authentication-checks.md)와 [실행 사례](../validation/index.md#oauth)에 있습니다. + +## IDE에서 OAuth 로그인이 이어지지 않을 때 + +HTTP 호출에 access token을 직접 넣는 방법과 IDE가 로그인부터 token 갱신까지 처리하는 방법은 설정이 다릅니다. IDE 연결은 다음 순서로 확인합니다. + +1. 인증되지 않은 요청의 `WWW-Authenticate` header에서 `resource_metadata` URL을 확인합니다. +2. 해당 protected-resource metadata(PRM)의 `resource`, `authorization_servers`, scope를 확인합니다. +3. 등록한 client ID와 redirect URI로 로그인하고 필요한 consent를 진행합니다. +4. MCP에 다시 연결하여 도구를 호출합니다. + +Client ID와 redirect URI는 **client 앱 등록**에 맞추고, 요청할 scope는 **MCP API 앱 등록**에 정의된 값을 사용합니다. Client에 해당 API permission과 필요한 consent를 부여합니다. Conditional Access가 추가 claims를 요구하면 이를 처리하는 client에서 다시 로그인합니다. + +## 오류별 확인 항목 + +| 증상 | 확인할 설정 | +|---|---| +| DNS 실패·연결 timeout | VPN/ExpressRoute 경로, private DNS, gateway 연결 | +| HTTP 401 | access token 누락, 서명·issuer·audience·유효 시간 | +| HTTP 403 | client allowlist, scope, API 인가 정책 | +| OBO 오류·AADSTS 코드 | 서버 앱 자격 증명, ARM delegated permission, consent, Conditional Access | +| ARM에서 접근 거부 | OBO 사용자에게 부여한 Azure RBAC와 대상 리소스 범위 | +| 도구 목록은 나오지만 호출 실패 | REST operation 연결, backend 연결·인증, 요청 인자 | +| HTTP 200이고 `isError=true` | MCP 응답에 담긴 도구 실행 오류 | + +실제 HTTP status는 APIM 정책과 앱 설정에 따라 달라질 수 있습니다. 오류 응답의 내용도 함께 확인합니다. 실행과 정리는 [sample walkthrough](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md), 버전별 동작과 응답 차이는 [사례 기록](../validation/index.md)을 참고하세요. diff --git a/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/apim-existing-mcp.svg b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/apim-existing-mcp.svg new file mode 100644 index 00000000..24f6ff13 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/apim-existing-mcp.svg @@ -0,0 +1,24 @@ + + APIM native proxy for an existing Microsoft Learn MCP server + Private clients authenticate to APIM with Entra. APIM uses a native MCP API and explicit backendId binding, removes Authorization, and connects to the existing anonymous Learn MCP endpoint. + + + + Existing MCP proxy: Entra at the front, anonymous Learn at the back + + Private APIM gateway + + Developer MCP clientEntra user tokenTLS / private route + + Validate intended MCP audienceRequire approved client + scopeRemove outbound Authorization + + Native MCP passthrough APIbackendId → Learn backendmessage endpoint → /mcp + + Microsoft Learn MCPPublic HTTPS endpointAnonymous upstreamActual discovered tools + + + + No Entra token + No REST-operation tool mapping here: Learn owns the tools. This is different from REST-to-MCP. + Private ingress does not make Learn private. Outbound connectivity and protocol compatibility remain required. + diff --git a/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/apim-rest-tools.svg b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/apim-rest-tools.svg new file mode 100644 index 00000000..17e747cb --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/apim-rest-tools.svg @@ -0,0 +1,30 @@ + + APIM native REST API operation exposed as an MCP tool + An Entra-protected native MCP API maps getInventory to an existing APIM REST operation. The operation invokes a private fixture backend and returns a fresh correlated invocation marker. This conversion is not OBO. + + + + APIM native REST-to-MCP: verify the operation, not just the endpoint + + Internal APIM Developer gateway + + Local MCP clientEntra user access tokenPrivate route + TLS + + JWT authorization gateTenant / audience / client / scopeProtect initialize, list and call + + Microsoft Entra IDRegistered MCP APIMcp.Access + + Native MCP API: type = mcptools/getInventory + + Existing REST operationGET /inventory + + Private REST backendFictitious widgets onlyFresh invocation_idValidated probe_id echoRejects any bearer header + POST /rest-tools/mcp + Trust + + operationId mapping + + Remove Entra bearer + Proof: REST baseline + real tools/list schema + tools/call + a different, correlated backend invocation. + Native protocol conversion does not create user OBO. Real business backends need their own authorization. + diff --git a/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/app-service-alternative.svg b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/app-service-alternative.svg new file mode 100644 index 00000000..08503a7a --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/app-service-alternative.svg @@ -0,0 +1,28 @@ + + App Service private MCP hosting reference alternative + A reference architecture not deployed in this lab. A private endpoint provides inbound access to an App Service MCP app, while VNet integration provides outbound networking. Entra authentication and OBO remain separate application responsibilities. + + + + App Service alternative: separate inbound and outbound networking + Reference design — not deployed in this execution + + Private networking boundary + + Local clientApproved VPN / routePrivate DNS + + Private endpointInbound only + + App ServiceMCP applicationEasy Auth / JWTConfidential clientUser OBO + + Regional VNet integrationOutbound network path + + Microsoft Entra IDAuthentication / OBO + + Authorized backendsPer-resource tokensPer-user permissions + + + + Disable public ingress explicitly. Private deployment also needs the appropriate SCM/Kudu DNS and route. + MCP authorization/PRM preview scope and custom application responsibilities must be checked separately. + diff --git a/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/container-apps-obo.svg b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/container-apps-obo.svg new file mode 100644 index 00000000..a2644980 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/container-apps-obo.svg @@ -0,0 +1,30 @@ + + Private Container Apps hosting Azure MCP with delegated OBO + A developer's token for the MCP API reaches a private HTTPS ingress. Azure MCP validates it, authenticates its confidential client with managed-identity federation, and acquires a separate ARM token on behalf of the user. + + + + Private Azure MCP: managed identity for the server, OBO for the user + + Local developer clientRegistered public client + + Lab VNet / internal ACA + + Private HTTPS ingressPrivate DNS + TLSNo public MCP ingressACA client-ID allowlist + + Azure MCP 2.0.5Validate token AUseOnBehalfOfRead-only group tools + + User-assigned managed IDFederated client assertion + + Microsoft Entra IDMCP API app + FICDelegated permissionsUser consent / policy + + Azure Resource ManagerToken B: ARM audienceUser's RBAC still applies + 1. Token A: MCP audience + + 2. User assertion + confidential-client proof + + 3. ARM token B + 4. Read lab resources + MI/FIC authenticates the middle tier. It does not replace the calling user with an application identity. + Token A is never sent to ARM. ACA-managed infrastructure is in a separate provider-managed resource group. + diff --git a/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/functions-alternative.svg b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/functions-alternative.svg new file mode 100644 index 00000000..2650288e --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/functions-alternative.svg @@ -0,0 +1,27 @@ + + Functions Flex Consumption MCP reference alternative + A reference architecture not deployed in this lab. Functions Flex Consumption can provide private networking. The self-hosted SDK MCP path is public preview and stateless Streamable HTTP; Entra and OBO must still be configured. + + + + Functions Flex alternative: transport, authentication and delegation + Reference design — not deployed in this execution; self-hosted SDK path is public preview + + Flex Consumption private networking + + Local MCP clientPrivate route / DNSEntra user token + + Azure Functions Flex ConsumptionPrivate endpoint + authenticationStateless Streamable HTTP serverSupported SDK / custom-handler model + + Explicit token acquisition + VNet egressUser OBO where required + + Microsoft Entra IDAPI / client registrationConsent and token policy + + Downstream APIsSeparate audiencesUser / workload roles + + + + + A function key is not Entra user delegation. Workload identity is not automatically OBO. + Do not apply Flex networking capabilities to legacy Consumption or assume every MCP transport is supported. + diff --git a/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/local-upstreams.svg b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/local-upstreams.svg new file mode 100644 index 00000000..576c06c1 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/hosting-reference/images/local-upstreams.svg @@ -0,0 +1,37 @@ + + Local MCP clients and four independent identity boundaries + Azure MCP and AKS MCP run as local stdio processes. They use developer Azure and Kubernetes credentials. GitHub MCP uses GitHub authorization. Microsoft Learn MCP is anonymous. + + + + Local MCP connections: one host, different credentials + + Trusted local workstation + + DeveloperVS Code / MCP SDKPer-server configurationRead-only tool selection + + Azure MCP 2.0.5Local stdioAzure CLI user credential + + AKS MCP 0.0.20Local stdio onlyLab-only kubeconfigNo remote HTTP / OBO + + Azure Resource ManagerUser's Azure RBAC + + AKS / Kubernetes APIEntra + Kubernetes RBAC + stdio + stdio + Azure token + API token + + GitHub hosted MCPGitHub OAuth / PAT + + GitHub APIGitHub permissions + HTTPS + GitHub credential + + + Microsoft Learn MCPAnonymous HTTPS + + Public documentationSearch / full fetch + HTTPS; no Entra bearer + + Tool filters are not RBAC. GitHub and Learn credentials are not interchangeable with Entra tokens. + diff --git a/docs/services/azure-architecture/mcp-configuration/hosting-reference/index.md b/docs/services/azure-architecture/mcp-configuration/hosting-reference/index.md new file mode 100644 index 00000000..9ec6136f --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/hosting-reference/index.md @@ -0,0 +1,240 @@ +--- +title: MCP backend 호스팅과 예제 서버 참고 +description: 자체 MCP 서버의 실행 환경·private network와 예제 서버별 인증 조건을 비교하고, APIM SKU 선택과 배포 절차를 연결합니다. +document_type: research +topic_order: 2 +redirect_from: + - research/azure-api-management/mcp-authentication-options/index.md +services: [azure-architecture, azure-api-management, azure-container-apps, azure-app-service, azure-functions, azure-kubernetes-service, microsoft-foundry, microsoft-entra-id] +technologies: [mcp, azure-cli, bicep, python] +tags: [design, evaluate, ai-agents, networking] +status: current +verification_status: needs-review +sources_checked_at: 2026-09-13 +published_at: 2026-09-12 +official_sources: + - title: What is Toolbox in Foundry? + url: https://learn.microsoft.com/azure/foundry/agents/concepts/toolbox-overview + - title: Create and manage a toolbox in Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox + - title: Network isolation for a toolbox in Microsoft Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox-network-isolation + - title: Choose an Azure service for your MCP server + url: https://learn.microsoft.com/azure/container-apps/mcp-choosing-azure-service + - title: Configure App Service built-in MCP (preview) + url: https://learn.microsoft.com/azure/app-service/configure-mcp-built-in + - title: Versioning and Compatibility + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning + - title: Authorization + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization + - title: MCP Python SDK + url: https://github.com/modelcontextprotocol/python-sdk/blob/v2.2.0/README.md + - title: Remote builds support with Azure Container Registry + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/remote-builds + - title: Azure Developer CLI schema reference + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-schema + - title: Customize your Azure Developer CLI workflows using command and event hooks + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-extensibility + - title: About MCP servers in Azure API Management + url: https://learn.microsoft.com/azure/api-management/mcp-server-overview + - title: Expose REST API in API Management as an MCP server + url: https://learn.microsoft.com/azure/api-management/export-rest-mcp-server + - title: Manage MCP servers programmatically in API Management + url: https://learn.microsoft.com/azure/api-management/manage-mcp-servers-rest-api + - title: Feature-based comparison of the Azure API Management tiers + url: https://learn.microsoft.com/azure/api-management/api-management-features + - title: Use a virtual network to secure inbound or outbound traffic for Azure API Management + url: https://learn.microsoft.com/azure/api-management/virtual-network-concepts + - title: Microsoft identity platform and OAuth 2.0 On-Behalf-Of flow + url: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow + - title: Secure applications and APIs by validating claims + url: https://learn.microsoft.com/entra/identity-platform/claims-validation + - title: Deploy Azure MCP Server with on-behalf-of authentication + url: https://learn.microsoft.com/azure/developer/azure-mcp-server/how-to/deploy-remote-mcp-server-on-behalf-of + - title: GitHub MCP Server + url: https://github.com/github/github-mcp-server + - title: AKS MCP v0.0.20 + url: https://github.com/Azure/aks-mcp/releases/tag/v0.0.20 + - title: Connect your Azure Kubernetes Service (AKS) cluster to AI agents using the Model Context Protocol (MCP) server + url: https://learn.microsoft.com/azure/aks/aks-model-context-protocol-server + - title: Microsoft Learn MCP Server developer reference documentation + url: https://learn.microsoft.com/training/support/mcp-developer-reference + - title: Networking in an Azure Container Apps environment + url: https://learn.microsoft.com/azure/container-apps/networking + - title: Enable authentication and authorization in Azure Container Apps with Microsoft Entra ID + url: https://learn.microsoft.com/azure/container-apps/authentication-entra + - title: Microsoft.App containerApps/authConfigs + url: https://learn.microsoft.com/azure/templates/microsoft.app/2025-01-01/containerapps/authconfigs + - title: Use private endpoints for Azure App Service apps + url: https://learn.microsoft.com/azure/app-service/overview-private-endpoint + - title: Configure built-in MCP server authorization (Preview) + url: https://learn.microsoft.com/azure/app-service/configure-authentication-mcp + - title: Self-hosted remote MCP server on Azure Functions (public preview) + url: https://learn.microsoft.com/azure/azure-functions/self-hosted-mcp-servers + - title: Azure Functions networking options + url: https://learn.microsoft.com/azure/azure-functions/functions-networking-options +--- + +# MCP backend 호스팅과 예제 서버 참고 + +**자체 MCP 서버의 실행 환경을 선택하거나 sample의 서버별 차이를 확인할 때** 읽는 자료입니다. 관리·도구 통합·인증의 전체 구성은 [대표 가이드](../index.md)를 사용합니다. + +| 선택할 것 | 읽을 부분 | +|---|---| +| 예제 서버의 실행·인증 방식 | 서버별 인증 차이와 고정 버전 조건 | +| 자체 MCP 코드의 실행 환경 | Azure 호스팅 옵션과 구성 패턴 | +| Private APIM의 SKU | APIM SKU 선택 | +| 실제 배포·호출 | [Sample walkthrough](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md) | + +## MCP protocol과 패키지 버전 + +“MCP 2.0”만으로는 어떤 버전을 사용하는지 알 수 없습니다. protocol revision, SDK, 서버 패키지를 각각 지정해야 연결 방식을 비교할 수 있습니다. + +| 구분 | 비교에 사용한 값 | 의미 | +|---|---|---| +| MCP protocol revision | `2026-07-28` | 날짜로 구분하는 MCP 사양 | +| Python SDK | `mcp==2.2.0` | SDK 패키지 버전 | +| 메시지 형식 | JSON-RPC `2.0` | MCP 요청·응답의 RPC 형식 | +| 인증 표준 | OAuth `2.0` / `2.1` | access token 발급과 사용에 관한 표준 | +| Azure MCP Server | `2.0.5` | Azure MCP 서버 패키지 버전 | + +Python SDK v2는 최신 사양과 이전 revision을 함께 지원합니다. SDK를 업데이트해도 연결한 서버가 사용하는 protocol revision까지 바뀌지는 않습니다. Learn MCP 연결에서 확인한 revision은 `2025-06-18`이며, Azure MCP 패키지 `2.0.5` 역시 “MCP protocol 2.0”을 뜻하지 않습니다. + +`2026-07-28` 사양은 `server/discover`와 요청별 metadata를 사용하며 protocol session과 GET stream을 제거했습니다. 이전 revision의 연결에서는 `initialize`와 해당 revision의 세션 규칙을 사용합니다. HTTP POST에 대한 SSE 응답은 최신 사양에도 있으므로 “SSE를 모두 제거했다”는 설명은 맞지 않습니다. 자세한 차이는 [versioning](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning)과 [Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)에 있습니다. + +MCP의 `server/discover`는 서버 기능을 확인하는 요청입니다. OAuth의 protected-resource metadata(PRM)는 클라이언트가 사용할 authorization server와 scope를 알려주는 문서로, 목적이 다릅니다. + +## Sample에 사용한 MCP 서버의 인증 차이 + +아래 표는 sample에서 사용하는 고정 버전과 연결 방식입니다. + +| MCP 서버 | 연결 방식 | 사용하는 자격 증명과 권한 | +|---|---|---| +| Azure MCP `2.0.5` | 로컬 stdio 또는 원격 HTTP | 로컬에서는 Azure 자격 증명, 원격에서는 Entra access token과 OBO 또는 호스팅 환경의 자격 증명 | +| AKS MCP `v0.0.20` | **로컬 stdio만 지원** | 로컬 사용자의 Azure CLI 로그인과 kubeconfig, Azure·Kubernetes RBAC | +| GitHub MCP | GitHub hosted 또는 로컬 서버 | GitHub OAuth 또는 PAT, 해당 GitHub 사용자의 repository 권한 | +| Microsoft Learn MCP | 공개 HTTP endpoint | 인증 없이 공개 문서 검색·조회 | + +![로컬 개발 클라이언트가 Azure와 AKS에는 로컬 사용자 자격 증명을, GitHub에는 GitHub 자격 증명을 사용하고 Learn에는 토큰을 보내지 않는 구조](images/local-upstreams.svg) + +### Azure MCP의 OBO와 read-only 설정 + +Azure MCP `2.0.5`의 incoming delegated scope는 `Mcp.Tools.ReadWrite`입니다. `--read-only`를 사용해도 scope 이름은 바뀌지 않습니다. 도구의 읽기 전용 설정과 사용자의 Azure RBAC를 함께 적용합니다. + +`--outgoing-auth-strategy UseOnBehalfOf`는 로그인한 사용자 대신 Azure 서비스를 호출합니다. `UseHostingEnvironmentIdentity`는 서버가 실행되는 환경의 자격 증명을 사용합니다. 이때 로컬에서는 Azure CLI 사용자, Azure에서는 managed identity가 사용될 수 있으므로 사용자별 권한이 필요한 서비스에는 OBO 여부를 명확히 설정해야 합니다. + +연결 예제는 Azure MCP `2.0.5`와 Python SDK `2.2.0`을 기준으로 합니다. 서버를 업그레이드할 때는 패키지 버전뿐 아니라 protocol revision, 요구 scope, 사용할 도구도 함께 확인합니다. + +### AKS MCP v0.0.20의 지원 범위 + +[v0.0.20 release](https://github.com/Azure/aks-mcp/releases/tag/v0.0.20)는 HTTP/SSE, browser-facing OAuth/OBO, token forwarding, container image·Helm·원격 배포를 제거했습니다. MCP 클라이언트가 로컬 binary를 stdio subprocess로 실행하는 방식만 지원합니다. `--access-level readonly`는 도구를 제한하는 옵션이며 Azure·Kubernetes RBAC를 대체하지 않습니다. + +확인일의 [AKS Learn 문서](https://learn.microsoft.com/azure/aks/aks-model-context-protocol-server)에는 아직 원격 모드와 `--transport` 예제가 남아 있습니다. **v0.0.20에는 release의 로컬 stdio 구성을 적용합니다.** 해당 버전을 APIM 뒤에 원격 배포하는 예제로 사용하지 않습니다. + +### GitHub OAuth와 익명 Learn 호출 + +Entra ID로 내부 MCP를 보호해도 GitHub API를 호출할 때는 GitHub OAuth 또는 PAT가 필요합니다. Entra OBO로 GitHub access token이 발급되는 것은 아닙니다. + +Learn은 익명으로 호출합니다. APIM에서 Entra 인증을 추가하더라도 backend Learn 요청에는 incoming `Authorization` header를 전달하지 않습니다. 이 구성은 내부 사용자의 APIM 접근을 제한하는 것이며, 공개 GitHub·Learn endpoint 자체를 private endpoint로 바꾸지는 않습니다. + +## Azure 호스팅 옵션 + +| 선택지 | 적합한 상황 | 내부망 접속 | 인증·OBO 구성 | +|---|---|---|---| +| Container Apps | 공식 Azure MCP나 자체 MCP를 container로 운영 | Internal environment와 private DNS | 앱의 JWT 검증·Container Apps authentication, 서버의 OBO | +| App Service | 기존 웹 앱 운영 방식으로 자체 MCP 배포 | Inbound private endpoint, outbound VNet integration | Easy Auth 또는 앱 middleware, 별도 OBO 구현 | +| Functions Flex Consumption | SDK 기반 stateless MCP 호스팅 | Private endpoint와 VNet integration | Built-in auth 또는 서버 코드, 별도 OBO 구현 | + +APIM은 위 실행 환경에 둔 MCP를 관리하거나 REST operation을 MCP tool로 제공합니다. 로컬 PC의 private 접속에는 VPN/ExpressRoute 경로와 DNS를, HTTP 요청에는 Entra 인증을 구성합니다. + +Container Apps dynamic sessions는 별도 선택지입니다. Platform이 제공하는 Python·shell 실행 도구와 session별 격리를 사용하며, 직접 작성한 MCP 서버를 배포하는 standalone Container Apps와는 다릅니다. + +### 여러 도구를 묶어 제공할 때 + +Toolbox는 MCP·OpenAPI·IQ 도구를 하나의 endpoint에 구성하고 connection 인증·version을 관리합니다. 기능과 선택 기준은 [대표 가이드의 Toolbox 장](../index.md#3-toolbox-iq-mcp-endpoint), 구성 명령은 [Toolbox sample](https://github.com/hellices/devguidesample/tree/main/docs/services/azure-architecture/mcp-configuration/samples/toolbox)에 정리합니다. + +### APIM 없이 REST API를 변환하는 옵션 + +App Service built-in MCP **preview**는 **Basic 이상 전용 tier**에서 기존 API의 OpenAPI **3.0.x** 명세를 읽어 operation을 MCP tool로 노출합니다. OpenAPI 3.1.x는 지원하지 않습니다. Foundry Toolbox의 OpenAPI tool도 REST API를 toolset에 포함할 수 있습니다. 따라서 REST-to-MCP가 필요하다는 이유만으로 APIM을 필수로 선택할 필요는 없습니다. + +## 구성 패턴과 적용 조건 + +### 1. Container Apps에서 Azure MCP OBO 사용 + +![내부 Container Apps의 Azure MCP가 Entra 사용자 토큰을 검증하고 관리 ID 기반 federated credential로 confidential client를 인증한 뒤 별도 ARM 토큰을 얻는 구조](images/container-apps-obo.svg) + +요청은 다음 순서로 처리됩니다. + +1. 로컬 클라이언트가 Azure MCP API를 대상으로 access token을 받습니다. +2. Container Apps authentication이 허용 client ID를 검사하고, Azure MCP가 tenant·audience·scope를 검사합니다. +3. Azure MCP가 managed identity와 FIC로 confidential client를 인증하고, 사용자 token을 OBO user assertion으로 제출합니다. +4. ARM용 access token으로 Azure 리소스를 조회합니다. 조회 범위는 사용자의 Azure RBAC에 따릅니다. + +Managed identity와 FIC는 이 흐름에서 **서버 앱의 인증 수단**입니다. ARM의 리소스 권한은 OBO를 통해 전달한 사용자에게 적용됩니다. + +Native Azure MCP의 허용 클라이언트는 Container Apps `authConfigs`의 `identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications`로 제한합니다. 앱 등록의 `preAuthorizedApplications`는 consent 설정이므로 이 ACL을 대신하지 않습니다. + +Internal Container Apps 환경의 앱 ingress `external: true`는 VNet에서 앱으로 접근할 수 있게 합니다. 인터넷 공개 설정이 아닙니다. 환경의 private DNS와 개발 PC의 DNS 전달 경로도 구성해야 합니다. + +### 2. APIM에서 REST API를 MCP tool로 제공 + +![Entra로 보호된 APIM이 기존 REST operation을 native MCP tool에 연결하고 실제 REST 백엔드를 호출하는 구조](images/apim-rest-tools.svg) + +APIM이 관리하는 REST API operation을 선택하면 MCP tool로 노출할 수 있습니다. 별도 MCP 서버 프로세스 없이 APIM이 MCP 요청을 REST 호출로 연결합니다. + +샘플은 가상 inventory REST API를 사용합니다. [Sample walkthrough](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md)에서는 inventory를 REST로 조회하고, MCP 도구 목록을 확인한 뒤 같은 항목을 tool로 조회합니다. Tool 리소스의 **`operationId`가 실제 REST operation의 리소스 ID를 가리켜야** 이 호출이 연결됩니다. + +이 기능 자체가 REST backend의 인증을 OBO로 바꾸지는 않습니다. 업무 API를 연결할 때는 그 API의 access token, managed identity 또는 다른 인증 방식을 별도로 구성합니다. Python MCP의 resource group OBO 조회는 이 REST inventory 경로와 별개의 MCP 도구입니다. + +### 3. APIM으로 기존 Learn MCP 프록시 + +![APIM이 Entra 토큰을 검증한 뒤 Authorization을 제거하고 기존 공개 Learn MCP 서버에 연결하는 구조](images/apim-existing-mcp.svg) + +이 방식은 기존 MCP 서버가 이미 제공하는 도구를 APIM을 통해 호출합니다. REST-to-MCP의 `operationId` 연결과 달리, MCP API에 **backend 리소스, `backendId`, `message` endpoint**를 연결합니다. Learn 요청에서는 APIM이 incoming Entra token을 검사한 뒤 `Authorization` header를 제거합니다. + +APIM의 `2025-09-01-preview` 관리 문서·Bicep 타입과 실제 API·공식 AI-Gateway 샘플 사이에는 차이가 있습니다. 문서의 endpoint 배열 대신 실제 구성은 `message`를 key로 둔 `endpoints` object와 `backendId`를 사용합니다. 이 차이는 [사례 기록](../validation/index.md)에 정리했으며, 관리 API 버전을 바꿀 때 다시 확인해야 합니다. + +### 4. App Service 대안 + +![App Service private endpoint로 들어온 요청을 Easy Auth 또는 JWT middleware에서 검증하고 별도 confidential middle tier에서 OBO를 수행하는 대안](images/app-service-alternative.svg) + +기존 App Service 운영 환경에서 자체 MCP 서버를 웹 앱으로 제공할 때 고려할 수 있습니다. + +Private endpoint는 앱으로 들어오는 요청에 사용하고, VNet integration은 앱이 private backend를 호출할 때 사용합니다. Private endpoint를 추가하는 것만으로 public access가 비활성화되지는 않습니다. 앱의 public access 설정과 SCM/Kudu용 private DNS도 함께 구성해야 합니다. + +App Service의 MCP authorization과 PRM 지원은 확인일 기준 **preview**입니다. Easy Auth를 적용하더라도 ARM 등 다른 API를 사용자 권한으로 호출하려면 앱의 OBO 구현과 delegated permission·consent가 필요합니다. + +### 5. Functions Flex Consumption 대안 + +![Functions Flex Consumption에서 private endpoint와 VNet integration을 사용하고 stateless SDK MCP와 인증·위임 코드를 분리하는 대안](images/functions-alternative.svg) + +SDK 기반 MCP 서버를 self-hosted/custom-handler 방식으로 호스팅하는 옵션입니다. [공식 문서](https://learn.microsoft.com/azure/azure-functions/self-hosted-mcp-servers)의 지원 범위는 **public preview, Flex Consumption, stateless Streamable HTTP**입니다. + +Flex Consumption은 private endpoint와 VNet integration을 지원하지만 legacy Consumption은 같은 네트워크 기능을 제공하지 않습니다. Function key는 사용자 access token이 아니므로, Entra 사용자 인증과 OBO가 필요하다면 built-in auth 또는 서버 코드에서 별도로 처리해야 합니다. + +## azd 기반 실습 구성 + +[Sample walkthrough](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md)에서 Entra 준비 → `azd` preview·배포 → 내부망 연결 → 도구·OBO 호출 → 정리를 순서대로 실행합니다. Sample은 Container Apps와 APIM Developer를 사용하며 ACR remote build로 image를 만듭니다. 도구 버전·비용·정리 명령은 해당 sample에서 확인합니다. + +## APIM SKU 선택 + +다음은 [MCP 지원 tier](https://learn.microsoft.com/azure/api-management/mcp-server-overview#availability)에서 private 구성을 선택할 때의 기준입니다. + +| SKU | Private 구성 | 적용 조건 | +|---|---|---| +| Developer | Internal VNet injection으로 내부 client·backend 연결 | 비운영·평가용, SLA 없음 | +| Basic / Standard classic | Inbound private endpoint | VNet injection·private backend 연결 기능은 지원하지 않음 | +| Basic v2 | Public gateway | Inbound private endpoint·VNet 연결 기능은 지원하지 않음 | +| Standard v2 | Inbound private endpoint + outbound VNet integration | 내부 client와 private backend 경로를 각각 구성 | +| Premium classic | Internal/external VNet injection | 배포 모드와 network 규칙에 맞춰 연결 | +| Premium v2 | Gateway VNet injection 또는 private endpoint·outbound integration | 선택한 networking model에 맞춰 구성 | + +세부 옵션은 [기능 비교표](https://learn.microsoft.com/azure/api-management/api-management-features)와 [VNet 구성 문서](https://learn.microsoft.com/azure/api-management/virtual-network-concepts)를 따릅니다. Developer portal의 “Entra integration” 항목을 API의 JWT 검증 정책 지원 여부와 혼동하지 않습니다. + +## OBO에 필요한 token과 권한 + +OBO의 입력은 중간 MCP API를 대상으로 발급한 **사용자 access token**입니다. v2 token의 `aud`는 중간 API의 client ID(GUID)여야 하며, ARM용 token이나 app-only token을 입력으로 사용할 수 없습니다. + +중간 API는 confidential client로 인증한 뒤 별도의 ARM access token을 받습니다. API 앱의 delegated permission·consent와 사용자의 Azure RBAC가 모두 필요합니다. Scope URI, JWT의 `scp`, PRM의 `resource` URL은 용도가 다른 값입니다. + +실제 앱 등록과 APIM 정책은 [Entra 인증 가이드](../authentication/index.md)에 있습니다. 다른 IDE나 사용자를 연결할 때는 client ID·redirect URI·consent·Conditional Access와 해당 사용자의 RBAC를 적용해 같은 호출 순서로 확인할 수 있습니다. diff --git a/docs/services/azure-architecture/mcp-configuration/images/mcp-oauth-obo.svg b/docs/services/azure-architecture/mcp-configuration/images/mcp-oauth-obo.svg new file mode 100644 index 00000000..f84fb96d --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/images/mcp-oauth-obo.svg @@ -0,0 +1,25 @@ + + PRM at APIM and OAuth with PKCE between the client and Entra ID + The MCP client reads protected-resource metadata from APIM, authenticates directly with Entra using authorization code and PKCE, then sends the issued access token to APIM. APIM validates the token and forwards the MCP request. Optional downstream OBO is a separate middle-tier flow. + + + + PRM: APIM / MCP server | OAuth + PKCE: client and Entra + Protected-resource discovery and user login are separate exchanges + + MCP clientRead PRMOAuth client / MSALPKCE verifier + challenge + + API ManagementPRM endpointJWT validationIssuer / audience / scope + + MCP serverTool executionBackend authorization + 1. PRM request + Issuer + scopes + 3. Token + MCP + 4. Forward + + Entra ID: authorization serverAuthorization and token endpoints + 2. Code + PKCE + Access token + Entra issues tokens; it does not serve the MCP PRM.APIM validates access tokens; it does not perform client PKCE.The MCP server can also serve PRM when used without APIM. + OBO, when needed, is a separate exchange between the middle-tier MCP API, Entra and the downstream API. + diff --git a/docs/services/azure-architecture/mcp-configuration/images/mcp-reference-architecture.svg b/docs/services/azure-architecture/mcp-configuration/images/mcp-reference-architecture.svg new file mode 100644 index 00000000..ecb003f8 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/images/mcp-reference-architecture.svg @@ -0,0 +1,24 @@ + + Azure API Management governs access to MCP servers and REST tools + MCP clients send requests to API Management in the center. It validates access, applies policies and routes to an internal MCP server, a partner MCP server, or a REST API exposed as MCP tools. The diagram shows request paths, not a merged tool catalog. + + + + 1. Azure API Management / AI gateway + A common place to apply policies to internal and partner MCP access + + MCP clientsIDE / applicationsLocal development + + API ManagementMCP API endpointsEntra token validationAccess / traffic policiesREST-to-MCP conversionRequest telemetry + MCP requests + + Internal MCPYour tools and data + + Partner MCPRemote SaaS tools + + REST APIExisting operations + MCP + MCP + REST + Each MCP API can have its own endpoint. Backend credentials and user delegation are configured per target. + diff --git a/docs/services/azure-architecture/mcp-configuration/images/toolbox-iq-mcp.svg b/docs/services/azure-architecture/mcp-configuration/images/toolbox-iq-mcp.svg new file mode 100644 index 00000000..39ad51e4 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/images/toolbox-iq-mcp.svg @@ -0,0 +1,27 @@ + + Microsoft Foundry Toolbox exposes IQ and MCP tools through one MCP endpoint + A client connects to one Toolbox MCP endpoint. Toolbox manages tool definitions, connection credentials, versions and search. It invokes Work IQ through the selected MCP or A2A connection, Fabric IQ item MCP endpoints, a Foundry IQ knowledge-base MCP endpoint, and other MCP servers. + + + + 2. Microsoft Foundry Toolbox + One MCP endpoint for a curated toolset; downstream protocols and identities remain connection-specific + + MCP clientsIDE / applicationsAny compatible runtime + + Foundry ToolboxOne MCP endpointCurated tools / versionsConnection authenticationPolicies / observabilityTool search + Discover / call + + Work IQMicrosoft 365 + + Fabric IQFabric data and semantics + + Foundry IQSearch knowledge base + + Other MCP serversInternal / partner tools + MCP / A2A + MCP + MCP + MCP + APIM is not required to aggregate this toolset. Add a gateway only where its policy and routing requirements apply. + diff --git a/docs/services/azure-architecture/mcp-configuration/index.md b/docs/services/azure-architecture/mcp-configuration/index.md new file mode 100644 index 00000000..8dbd65a7 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/index.md @@ -0,0 +1,356 @@ +--- +title: Azure MCP 구성 — APIM·Toolbox·IQ의 활용과 선택 기준 +description: 공통 정책 관리에는 APIM, 도구 집합 공유에는 Toolbox, 기업 데이터 연결에는 IQ를 사용하는 이유와 조합 기준을 설명합니다. +document_type: guide +redirect_from: + - guides/azure-architecture/mcp-configuration/index.md + - labs/azure-architecture/mcp-configuration/index.md + - labs/azure-api-management/mcp-rest-and-upstream/index.md +services: [azure-architecture, azure-api-management, microsoft-foundry, microsoft-entra-id, azure-container-apps, azure-ai-search] +technologies: [mcp, azure-cli] +tags: [design, secure, ai-agents, identity] +status: current +verification_status: verified +sources_checked_at: 2026-09-13 +official_sources: + - title: AI gateway in Azure API Management + url: https://learn.microsoft.com/azure/api-management/genai-gateway-capabilities + - title: About MCP servers in Azure API Management + url: https://learn.microsoft.com/azure/api-management/mcp-server-overview + - title: Expose and govern an existing MCP server + url: https://learn.microsoft.com/azure/api-management/expose-existing-mcp-server + - title: Expose REST API in API Management as an MCP server + url: https://learn.microsoft.com/azure/api-management/export-rest-mcp-server + - title: Secure access to MCP servers in API Management + url: https://learn.microsoft.com/azure/api-management/secure-mcp-servers + - title: About API credentials and credential manager + url: https://learn.microsoft.com/azure/api-management/credentials-overview + - title: Validate Microsoft Entra token + url: https://learn.microsoft.com/azure/api-management/validate-azure-ad-token-policy + - title: Secure a Model Context Protocol (MCP) server with Microsoft Entra ID + url: https://learn.microsoft.com/entra/agent-id/secure-mcp-server-with-entra-id + - title: Microsoft identity platform and OAuth 2.0 authorization code flow + url: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow + - title: How to integrate Azure API Management with Azure Application Insights + url: https://learn.microsoft.com/azure/api-management/api-management-howto-app-insights + - title: Feature-based comparison of the Azure API Management tiers + url: https://learn.microsoft.com/azure/api-management/api-management-features + - title: Use a virtual network to secure inbound or outbound traffic for Azure API Management + url: https://learn.microsoft.com/azure/api-management/virtual-network-concepts + - title: What is Toolbox in Foundry? + url: https://learn.microsoft.com/azure/foundry/agents/concepts/toolbox-overview + - title: Create and manage a toolbox in Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox + - title: How toolbox authentication works in Microsoft Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/tool-authentication + - title: Enable tool search in a toolbox + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/tool-search + - title: Network isolation for a toolbox in Microsoft Foundry + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox-network-isolation + - title: What is Foundry IQ? + url: https://learn.microsoft.com/azure/foundry/agents/concepts/what-is-foundry-iq + - title: Connect a Foundry IQ knowledge base to Foundry Agent Service + url: https://learn.microsoft.com/azure/foundry/agents/how-to/foundry-iq-connect + - title: "Quickstart: Add a Foundry IQ knowledge base to a hosted agent with a toolbox" + url: https://learn.microsoft.com/azure/foundry/agents/quickstarts/quickstart-foundry-iq-hosted-agent + - title: Connect agents to Microsoft 365 with Work IQ (preview) + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/work-iq + - title: Connect agents to Microsoft Fabric with Fabric IQ (preview) + url: https://learn.microsoft.com/azure/foundry/agents/how-to/tools/fabric-iq + - title: Microsoft identity platform and OAuth 2.0 On-Behalf-Of flow + url: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-on-behalf-of-flow + - title: Choose an Azure service for your MCP server + url: https://learn.microsoft.com/azure/container-apps/mcp-choosing-azure-service + - title: Versioning + url: https://modelcontextprotocol.io/docs/2026-07-28/learn/versioning + - title: Key Changes + url: https://modelcontextprotocol.io/specification/2026-07-28/changelog + - title: Overview + url: https://modelcontextprotocol.io/specification/2026-07-28/basic + - title: Streamable HTTP + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http + - title: Versioning and Compatibility + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning + - title: Discovery + url: https://modelcontextprotocol.io/specification/2026-07-28/server/discover + - title: Lifecycle + url: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle + - title: Transports + url: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports + - title: Authorization + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization + - title: Authorization Server Discovery + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery + - title: Client Registration + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration + - title: Authorization Security Considerations + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations +last_verified: 2026-09-13 +review_cycle_days: 90 +applies_to: + - Azure API Management MCP endpoints + - Microsoft Foundry Toolbox and IQ tool connections + - Microsoft Entra ID authorization +related_cases: + - validation/index.md +--- + +# Azure MCP 구성 — APIM·Toolbox·IQ의 활용과 선택 기준 + +**Azure API Management(APIM)**는 여러 API와 MCP 서버에 공통 접근 정책을 적용하는 API 관리 서비스입니다. 개발 도구가 사용할 endpoint를 제공하고 인증·인가, 호출 제한, backend routing과 요청 기록을 중앙에서 관리합니다. + +**Microsoft Foundry Toolbox**는 MCP·REST API·IQ 도구를 업무에 필요한 toolset으로 묶어 **하나의 MCP endpoint**로 제공하는 서비스입니다. 도구별 connection 인증, version, 검색과 정책을 관리하므로 여러 client가 같은 도구 집합을 재사용할 수 있습니다. + +## 소개하는 서비스 + +| 서비스 | 사용하는 이유 | 핵심 기능 | 공식 문서 | +|---|---|---|---| +| **APIM / AI gateway** | 여러 팀의 API·MCP에 조직 공통 운영 정책을 적용 | 인증·인가, 호출 제한, routing, 요청 기록, REST-to-MCP | [AI gateway](https://learn.microsoft.com/azure/api-management/genai-gateway-capabilities), [MCP 연결](https://learn.microsoft.com/azure/api-management/expose-existing-mcp-server) | +| **Foundry Toolbox** | Client마다 반복하는 도구 연결·인증·변경 관리를 줄이고 도구 집합 공유 | 단일 MCP endpoint, connection, version, Tool search, 정책·관측 | [Toolbox 개요](https://learn.microsoft.com/azure/foundry/agents/concepts/toolbox-overview), [Tool search](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/tool-search) | +| **Work IQ·Fabric IQ·Foundry IQ** | AI가 조직의 업무 context와 데이터·지식을 활용 | Microsoft 365, Fabric 의미 모델, 기업 문서 knowledge base 연결 | [Work IQ](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/work-iq), [Fabric IQ](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/fabric-iq), [Foundry IQ](https://learn.microsoft.com/azure/foundry/agents/concepts/what-is-foundry-iq) | + +### 이 문서의 범위와 읽는 순서 + +대상은 **HTTP 기반 MCP tool의 제공·호출, Entra 인증, 사용자 위임과 내부망 접속**입니다. `tools/list`와 `tools/call`을 중심으로 설명합니다. + +| 목적 | 읽을 곳 | 결정하거나 완성할 것 | +|---|---|---| +| 솔루션 선택 | 서비스 표 → 1–3장 → [6장](#6) | APIM 관리, Toolbox 통합 또는 두 서비스 조합 선택 | +| 접근·권한 설계 | [4장](#4) → [인증 상세](authentication/index.md) | 누가 접속하고 누구의 권한으로 도구를 실행할지 결정 | +| 자체 MCP 서버 배포 | [5장](#5-mcp) → [호스팅 참고](hosting-reference/index.md) | 실행 환경·private network·배포 방식 선택 | +| 명령 실행과 결과 비교 | [7장](#7) → sample·[실행 사례](validation/index.md) | 배포·도구 호출·인가 확인·리소스 정리 | + +SDK나 gateway의 wire 동작을 구현·진단할 때는 Appendix를 읽습니다. + +## 1. APIM / AI gateway로 MCP 접근 관리 + +APIM은 client와 backend 사이에서 요청을 처리하는 **API gateway와 관리 기능**을 제공합니다. 여러 팀이 운영하는 API·MCP 서버를 등록하고, 인증·접근 제어·호출 제한·routing 정책을 공통 방식으로 적용할 수 있습니다. + +[AI gateway](https://learn.microsoft.com/azure/api-management/genai-gateway-capabilities)는 이 APIM gateway를 AI 모델·MCP 도구·agent API에 활용하는 기능입니다. 이 문서에서는 **MCP에 대한 조직 공통 정책과 기존 API의 도구화**에 집중합니다. + +### MCP 운영에서 제공하는 기능 + +| 기능 | 활용 상황 | +|---|---| +| **공유 MCP 접점** | 개발 도구와 애플리케이션에 승인된 gateway 주소 제공 | +| **인증·인가 정책** | Entra token과 scope·허용 client를 검사해 업무별 접근 통제 | +| **호출 제한·routing** | 사용자·애플리케이션별 사용량을 관리하고 요청을 지정한 backend로 전달 | +| **사내·타사 서비스 연결** | Azure뿐 아니라 호환되는 외부 MCP에 backend별 인증과 정책 적용 | +| **REST-to-MCP** | 운영 중인 REST operation을 MCP tool로 제공 | +| **운영 관측** | 요청·응답 상태, latency, 정책 차단과 correlation 정보를 수집 | + +기존 API 관리 체계를 MCP까지 확장하거나, 여러 팀의 도구 접근을 같은 정책으로 운영하려는 경우에 적합합니다. + +[![MCP client가 중앙의 APIM으로 요청하고 APIM이 사내 MCP·타사 MCP·REST API로 전달하는 단순한 요청 흐름](images/mcp-reference-architecture.svg)](images/mcp-reference-architecture.svg) + +### 기존 MCP와 REST API + +- **기존 MCP:** APIM에 remote MCP endpoint를 연결하고 정책을 적용합니다. +- **기존 REST API:** 선택한 REST operation을 MCP tool로 제공합니다. 별도 MCP 업무 서버 코드를 작성하지 않고 기존 API를 도구로 사용할 수 있습니다. + +APIM에서는 API/MCP별 endpoint와 정책을 관리합니다. 업무용 도구 집합을 하나의 consumer endpoint로 구성하는 방법은 Toolbox 장에서 설명합니다. + +### 운영 정책을 한곳에 모으는 이유 + +Client별로 인증과 호출 제한을 반복 구현하는 대신 gateway에서 운영 기준을 적용하고, 요청 기록을 backend 로그와 연결해 장애·접근 문제를 추적할 수 있습니다. 감사가 필요하면 수집·보관·접근 통제까지 설계합니다. [Application Insights의 성능 telemetry만으로 전수 감사를 보장하지는 않습니다](https://learn.microsoft.com/azure/api-management/api-management-howto-app-insights#performance-implications-and-log-sampling). + +## 2. IQ 도구와 MCP는 어떻게 연결되는가 + +IQ는 **업무 데이터·지식을 제공하는 서비스**이며, MCP는 연결 방식입니다. AI의 답변에 사내 문서, Microsoft 365 업무 이력, Fabric의 지표·의미 모델이 필요할 때 해당 IQ를 연결합니다. 데이터 서비스가 관리하는 context와 권한을 활용하면서 필요한 기능을 도구로 사용할 수 있습니다. + +| IQ | 제공하는 기능 | MCP·Toolbox 연결 | +|---|---|---| +| **Work IQ** | Microsoft 365의 메일·회의·파일·채팅 등 업무 context | Work IQ Chat은 A2A를 사용하며 해당 경로의 OBO를 명시. 다른 선택 항목은 MCP endpoint 사용. Toolbox는 선택한 도구를 자신의 MCP endpoint로 제공 | +| **Fabric IQ** | Ontology, Power BI semantic model, Fabric data agent를 통한 업무 데이터 조회·추론 | Fabric item 유형별 MCP endpoint 제공. 연결 identity와 인증 방식은 item·connection 경로에 따라 다름 | +| **Foundry IQ** | Azure AI Search 기반 knowledge base에서 여러 source를 검색하고 근거 있는 결과 반환 | Knowledge base의 MCP endpoint를 Toolbox connection으로 연결하는 [공식 예제](https://learn.microsoft.com/azure/foundry/agents/quickstarts/quickstart-foundry-iq-hosted-agent#step-3-provision-azure-resources-and-the-knowledge-base) 제공 | + +| 주의점 | 확인 | +|---|---| +| 지원 상태 | Work IQ·Fabric IQ 연결은 preview 포함. Foundry IQ 일부는 GA지만 [MCP 연결 예제](https://learn.microsoft.com/azure/foundry/agents/how-to/foundry-iq-connect)는 preview API 사용 | +| 데이터 권한 | Source ACL·사용자 token 전달 설정 확인. MCP 연결 자체가 데이터 권한 검사를 완성하지 않음 | +| Identity | 위 Foundry IQ 예제는 `agentic-identity`이며 사용자 OBO 예제가 아님 | +| 운영 조건 | 서비스별 비용·라이선스·데이터 처리 위치 확인 | + +## 3. Toolbox로 IQ와 MCP를 하나의 endpoint에 구성 + +**Toolbox는 Microsoft Foundry에서 도구 집합을 만들고 공유하는 관리형 서비스**입니다. 여러 MCP 서버·OpenAPI·IQ 도구를 선택해 하나의 toolset으로 구성하고, client에는 하나의 MCP endpoint를 제공합니다. + +예를 들어 기업 문서 검색, 업무 REST API, Microsoft 365 도구를 사용하는 assistant를 만들 때 각 client에 서버 주소와 인증 로직을 반복해서 구성하는 대신 Toolbox에 connection을 관리할 수 있습니다. 같은 toolset을 여러 애플리케이션에서 재사용하며, Foundry 밖의 MCP-compatible client에서도 연결할 수 있습니다. + +### Toolbox의 핵심 기능 + +| 기능 | 제공하는 내용 | +|---|---| +| **도구 구성·공유** | 필요한 MCP·OpenAPI·IQ 도구를 선택해 업무별 toolset으로 제공 | +| **단일 MCP endpoint** | Client가 도구별 endpoint 대신 Toolbox를 통해 목록을 조회하고 호출 | +| **Connection 인증** | 도구별 사용자 OAuth, 서비스 identity, key와 token lifecycle 관리 | +| **Version 관리** | 도구 집합의 새 version을 확인하고 default version으로 전환 | +| **Tool search** | 요청에 관련된 도구 정의를 검색해 model context에 필요한 도구만 제공 | +| **정책·관측** | Toolset의 인증·인가, guardrail과 observability를 중앙에서 관리 | + +[![하나의 Toolbox MCP endpoint가 Work IQ·Fabric IQ·Foundry IQ 및 사내·타사 MCP 도구에 연결하며 downstream protocol은 MCP 또는 A2A로 구분되는 구성](images/toolbox-iq-mcp.svg)](images/toolbox-iq-mcp.svg) + +Microsoft Learn은 이 기능을 [Build·Discover·Consume·Govern](https://learn.microsoft.com/azure/foundry/agents/concepts/toolbox-overview)으로 설명합니다. 도구 통합과 인증·version 관리를 먼저 구성하고, 도구가 많아지면 Tool search로 탐색을 최적화할 수 있습니다. + +### Tool search와 토큰 절감 데모 + +도구가 늘어날수록 모든 도구 정의를 매번 model context에 넣는 부담이 커집니다. **Tool search는 필요한 도구를 찾아 정의를 제공**하고, 자주 쓰는 도구는 pin·auto-pin으로 노출해 검색 부담을 줄입니다. + +Microsoft Learn의 [Toolbox 개요](https://learn.microsoft.com/azure/foundry/agents/concepts/toolbox-overview)에 삽입된 **[Toolboxes in Microsoft Foundry 영상](https://www.youtube.com/watch?v=7bBvmifVMew)**에서 이 차이를 보여줍니다. + +| 영상 위치 | 확인할 내용 | +|---|---| +| [3:22부터](https://www.youtube.com/watch?v=7bBvmifVMew&t=202s) | Work IQ 도구 추가와 connection 인증 | +| [6:05부터](https://www.youtube.com/watch?v=7bBvmifVMew&t=365s) | 전체 도구 정의가 context를 차지하는 문제와 필요한 도구만 찾는 방식 | +| [8:34–9:13](https://www.youtube.com/watch?v=7bBvmifVMew&t=514s) | 같은 상품 catalog 질문에 대해 발표자가 제시한 input token 비교 | + +| 해당 데모 | Input tokens | +|---|---:| +| 전체 도구 정의를 사용하는 비교 대상 | 4,676 | +| 필요한 도구를 검색해 사용하는 비교 대상 | 467 | + +영상의 **상품 catalog 조회 시연에서는 input tokens가 약 90% 감소**했습니다. 결과는 model·도구·질문·cache 조건에 따라 달라집니다. + +- 같은 model·도구·질문 조건에서 업무 완료까지의 input/output tokens, cache, 검색 횟수, latency·재시도를 비교합니다. +- 설정: [Tool search 공식 문서](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/tool-search) · [Toolbox sample](https://github.com/hellices/devguidesample/tree/main/docs/services/azure-architecture/mcp-configuration/samples/toolbox) + +## 4. 접근과 사용자 권한을 통제하는 이유 + +MCP 도구는 실제 업무 데이터와 API를 사용합니다. **누가 MCP에 접속할 수 있는지**와 **도구가 누구의 권한으로 실행되는지**를 나눠 설계합니다. + +| 통제 지점 | 필요한 이유 | 사용하는 기능 | +|---|---|---| +| **APIM의 MCP 접점** | 승인된 사용자·client만 업무 도구에 접근 | Entra 로그인, PRM 제공, JWT·scope 정책 | +| **Toolbox의 connection** | 여러 client가 도구별 인증을 반복 구현하지 않고 재사용 | 사용자 OAuth, 서비스 identity, credential·token lifecycle 관리 | +| **데이터 API 접근** | 사용자가 원래 가진 데이터 권한을 도구 호출에도 적용 | 사용자 위임이 필요한 중간 MCP API의 Entra OBO 또는 지원되는 사용자 connection | + +**OAuth + PKCE는 client와 Entra**, **PRM·token 검증은 APIM/MCP Server**에 적용합니다. 앱 등록·claim·정책·완료 기준은 [인증 상세](authentication/index.md), 실제 명령과 결과는 [인증 확인 절차](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/authentication-checks.md)와 [실행 사례](validation/index.md#oauth)에서 설명합니다. + +## 5. MCP 서버를 준비하는 방법 + +| 방법 | 사용할 때 | 구현·확인 위치 | +|---|---|---| +| SDK로 MCP 서버 작성 | 자체 업무 로직과 데이터 접근을 tool로 구현 | Container Apps·App Service·Functions·AKS 등에서 실행. [Azure hosting 비교](https://learn.microsoft.com/azure/container-apps/mcp-choosing-azure-service) | +| 기존 REST API 변환 | 이미 운영하는 API operation을 tool로 제공 | APIM REST-to-MCP 또는 지원되는 Toolbox OpenAPI 도구 구성 | +| 기존 remote MCP 연결 | 사내·타사에서 제공하는 도구 재사용 | 원래 endpoint의 protocol·인증·network 조건을 확인해 직접 또는 gateway/Toolbox를 통해 연결 | + +- 기존 remote MCP는 Azure에 다시 배포하지 않고 연결할 수 있습니다. +- 서버 실행 위치, toolset 통합, 공통 정책 적용 위치는 따로 선택합니다. + +## 6. 엔터프라이즈 구성에 적용할 때의 판단 + +**설계 제안**이며 제품 지원 판정과 구분합니다. + +| 요구사항 | 우선 검토 | 선택하는 이유 | +|---|---|---| +| 여러 MCP·IQ를 한 주소로 제공 | Toolbox 단독 | 도구 연결·인증·version을 한곳에서 관리 | +| 여러 팀과 기존 API의 공통 통제 | APIM | 승인 endpoint와 운영 정책을 MCP에도 공통 적용 | +| 조직의 업무 데이터·지식 활용 | 필요한 IQ 도구 + Toolbox | 데이터별 context를 도구로 연결하고 재사용 | +| 도구 공유와 조직 공통 정책이 모두 필요 | Toolbox의 custom MCP connection에 APIM endpoint 연결 | Client의 도구 통합과 API 운영 정책을 함께 적용 | + +**도구를 묶는 요구만 있다면 Toolbox부터**, **기존 API까지 같은 운영 기준으로 관리하려면 APIM을 함께 검토**합니다. 내부망 구성은 [APIM](https://learn.microsoft.com/azure/api-management/virtual-network-concepts)과 [Toolbox](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox-network-isolation)의 network 조건에 맞춥니다. + +### 적용 시 명시적인 제약 + +| 항목 | 적용할 조건 | +|---|---| +| APIM의 remote MCP 연결 | [공식 연결 조건](https://learn.microsoft.com/azure/api-management/expose-existing-mcp-server)에 맞는 `2025-06-18` 이상 서버와 Streamable HTTP 또는 SSE transport 사용 | +| APIM Developer | 비운영·평가용 tier이며 SLA 없음. 운영 환경의 private network는 [SKU별 기능](hosting-reference/index.md#apim-sku)으로 선택 | +| MCP streaming | APIM에서 response body를 읽어 buffering을 유발하지 않도록 구성 | +| Toolbox tool 호출 | [비스트리밍 `tools/call` 미지원](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox#troubleshoot). 지원되는 streaming client 방식 사용 | +| Client의 부가 기능 로드 | APIM은 prompts 미지원, Toolbox는 `prompts/list` 미구현. Tools를 사용할 client에서 prompts 자동 로드가 필요 없다면 해제 | + +## 7. 실행 예제와 확인 결과 + +**명령·정리는 sample**, **실행 환경·응답·캡처는 사례**에서 확인합니다. GitHub·Azure·AKS·Learn MCP를 예제 대상으로 사용합니다. + +| 할 일 | 실행 자료 | 결과를 볼 곳 | +|---|---|---| +| `azd` 배포와 내부망 접속 | [Walkthrough 1–3](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md#1-azd로-환경-배포) | [배포·연결 기록](validation/index.md) | +| APIM REST wrapping·MCP proxy·OBO·인가 확인 | [Walkthrough 4–8](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md#4-entra-access-token-준비) | [도구 호출과 인증 응답](validation/index.md#apim-rest-to-mcp) | +| PRM·앱 등록·token 오류 진단 | [인증 확인 절차](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/authentication-checks.md) | [단계별 관측](validation/index.md#oauth) | +| Toolbox 구성·Tool search·version 관리 | [Toolbox sample](https://github.com/hellices/devguidesample/tree/main/docs/services/azure-architecture/mcp-configuration/samples/toolbox) | Sample의 확인 방법과 앞의 공식 데모 | +| 실습 종료 | [Walkthrough 9](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md#9-환경-정리) | ARM 리소스와 Entra 앱 정리 | + +## Appendix. “MCP 2.0”과 실제 protocol 변경 + +**2026-09-13 확인 기준** 공식 Current MCP protocol은 [`2026-07-28`](https://modelcontextprotocol.io/docs/2026-07-28/learn/versioning)입니다. Protocol은 날짜형 revision을 사용합니다. **JSON-RPC 2.0**, Python SDK `2.x`, Azure MCP 제품 `2.x`는 서로 다른 버전 축입니다. + +이 부록은 **자체 MCP server·SDK·gateway의 wire 호환성을 구현하거나 진단할 때** 사용합니다. 아래 규격 설명을 제품별 지원표로 해석하지 않습니다. + +### `2025-11-25`와 `2026-07-28` 비교 + +[이전 lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle), [이전 transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports), [현재 변경 목록](https://modelcontextprotocol.io/specification/2026-07-28/changelog)을 기준으로 비교합니다. + +| 항목 | `2025-11-25` | `2026-07-28` | +|---|---|---| +| 시작 절차 | `initialize` → `notifications/initialized` | Handshake 제거, 요청별 version·capability 선언 | +| Protocol session | 서버가 선택적으로 session ID 발급 | Protocol session과 `Mcp-Session-Id` 제거 | +| 서버 정보 | 초기화 결과의 정보·capability | `server/discover`로 조회. 서버 구현은 필수, client의 선행 호출은 선택 | +| HTTP 요청·응답 | POST 요청, JSON 또는 SSE 응답 | POST 요청과 JSON/SSE 응답 유지 | +| 독립 알림 스트림 | 선택적 GET SSE | GET 제거, `subscriptions/listen`의 POST 응답 스트림 | +| 추가 입력 요청 | 서버가 독립 JSON-RPC 요청을 전달 가능 | Multi-Round Trip Requests(MRTR)의 `input_required` 결과와 후속 요청 | +| 결과 구분 | `resultType` 없음 | `resultType` 필수. 구형 응답의 생략 값은 `complete`로 해석 | +| SSE 재개·취소 | 재개를 선택적으로 지원, 단절 자체는 취소가 아님 | `Last-Event-ID` 재개 제거, 응답 스트림 종료로 해당 요청 취소 | + +**SSE가 없어졌다는 뜻이 아닙니다.** 독립 GET 스트림과 session 계약이 바뀌었으며 POST의 SSE 응답은 유지됩니다. 이전 구현도 session과 GET 스트림을 반드시 제공해야 했던 것은 아닙니다. + +
+상세 wire 규격 — SDK·전송·오류 처리를 직접 구현하거나 진단할 때 + +### 요청별 metadata와 HTTP header + +아래는 현재 [기본 메시지 계약](https://modelcontextprotocol.io/specification/2026-07-28/basic)과 [Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http)의 **RPC 요청** 요구사항입니다. + +| 위치 | 요구사항 | +|---|---| +| `params._meta["io.modelcontextprotocol/protocolVersion"]` | 매 요청 필수 | +| `params._meta["io.modelcontextprotocol/clientCapabilities"]` | 매 요청 필수. 선택적 capability가 없으면 `{}` | +| `params._meta["io.modelcontextprotocol/clientInfo"]` | 매 요청 권고 | +| `result._meta["io.modelcontextprotocol/serverInfo"]` | 매 결과 권고 | +| `MCP-Protocol-Version` | 본문의 protocol version과 일치해야 함 | +| `Mcp-Method` | 본문의 JSON-RPC `method`와 일치해야 함 | +| `Mcp-Name` | `tools/call`, `resources/read`, `prompts/get`에서 필수. 해당 `params.name` 또는 `params.uri` 사용 | +| `Accept` | `application/json`과 `text/event-stream`을 모두 포함 | + +`clientInfo`와 `serverInfo`는 자체 신고 정보이지 인증된 identity가 아닙니다. `Mcp-Name`을 안전한 ASCII header 값으로 표현할 수 없다면 명세의 Base64 sentinel 인코딩을 적용합니다. HTTP POST에는 단일 JSON-RPC request 또는 notification을 보내며 client가 JSON-RPC response를 보내지는 않습니다. 현재 core에는 client→server HTTP notification이 없으므로 RPC header 요구사항을 notification에 임의로 확장하지 않습니다. + +### Discovery와 오류 처리 + +[`server/discover`](https://modelcontextprotocol.io/specification/2026-07-28/server/discover)의 정상 응답은 `result.supportedVersions`를 사용합니다. 지원하지 않는 버전 오류는 `error.data.supported`와 `error.data.requested`를 사용합니다. OAuth authorization server discovery와는 다른 기능입니다. + +| 상황 | 현재 HTTP / JSON-RPC 계약 | +|---|---| +| 지원하지 않는 revision | `400` / `-32022` | +| 필수 본문 metadata 누락 | `400` / `-32602` | +| 필요한 client capability 누락 | `400` / `-32021` | +| 필수 header 누락·형식 오류·본문과 불일치 | `400` / `-32020` | +| 구현하지 않은 RPC method | `404` / `-32601` | +| Modern-only endpoint의 GET/DELETE | `405` 권고 | +| Access token 부재·무효 / 권한 부족 | 각각 `401` / `403` | + +구형 session의 `404`는 재초기화를 의미할 수도 있었습니다. [버전 호환 처리](https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning)는 상태 코드만 보지 않고 구조화된 오류를 검사합니다. 특히 `401/403`을 protocol downgrade로 처리하거나, modern header 오류를 무조건 `initialize` 재시도로 바꾸지 않습니다. Notification의 `202 Accepted`·빈 본문을 일반 tool 실행 완료 응답으로 해석하지 않습니다. + +### HTTP authorization의 protocol 요구사항 + +MCP authorization 자체는 선택적이며, 다음은 [HTTP authorization을 채택하는 경우](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization)의 계약입니다. + +| 항목 | 현재 요구사항 | +|---|---| +| Protected resource | RFC 9728 metadata와 하나 이상의 `authorization_servers` 제공 | +| Metadata 위치 | 서버는 401 challenge의 `resource_metadata` 또는 well-known 방식 제공. Client는 둘 다 지원하고 challenge URL 우선 | +| Authorization server | RFC 8414 또는 OIDC Discovery 지원. Client는 두 방식과 발견된 `issuer`의 일치 여부 확인 | +| 대상 리소스 | Authorization·token 요청 모두에 RFC 8707 `resource` 포함. 대상 MCP 서버의 canonical URI 사용 | +| PKCE | 구현과 서버 지원 확인 필수. 기술적으로 가능하면 `S256` 사용 | +| Client 등록 | Client ID는 필요하지만 DCR은 필수가 아님. 사전 등록 옵션·CIMD 지원은 권고, DCR은 현재 선택적·deprecated | +| Token | 대상 리소스·유효성·필요 권한을 검증. `resource`를 보냈다는 사실이 서버 검증을 대신하지 않음 | + +세부 요구사항은 [authorization server discovery](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/authorization-server-discovery), [client registration](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/client-registration), [security considerations](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations)를 따릅니다. 해당 규격의 client는 metadata의 `code_challenge_methods_supported` 선언이 없으면 authorization을 중단합니다. 사전 등록·DCR credential은 issuer별로 관리합니다. CIMD의 참조 규격은 현재 IETF draft입니다. + +### Gateway를 사이에 둘 때 + +Client→gateway와 gateway→MCP backend의 실제 revision을 각각 확인합니다. 헤더를 추가하거나 session header만 제거하는 것으로 신·구 protocol 변환이 끝나지 않습니다. JSON/SSE 보존, buffering·timeout과 취소 동작도 확인합니다. + +단절된 요청을 재실행하려면 새 request ID를 사용하지만, 이미 발생한 업무 부작용이 롤백되었다는 뜻은 아닙니다. 쓰기 도구의 자동 재시도는 별도의 멱등성 정책이 필요합니다. Protocol의 session 제거는 token audience를 없애거나 Entra OBO를 자동으로 제공하는 변경이 아닙니다. + +
diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/.dockerignore b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/.dockerignore new file mode 100644 index 00000000..77659812 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/.dockerignore @@ -0,0 +1,7 @@ +** +!Dockerfile +!requirements.txt +!service/ +!service/** +**/__pycache__/ +**/*.py[cod] diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/.gitignore b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/.gitignore new file mode 100644 index 00000000..215067c5 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/.gitignore @@ -0,0 +1,7 @@ +.azure/ +.venv/ +.private/ +evidence/ +node_modules/ +__pycache__/ +*.py[cod] diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/Dockerfile b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/Dockerfile new file mode 100644 index 00000000..9ecdc3fa --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt \ + && addgroup --system app \ + && adduser --system --ingroup app app + +COPY service ./service + +USER app +EXPOSE 8000 + +# Behind the Container Apps ingress / APIM boundary, TLS ends before this +# process; trust the proxy's X-Forwarded-* headers so redirects and +# transport-security checks see the real scheme and host. +CMD ["uvicorn", "service.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers", "--forwarded-allow-ips=*"] diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/README.md b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/README.md new file mode 100644 index 00000000..8fb21f2d --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/README.md @@ -0,0 +1,95 @@ +# Azure MCP + Entra ID walkthrough + +Read the [Azure MCP solution guide](../../index.md) for APIM, Toolbox, IQ tools and the distinction between MCP authorization and OBO. Follow [walkthrough.md](walkthrough.md) for this sample's concrete deployment and requests. GitHub, Azure, AKS and Learn are demonstration backends, not competing architecture choices. + +## What is deployed + +- Internal Azure API Management Developer gateway: native REST-to-MCP and a Microsoft Learn MCP proxy. +- Internal Container Apps environment: Azure MCP 2.0.5 with Entra OBO and a Python MCP server. +- AKS cluster for the local AKS MCP example and an optional private-network tunnel. +- ACR for `azd` remote builds, private DNS and managed identities. + +One project resource group plus AKS/Container Apps managed resource groups are created. Three Entra app registrations are tracked separately because they are directory objects, not ARM resources. + +## Prerequisites + +- Azure Developer CLI (`azd`) 1.29+, Azure CLI and Bicep. +- Python 3.13 for local MCP clients; the azd identity hook also supports Python 3.9+. +- Node.js 22.19+, npm, `curl`, `jq`, `kubectl`, `kubelogin` and GitHub CLI. +- An Azure subscription with permissions to create resources and role assignments. +- Entra permissions to register applications and grant the operator's delegated permission to ARM. + +Use Bash for the documented commands. No local Docker daemon is required: `docker.remoteBuild: true` uses ACR. + +## Deploy + +Run inside this sample directory: + +```bash +az login +azd auth login +azd env new mcpdemo01 +azd env set AZURE_SUBSCRIPTION_ID "$(az account show --query id -o tsv)" +azd env set AZURE_LOCATION koreacentral +python3 scripts/identity.py prepare +azd provision --preview +azd up +``` + +Choose a unique environment name of 6–10 lowercase letters/digits. The template creates `rg-`. It does not adopt unrelated resource groups. + +Prepare the directory objects before `azd provision --preview`, because `azd` resolves required Bicep inputs before a provisioning hook. `azd up` also performs this idempotent preparation through its `preup` hook, then provisions Bicep, builds the image in ACR and deploys the declared `mcp` service. The hooks only manage Entra objects; they do not run a custom Azure deployment pipeline. + +The `.azure/` directory contains environment settings and a short-lived credential. It is ignored by Git. Do not publish it or capture `azd env get-values` output. `azd provision` alone leaves a placeholder Python application until `azd deploy mcp` runs. + +## Follow the scenarios + +Use the [sample walkthrough](walkthrough.md) in order: + +1. Inspect the deployed resources and private endpoints. +2. Connect local GitHub, Azure, AKS and Microsoft Learn MCP clients. +3. Connect from the local PC to the internal endpoints. +4. Obtain Entra access tokens and inspect an unauthenticated response. +5. Call an existing REST API as an APIM MCP tool. +6. Call Azure MCP and the Python MCP tool with OBO. +7. Call Microsoft Learn through APIM. +8. Check 401/403 behavior and remove the environment when finished. + +[requests/](requests/) contains JSON-RPC request bodies, not tests. [mcp.example.json](mcp.example.json) shows VS Code MCP connections. [network/](network/) provides an optional loopback-only CONNECT tunnel for a workstation without a VPN route to the VNet. + +Use [authentication-checks.md](authentication-checks.md) to distinguish product OAuth/PKCE support from the specific MCP client flow tested. It records discovery and resource-registration observations, PKCE metadata interoperability notes, and the read-only OBO recheck. + +## Application configuration + +The Python application runs `service.app:create_app` on port 8000. + +| Setting | Purpose | +|---|---| +| `ENTRA_TENANT_ID` | Tenant GUID used for issuer and JWKS | +| `ENTRA_API_CLIENT_ID` | Intended access-token audience | +| `ENTRA_API_CLIENT_SECRET` | Short-lived confidential-client credential for the OBO example | +| `ENTRA_ALLOWED_CLIENT_IDS` | Required, nonempty client allowlist | +| `MCP_RESOURCE_URL` | HTTPS MCP URL | +| `LAB_SUBSCRIPTION_ID`, `LAB_RESOURCE_GROUP` | Fixed ARM resource-group target | + +The authenticated `/mcp` endpoint exposes `lab_inventory`, `read_lab_resource_group` and `learn_search`. `/healthz` and `/inventory` contain no protected business data. The REST inventory rejects Authorization headers so an APIM policy that accidentally forwards a token is visible. + +Native Azure MCP uses a separate Entra API and `Mcp.Tools.ReadWrite`. Its managed identity authenticates the confidential client through federation; the downstream ARM request uses the user's OBO token. Container Apps authentication restricts permitted client applications. + +## Cleanup + +Close any local port-forward first. Remove ARM resources with: + +```bash +azd down +``` + +Then explicitly remove this environment's Entra objects: + +```bash +python3 scripts/identity.py remove --confirm "$(azd env get-value AZURE_ENV_NAME)" +``` + +The identity command checks recorded app IDs and ownership tags. It does not grant tenant-wide consent or remove unrelated apps. Keep `.azure/` until both cleanup steps have finished. + +APIM Developer, the AKS node and other infrastructure incur charges even when no MCP tool is called. The `expiresOn` tag is informational, not automatic cleanup. diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-auth-stages.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-auth-stages.json new file mode 100644 index 00000000..b922eee4 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-auth-stages.json @@ -0,0 +1,75 @@ +{ + "checked_at": "2026-09-13T13:35:32Z", + "scope": "Read-only HTTP and Azure CLI checks against the existing MCP lab", + "resource_configuration_changed": false, + "checks": { + "apim_unauthenticated": { + "http_status": 401, + "www_authenticate_present": true, + "resource_metadata_link_present": true + }, + "apim_protected_resource_metadata": { + "http_status": 200, + "resource_matches_mcp_endpoint": true, + "authorization_server_count": 1, + "scope_count": 1, + "bearer_methods_supported": ["header"] + }, + "python_protected_resource_metadata": { + "unauthenticated_http_status": 401, + "resource_metadata_link_present": true, + "metadata_http_status": 200, + "resource_matches_mcp_endpoint": true + }, + "native_azure_mcp_metadata": { + "unauthenticated_http_status": 401, + "resource_metadata_link_present": false, + "path_specific_metadata_http_status": 401, + "root_metadata_http_status": 200, + "advertised_resource": "HTTPS server origin, not the /mcp transport URL" + }, + "entra_discovery": { + "oidc_http_status": 200, + "authorization_token_jwks_endpoints_present": true, + "code_challenge_methods_supported_present": false, + "registration_endpoint_present": false, + "client_id_metadata_document_supported_present": false, + "rfc8414_candidates_http_status": [404, 404] + }, + "lab_api_registrations": { + "access_token_version": 2, + "api_guid_identifier_uri_present": true, + "https_mcp_identifier_uris_present": false, + "registered_public_client_preauthorized": true + }, + "apim_with_preacquired_api_token": { + "http_status": 200, + "rpc_error": false, + "initialize_protocol_version": "2025-06-18" + }, + "wrong_audience": { + "apim_http_status": 401, + "python_mcp_http_status": 401 + }, + "python_user_obo": { + "request_protocol_version": "2026-07-28", + "http_status": 200, + "result_type": "complete", + "is_error": false, + "resource_group_exists": true, + "region": "koreacentral", + "provisioning_succeeded": true + }, + "azure_cli_canonical_url_token_request": { + "succeeded": false, + "error_code": "AADSTS500011", + "note": "A CLI target-resource check, not a full browser authorization-code/PKCE exchange" + } + }, + "not_verified": [ + "End-to-end MCP client authorization-code and PKCE login", + "Refresh-token lifecycle", + "Toolbox consumer authentication: no Foundry project or Toolbox existed in this lab group", + "Complete conformance to MCP protocol revision 2026-07-28" + ] +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-responses.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-responses.json new file mode 100644 index 00000000..c7a5ba7e --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-responses.json @@ -0,0 +1,150 @@ +{ + "recorded_at": "2026-09-13T04:29:17Z", + "note": "Public excerpts of actual azd/Inspector/curl output. Environment identifiers, tokens and resource IDs omitted.", + "deployment": { + "command": "azd up", + "output": [ + "SUCCESS: Your application was provisioned and deployed to Azure in 33 minutes 26 seconds.", + " Provisioning: 32 minutes 25 seconds", + " Deploying: 1 minute 1 second" + ] + }, + "rest": { + "command": "curl ", + "response": { + "source": "inventory-rest", + "widgets": [ + { + "sku": "WID-100", + "name": "Widget Sprocket", + "quantity": 42, + "unit": "each" + }, + { + "sku": "WID-200", + "name": "Widget Gizmo", + "quantity": 7, + "unit": "crate" + }, + { + "sku": "WID-300", + "name": "Widget Doohickey", + "quantity": 128, + "unit": "each" + } + ], + "authorization_present": false + } + }, + "rest_mcp": { + "request": "requests/inventory.json", + "response": { + "source": "inventory-rest", + "widgets": [ + { + "sku": "WID-100", + "name": "Widget Sprocket", + "quantity": 42, + "unit": "each" + }, + { + "sku": "WID-200", + "name": "Widget Gizmo", + "quantity": 7, + "unit": "crate" + }, + { + "sku": "WID-300", + "name": "Widget Doohickey", + "quantity": 128, + "unit": "each" + } + ], + "authorization_present": false + } + }, + "azure_obo": { + "method": "tools/call: group_resource_list", + "response": { + "status": 200, + "resourceTypes": [ + "Microsoft.ApiManagement/service", + "Microsoft.App/containerApps", + "Microsoft.App/managedEnvironments", + "Microsoft.ContainerRegistry/registries", + "Microsoft.ContainerService/managedClusters", + "Microsoft.ManagedIdentity/userAssignedIdentities", + "Microsoft.Network/networkSecurityGroups", + "Microsoft.Network/privateDnsZones", + "Microsoft.Network/privateDnsZones/virtualNetworkLinks", + "Microsoft.Network/publicIPAddresses", + "Microsoft.Network/virtualNetworks" + ] + } + }, + "python_obo": { + "method": "tools/call: read_lab_resource_group", + "protocol": { + "protocolVersion": "2026-07-28", + "serverInfo": { + "name": "azure-mcp-entra-lab", + "version": "0.2.0" + } + }, + "response": { + "exists": true, + "region": "koreacentral", + "provisioning_succeeded": true + } + }, + "learn_proxy": { + "method": "tools/call: microsoft_docs_search", + "response": { + "results": [ + { + "title": "About MCP servers in Azure API Management", + "contentUrl": "https://learn.microsoft.com/azure/api-management/mcp-server-overview#mcp-server-endpoints" + }, + { + "title": "Govern MCP servers with API Management products", + "contentUrl": "https://learn.microsoft.com/azure/api-management/govern-mcp-server-products" + } + ] + } + }, + "github": { + "method": "tools/call: search_repositories", + "response": { + "total_count": 1, + "items": [ + { + "full_name": "hellices/devguidesample", + "private": false + } + ] + } + }, + "aks": { + "method": "tools/call: call_kubectl", + "response": { + "items": [ + { + "name": "mcp-tunnel", + "namespace": "mcp-lab", + "phase": "Running" + } + ] + } + }, + "authorization": { + "anonymous": "HTTP/1.1 401 Unauthorized", + "wrong_audience": "HTTP/1.1 401 Unauthorized", + "client_not_allowed": "HTTP/2 403 ", + "after_restore": { + "tools": [ + "group_list", + "group_resource_list" + ] + } + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/authentication-checks.md b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/authentication-checks.md new file mode 100644 index 00000000..7e1c493a --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/authentication-checks.md @@ -0,0 +1,111 @@ +# MCP HTTP 인증 단계별 확인 + +[Walkthrough](walkthrough.md)의 환경 변수, private 접속 경로와 token 준비를 마친 뒤 실행합니다. 이 절차는 **사전 발급한 token으로 MCP를 호출하는 것**과 **MCP client가 discovery부터 OAuth 로그인을 완료하는 것**을 구분합니다. + +배포나 앱 등록을 변경하지 않고 현재 상태를 확인합니다. Raw 응답에는 환경 주소·식별자가 포함될 수 있으므로 `.private/`에만 저장합니다. + +```bash +umask 077 +export AUTH_CHECK="$PRIVATE/auth-recheck" +mkdir -p "$AUTH_CHECK" +``` + +## 1. APIM의 401 challenge와 PRM + +```bash +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --data-binary @requests/initialize.json \ + --suppress-connect-headers \ + --dump-header "$AUTH_CHECK/apim-401.headers" \ + --output "$AUTH_CHECK/apim-401.body" "$REST_MCP_URL" +``` + +Header에서 `401`과 `WWW-Authenticate`의 `resource_metadata` 값을 확인합니다. 그 URL을 그대로 입력해 인증 없이 metadata를 조회합니다. + +```bash +read -r -p "resource_metadata URL from the challenge: " PRM_URL +curl --silent --show-error --fail --max-time 90 \ + --config "$PRIVATE/transport.conf" \ + --output "$AUTH_CHECK/apim-prm.json" "$PRM_URL" +jq '{resource,authorization_servers,scopes_supported,bearer_methods_supported}' \ + "$AUTH_CHECK/apim-prm.json" +``` + +확인할 값은 `resource`, `authorization_servers`, 필요한 scope와 header 기반 token 전달 방식입니다. **PRM 200만으로 OAuth 로그인까지 성공한 것은 아닙니다.** + +## 2. Native Azure MCP의 well-known fallback + +401 challenge에 `resource_metadata`가 없으면 well-known 경로도 확인합니다. + +```bash +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" \ + --output "$AUTH_CHECK/native-prm.json" \ + --write-out 'HTTP %{http_code}\n' \ + "${AZURE_MCP_URL%/mcp}/.well-known/oauth-protected-resource" +``` + +이번 환경은 root 경로에서 200을 반환했고, `/.well-known/oauth-protected-resource/mcp`는 401이었습니다. 반환된 `resource`는 `/mcp`가 없는 server origin이므로 transport URL과 같은 문자열이라고 가정하지 않습니다. + +## 3. Entra discovery와 PKCE 지원 선언 + +```bash +curl --silent --show-error --fail --max-time 30 \ + --output "$AUTH_CHECK/entra-oidc.json" \ + "https://login.microsoftonline.com/$TENANT_ID/v2.0/.well-known/openid-configuration" +jq '{ + authorization_endpoint_present:has("authorization_endpoint"), + token_endpoint_present:has("token_endpoint"), + pkce_metadata_present:has("code_challenge_methods_supported"), + pkce_methods:.code_challenge_methods_supported, + dcr_endpoint_present:has("registration_endpoint"), + cimd_advertised:.client_id_metadata_document_supported +}' "$AUTH_CHECK/entra-oidc.json" +``` + +Entra는 [authorization code flow에서 S256을 지원](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code)하며, [MSAL Python의 interactive flow](https://learn.microsoft.com/entra/msal/python/getting-started/acquiring-tokens#acquire-token-interactive)는 PKCE를 자동 적용합니다. **PRM은 MCP Server/APIM이 제공하고, 이 단계에서 읽는 OIDC metadata는 Entra가 제공합니다.** + +이번 OIDC 응답에는 `code_challenge_methods_supported`가 없었습니다. [MCP `2026-07-28` 보안 요구사항](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#authorization-code-protection)은 client가 이 선언을 확인하도록 요구하므로 metadata 기반 자동 연결의 상호운용성 관측으로 남깁니다. **Entra PKCE의 미지원, PRM 제공 실패 또는 실제 MSAL 로그인 실패를 검증한 결과가 아닙니다.** 검증을 우회하거나 metadata를 임의로 보충하지 않습니다. + +## 4. Canonical resource와 앱 등록 비교 + +```bash +az ad app show --id "$CUSTOM_ID" \ + --query '{identifierUris:identifierUris,tokenVersion:api.requestedAccessTokenVersion}' \ + --output json > "$AUTH_CHECK/custom-registration.json" +jq . "$AUTH_CHECK/custom-registration.json" +``` + +[Entra MCP 구성 문서](https://learn.microsoft.com/entra/agent-id/secure-mcp-server-with-entra-id)는 PRM의 `resource`, client의 대상 resource와 앱의 Application ID URI를 맞추도록 안내합니다. + +현재 실습 앱에는 `api://`가 있고 MCP의 HTTPS resource URI는 없습니다. 다음 요청으로 canonical URL을 token 대상으로 사용하는 경로를 별도로 확인할 수 있습니다. + +```bash +RESOURCE="$(jq -r .resource "$AUTH_CHECK/apim-prm.json")" +az account get-access-token --tenant "$TENANT_ID" --resource "$RESOURCE" \ + --output json > "$AUTH_CHECK/resource-token.json" \ + 2> "$AUTH_CHECK/resource-token-error.txt" +``` + +이번 결과는 `AADSTS500011`입니다. 기존 `api://.../Mcp.Access` scope를 사용한 token 발급은 성공했습니다. **이 CLI 요청은 target-resource 확인이며, `resource`와 scope를 포함한 브라우저 PKCE 교환 전체를 재현한 것은 아닙니다.** 기존 scope로 구성한 MSAL의 Authorization Code + PKCE가 불가능하다는 의미도 아닙니다. + +## 5. Token 검증과 OBO는 별도로 확인 + +| 확인 | 실행 위치 | 이번 결과 | +|---|---|---| +| 정상 API token | [Walkthrough 5](walkthrough.md#5-rest-api를-apim-mcp-tool로-호출) | APIM initialize 200, RPC error 없음 | +| 다른 audience token | [Walkthrough 8](walkthrough.md#8-401과-403-확인) | APIM·Python MCP에서 401 | +| 사용자 OBO | [Walkthrough 6](walkthrough.md#6-azure-mcp에서-obo로-azure-조회) | Python MCP `read_lab_resource_group`에서 ARM 조회, `isError: false` | + +이 결과는 **사전 token 발급·MCP API 검증·downstream OBO**의 증거입니다. 자동 OAuth 로그인, token refresh나 Toolbox의 consumer 인증까지 확인한 결과는 아닙니다. + +## 결과와 후속 확인 + +- [비식별화한 결과](assets/captures/2026-09-13-auth-stages.json) +- [실행 사례](../../validation/index.md#oauth) +- 제품의 OAuth/PKCE 지원과 별개로, 선택한 MCP client의 resource URI 처리·metadata 자동 발견·로그인 흐름을 추가 확인합니다. +- 이번 RG에는 Foundry project/Toolbox가 없어서 그 endpoint는 실측하지 않았습니다. 새 Foundry 리소스나 model은 배포하지 않았습니다. +- 확인용 token 파일은 사용 후 삭제하고, 사용한 port-forward 프로세스를 종료합니다. 기존 Azure 리소스의 삭제 절차는 변경하지 않습니다. diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/azure.yaml b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/azure.yaml new file mode 100644 index 00000000..85cc154d --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/azure.yaml @@ -0,0 +1,26 @@ +name: mcp-entra-lab +requiredVersions: + azd: ">=1.29.0" +infra: + provider: bicep + path: infra + module: main +services: + mcp: + project: . + language: python + host: containerapp + module: python-app + docker: + path: Dockerfile + context: . + remoteBuild: true +hooks: + preup: + shell: sh + run: python3 scripts/identity.py prepare + continueOnError: false + postprovision: + shell: sh + run: python3 scripts/identity.py federate + continueOnError: false diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/apim.bicep b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/apim.bicep new file mode 100644 index 00000000..57d10df3 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/apim.bicep @@ -0,0 +1,177 @@ +param serviceName string +param gatewayUrl string +param backendUrl string +param tenantId string +param apiClientId string +param allowedClientIds array +param scopeUri string + +var clientsXml = join(map(allowedClientIds, client => '${client}'), '') +var globalTenant = replace(loadTextContent('policies/global.xml'), '__TENANT_ID__', tenantId) +var globalClients = replace(globalTenant, '__CLIENT_APPLICATIONS__', clientsXml) +var globalAudience = replace(globalClients, '__API_CLIENT_ID__', apiClientId) +var globalGateway = replace(globalAudience, '__GATEWAY_URL__', gatewayUrl) +var globalXml = replace(globalGateway, '__SCOPE__', scopeUri) +var metadataDefinitions = [ + { name: 'rest-tools', endpoint: 'rest-tools/mcp' } + { name: 'learn', endpoint: 'learn/mcp' } +] + +resource service 'Microsoft.ApiManagement/service@2025-09-01-preview' existing = { + name: serviceName +} + +resource authorization 'Microsoft.ApiManagement/service/policies@2025-09-01-preview' = { + parent: service + name: 'policy' + properties: { format: 'rawxml', value: globalXml } +} + +resource restApi 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = { + parent: service + name: 'lab-rest' + properties: { + type: 'http' + displayName: 'Private lab inventory REST API' + path: 'rest' + protocols: ['https'] + serviceUrl: backendUrl + subscriptionRequired: false + } + dependsOn: [authorization] +} + +resource inventoryOperation 'Microsoft.ApiManagement/service/apis/operations@2025-09-01-preview' = { + parent: restApi + name: 'get-inventory' + properties: { + displayName: 'Get fictitious inventory' + description: 'Invoke the private REST fixture and return a fresh invocation marker.' + method: 'GET' + urlTemplate: '/inventory' + templateParameters: [] + responses: [{ + statusCode: 200 + description: 'A harmless inventory fixture from the real REST backend.' + representations: [{ contentType: 'application/json' }] + }] + } +} + +resource restOutbound 'Microsoft.ApiManagement/service/apis/policies@2025-09-01-preview' = { + parent: restApi + name: 'policy' + properties: { + format: 'rawxml' + value: loadTextContent('policies/remove-upstream-token.xml') + } +} + +resource nativeMcp 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = { + parent: service + name: 'rest-tools' + properties: { + type: 'mcp' + displayName: 'REST operations as native MCP tools' + path: 'rest-tools' + protocols: ['https'] + subscriptionRequired: false + } + dependsOn: [authorization, restOutbound] +} + +resource inventoryTool 'Microsoft.ApiManagement/service/apis/tools@2025-09-01-preview' = { + parent: nativeMcp + name: 'getInventory' + properties: { + displayName: 'getInventory' + description: 'Read the fictitious inventory through the existing REST operation.' + operationId: inventoryOperation.id + } +} + +resource learnBackend 'Microsoft.ApiManagement/service/backends@2025-09-01-preview' = { + parent: service + name: 'learn-backend' + properties: { + url: 'https://learn.microsoft.com/api' + protocol: 'http' + type: 'Single' + tls: { validateCertificateChain: true, validateCertificateName: true } + } +} + +resource learnMcp 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = { + parent: service + name: 'learn-mcp' + properties: { + type: 'mcp' + displayName: 'Microsoft Learn MCP proxy' + path: 'learn' + protocols: ['https'] + #disable-next-line BCP037 // The live API and official AI-Gateway sample require backendId; this pinned Bicep model omits it. + backendId: learnBackend.name + mcpProperties: { + transportType: 'streamable' + #disable-next-line BCP036 // The live API and official AI-Gateway sample use a keyed object, not the published array type. + endpoints: { + message: { uriTemplate: '/mcp' } + } + } + subscriptionRequired: false + } + dependsOn: [authorization] +} + +resource learnOutbound 'Microsoft.ApiManagement/service/apis/policies@2025-09-01-preview' = { + parent: learnMcp + name: 'policy' + properties: { + format: 'rawxml' + value: loadTextContent('policies/remove-upstream-token.xml') + } +} + +resource metadataApi 'Microsoft.ApiManagement/service/apis@2025-09-01-preview' = { + parent: service + name: 'mcp-metadata' + properties: { + type: 'http' + displayName: 'OAuth protected resource metadata only' + path: 'oauth-metadata' + protocols: ['https'] + subscriptionRequired: false + } + dependsOn: [authorization] +} + +resource metadataOperations 'Microsoft.ApiManagement/service/apis/operations@2025-09-01-preview' = [for definition in metadataDefinitions: { + parent: metadataApi + name: definition.name + properties: { + displayName: 'Metadata for ${definition.name}' + method: 'GET' + urlTemplate: '/${definition.endpoint}' + templateParameters: [] + responses: [{ statusCode: 200, description: 'Public OAuth metadata, not tool data.' }] + } +}] + +resource metadataPolicies 'Microsoft.ApiManagement/service/apis/operations/policies@2025-09-01-preview' = [for (definition, index) in metadataDefinitions: { + parent: metadataOperations[index] + name: 'policy' + properties: { + format: 'rawxml' + value: replace(loadTextContent('policies/metadata.xml'), '__METADATA_JSON__', string({ + resource: '${gatewayUrl}/${definition.endpoint}' + authorization_servers: ['${az.environment().authentication.loginEndpoint}${tenantId}/v2.0'] + scopes_supported: [scopeUri] + bearer_methods_supported: ['header'] + })) + } +}] + +output restUrl string = '${gatewayUrl}/rest/inventory' +output nativeMcpUrl string = '${gatewayUrl}/rest-tools/mcp' +output learnMcpUrl string = '${gatewayUrl}/learn/mcp' +output nativeToolOperationId string = inventoryTool.properties.operationId diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/apps.bicep b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/apps.bicep new file mode 100644 index 00000000..d35d9587 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/apps.bicep @@ -0,0 +1,137 @@ +param location string +param suffix string +param environmentName string +param environmentDomain string +param environmentStaticIp string +param vnetId string +param appIdentityId string +param appIdentityClientId string +param registryHost string +param pythonImage string +param tenantId string +param customApiClientId string +param azureApiClientId string +param allowedClientIds string +@minLength(1) +param nativeAllowedClientIds array + +@secure() +param customApiClientSecret string + +var pythonName = 'ca-mcp-python-${suffix}' +var azureName = 'ca-mcp-azure-${suffix}' +var tags = { + purpose: 'mcp-entra-walkthrough' + environment: 'lab' + labId: suffix + 'azd-env-name': suffix +} + +resource environment 'Microsoft.App/managedEnvironments@2025-01-01' existing = { + name: environmentName +} + +module dns './private-dns.bicep' = { + name: 'container-apps-dns' + params: { + zoneName: environmentDomain + address: environmentStaticIp + recordName: '*' + vnetId: vnetId + tags: tags + } +} + +module pythonApp './python-app.bicep' = { + name: 'python-mcp' + params: { + location: location + suffix: suffix + environmentId: environment.id + environmentDomain: environmentDomain + appIdentityId: appIdentityId + registryHost: registryHost + pythonImage: pythonImage + tenantId: tenantId + customApiClientId: customApiClientId + customApiClientSecret: customApiClientSecret + allowedClientIds: allowedClientIds + } +} + +resource azureApp 'Microsoft.App/containerApps@2025-01-01' = { + name: azureName + location: location + tags: tags + identity: { + type: 'UserAssigned' + userAssignedIdentities: { '${appIdentityId}': {} } + } + properties: { + managedEnvironmentId: environment.id + workloadProfileName: 'Consumption' + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + allowInsecure: false + targetPort: 8080 + transport: 'http' + } + } + template: { + containers: [{ + name: 'azure-mcp' + image: 'mcr.microsoft.com/azure-sdk/azure-mcp@sha256:2285f62dc1720ebf5da90498828b27e73d8fae6fd6fb89cab8cf67e3646fce3a' + args: [ + '--transport' + 'http' + '--outgoing-auth-strategy' + 'UseOnBehalfOf' + '--mode' + 'all' + '--read-only' + '--namespace' + 'group' + ] + resources: { cpu: json('0.5'), memory: '1Gi' } + env: [ + { name: 'ASPNETCORE_ENVIRONMENT', value: 'Production' } + { name: 'ASPNETCORE_URLS', value: 'http://+:8080' } + { name: 'AZURE_MCP_COLLECT_TELEMETRY', value: 'false' } + { name: 'AzureAd__Instance', value: az.environment().authentication.loginEndpoint } + { name: 'AzureAd__TenantId', value: tenantId } + { name: 'AzureAd__ClientId', value: azureApiClientId } + { name: 'AzureAd__ClientCredentials__0__SourceType', value: 'SignedAssertionFromManagedIdentity' } + { name: 'AzureAd__ClientCredentials__0__ManagedIdentityClientId', value: appIdentityClientId } + { name: 'AzureAd__ClientCredentials__0__TokenExchangeUrl', value: 'api://AzureADTokenExchange' } + // TLS terminates at the private ACA ingress; never expose port 8080 directly. + { name: 'AZURE_MCP_DANGEROUSLY_DISABLE_HTTPS_REDIRECTION', value: 'true' } + { name: 'AZURE_MCP_DANGEROUSLY_ENABLE_FORWARDED_HEADERS', value: 'true' } + ] + probes: [{ + type: 'Readiness' + httpGet: { path: '/health', port: 8080, scheme: 'HTTP' } + initialDelaySeconds: 5 + periodSeconds: 10 + }] + }] + scale: { minReplicas: 0, maxReplicas: 1 } + } + } +} + +module nativeAuth './native-auth.bicep' = { + name: 'native-mcp-client-authorization' + params: { + appName: azureApp.name + tenantId: tenantId + apiClientId: azureApiClientId + allowedClientIds: nativeAllowedClientIds + } +} + +output pythonUrl string = 'https://${pythonName}.${environmentDomain}' +output azureUrl string = 'https://${azureName}.${environmentDomain}' +output pythonAppName string = pythonApp.outputs.pythonAppName +output azureAppName string = azureApp.name diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/foundation.bicep b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/foundation.bicep new file mode 100644 index 00000000..3b1d6791 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/foundation.bicep @@ -0,0 +1,255 @@ +@description('Azure region for the isolated, non-production lab.') +param location string = resourceGroup().location + +@minLength(6) +@maxLength(10) +@description('The alphanumeric azd environment name.') +param suffix string + +@description('Object ID of the lab operator, used only on the new AKS cluster.') +param operatorObjectId string + +@description('A supported non-preview AKS minor version, checked before deployment.') +param kubernetesVersion string = '1.35' + +@description('Informational expiry tag; tags do not automatically delete resources.') +param expiresOn string + +var tags = { + purpose: 'mcp-entra-walkthrough' + environment: 'lab' + labId: suffix + 'azd-env-name': suffix + expiresOn: expiresOn +} +var apimName = 'apim-mcplab-${suffix}' +var environmentName = 'cae-mcplab-${suffix}' + +resource apimNsg 'Microsoft.Network/networkSecurityGroups@2024-05-01' = { + name: 'nsg-apim-${suffix}' + location: location + tags: tags + properties: { + securityRules: [ + { + name: 'AllowApiManagementControlPlane' + properties: { + priority: 100 + direction: 'Inbound' + access: 'Allow' + protocol: 'Tcp' + sourcePortRange: '*' + destinationPortRange: '3443' + sourceAddressPrefix: 'ApiManagement' + destinationAddressPrefix: '*' + } + } + ] + } +} + +resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = { + name: 'vnet-mcplab-${suffix}' + location: location + tags: tags + properties: { + addressSpace: { + addressPrefixes: ['10.62.0.0/16'] + } + subnets: [ + { + name: 'container-apps' + properties: { + addressPrefix: '10.62.0.0/23' + delegations: [ + { + name: 'container-apps' + properties: { + serviceName: 'Microsoft.App/environments' + } + } + ] + } + } + { + name: 'apim' + properties: { + addressPrefix: '10.62.2.0/24' + networkSecurityGroup: { id: apimNsg.id } + } + } + { + name: 'aks' + properties: { + addressPrefix: '10.62.4.0/22' + } + } + ] + } +} + +resource apimIp 'Microsoft.Network/publicIPAddresses@2024-05-01' = { + name: 'pip-apim-${suffix}' + location: location + tags: tags + sku: { name: 'Standard' } + properties: { + publicIPAllocationMethod: 'Static' + dnsSettings: { domainNameLabel: apimName } + } +} + +resource apim 'Microsoft.ApiManagement/service@2024-05-01' = { + name: apimName + location: location + tags: tags + sku: { name: 'Developer', capacity: 1 } + identity: { type: 'SystemAssigned' } + properties: { + publisherName: 'MCP isolated lab' + publisherEmail: 'mcp-lab@example.com' + virtualNetworkType: 'Internal' + virtualNetworkConfiguration: { + subnetResourceId: '${vnet.id}/subnets/apim' + } + publicIpAddressId: apimIp.id + } +} + +resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' = { + name: 'acrmcplab${suffix}' + location: location + tags: tags + sku: { name: 'Basic' } + properties: { + adminUserEnabled: false + publicNetworkAccess: 'Enabled' + } +} + +resource appIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: 'id-mcp-app-${suffix}' + location: location + tags: tags +} + +resource registryPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(registry.id, appIdentity.id, 'AcrPull') + scope: registry + properties: { + principalId: appIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + } +} + +resource environment 'Microsoft.App/managedEnvironments@2025-01-01' = { + name: environmentName + location: location + tags: tags + properties: { + infrastructureResourceGroup: '${resourceGroup().name}-aca-managed' + vnetConfiguration: { + infrastructureSubnetId: '${vnet.id}/subnets/container-apps' + internal: true + } + workloadProfiles: [ + { name: 'Consumption', workloadProfileType: 'Consumption' } + ] + } +} + +module apimDns './private-dns.bicep' = { + name: 'apim-dns' + params: { + zoneName: '${apim.name}.azure-api.net' + address: apim.properties.privateIPAddresses[0] + recordName: '@' + vnetId: vnet.id + tags: tags + } +} + +resource aksIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: 'id-mcp-aks-${suffix}' + location: location + tags: tags +} + +resource aksNetworkRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vnet.id, aksIdentity.id, 'NetworkContributor') + scope: vnet + properties: { + principalId: aksIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4d97b98b-1d4f-4787-a291-c67834d212e7') + } +} + +resource aks 'Microsoft.ContainerService/managedClusters@2025-01-01' = { + name: 'aks-mcplab-${suffix}' + location: location + tags: tags + sku: { name: 'Base', tier: 'Free' } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { '${aksIdentity.id}': {} } + } + properties: { + kubernetesVersion: kubernetesVersion + dnsPrefix: 'aks-mcplab-${suffix}' + nodeResourceGroup: '${resourceGroup().name}-nodes' + enableRBAC: true + disableLocalAccounts: true + aadProfile: { managed: true, enableAzureRBAC: true } + agentPoolProfiles: [ + { + name: 'system' + count: 1 + vmSize: 'Standard_D4as_v5' + osType: 'Linux' + osSKU: 'Ubuntu' + mode: 'System' + type: 'VirtualMachineScaleSets' + osDiskSizeGB: 64 + osDiskType: 'Managed' + vnetSubnetID: '${vnet.id}/subnets/aks' + } + ] + networkProfile: { + networkPlugin: 'azure' + networkPluginMode: 'overlay' + podCidr: '172.20.0.0/16' + serviceCidr: '10.64.0.0/16' + dnsServiceIP: '10.64.0.10' + loadBalancerSku: 'standard' + outboundType: 'loadBalancer' + } + } + dependsOn: [aksNetworkRole] +} + +resource operatorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(aks.id, operatorObjectId, 'lab-operator') + scope: aks + properties: { + principalId: operatorObjectId + principalType: 'User' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b1ff04bb-8a4e-4dc4-8eb5-8693973ce19b') + } +} + +output apimName string = apim.name +output apimGateway string = apim.properties.gatewayUrl +output registryName string = registry.name +output registryHost string = registry.properties.loginServer +output environmentName string = environment.name +output environmentDomain string = environment.properties.defaultDomain +output environmentStaticIp string = environment.properties.staticIp +output vnetId string = vnet.id +output appIdentityId string = appIdentity.id +output appIdentityClientId string = appIdentity.properties.clientId +output appIdentityPrincipalId string = appIdentity.properties.principalId +output environmentId string = environment.id +output aksName string = aks.name +output managedNodeResourceGroup string = aks.properties.nodeResourceGroup diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/main.bicep b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/main.bicep new file mode 100644 index 00000000..ad19a6d6 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/main.bicep @@ -0,0 +1,97 @@ +targetScope = 'subscription' + +@minLength(6) +@maxLength(10) +param environmentName string +param location string +param operatorObjectId string +param expiresOn string +param customApiClientId string +@secure() +param customApiClientSecret string +param azureApiClientId string +param publicClientId string +param pythonImage string = '' + +var cliClientId = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' +var vsCodeClientId = 'aebc6443-996d-45c2-90f0-388ff96faa56' +var clients = [cliClientId, publicClientId] + +resource group 'Microsoft.Resources/resourceGroups@2024-03-01' = { + name: 'rg-${environmentName}' + location: location + tags: { + 'azd-env-name': environmentName + purpose: 'mcp-entra-walkthrough' + expiresOn: expiresOn + } +} + +module foundation './foundation.bicep' = { + name: 'foundation' + scope: group + params: { + location: location + suffix: environmentName + operatorObjectId: operatorObjectId + expiresOn: expiresOn + } +} + +module apps './apps.bicep' = { + name: 'apps' + scope: group + params: { + location: location + suffix: environmentName + environmentName: foundation.outputs.environmentName + environmentDomain: foundation.outputs.environmentDomain + environmentStaticIp: foundation.outputs.environmentStaticIp + vnetId: foundation.outputs.vnetId + appIdentityId: foundation.outputs.appIdentityId + appIdentityClientId: foundation.outputs.appIdentityClientId + registryHost: foundation.outputs.registryHost + pythonImage: pythonImage + tenantId: tenant().tenantId + customApiClientId: customApiClientId + customApiClientSecret: customApiClientSecret + azureApiClientId: azureApiClientId + allowedClientIds: join(clients, ',') + nativeAllowedClientIds: concat(clients, [vsCodeClientId]) + } +} + +module apim './apim.bicep' = { + name: 'apim-apis' + scope: group + params: { + serviceName: foundation.outputs.apimName + gatewayUrl: foundation.outputs.apimGateway + backendUrl: apps.outputs.pythonUrl + tenantId: tenant().tenantId + apiClientId: customApiClientId + allowedClientIds: clients + scopeUri: 'api://${customApiClientId}/Mcp.Access' + } +} + +output AZURE_RESOURCE_GROUP string = group.name +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = foundation.outputs.registryHost +output AZURE_CONTAINER_REGISTRY_NAME string = foundation.outputs.registryName +output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = foundation.outputs.environmentId +output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = foundation.outputs.environmentName +output MCP_ENVIRONMENT_DOMAIN string = foundation.outputs.environmentDomain +output MCP_ALLOWED_CLIENT_IDS string = join(clients, ',') +output AZURE_TENANT_ID string = tenant().tenantId +output MCP_APP_IDENTITY_OBJECT_ID string = foundation.outputs.appIdentityPrincipalId +output MCP_APP_IDENTITY_CLIENT_ID string = foundation.outputs.appIdentityClientId +output MCP_APP_IDENTITY_ID string = foundation.outputs.appIdentityId +output MCP_PYTHON_APP_NAME string = apps.outputs.pythonAppName +output MCP_AZURE_APP_NAME string = apps.outputs.azureAppName +output MCP_PYTHON_URL string = '${apps.outputs.pythonUrl}/mcp' +output MCP_AZURE_URL string = '${apps.outputs.azureUrl}/' +output MCP_APIM_NAME string = foundation.outputs.apimName +output MCP_APIM_REST_URL string = apim.outputs.nativeMcpUrl +output MCP_APIM_LEARN_URL string = apim.outputs.learnMcpUrl +output MCP_REST_URL string = apim.outputs.restUrl +output MCP_AKS_NAME string = foundation.outputs.aksName diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/main.parameters.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/main.parameters.json new file mode 100644 index 00000000..4a80f0f9 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/main.parameters.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { "value": "${AZURE_ENV_NAME}" }, + "location": { "value": "${AZURE_LOCATION}" }, + "operatorObjectId": { "value": "${MCP_OPERATOR_ID}" }, + "expiresOn": { "value": "${MCP_EXPIRES_ON}" }, + "customApiClientId": { "value": "${MCP_CUSTOM_API_CLIENT_ID}" }, + "customApiClientSecret": { "value": "${MCP_CUSTOM_API_CLIENT_SECRET}" }, + "azureApiClientId": { "value": "${MCP_AZURE_API_CLIENT_ID}" }, + "publicClientId": { "value": "${MCP_PUBLIC_CLIENT_ID}" }, + "pythonImage": { "value": "${SERVICE_MCP_IMAGE_NAME}" } + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/native-auth.bicep b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/native-auth.bicep new file mode 100644 index 00000000..2049954f --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/native-auth.bicep @@ -0,0 +1,46 @@ +param appName string +param tenantId string +param apiClientId string + +@minLength(1) +param allowedClientIds array + +resource app 'Microsoft.App/containerApps@2025-01-01' existing = { + name: appName +} + +resource auth 'Microsoft.App/containerApps/authConfigs@2025-01-01' = { + parent: app + name: 'current' + properties: { + platform: { enabled: true } + globalValidation: { + unauthenticatedClientAction: 'Return401' + excludedPaths: ['/health', '/.well-known/oauth-protected-resource'] + } + httpSettings: { + requireHttps: true + forwardProxy: { convention: 'Standard' } + } + identityProviders: { + azureActiveDirectory: { + enabled: true + registration: { + clientId: apiClientId + openIdIssuer: '${az.environment().authentication.loginEndpoint}${tenantId}/v2.0' + } + validation: { + allowedAudiences: [apiClientId] + defaultAuthorizationPolicy: { + allowedApplications: allowedClientIds + } + } + } + } + login: { + tokenStore: { enabled: false } + } + } +} + +output authConfigId string = auth.id diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/global.xml b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/global.xml new file mode 100644 index 00000000..fb3f7cd6 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/global.xml @@ -0,0 +1,31 @@ + + + + + + __CLIENT_APPLICATIONS__ + __API_CLIENT_ID__ + + Mcp.Access + + + + + + + + + + + + + + @(context.Api.Id == "lab-rest" ? "Bearer scope=\"__SCOPE__\"" : "Bearer resource_metadata=\"__GATEWAY_URL__/oauth-metadata/" + (context.Api.Id == "learn-mcp" ? "learn/mcp" : "rest-tools/mcp") + "\", scope=\"__SCOPE__\"") + + application/json + {"error":"invalid_token"} + + + + + diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/metadata.xml b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/metadata.xml new file mode 100644 index 00000000..2b676350 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/metadata.xml @@ -0,0 +1,13 @@ + + + + + + application/json + + + + + + + diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/remove-upstream-token.xml b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/remove-upstream-token.xml new file mode 100644 index 00000000..1ce29dd9 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/policies/remove-upstream-token.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/private-dns.bicep b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/private-dns.bicep new file mode 100644 index 00000000..93b6033d --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/private-dns.bicep @@ -0,0 +1,30 @@ +param zoneName string +param address string +param recordName string +param vnetId string +param tags object + +resource zone 'Microsoft.Network/privateDnsZones@2024-06-01' = { + name: zoneName + location: 'global' + tags: tags +} + +resource link 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = { + parent: zone + name: 'lab-vnet' + location: 'global' + properties: { + registrationEnabled: false + virtualNetwork: { id: vnetId } + } +} + +resource record 'Microsoft.Network/privateDnsZones/A@2024-06-01' = { + parent: zone + name: recordName + properties: { + ttl: 60 + aRecords: [{ ipv4Address: address }] + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/python-app.bicep b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/python-app.bicep new file mode 100644 index 00000000..082aecf6 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/python-app.bicep @@ -0,0 +1,70 @@ +param location string +param suffix string +param environmentId string +param environmentDomain string +param appIdentityId string +param registryHost string +param pythonImage string = '' +param tenantId string +param customApiClientId string +param allowedClientIds string +@secure() +param customApiClientSecret string + +var appName = 'ca-mcp-python-${suffix}' +var placeholder = empty(pythonImage) + +resource app 'Microsoft.App/containerApps@2025-01-01' = { + name: appName + location: location + tags: { + 'azd-service-name': 'mcp' + 'azd-env-name': suffix + purpose: 'mcp-entra-walkthrough' + } + identity: { + type: 'UserAssigned' + userAssignedIdentities: { '${appIdentityId}': {} } + } + properties: { + managedEnvironmentId: environmentId + workloadProfileName: 'Consumption' + configuration: { + activeRevisionsMode: 'Single' + secrets: [{ name: 'entra-client-secret', value: customApiClientSecret }] + registries: [{ server: registryHost, identity: appIdentityId }] + ingress: { + external: true + allowInsecure: false + targetPort: placeholder ? 80 : 8000 + transport: 'http' + } + } + template: { + containers: [{ + name: 'mcp' + image: placeholder ? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' : pythonImage + resources: { cpu: json('0.5'), memory: '1Gi' } + env: [ + { name: 'ENTRA_TENANT_ID', value: tenantId } + { name: 'ENTRA_API_CLIENT_ID', value: customApiClientId } + { name: 'ENTRA_API_CLIENT_SECRET', secretRef: 'entra-client-secret' } + { name: 'ENTRA_ALLOWED_CLIENT_IDS', value: allowedClientIds } + { name: 'MCP_RESOURCE_URL', value: 'https://${appName}.${environmentDomain}/mcp' } + { name: 'LAB_SUBSCRIPTION_ID', value: subscription().subscriptionId } + { name: 'LAB_RESOURCE_GROUP', value: resourceGroup().name } + ] + probes: placeholder ? [] : [{ + type: 'Readiness' + httpGet: { path: '/healthz', port: 8000, scheme: 'HTTP' } + initialDelaySeconds: 3 + periodSeconds: 10 + }] + }] + scale: { minReplicas: 0, maxReplicas: 1 } + } + } +} + +output pythonUrl string = 'https://${appName}.${environmentDomain}' +output pythonAppName string = app.name diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/python-app.parameters.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/python-app.parameters.json new file mode 100644 index 00000000..a2c865d9 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/infra/python-app.parameters.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { "value": "${AZURE_LOCATION}" }, + "suffix": { "value": "${AZURE_ENV_NAME}" }, + "environmentId": { "value": "${AZURE_CONTAINER_APPS_ENVIRONMENT_ID}" }, + "environmentDomain": { "value": "${MCP_ENVIRONMENT_DOMAIN}" }, + "appIdentityId": { "value": "${MCP_APP_IDENTITY_ID}" }, + "registryHost": { "value": "${AZURE_CONTAINER_REGISTRY_ENDPOINT}" }, + "pythonImage": { "value": "${SERVICE_MCP_IMAGE_NAME}" }, + "tenantId": { "value": "${AZURE_TENANT_ID}" }, + "customApiClientId": { "value": "${MCP_CUSTOM_API_CLIENT_ID}" }, + "customApiClientSecret": { "value": "${MCP_CUSTOM_API_CLIENT_SECRET}" }, + "allowedClientIds": { "value": "${MCP_ALLOWED_CLIENT_IDS}" } + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/inspector.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/inspector.json new file mode 100644 index 00000000..95f0f28a --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/inspector.json @@ -0,0 +1,17 @@ +{ + "mcpServers": { + "azure": { + "type": "stdio", + "command": "./node_modules/.bin/azmcp", + "args": ["server", "start", "--mode", "all", "--read-only", "--namespace", "group"], + "env": { + "AZURE_MCP_COLLECT_TELEMETRY": "false", + "AZURE_TOKEN_CREDENTIALS": "AzureCliCredential" + } + }, + "learn": { + "url": "https://learn.microsoft.com/api/mcp", + "type": "streamable-http" + } + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/mcp.example.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/mcp.example.json new file mode 100644 index 00000000..0a0b06a0 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/mcp.example.json @@ -0,0 +1,124 @@ +{ + "inputs": [ + { + "id": "aksMcpExecutable", + "type": "promptString", + "description": "Absolute path to the verified AKS MCP 0.0.20 executable" + }, + { + "id": "labKubeconfig", + "type": "promptString", + "description": "Absolute path to this lab's private kubeconfig" + }, + { + "id": "nativeAzureMcpUrl", + "type": "promptString", + "description": "Private native Azure MCP HTTPS endpoint (host root)" + }, + { + "id": "nativeAzureToken", + "type": "promptString", + "description": "Short-lived user token for the native Azure MCP API, not ARM", + "password": true + }, + { + "id": "apimRestMcpUrl", + "type": "promptString", + "description": "Private APIM REST-to-MCP endpoint ending in /rest-tools/mcp" + }, + { + "id": "apimLearnMcpUrl", + "type": "promptString", + "description": "Private APIM Learn proxy endpoint ending in /learn/mcp" + }, + { + "id": "pythonMcpUrl", + "type": "promptString", + "description": "Private Python MCP endpoint ending in /mcp" + }, + { + "id": "customApiToken", + "type": "promptString", + "description": "Short-lived user token for the custom lab API with Mcp.Access", + "password": true + } + ], + "servers": { + "microsoft-learn": { + "type": "http", + "url": "https://learn.microsoft.com/api/mcp" + }, + "github-readonly": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Readonly": "true", + "X-MCP-Tools": "search_repositories" + } + }, + "azure-local-readonly": { + "type": "stdio", + "command": "npx", + "args": [ + "--yes", + "@azure/mcp@2.0.5", + "server", + "start", + "--mode", + "all", + "--read-only", + "--namespace", + "group", + "--outgoing-auth-strategy", + "UseHostingEnvironmentIdentity" + ], + "env": { + "AZURE_MCP_COLLECT_TELEMETRY": "false", + "AZURE_TOKEN_CREDENTIALS": "AzureCliCredential" + } + }, + "aks-local-readonly": { + "type": "stdio", + "command": "${input:aksMcpExecutable}", + "args": [ + "--access-level", + "readonly", + "--enabled-components", + "az_cli,kubectl", + "--allow-namespaces", + "mcp-lab" + ], + "env": { + "KUBECONFIG": "${input:labKubeconfig}" + } + }, + "azure-private-obo-manual-token": { + "type": "http", + "url": "${input:nativeAzureMcpUrl}", + "headers": { + "Authorization": "Bearer ${input:nativeAzureToken}" + } + }, + "apim-native-rest-tools-manual-token": { + "type": "http", + "url": "${input:apimRestMcpUrl}", + "headers": { + "Authorization": "Bearer ${input:customApiToken}" + } + }, + "apim-learn-proxy-manual-token": { + "type": "http", + "url": "${input:apimLearnMcpUrl}", + "headers": { + "Authorization": "Bearer ${input:customApiToken}" + } + }, + "python-private-mcp-manual-token": { + "type": "http", + "url": "${input:pythonMcpUrl}", + "headers": { + "Authorization": "Bearer ${input:customApiToken}" + } + } + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/network/connect_proxy.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/network/connect_proxy.py new file mode 100644 index 00000000..a0b9474d --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/network/connect_proxy.py @@ -0,0 +1,107 @@ +"""Loopback-only HTTPS CONNECT endpoint used with kubectl port-forward.""" + +from __future__ import annotations + +import argparse +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import logging +import os +import select +import socket +from urllib.parse import urlsplit + + +LOGGER = logging.getLogger(__name__) + + +def relay(client: socket.socket, upstream: socket.socket, idle_timeout: float = 180) -> None: + readers = {client, upstream} + peers = {client: upstream, upstream: client} + while readers: + ready, _, _ = select.select(list(readers), [], [], idle_timeout) + if not ready: + raise TimeoutError("idle CONNECT tunnel") + for current in ready: + data = current.recv(65536) + if data: + peers[current].sendall(data) + else: + readers.remove(current) + peers[current].shutdown(socket.SHUT_WR) + + +def approved_target( + authority: str, targets: dict[str, tuple[str, int]] +) -> tuple[str, int] | None: + try: + parsed = urlsplit("//" + authority) + port = parsed.port + except ValueError: + return None + if ( + port != 443 or parsed.username is not None or parsed.password is not None + or parsed.path or parsed.query or parsed.fragment + ): + return None + return targets.get((parsed.hostname or "").casefold()) + + +class ProxyHandler(BaseHTTPRequestHandler): + rbufsize = 0 + server: "ProxyServer" + + def log_request(self, code="-", size="-"): + LOGGER.info("CONNECT response status=%s", code) + + def log_error(self, format, *args): + LOGGER.warning("CONNECT protocol error") + + def do_CONNECT(self) -> None: + target = approved_target(self.path, self.server.targets) + if target is None: + self.send_error(403, "Destination is not an approved lab HTTPS endpoint") + return + try: + upstream = socket.create_connection(target, timeout=15) + except OSError as error: + LOGGER.warning("CONNECT upstream failed: %s", type(error).__name__) + self.send_error(502, "Approved lab endpoint is not reachable") + return + with upstream: + self.send_response(200, "Connection Established") + self.end_headers() + self.close_connection = True + try: + relay(self.connection, upstream) + except OSError as error: + LOGGER.warning("CONNECT relay failed: %s", type(error).__name__) + + def do_GET(self) -> None: + self.send_error(405, "Only HTTPS CONNECT is supported") + + +class ProxyServer(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + def __init__(self, address: tuple[str, int], targets: dict[str, tuple[str, int]]): + self.targets = targets + super().__init__(address, ProxyHandler) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--allow-host", action="append") + parser.add_argument("--port", type=int, default=8080) + args = parser.parse_args() + hosts = args.allow_host or os.environ.get("MCP_ALLOWED_HOSTS", "").split(",") + if not hosts or any(not host.strip() for host in hosts): + parser.error("set MCP_ALLOWED_HOSTS or provide --allow-host") + targets = {host.strip().casefold(): (host.strip(), 443) for host in hosts} + logging.basicConfig(level=logging.INFO) + with ProxyServer(("127.0.0.1", args.port), targets) as server: + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/network/tunnel.yaml b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/network/tunnel.yaml new file mode 100644 index 00000000..1131760b --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/network/tunnel.yaml @@ -0,0 +1,43 @@ +apiVersion: v1 +kind: Pod +metadata: + name: mcp-tunnel + namespace: mcp-lab + labels: + app: mcp-tunnel +spec: + automountServiceAccountToken: false + containers: + - name: connect + image: python@sha256:7415fbc3c9e4979cc717d92377ab2bc7b2b4a2af1ac03cc52b5f3f88efedaf3a + command: [python, -B, /app/connect_proxy.py] + env: + - name: MCP_ALLOWED_HOSTS + valueFrom: + configMapKeyRef: + name: mcp-endpoints + key: hosts + securityContext: + runAsNonRoot: true + runAsUser: 65532 + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + seccompProfile: + type: RuntimeDefault + resources: + requests: + cpu: 20m + memory: 24Mi + limits: + cpu: 150m + memory: 64Mi + volumeMounts: + - name: code + mountPath: /app + readOnly: true + volumes: + - name: code + configMap: + name: mcp-tunnel-code diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/package-lock.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/package-lock.json new file mode 100644 index 00000000..46d0f192 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/package-lock.json @@ -0,0 +1,3702 @@ +{ + "name": "mcp-entra-lab-tools", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-entra-lab-tools", + "version": "0.1.0", + "dependencies": { + "@azure/mcp": "2.0.5" + }, + "devDependencies": { + "@modelcontextprotocol/inspector": "2.5.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.5.tgz", + "integrity": "sha1-BG7yXkbI0fCksZDLDWQ0MJOg7r0=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@azure/mcp": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/mcp/-/mcp-2.0.5.tgz", + "integrity": "sha1-RSp4Ri+UqXMXzgFyGdTbWMZJngE=", + "cpu": [ + "x64", + "arm64" + ], + "hasInstallScript": true, + "license": "MIT", + "os": [ + "linux", + "darwin", + "win32" + ], + "bin": { + "azmcp": "index.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "optionalDependencies": { + "@azure/mcp-darwin-arm64": "2.0.5", + "@azure/mcp-darwin-x64": "2.0.5", + "@azure/mcp-linux-arm64": "2.0.5", + "@azure/mcp-linux-x64": "2.0.5", + "@azure/mcp-win32-arm64": "2.0.5", + "@azure/mcp-win32-x64": "2.0.5" + } + }, + "node_modules/@azure/mcp-darwin-arm64": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/mcp-darwin-arm64/-/mcp-darwin-arm64-2.0.5.tgz", + "integrity": "sha1-MR47aZDfrfNjeWaE/amy6No2IHI=", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "azmcp-darwin-arm64": "dist/azmcp" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/mcp-darwin-x64": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/mcp-darwin-x64/-/mcp-darwin-x64-2.0.5.tgz", + "integrity": "sha1-LBnq8/OdKEF8Irq3amwWxeSYodg=", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "azmcp-darwin-x64": "dist/azmcp" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/mcp-linux-arm64": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/mcp-linux-arm64/-/mcp-linux-arm64-2.0.5.tgz", + "integrity": "sha1-cWUgD9+2MKpaFQfVBhTiKBnxm1I=", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "azmcp-linux-arm64": "dist/azmcp" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/mcp-linux-x64": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/mcp-linux-x64/-/mcp-linux-x64-2.0.5.tgz", + "integrity": "sha1-0RU4fIG9SZLdfZfZ3Wyj58XqIy0=", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "azmcp-linux-x64": "dist/azmcp" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/mcp-win32-arm64": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/mcp-win32-arm64/-/mcp-win32-arm64-2.0.5.tgz", + "integrity": "sha1-qiyejzOAKLrjkY8csjivS/BqA8U=", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "azmcp-win32-arm64": "dist/azmcp.exe" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@azure/mcp-win32-x64": { + "version": "2.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@azure/mcp-win32-x64/-/mcp-win32-x64-2.0.5.tgz", + "integrity": "sha1-wJvRQpL5T07cuOfI9ynkNd9y7vU=", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "azmcp-win32-x64": "dist/azmcp.exe" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha1-nPqGSensvNSO31BbEDACQC6lHnA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@modelcontextprotocol/client/-/client-2.0.0.tgz", + "integrity": "sha1-TcGMmDx6y0AEDwhEISPjxbiNYHQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha1-yRikxq7yKnxZ37CqvCqCwYfM4Vc=", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "1.7.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", + "integrity": "sha1-UwBPrfe+95xTPiixCJMZnHv0ZpM=", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/inspector": { + "version": "2.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@modelcontextprotocol/inspector/-/inspector-2.5.0.tgz", + "integrity": "sha1-ATOZo3YhgZQRxPj4cfGyniu6CYc=", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^2.0.12", + "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/ext-apps": "^1.7.4", + "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server-legacy": "2.0.0", + "@napi-rs/keyring": "^1.3.0", + "@vitejs/plugin-react": "^6.0.0", + "ajv": "^8.17.1", + "atomically": "^2.1.1", + "chokidar": "^4.0.3", + "commander": "^13.1.0", + "hono": "^4.13.1", + "ink": "^6.0.0", + "open": "^10.2.0", + "pino": "^9.14.0", + "proper-lockfile": "^4.1.2", + "react": "^19.0.0", + "undici": "^8.5.0", + "vite": "^8.1.5", + "yaml": "^2.9.0", + "zod": "^4.4.3" + }, + "bin": { + "mcp-inspector": "clients/launcher/build/index.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha1-36ikg0fsLSwNR5F9fcV/dU439f8=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha1-T65cy495Je2aa/ZZW7O1hhcGP/0=", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server-legacy": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@modelcontextprotocol/server-legacy/-/server-legacy-2.0.0.tgz", + "integrity": "sha1-B8k7E6dLws3+jPbWhKdTVWq+KBE=", + "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", + "dev": true, + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + } + }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha1-FPVywFdoxCseuheArluETBw3kB4=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha1-EeNWktDBldYqYSgNZImBLuVKfIA=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha1-mcC+M7jZGJ8/Uer2IuX5JFFf/GI=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha1-/tUeWqJSNSKt7SjmGBTM7ffZiYE=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha1-PNVjMQ7zV56Ph8GTPUaWg9n4Rwg=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha1-mx7h6Ldv4uaiV63SuthFjuJxbf0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha1-lIUrBtD2SttbS9y2i/NmUCDmOAw=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha1-okl2LLrNr2IChdSXkEAqOwXii2U=", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha1-HGr9YFBfdVwb3QAoZF+43GXaNoU=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha1-vV8Uys09q+wyE+8dSjHZT+RAq6k=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha1-3L7I7dhBl7Gu3FUJxramn15gYOY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha1-UTQl7EfDHCDI0IEeJ+SdysDlicg=", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha1-jnWsLuy4yQNchE2YUN0PK3mzP3s=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha1-gR0Yii4a81eERhuKBJDhOj12g3w=", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha1-w94GDdEmQNzIOFFqoqaAPMey6dY=", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha1-mg+bZ1LtUiJU83FegcBm5dpt6ak=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha1-u8Wl453qocylZY0HO4x0uc7jKeg=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha1-/zhYUx3yWSARxeoZ57q63yKgNvo=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha1-e+SAxg36IwSG6E7EMYSAP34XK64=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha1-UzsbWGI8ZTG694w6kRrtWGyS+80=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha1-JIBrXKSeztMdioyYv9eHWULwj8Q=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha1-fnPEJRiHECpGX3lAu0DNtsjyMVw=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha1-9y8sqZR5Fq5o/XkLNtsXdsYbnF0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha1-KmvIYlTp9ABHavfUlxuMvFMr7R0=", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha1-B4iLk2r6kzb96su7K/vxSOMPZO8=", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha1-xqOBHJoJMj9evS1Pu3lGUJjhhFk=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha1-7xYob50sCRJj5lFC0fYgSrb8Sjo=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha1-6FwfbP7QH7+eOxbFFZ4yVItZqW0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha1-gzVG+LBJBNFFPr2vge+GcflaySY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha1-lhhhBQCMY/1Sssqe1e9edPr1kH8=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha1-4/zuCT+7XOdl4a0Ij/TeKIn2+b4=", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha1-p5tV26+GBIEvUtFAssmrQbwVC7g=", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha1-R/oaL5VuiOr3eOv2Xdzo4rNll+c=", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha1-u89LpQdUZ/PyEx6rPP/HPC9deJU=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha1-MEs2Nq3Yi6fZNnYN1Q7OAG3qlfk=", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha1-PV3HYryhdnnDwup+kK1rdTIwlXg=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha1-U5W7dLIVCkodbjwlZfSuynjShic=", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha1-JHyOe3ChpDsQzhTAIm/L9Y6IFdU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha1-wETV3MUhoHZBNHJZehrLHxA8QEE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha1-64W3emAfyTLP5DLFrNNkqeLJB1s=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha1-xU77YF7MkuHot1JjiAbSwUwx540=", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha1-UNjmPqWh3dy15eNkUcGoJm/7sq4=", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha1-bYZi9NjDNgKLismqJCUbDKZLpDc=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha1-2TicQ8Coz2o1XbRk0h4HCSpASTo=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha1-87lrNBYNZDGhnXaIE1r3z7h5eIk=", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha1-iwvuuYYFrfGxKPpDhkA8AJ4CIaU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha1-I43pNdKippKSjFOMfM+pEGf9Bio=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha1-sSOLbiPqM3r3HH+KKV21rwwViuo=", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha1-e+N6TAPJruHs/oYqSiOyxwwgXTA=", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha1-caEMcW/uugBeRQTzYynvCxfPMUU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha1-POz+NzS/T+Aqg2HL3A9v4oxqV+o=", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha1-yOcqrKgznHc9Eow24KF8YxW2lOs=", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha1-LefUbphRQ4XLAfezt0EyARX0yV4=", + "dev": true, + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/commander": { + "version": "13.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-13.1.0.tgz", + "integrity": "sha1-d2Fn22jHjzjczh+bjXuLmkiKv0Y=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha1-89t4nHUtRVZMx+nh4LMXkNSjjhc=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha1-i3cxYmVtHRCGeEyPI6VM5tc9eRg=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha1-YabJj4qmJsFrKWuGKpFBKjO862s=", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha1-VWNpxHKiupEPKXmJG1JrNDYjftc=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha1-V8f8PMKTrKuf7FTXPhVpDr5KF5M=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cors/-/cors-2.8.6.tgz", + "integrity": "sha1-/13Wm9leVHUDgg0pq6T4+vjf7JY=", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha1-ilj+ePANzXDDcEUXWd+/rwPo7p8=", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha1-F5Cv/FJoD7sR4XyrJ1LWmqKjfSw=", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha1-96fMuPUQS/jg9xujscz6Xq/bIeg=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha1-27Ga37dG1/xtc0oGty9KANAhJV8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/depd/-/depd-2.0.0.tgz", + "integrity": "sha1-tpYWPMdXVg0JzyLMj60Vcbeedt8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha1-aJxdzcGQDvVYOky59te0c3QgdK0=", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha1-vz1uj3+P0ipl2XA0dbwBRzV6aw0=", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha1-e46omAd9fkCdOsRUdOo46vCFelg=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/environment/-/environment-1.1.0.tgz", + "integrity": "sha1-jobGaxgPNjx6sxF4fgJZZl9FqfE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha1-cerxqLGINO93Y37Mu4hbpMA81t0=", + "dev": true, + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha1-owME6Z2qMuI7L9IPUbq9B8/8o0Q=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/etag/-/etag-1.8.1.tgz", + "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha1-EVdiLi9Td7tq7yEUNycougwVaYk=", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha1-uWy7fazk83dPWKnjsa6aSlJIcuI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/express/-/express-5.2.1.tgz", + "integrity": "sha1-jyHRW20yf5K0eU7PjLCKcvlWrAQ=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha1-h3OaNTXn2zyoe0p3sexcwsCnEeQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU=", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha1-dDFX2Vfzy7TGUxDgM9wq1K19xgo=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha1-7Sq5Z6MxreYvGNB32uGSaE1Q01A=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha1-osUXplWYUrzbBtH4vX9Rto+tgJk=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha1-ImmTZCiq1MFcfr6XeahL8LKoGBE=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha1-jdffahs6Gzpc8YbAWl3SZ2ImNaQ=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha1-IWkA+R3xGossGYw+HZPWwDWndrk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha1-QYPk6L8Iu24Fu7L30uDI9xLKQOM=", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/hono/-/hono-4.13.7.tgz", + "integrity": "sha1-gP2rgybwPQLwSRF/2WgQGTNJNgA=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha1-NtL2W8kJyHkAGN02+02T2myq4Gs=", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha1-hO4S+WPn3lC8AaE+FgoHizsPQV8=", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha1-T9KYD8yvhiLRTGTWlPTPM8gZUaU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", + "dev": true, + "license": "ISC" + }, + "node_modules/ink": { + "version": "6.8.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ink/-/ink-6.8.0.tgz", + "integrity": "sha1-wSWMIdENDkZ5ipbzNupE+Kr5NaA=", + "dev": true, + "license": "MIT", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.2.4", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.1", + "auto-bind": "^5.0.1", + "chalk": "^5.6.0", + "cli-boxes": "^3.0.0", + "cli-cursor": "^4.0.0", + "cli-truncate": "^5.1.1", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.39.10", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^8.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.1.1", + "terminal-size": "^4.0.1", + "type-fest": "^5.4.1", + "widest-line": "^6.0.0", + "wrap-ansi": "^9.0.0", + "ws": "^8.18.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": ">=19.0.0", + "react": ">=19.0.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha1-lxNCnxZ4ft6PZC9iVbQftRXIJdk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha1-v/OFQ+64mEglB5/zoqjmy9RngbM=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha1-kAk6oxBid9inelkQ265xdH4VogA=", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha1-BGsqbU9rFWsiM9MgfUtal4OZm5g=", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha1-5LNHHFVbR1CagxGGnDd8I0lnB58=", + "dev": true, + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha1-6B+6aZZi6zHb2vJnZqYdSBRxfqQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha1-Qv+fhCBsGZHSbev1IN1cAQQt0vM=", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha1-MniXsmgyo+sRfabCdJLQTKEyWU8=", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/jose/-/jose-6.2.12.tgz", + "integrity": "sha1-ZWY+FG7dAQuY7Og/gXN9Oqlf0Nc=", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha1-rnvLNlard6c7pcSb9lTzjmtoYOI=", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha1-6Y7nsYmf9KGEU00fFnwojGa77/Q=", + "dev": true, + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha1-wIhn1xp5OFxuGQIU/XL+8+X5Xws=", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha1-mmhB+IrlD8g1ApA4krQa9BvCuQc=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha1-wPLDHAv9GfpN0/GOlXofGhUgl9Y=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha1-ywcFllrLU4xmg5Sc5pJfs833w2E=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha1-djU4gosmurJoDa2vzITueLDrUCs=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha1-aGLjF2ozGu297B7TUrTX0N0HhN4=", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha1-xqOi7RUUHa9r3CYokw+OOb30c6o=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha1-f6EzSXH8goRfmCffbvigsgkUusY=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha1-i5J4YuqMK7xoMaRlCSRLUNmTblU=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha1-DFJbsHff2UQEwFnP5C2teX6Wrq8=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha1-hQ7hED2smJz6tQ46wi0aaeOU5j0=", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha1-40OuFS7tNgncbhGUnRo785oclG8=", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha1-bwNUAN/jq51WB7x3VGzjDML5xrg=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha1-6pIvZgY1oiSe5WXgRJ+VHmtgOAg=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha1-zds+5PnGRTDf9kAjZmHULLajFPU=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha1-OQAtQYJXXVrwNv+hGBAPJSSy4qs=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha1-ftLCzMyvhNP/y3pptXcR/CCDQBs=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha1-9mot4Rmf/eD88hyKXxMQaxwIGRM=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha1-FuAD0NtKwk/Z3xaO34cWJd7q498=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha1-2TicQ8Coz2o1XbRk0h4HCSpASTo=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha1-g3UmXiG8IND6WCwi4bE0hdbgAhM=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha1-/tGVyevdt9nkw4Qvk/KBrI2t07g=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha1-WMjEQRblSEWtV/FKsQsDUzGErD8=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha1-0Oluu1awdHbfHdnEgG5SN5hcpF4=", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/open/-/open-10.2.0.tgz", + "integrity": "sha1-udhVvgB2IOgLb7BfrJgUH+Yttzw=", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha1-naGee+6NEt/wUT7Vt2lXeTvC6NQ=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha1-kCP0ZlhA5m9A6c53T5BKYxZ0M7s=", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha1-WB9q3mWMu6ZaDTOA3ndTKVBU83U=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha1-eVxCDE98pFxbiHNm9iLuDJhSzM0=", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha1-PTIa8+q5ObCDyPkpodEs2oHCa2s=", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha1-YxM2ADTMs2s9xh7L3/eBIfkP4h8=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino/-/pino-9.14.0.tgz", + "integrity": "sha1-Zz2XEcLR5k0YZwwewF73uhRWJVY=", + "dev": true, + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha1-3iQVeEBqx7ijPODXeubooLO2imA=", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha1-p7DNZSJfKeklQOeFO9c7B0eYk/w=", + "dev": true, + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha1-O0RGhlsXsXRems4gFqMfSN32Iw0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha1-2kVjqZoG5i1sHNGsrjYyJLyu1uk=", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha1-DqCUsYthD1lCQ+/FheIPLb7lVdw=", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha1-yLneKvay8WAQZ/mOAaxmuqIjFB8=", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha1-8Z/mnOqzEe65S0LnDowgcPm6ECU=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/qs/-/qs-6.16.0.tgz", + "integrity": "sha1-wixyOiipIPOqzc6Cifq9Q+zLef0=", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha1-k+9t2NNFPLx5cN1hT61MWVTWtac=", + "dev": true, + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha1-1/Gb6BK7YnIUcrRdO+IZ7wlXK0c=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha1-PjraWuVWj5CV2EN2/TpJuPsAClE=", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react/-/react-19.2.8.tgz", + "integrity": "sha1-qAZj27WNacb+P9KR08syTop9/y0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha1-ndICCNRbqlsLRwF4H4WCNmV/FeE=", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha1-64WAFDX78qfuWPGeCSGwaPxplI0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha1-IJYy3qGBC+KuBjpqwIT+5+M/ung=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha1-iaf92TgmEmcxjq/hT5wy5ZjDaQk=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha1-UZVgpDGJdQlt725gnUQQDtqkzLk=", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/retry/-/retry-0.12.0.tgz", + "integrity": "sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha1-vPxIQwQxNW+V/fOZ+kBCmOePR0A=", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/router/-/router-2.2.0.tgz", + "integrity": "sha1-AZvmILcRyHZBFnzHm5kJDwCxRu8=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha1-Lp5UxGZOwxBsW1Yw4knT1llcSRE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha1-TKL444XygxxDKnGbEIo7969Cod0=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha1-RPoWGwGHuVSd2Eu5GAL5vYOFzWo=", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha1-DE74LWfR5cHjWej8dtOofwRf5b0=", + "dev": true, + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/send/-/send-1.2.1.tgz", + "integrity": "sha1-nqt0O4dPNVD0CiaGe/KGrWDT8+0=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha1-fxhqSk5fW2Y616QpT/G/N88OmKk=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha1-ZsmiSnP5/CjL5msJ/tPTPcrxtCQ=", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha1-zNCvT4g1+9wmW4JGGq8MNmY/NOo=", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha1-rhbxZE2HPsrYQ7AwexQzYtTEIXI=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha1-6gLGLgXcS+pn1EQvD7ce4ZL44Ks=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha1-wuC1oUpUCuvuO7xsP4ZmzJtQkSc=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha1-1rtrN5Asb+9RdOX1M/q0xzKib0I=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha1-Ed2hnVNo5Azp7CvcH7DsvAeQ7Oo=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha1-qaF2f4r4QVURTqq9c/mSc8j1mtk=", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha1-ItC2bRi8XFf0iL/PNsveO+9zFTc=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha1-KFmCUN9ImcCsVy1+LwRgaQumoDA=", + "dev": true, + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha1-HOVlD93YerwJnto33P8CTCZnrkY=", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/split2/-/split2-4.2.0.tgz", + "integrity": "sha1-ycWSCQTRSLqwufZxRfJFqGqtv6Q=", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha1-qvB0gWnAL8M8gjKrzPkz9Uocw08=", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha1-j3XuzvdlteHPzcCA2llAntQk44I=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha1-cxBRZJPfV1dC/pivb66H2F1e0Kw=", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha1-0iomlSKDamJ6+NBLXD/Sx/o+MuM=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha1-YodQ+BxRxEwE71D8cO1NHK6k8ek=", + "dev": true, + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha1-DZxYq1UPQJNiNQVsfqb+vZJcTUE=", + "dev": true, + "license": "MIT" + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha1-oLWRfChky6VIQUlav6P2sT7c9NY=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha1-T+jLdIKq4lPwQrzQxnth8t3peQE=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha1-NyC0ltZGyAd7V0BtU+xaU4Xjk4w=", + "dev": true, + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha1-ViqabJ6ys7Ej05cZ+a9btE/NdjE=", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha1-O+NDIaiKgg7RvYDfqjPkefu43TU=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha1-Ck3VVNOX8aRER0d+rrPR9NjHaA8=", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha1-cdGnBTKTWC4WrJ8+uvGrmqSeVXA=", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha1-2TicQ8Coz2o1XbRk0h4HCSpASTo=", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/undici/-/undici-8.10.2.tgz", + "integrity": "sha1-lgvLC0PCZ/hpEOp8pAitqEOkS8c=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vary/-/vary-1.1.2.tgz", + "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/vite/-/vite-8.2.2.tgz", + "integrity": "sha1-OZrvrTZWFFFFvhENE3oH6lu1UBQ=", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha1-U/pP+iukx5IhP7ZhfrfQjw3LGp8=", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/which/-/which-2.0.2.tgz", + "integrity": "sha1-fGqN0KY2oDJ+ELWckobu6T8/UbE=", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha1-aoY4r4NwWVAdYcXTvfcsuTT2x5I=", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha1-lWgy3qlJQwbm0gnrhxZDu4c9fJg=", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha1-tbuOIWXOJ11NQ0dt0nAK2Qkdttw=", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ws/-/ws-8.21.3.tgz", + "integrity": "sha1-ZgtPrdtqPldchuB4EmkZlh9N5Pw=", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha1-h4PU32cdTVA2W+LuTHGRegVXuqs=", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha1-eCdK/ZNZih391hMN9qVm3vy/mqQ=", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha1-0tG6BvDoHC62UMPlrYsLSt3h6EM=", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod/-/zod-4.5.4.tgz", + "integrity": "sha1-4hXGJCDFKN15UeMftSxUOPH9GEo=", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha1-P6eZp7rdVUVBRy+2WEP9xGCy5ao=", + "dev": true, + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/package.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/package.json new file mode 100644 index 00000000..16013b11 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/package.json @@ -0,0 +1,18 @@ +{ + "name": "mcp-entra-lab-tools", + "version": "0.1.0", + "private": true, + "description": "Pinned local Azure MCP runtime for the isolated Entra lab", + "engines": { + "node": ">=22.19.0" + }, + "dependencies": { + "@azure/mcp": "2.0.5" + }, + "scripts": { + "azure-mcp": "azmcp server start --mode all --read-only --namespace group" + }, + "devDependencies": { + "@modelcontextprotocol/inspector": "2.5.0" + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/initialize.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/initialize.json new file mode 100644 index 00000000..a5b89067 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/initialize.json @@ -0,0 +1,13 @@ +{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": { + "name": "developer-walkthrough", + "version": "1.0.0" + } + } +} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/initialized.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/initialized.json new file mode 100644 index 00000000..8b34a202 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/initialized.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","method":"notifications/initialized"} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/inventory.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/inventory.json new file mode 100644 index 00000000..667b39a9 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/inventory.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"getInventory","arguments":{}}} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/tools-list.json b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/tools-list.json new file mode 100644 index 00000000..0ce8074e --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requests/tools-list.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requirements.txt b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requirements.txt new file mode 100644 index 00000000..21afd3f1 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/requirements.txt @@ -0,0 +1,6 @@ +mcp==2.2.0 +msal==1.38.0 +PyJWT[crypto]==2.14.0 +httpx==0.28.1 +httpx2==2.12.0 +uvicorn[standard]==0.35.0 diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/sample.yml b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/sample.yml new file mode 100644 index 00000000..8bcd49fc --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/sample.yml @@ -0,0 +1,8 @@ +title: APIM and Entra MCP lab +description: Native azd deployment and manual MCP, REST wrapping, OBO and authorization scenarios. +kind: runnable +used_by: + - index + - authentication + - hosting-reference + - validation diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/scripts/identity.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/scripts/identity.py new file mode 100644 index 00000000..debe473e --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/scripts/identity.py @@ -0,0 +1,248 @@ +"""Manage only the Entra objects belonging to the selected azd environment.""" + +import argparse +from datetime import datetime, timedelta, timezone +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import uuid + + +ROOT = Path(__file__).resolve().parents[1] +CLI_CLIENT = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" +VSCODE_CLIENT = "aebc6443-996d-45c2-90f0-388ff96faa56" +ARM_APP = "797f4846-ba00-4fd7-ba43-dac1f8f63013" +GRAPH = "https://graph.microsoft.com/v1.0" + + +def command(arguments): + result = subprocess.run(arguments, cwd=ROOT, text=True, capture_output=True, timeout=180) + if result.returncode: + # CLI errors can contain tenant/user IDs; keep the full diagnostic local. + path = ROOT / ".azure" / "identity-error.txt" + private_json(path, {"command": arguments[:3], "stderr": result.stderr}) + raise RuntimeError("Identity operation failed; see .azure/identity-error.txt") + return json.loads(result.stdout) if result.stdout.strip() else None + + +def private_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, delete=False, encoding="utf-8") as handle: + json.dump(value, handle, indent=2) + temporary = Path(handle.name) + os.replace(temporary, path) + + +class Identity: + def __init__(self): + self.env = command(["azd", "env", "get-values", "--output", "json"]) + name = self.env.get("AZURE_ENV_NAME", "") + if not re.fullmatch(r"[a-z0-9]{6,10}", name): + raise ValueError("Use an azd environment name of 6-10 lowercase letters/digits.") + self.folder = ROOT / ".azure" / name + self.path = self.folder / "identity.json" + account = command([ + "az", "account", "show", "--subscription", + self.env["AZURE_SUBSCRIPTION_ID"], "-o", "json", + ]) + if account["user"]["type"] != "user": + raise ValueError("Use an Azure CLI user login for delegated OBO.") + binding = {"environment": name, "subscription": account["id"], "tenant": account["tenantId"]} + self.state = json.loads(self.path.read_text()) if self.path.exists() else {"context": binding} + if self.state["context"] != binding: + raise ValueError("The selected azd environment does not match its Entra state.") + self.name = name + self.tenant = account["tenantId"] + self.subscription = account["id"] + self.tags = ["mcp-entra-walkthrough", name] + + def save(self): + private_json(self.path, self.state) + + def graph(self, method, path, body=None): + arguments = [ + "az", "rest", "--method", method, "--url", GRAPH + path, + "--subscription", self.subscription, "-o", "json", + ] + if body is not None: + file = self.folder / "graph-request.json" + private_json(file, body) + arguments.extend(["--headers", "Content-Type=application/json", "--body", "@" + str(file)]) + return command(arguments) + + def app(self, key, manifest): + if key not in self.state: + created = self.graph("post", "/applications", {**manifest, "tags": self.tags}) + self.state[key] = {"id": created["id"], "appId": created["appId"]} + self.save() + record = self.state[key] + app = self.graph("get", "/applications/" + record["id"]) + if app["appId"] != record["appId"] or not set(self.tags).issubset(app.get("tags", [])): + raise ValueError("Application ownership does not match this azd environment.") + if "spId" not in record: + found = self.graph("get", "/servicePrincipals?$filter=appId eq '" + record["appId"] + "'")["value"] + if len(found) > 1: + raise ValueError("Service principal lookup is ambiguous.") + principal = found[0] if found else self.graph("post", "/servicePrincipals", {"appId": record["appId"]}) + record["spId"] = principal["id"] + self.save() + return record + + def export(self, values): + # Pass secrets through a private file, not command-line arguments or stdout. + path = self.folder / "identity.env" + text = "".join(key + "=" + json.dumps(value) + "\n" for key, value in values.items()) + with tempfile.NamedTemporaryFile(mode="w", dir=self.folder, delete=False) as handle: + handle.write(text) + temporary = Path(handle.name) + os.replace(temporary, path) + command(["azd", "env", "set", "--file", str(path)]) + (self.folder / ".env").chmod(0o600) + path.unlink() + + def prepare(self): + exists = command([ + "az", "group", "exists", "--name", "rg-" + self.name, + "--subscription", self.subscription, "-o", "json", + ]) + if exists: + group = command([ + "az", "group", "show", "--name", "rg-" + self.name, + "--subscription", self.subscription, "-o", "json", + ]) + if ( + not self.path.exists() + or group.get("tags", {}).get("azd-env-name") != self.name + or group.get("tags", {}).get("purpose") != "mcp-entra-walkthrough" + ): + raise ValueError("Choose a new environment; an existing RG is not owned by this project.") + operator = self.graph("get", "/me?$select=id")["id"] + if self.state.get("operatorId", operator) != operator: + raise ValueError("Use the original operator for this environment's delegated consent.") + self.state["operatorId"] = operator + self.save() + arm = self.graph("get", "/servicePrincipals?$filter=appId eq '" + ARM_APP + "'")["value"] + if len(arm) != 1: + raise ValueError("ARM service principal was not uniquely found.") + scopes = [s for s in arm[0]["oauth2PermissionScopes"] if s["value"] == "user_impersonation" and s["isEnabled"]] + if len(scopes) != 1: + raise ValueError("ARM delegated scope was not uniquely found.") + public = self.app("public", { + "displayName": self.name + "-client", "signInAudience": "AzureADMyOrg", + "publicClient": {"redirectUris": ["http://localhost"]}, + "isFallbackPublicClient": True, + }) + accesses = [] + for key, scope in (("custom", "Mcp.Access"), ("azure", "Mcp.Tools.ReadWrite")): + record = self.app(key, {"displayName": self.name + "-" + key, "signInAudience": "AzureADMyOrg"}) + record.setdefault("scopeId", str(uuid.uuid4())) + record["scope"] = scope + self.save() + clients = [CLI_CLIENT, public["appId"]] + ([VSCODE_CLIENT] if key == "azure" else []) + manifest = { + "identifierUris": ["api://" + record["appId"]], + "api": { + "requestedAccessTokenVersion": 2, + "oauth2PermissionScopes": [{ + "id": record["scopeId"], "value": scope, "type": "User", "isEnabled": True, + "adminConsentDisplayName": "Access MCP walkthrough", + "adminConsentDescription": "Use this MCP API as the signed-in user.", + "userConsentDisplayName": "Access MCP walkthrough", + "userConsentDescription": "Use this MCP API with your own permissions.", + }], + }, + "requiredResourceAccess": [{ + "resourceAppId": ARM_APP, + "resourceAccess": [{"id": scopes[0]["id"], "type": "Scope"}], + }], + } + self.graph("patch", "/applications/" + record["id"], manifest) + manifest["api"]["preAuthorizedApplications"] = [ + {"appId": client, "delegatedPermissionIds": [record["scopeId"]]} + for client in clients + ] + self.graph("patch", "/applications/" + record["id"], manifest) + grants = self.graph("get", "/oauth2PermissionGrants?$filter=clientId eq '" + record["spId"] + "'")["value"] + matches = [g for g in grants if g["resourceId"] == arm[0]["id"] and g["consentType"] == "Principal" and g.get("principalId") == operator] + if not matches: + self.graph("post", "/oauth2PermissionGrants", { + "clientId": record["spId"], "resourceId": arm[0]["id"], + "consentType": "Principal", "principalId": operator, "scope": "user_impersonation", + }) + elif len(matches) != 1 or "user_impersonation" not in matches[0]["scope"].split(): + raise ValueError("The recorded user's downstream consent is inconsistent.") + accesses.append({"resourceAppId": record["appId"], "resourceAccess": [{"id": record["scopeId"], "type": "Scope"}]}) + self.graph("patch", "/applications/" + public["id"], {"requiredResourceAccess": accesses}) + custom = self.state["custom"] + if "secret" not in custom: + expiry = datetime.now(timezone.utc) + timedelta(days=2) + secret = self.graph("post", "/applications/" + custom["id"] + "/addPassword", { + "passwordCredential": {"displayName": "walkthrough-only", "endDateTime": expiry.isoformat()} + }) + custom["secret"] = secret["secretText"] + custom["secretExpiresAt"] = secret["endDateTime"] + self.save() + if datetime.fromisoformat(custom["secretExpiresAt"].replace("Z", "+00:00")) <= datetime.now(timezone.utc): + raise ValueError("The walkthrough credential expired; rotate it explicitly before redeploying.") + self.export({ + "MCP_OPERATOR_ID": operator, "AZURE_TENANT_ID": self.tenant, + "MCP_CUSTOM_API_CLIENT_ID": custom["appId"], "MCP_CUSTOM_API_CLIENT_SECRET": custom["secret"], + "MCP_AZURE_API_CLIENT_ID": self.state["azure"]["appId"], + "MCP_PUBLIC_CLIENT_ID": public["appId"], + "MCP_EXPIRES_ON": self.env.get("MCP_EXPIRES_ON") or (datetime.now(timezone.utc) + timedelta(days=2)).date().isoformat(), + }) + print("Entra: three project apps and current-user delegated consent configured.") + + def federate(self): + app = self.state["azure"] + path = "/applications/" + app["id"] + "/federatedIdentityCredentials" + body = { + "name": "container-apps", "audiences": ["api://AzureADTokenExchange"], + "issuer": "https://login.microsoftonline.com/" + self.tenant + "/v2.0", + "subject": self.env["MCP_APP_IDENTITY_OBJECT_ID"], + } + existing = [x for x in self.graph("get", path)["value"] if x["name"] == body["name"]] + if not existing: + self.graph("post", path, body) + elif len(existing) != 1 or any(existing[0][k] != body[k] for k in ("issuer", "subject", "audiences")): + raise ValueError("The native MCP federation belongs to a different identity.") + print("Entra: native Azure MCP confidential-client federation configured.") + + def remove(self, confirm): + if confirm != self.name: + raise ValueError("Confirm directory cleanup with the exact azd environment name.") + for key in ("public", "custom", "azure"): + record = self.state.get(key) + if not record or record.get("deleted"): + continue + apps = self.graph("get", "/applications?$filter=appId eq '" + record["appId"] + "'")["value"] + if apps and (len(apps) != 1 or apps[0]["id"] != record["id"] or not set(self.tags).issubset(apps[0].get("tags", []))): + raise ValueError("Application ownership changed; cleanup refused.") + principals = self.graph("get", "/servicePrincipals?$filter=appId eq '" + record["appId"] + "'")["value"] + if principals: + if len(principals) != 1 or principals[0]["id"] != record["spId"]: + raise ValueError("Service principal ownership changed; cleanup refused.") + self.graph("delete", "/servicePrincipals/" + record["spId"]) + if apps: + self.graph("delete", "/applications/" + record["id"]) + record["deleted"] = True + record.pop("secret", None) + self.save() + print("Entra: recorded project apps and service principals removed.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=["prepare", "federate", "remove"]) + parser.add_argument("--confirm") + args = parser.parse_args() + identity = Identity() + if args.action == "prepare": + identity.prepare() + elif args.action == "federate": + identity.federate() + else: + identity.remove(args.confirm) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/__init__.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/__init__.py new file mode 100644 index 00000000..6c484d81 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/__init__.py @@ -0,0 +1,7 @@ +"""Private MCP + Entra delegated-OBO lab service. + +Owns one isolated component of the Azure MCP live lab: the ASGI application +in `service.app`, its Entra token verification, and the three read-only MCP +tools. Infra, deployment, evidence collection and documentation live outside +this package. +""" diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/app.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/app.py new file mode 100644 index 00000000..0934f774 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/app.py @@ -0,0 +1,48 @@ +"""Entra-protected MCP server with inventory, ARM OBO and Learn tools.""" + +import os +from urllib.parse import urlsplit + +from mcp.server import MCPServer +from mcp.server.auth.settings import AuthSettings +from mcp.server.transport_security import TransportSecuritySettings +from starlette.applications import Starlette + +from .arm import HttpArmResourceGroupReader +from .auth import EntraTokenVerifier +from .config import load_config +from .learn import LearnConnector +from .obo import MsalOnBehalfOfExchanger +from .rest import register_rest_routes +from .tools import register_tools + + +def create_app() -> Starlette: + config = load_config(os.environ) + hostname = urlsplit(config.resource_url).hostname + server = MCPServer( + "azure-mcp-entra-lab", + version="0.2.0", + token_verifier=EntraTokenVerifier(config), + auth=AuthSettings( + issuer_url=config.issuer, + resource_server_url=config.resource_url, + required_scopes=[config.scope_uri], + validate_token_resource=True, + ), + ) + register_tools( + server, + config=config, + obo_exchanger=MsalOnBehalfOfExchanger(config), + arm_reader=HttpArmResourceGroupReader(), + learn_connector=LearnConnector(), + ) + register_rest_routes(server) + return server.streamable_http_app( + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=[hostname, f"{hostname}:443"], + allowed_origins=[f"https://{hostname}", f"https://{hostname}:443"], + ) + ) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/arm.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/arm.py new file mode 100644 index 00000000..c83b0605 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/arm.py @@ -0,0 +1,95 @@ +"""Sanitized, fixed Azure Resource Manager resource-group lookup. + +Reads exactly one resource group, at one pinned api-version. The bearer +attached here is always the token `obo.py` exchanged for +`https://management.azure.com/.default`; this module never sees, and cannot +accidentally forward, the inbound MCP caller's own token. The response is +reduced to booleans and the region: no resource IDs or names cross this +boundary into a tool result. +""" + +from __future__ import annotations + +from typing import Protocol + +import httpx +from pydantic import BaseModel + +#: The one ARM api-version this lab reads against. Fixed, not caller-configurable. +ARM_API_VERSION = "2024-03-01" + +_ARM_BASE_URL = "https://management.azure.com" + + +class ArmError(Exception): + """A resource-group read failed. `safe_code` is the only detail meant to reach a caller.""" + + def __init__(self, safe_code: str, http_status: int | None = None) -> None: + super().__init__(safe_code) + self.safe_code = safe_code + self.http_status = http_status + + +class ResourceGroupStatus(BaseModel): + exists: bool + region: str | None = None + provisioning_succeeded: bool | None = None + + +class ArmResourceGroupReader(Protocol): + async def read( + self, *, subscription_id: str, resource_group: str, bearer_token: str + ) -> ResourceGroupStatus: ... # pragma: no cover + + +class HttpArmResourceGroupReader: + """Production reader: one fixed ARM GET over `httpx`. + + Pass a shared `client` to reuse connections; otherwise a short-lived + client is created per call, which is fine for a low-traffic lab tool. + """ + + def __init__(self, *, client: httpx.AsyncClient | None = None) -> None: + self._client = client + + async def read( + self, *, subscription_id: str, resource_group: str, bearer_token: str + ) -> ResourceGroupStatus: + url = f"{_ARM_BASE_URL}/subscriptions/{subscription_id}/resourceGroups/{resource_group}" + headers = {"Authorization": f"Bearer {bearer_token}"} + params = {"api-version": ARM_API_VERSION} + + if self._client is not None: + response = await self._get(self._client, url, headers, params) + else: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await self._get(client, url, headers, params) + return self._parse(response) + + @staticmethod + async def _get( + client: httpx.AsyncClient, url: str, headers: dict[str, str], params: dict[str, str] + ) -> httpx.Response: + try: + return await client.get(url, headers=headers, params=params) + except httpx.TimeoutException as exc: + raise ArmError("Timeout") from exc + except httpx.HTTPError as exc: + raise ArmError("NetworkUnavailable") from exc + + @staticmethod + def _parse(response: httpx.Response) -> ResourceGroupStatus: + if response.status_code == 404: + return ResourceGroupStatus(exists=False, region=None, provisioning_succeeded=None) + if response.status_code in (401, 403): + raise ArmError("AuthorizationFailed", response.status_code) + if response.status_code >= 400: + raise ArmError("HttpError", response.status_code) + + body = response.json() + properties = body.get("properties") or {} + return ResourceGroupStatus( + exists=True, + region=body.get("location"), + provisioning_succeeded=properties.get("provisioningState") == "Succeeded", + ) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/auth.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/auth.py new file mode 100644 index 00000000..42dbe2cf --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/auth.py @@ -0,0 +1,128 @@ +"""Entra ID v2 delegated-token verification for the MCP resource server. + +Implements the SDK's `TokenVerifier` protocol against one fixed tenant and +one fixed application (audience). App-only tokens can carry a service +principal `oid`; the required delegated scope is enforced separately at +the HTTP boundary. Rejections log reason labels, not tokens or claim values. +""" + +from __future__ import annotations + +import logging +from typing import Any, Protocol + +import anyio +import jwt + +from mcp.server.auth.provider import AccessToken, TokenVerifier + +from .config import Config, REQUIRED_SCOPE + +logger = logging.getLogger(__name__) + +#: Entra v2 access tokens for this lab are always RS256; anything else +#: (including "none" or an HMAC algorithm run against the RSA public key, +#: the classic verification-downgrade attack) is rejected before the key +#: resolver or `jwt.decode` ever sees the token. +_ALGORITHM = "RS256" + + +class SigningKeyResolver(Protocol): + """Resolves a JWT `kid` to the public key material `jwt.decode` verifies against.""" + + def resolve(self, kid: str | None) -> Any: ... # pragma: no cover - protocol + + +class EntraJwksResolver: + """Production resolver: fetches and caches signing keys from the tenant's JWKS endpoint.""" + + def __init__(self, jwks_uri: str) -> None: + self._client = jwt.PyJWKClient(jwks_uri, lifespan=300) + + def resolve(self, kid: str | None) -> Any: + if not kid: + raise jwt.InvalidTokenError("token header is missing 'kid'") + return self._client.get_signing_key(kid).key + + +class EntraTokenVerifier(TokenVerifier): + """Verifies bearer tokens issued by one fixed Entra tenant for one fixed application.""" + + def __init__(self, config: Config) -> None: + self._config = config + self._key_resolver: SigningKeyResolver = EntraJwksResolver(config.jwks_uri) + + async def verify_token(self, token: str) -> AccessToken | None: + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError: + logger.info("rejected bearer token: unparseable header") + return None + + if header.get("alg") != _ALGORITHM: + logger.info("rejected bearer token: unsupported algorithm") + return None + + try: + signing_key = await anyio.to_thread.run_sync( + self._key_resolver.resolve, header.get("kid") + ) + except jwt.PyJWKClientConnectionError: + # The JWKS endpoint is unreachable: our backend is broken, the + # presented token was never actually evaluated. Surface this as a + # server error rather than reporting it as an invalid token. + raise + except jwt.PyJWTError: + logger.info("rejected bearer token: signing key not resolvable") + return None + + try: + claims = jwt.decode( + token, + key=signing_key, + algorithms=[_ALGORITHM], + audience=self._config.api_client_id, + issuer=self._config.issuer, + options={"require": ["exp", "nbf"]}, + ) + except jwt.PyJWTError as exc: + logger.info("rejected bearer token: %s", type(exc).__name__) + return None + + if claims.get("tid") != self._config.tenant_id: + logger.info("rejected bearer token: tid mismatch") + return None + + oid = claims.get("oid") + if not oid: + logger.info("rejected bearer token: missing oid (not a delegated user token)") + return None + + raw_scopes = claims.get("scp", "") + # Entra emits bare scp values; OAuth metadata advertises qualified scopes. + scopes = ( + [self._config.scope_prefix + value for value in raw_scopes.split()] + if isinstance(raw_scopes, str) else [] + ) + # Deliberately not rejected here even if `REQUIRED_SCOPE` is absent: + # a structurally valid delegated token with the wrong scope must + # still become an `AccessToken` (with its real, possibly-insufficient + # scopes) so the SDK's own scope check reports 403, not 401. Rejecting + # it here instead would report every wrong-scope token as "no token". + + azp = claims.get("azp") + if not isinstance(azp, str) or azp.lower() not in self._config.allowed_client_ids: + logger.info("rejected bearer token: azp not allowlisted") + return None + + return AccessToken( + token=token, + client_id=azp, + scopes=scopes, + expires_at=claims.get("exp"), + # Only set once aud has been validated above: this reflects the + # canonical MCP endpoint, never the raw `aud` claim value. + resource=self._config.resource_url, + subject=oid, + claims=claims, + ) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/config.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/config.py new file mode 100644 index 00000000..19de2a13 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/config.py @@ -0,0 +1,128 @@ +"""Environment configuration, validated fail-closed. + +There is no permissive default: `load_config` raises `ConfigError` for any +missing or malformed setting, so `create_app()` refuses to build a server +rather than start one with an implicit "accept anything" auth posture. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import Mapping +from urllib.parse import urlsplit + +_GUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) +REQUIRED_SCOPE = "Mcp.Access" +# Azure resource group names: alphanumerics, underscore, parentheses, hyphen, period, +# Unicode letters (\w is Unicode-aware for str patterns); trailing period is rejected below. +_RESOURCE_GROUP_RE = re.compile(r"^[\w().-]{1,90}$", re.UNICODE) + + +class ConfigError(ValueError): + """The environment is missing a required setting or one is malformed.""" + + +@dataclass(frozen=True, slots=True) +class Config: + tenant_id: str + api_client_id: str + api_client_secret: str = field(repr=False) + resource_url: str + subscription_id: str + resource_group: str + allowed_client_ids: frozenset[str] + + @property + def scope_prefix(self) -> str: + return f"api://{self.api_client_id}/" + + @property + def scope_uri(self) -> str: + return self.scope_prefix + REQUIRED_SCOPE + + @property + def issuer(self) -> str: + """The Entra v2 issuer for this tenant, matched exactly against `iss`.""" + return f"https://login.microsoftonline.com/{self.tenant_id}/v2.0" + + @property + def jwks_uri(self) -> str: + """The tenant's JSON Web Key Set endpoint, used to verify token signatures.""" + return f"https://login.microsoftonline.com/{self.tenant_id}/discovery/v2.0/keys" + + +def _require(env: Mapping[str, str], name: str) -> str: + value = env.get(name, "").strip() + if not value: + raise ConfigError(f"{name} is required and must not be empty") + return value + + +def _require_guid(env: Mapping[str, str], name: str) -> str: + value = _require(env, name) + if not _GUID_RE.match(value): + raise ConfigError(f"{name} must be a GUID, got {value!r}") + return value.lower() + + +def _require_https_url(env: Mapping[str, str], name: str) -> str: + value = _require(env, name) + try: + parts = urlsplit(value) + port = parts.port + except ValueError as exc: + raise ConfigError(f"{name} must be a valid HTTPS URL") from exc + if ( + parts.scheme != "https" or not parts.hostname or port not in (None, 443) + or parts.username is not None or parts.password is not None + or parts.query or parts.fragment or parts.path != "/mcp" + ): + raise ConfigError(f"{name} must be a credential-free https:// URL ending in /mcp") + return value + + +def _require_resource_group(env: Mapping[str, str], name: str) -> str: + value = _require(env, name) + if not _RESOURCE_GROUP_RE.match(value) or value.endswith("."): + raise ConfigError(f"{name} is not a valid Azure resource group name: {value!r}") + return value + + +def _parse_allowed_client_ids(env: Mapping[str, str]) -> frozenset[str]: + raw = _require(env, "ENTRA_ALLOWED_CLIENT_IDS") + ids: set[str] = set() + for part in raw.split(","): + candidate = part.strip() + if not candidate: + continue + if not _GUID_RE.match(candidate): + raise ConfigError( + f"ENTRA_ALLOWED_CLIENT_IDS contains a non-GUID entry: {candidate!r}" + ) + ids.add(candidate.lower()) + if not ids: + raise ConfigError( + "ENTRA_ALLOWED_CLIENT_IDS is set but contains no usable client IDs" + ) + return frozenset(ids) + + +def load_config(env: Mapping[str, str]) -> Config: + """Build a `Config` from an environment mapping, or raise `ConfigError`. + + Every required setting is checked; nothing is defaulted to a permissive + value. Call this once at process startup (`create_app()` does), not per + request. + """ + return Config( + tenant_id=_require_guid(env, "ENTRA_TENANT_ID"), + api_client_id=_require_guid(env, "ENTRA_API_CLIENT_ID"), + api_client_secret=_require(env, "ENTRA_API_CLIENT_SECRET"), + resource_url=_require_https_url(env, "MCP_RESOURCE_URL"), + subscription_id=_require_guid(env, "LAB_SUBSCRIPTION_ID"), + resource_group=_require_resource_group(env, "LAB_RESOURCE_GROUP"), + allowed_client_ids=_parse_allowed_client_ids(env), + ) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/inventory.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/inventory.py new file mode 100644 index 00000000..b28d2dce --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/inventory.py @@ -0,0 +1,29 @@ +"""Fixed, fictitious inventory fixture. + +Shared by the `lab_inventory` MCP tool and the `/inventory` REST route. Every +entry is made up: no real identities, subscriptions or resource names. This +is a harmless fixture, not a live read of anything -- see `rest.py` for why +`/inventory` is unauthenticated. +""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class LabWidget(BaseModel): + sku: str + name: str + quantity: int + unit: str + + +FIXED_INVENTORY: tuple[LabWidget, ...] = ( + LabWidget(sku="WID-100", name="Widget Sprocket", quantity=42, unit="each"), + LabWidget(sku="WID-200", name="Widget Gizmo", quantity=7, unit="crate"), + LabWidget(sku="WID-300", name="Widget Doohickey", quantity=128, unit="each"), +) + + +def lab_inventory_as_dicts() -> list[dict[str, object]]: + return [widget.model_dump() for widget in FIXED_INVENTORY] diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/learn.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/learn.py new file mode 100644 index 00000000..281331d8 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/learn.py @@ -0,0 +1,158 @@ +"""Read-only connector to the public Microsoft Learn MCP server. + +Always connects anonymously: this server's own inbound bearer token is never +attached to this outbound connection, regardless of who called `learn_search` +or what auth this server enforces on `/mcp`. The caller picks a topic from a +fixed enum; the actual query text sent upstream is one of a small set of +fixed, public strings, never caller-supplied free text. +""" + +from __future__ import annotations + +import json +import logging +from enum import Enum +from typing import Any + +import httpx2 +from pydantic import BaseModel + +from mcp import Client +from mcp.shared.exceptions import MCPError + +logger = logging.getLogger(__name__) + +LEARN_MCP_URL = "https://learn.microsoft.com/api/mcp" + +# The documented, stable tool name (per current Microsoft Learn MCP docs). +_KNOWN_SEARCH_TOOL_NAME = "microsoft_docs_search" +# Fallback discovery if the stable name ever changes: match by shape instead. +_SEARCH_NAME_HINTS = ("search",) +_SEARCH_DESC_HINTS = ("microsoft", "docs", "documentation", "learn") + +_MAX_RESULTS = 5 +_SNIPPET_LENGTH = 500 + + +class LearnTopic(str, Enum): + """A fixed, safe set of public topics. There is no free-text query input.""" + + AZURE_API_MANAGEMENT = "azure-api-management" + AZURE_CONTAINER_APPS = "azure-container-apps" + ENTRA_ID = "entra-id" + MODEL_CONTEXT_PROTOCOL = "model-context-protocol" + + +_TOPIC_QUERIES: dict[LearnTopic, str] = { + LearnTopic.AZURE_API_MANAGEMENT: "Azure API Management overview", + LearnTopic.AZURE_CONTAINER_APPS: "Azure Container Apps overview", + LearnTopic.ENTRA_ID: "Microsoft Entra ID overview", + LearnTopic.MODEL_CONTEXT_PROTOCOL: "Model Context Protocol overview", +} + + +class LearnSearchError(Exception): + """A Learn search failed. `safe_code` is the only detail meant to reach a caller.""" + + def __init__(self, safe_code: str) -> None: + super().__init__(safe_code) + self.safe_code = safe_code + + +class LearnResultItem(BaseModel): + title: str + snippet: str + + +class LearnSearchResult(BaseModel): + topic: LearnTopic + tool_used: str + results: list[LearnResultItem] + + +def _looks_like_search_tool(tool: Any) -> bool: + name = (tool.name or "").lower() + if name == _KNOWN_SEARCH_TOOL_NAME: + return True + description = (tool.description or "").lower() + return any(hint in name for hint in _SEARCH_NAME_HINTS) and any( + hint in description for hint in _SEARCH_DESC_HINTS + ) + + +def _pick_query_parameter(tool: Any) -> str: + schema = tool.input_schema or {} + properties = schema.get("properties") or {} + if "query" in properties: + return "query" + for name in schema.get("required") or []: + if properties.get(name, {}).get("type") == "string": + return name + for name, spec in properties.items(): + if isinstance(spec, dict) and spec.get("type") == "string": + return name + raise LearnSearchError("Unsupported") + + +def _parse_results(raw_text: str) -> list[LearnResultItem]: + try: + payload = json.loads(raw_text) + raw_results = payload["results"] + if not isinstance(raw_results, list): + raise TypeError("results is not a list") + except (json.JSONDecodeError, KeyError, TypeError) as exc: + logger.warning("learn search returned an unsupported result structure") + raise LearnSearchError("Unsupported") from exc + + items: list[LearnResultItem] = [] + for entry in raw_results[:_MAX_RESULTS]: + if not isinstance(entry, dict): + logger.warning("learn search returned an invalid result entry") + raise LearnSearchError("Unsupported") + title = str(entry.get("title") or "untitled") + snippet = str(entry.get("content") or entry.get("snippet") or "")[:_SNIPPET_LENGTH] + items.append(LearnResultItem(title=title, snippet=snippet)) + return items + + +class LearnConnector: + """Anonymous Learn connection; the SDK negotiates the upstream revision.""" + + async def search(self, topic: LearnTopic) -> LearnSearchResult: + try: + return await self._search(topic) + except* (MCPError, httpx2.HTTPError): + logger.warning("learn connection, discovery or call failed") + raise LearnSearchError("McpError") from None + + async def _search(self, topic: LearnTopic) -> LearnSearchResult: + query = _TOPIC_QUERIES[topic] + + async with Client(LEARN_MCP_URL) as client: + listing = await client.list_tools() + tool = next( + (t for t in listing.tools if t.name == _KNOWN_SEARCH_TOOL_NAME), None + ) + if tool is None: + candidates = [t for t in listing.tools if _looks_like_search_tool(t)] + tool = candidates[0] if len(candidates) == 1 else None + if tool is None: + logger.info("learn search failed: no recognizable search tool advertised") + raise LearnSearchError("Unsupported") + + param_name = _pick_query_parameter(tool) + result = await client.call_tool(tool.name, {param_name: query}) + + if result.is_error: + logger.info("learn search failed: tool reported is_error") + raise LearnSearchError("McpError") + + text_items = [c.text for c in result.content if getattr(c, "type", None) == "text"] + if not text_items: + raise LearnSearchError("Unsupported") + + return LearnSearchResult( + topic=topic, + tool_used=tool.name, + results=_parse_results(text_items[0]), + ) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/obo.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/obo.py new file mode 100644 index 00000000..47c760e7 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/obo.py @@ -0,0 +1,120 @@ +"""On-behalf-of exchange of the inbound delegated token for an ARM token. + +Uses MSAL's confidential-client OBO grant. The inbound MCP caller's own +token is used only as the `user_assertion`; it is never itself forwarded to +a downstream API. A failed exchange (including consent-required) never +raises the identity provider's free-text `error_description`: only a numeric +AADSTS code (or, failing that, a fixed safe label) crosses this boundary. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any, Protocol + +import anyio +import msal + +from .config import Config + +logger = logging.getLogger(__name__) + +#: The one downstream scope this lab exchanges for: Azure Resource Manager, +#: default-scoped. Never caller-configurable. +ARM_SCOPE = "https://management.azure.com/.default" + +_AADSTS_RE = re.compile(r"\bAADSTS\d{5,9}\b") +_OAUTH_ERRORS = { + "invalid_grant", "invalid_client", "server_error", "temporarily_unavailable", + "interaction_required", "consent_required", "invalid_scope", + "unauthorized_client", "invalid_request", +} + + +class OboError(Exception): + """The on-behalf-of exchange did not produce an access token. + + `safe_code` is the only detail meant to reach a tool caller: an AADSTS + code when the identity provider's response carried one, else a short + fixed label. The provider's `error_description` text is discarded. + """ + + def __init__(self, safe_code: str) -> None: + super().__init__(safe_code) + self.safe_code = safe_code + + +def extract_aadsts_code(error_description: str | None) -> str | None: + """Pull a bare `AADSTSxxxxx` code out of an AAD error description, if present.""" + if not isinstance(error_description, str): + return None + match = _AADSTS_RE.search(error_description) + return match.group(0) if match else None + + +def _safe_failure_code(result: dict[str, Any]) -> str: + codes = result.get("error_codes") + if isinstance(codes, list): + for code in codes: + if type(code) is int and 10000 <= code <= 999999999: + return f"AADSTS{code}" + extracted = extract_aadsts_code(result.get("error_description")) + if extracted: + return extracted + error = result.get("error") + if isinstance(error, str) and error in _OAUTH_ERRORS: + return error + return "AuthorizationFailed" + + +class OnBehalfOfExchanger(Protocol): + """Exchanges an inbound delegated assertion for a token in another resource's scope.""" + + async def acquire_token(self, user_assertion: str, scopes: list[str]) -> str: ... # pragma: no cover + + +class MsalOnBehalfOfExchanger: + """Production exchanger backed by a real `msal.ConfidentialClientApplication`. + + The client is built lazily, on first use. `ConfidentialClientApplication.__init__` + performs a blocking OIDC discovery call against the tenant; that does not + belong on the path that builds the ASGI app (`create_app()`, called once + by the ASGI server's factory, with no event loop guaranteed running yet). + """ + + def __init__(self, config: Config) -> None: + self._config = config + self._app: msal.ConfidentialClientApplication | None = None + self._build_lock = anyio.Lock() + + async def acquire_token(self, user_assertion: str, scopes: list[str]) -> str: + app = await self._get_app() + # MSAL performs blocking network I/O; keep it off the event loop. + result = await anyio.to_thread.run_sync(self._acquire, app, user_assertion, scopes) + if "access_token" in result: + return result["access_token"] + code = _safe_failure_code(result) + logger.info("on-behalf-of exchange failed: %s", code) + raise OboError(code) + + async def _get_app(self) -> msal.ConfidentialClientApplication: + if self._app is None: + async with self._build_lock: + if self._app is None: + self._app = await anyio.to_thread.run_sync(self._build_app) + return self._app + + def _build_app(self) -> msal.ConfidentialClientApplication: + return msal.ConfidentialClientApplication( + client_id=self._config.api_client_id, + client_credential=self._config.api_client_secret, + authority=f"https://login.microsoftonline.com/{self._config.tenant_id}", + enable_pii_log=False, + ) + + @staticmethod + def _acquire( + app: msal.ConfidentialClientApplication, user_assertion: str, scopes: list[str] + ) -> dict[str, Any]: + return app.acquire_token_on_behalf_of(user_assertion=user_assertion, scopes=scopes) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/rest.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/rest.py new file mode 100644 index 00000000..d5aaf87d --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/rest.py @@ -0,0 +1,44 @@ +"""Public health and fictitious REST inventory; delegated operations use MCP.""" + +from __future__ import annotations + +import re +import secrets + +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from mcp.server import MCPServer +from .inventory import lab_inventory_as_dicts + + +def register_rest_routes(server: MCPServer) -> None: + + @server.custom_route("/healthz", methods=["GET"]) + async def healthz(request: Request) -> Response: + """Public liveness probe. No sensitive data, no auth.""" + return JSONResponse({"status": "ok"}) + + @server.custom_route("/inventory", methods=["GET"]) + async def inventory(request: Request) -> Response: + """Harmless fixture backend for APIM REST wrapping. + + Fixed, fictitious data -- not a live read of anything -- and + deliberately unauthenticated at this layer. Any access control for a + real deployment is APIM/network policy in front of this container, + which is outside this component's scope. + """ + if "authorization" in request.headers: + return JSONResponse({"error": "unexpected_authorization_header"}, status_code=400) + probe_id = request.headers.get("x-lab-probe-id") + if probe_id is not None and not re.fullmatch(r"[a-f0-9]{16}", probe_id): + return JSONResponse({"error": "invalid_probe_id"}, status_code=400) + body = { + "source": "inventory-rest", + "widgets": lab_inventory_as_dicts(), + "invocation_id": secrets.token_hex(16), + "authorization_present": False, + } + if probe_id is not None: + body["probe_id"] = probe_id + return JSONResponse(body) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/tools.py b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/tools.py new file mode 100644 index 00000000..aadd7839 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/service/tools.py @@ -0,0 +1,109 @@ +"""Registers the lab's three read-only MCP tools onto an `MCPServer`. + +Wiring only: each tool delegates to `obo.py` / `arm.py` / `learn.py` / +`inventory.py`. `read_lab_resource_group` is the one place that ties the +verified caller identity (`get_access_token()`) to a downstream call, and it +never has another way to get an identity: no managed-identity or +client-credential fallback if the on-behalf-of exchange fails. +""" + +from __future__ import annotations + +import logging + +from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.mcpserver.exceptions import ToolError +from mcp_types import ToolAnnotations + +from .arm import ArmError, ArmResourceGroupReader, ResourceGroupStatus +from .config import Config +from .inventory import FIXED_INVENTORY, LabWidget +from .learn import LearnConnector, LearnSearchError, LearnSearchResult, LearnTopic +from .obo import ARM_SCOPE, OboError, OnBehalfOfExchanger + +logger = logging.getLogger(__name__) + +_READ_ONLY_LOCAL = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=False) +_READ_ONLY_EXTERNAL = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=True) + + +def register_tools( + server: MCPServer, + *, + config: Config, + obo_exchanger: OnBehalfOfExchanger, + arm_reader: ArmResourceGroupReader, + learn_connector: LearnConnector, +) -> None: + """Add `lab_inventory`, `read_lab_resource_group` and `learn_search` to `server`.""" + + def lab_inventory() -> list[LabWidget]: + """Return a fixed, fictitious widget inventory. No live data, no identities.""" + return list(FIXED_INVENTORY) + + server.add_tool( + lab_inventory, + name="lab_inventory", + description=lab_inventory.__doc__, + annotations=_READ_ONLY_LOCAL, + ) + + async def read_lab_resource_group() -> ResourceGroupStatus: + """Read this lab's fixed Azure resource group, via delegated on-behalf-of. + + Exchanges the caller's own MCP access token for an Azure Resource + Manager token (the ARM_SCOPE default scope) using this server's + confidential client, then reads one fixed resource group at + ARM_API_VERSION. The ARM call always carries the exchanged token, + never the caller's inbound token. Only sanitized status crosses back: + no resource IDs or names. + """ + access_token = get_access_token() + if access_token is None: + # Reachable only if this tool is ever invoked outside an + # authenticated HTTP request (e.g. in-process); there is no + # fallback identity to use instead. + raise ToolError("AuthenticationFailed: no verified caller identity") + + try: + arm_token = await obo_exchanger.acquire_token(access_token.token, [ARM_SCOPE]) + except OboError as exc: + raise ToolError(f"ConsentRequired: {exc.safe_code}") from exc + + try: + return await arm_reader.read( + subscription_id=config.subscription_id, + resource_group=config.resource_group, + bearer_token=arm_token, + ) + except ArmError as exc: + detail = exc.safe_code if exc.http_status is None else f"{exc.safe_code}: {exc.http_status}" + raise ToolError(detail) from exc + + server.add_tool( + read_lab_resource_group, + name="read_lab_resource_group", + description=read_lab_resource_group.__doc__, + annotations=_READ_ONLY_EXTERNAL, + ) + + async def learn_search(topic: LearnTopic) -> LearnSearchResult: + """Search public Microsoft Learn documentation for one fixed topic. + + Connects to the public Learn MCP server anonymously -- this server's + own bearer token is never attached to that outbound connection. The + query text is one of a small set of fixed, public strings selected by + `topic`; there is no free-text query parameter. + """ + try: + return await learn_connector.search(topic) + except LearnSearchError as exc: + raise ToolError(exc.safe_code) from exc + + server.add_tool( + learn_search, + name="learn_search", + description=learn_search.__doc__, + annotations=_READ_ONLY_EXTERNAL, + ) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md new file mode 100644 index 00000000..6f8a77fe --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md @@ -0,0 +1,654 @@ +# APIM과 Container Apps MCP 실행 예제 + +[Azure MCP 운영 아키텍처](../../index.md)의 구성 요소를 직접 확인하는 sample입니다. GitHub·Azure·AKS·Learn은 연결 동작을 보여주기 위한 예제 서버이며 고객이 선택할 운영 전략의 목록이 아닙니다. + +이 sample은 APIM Developer의 수동 MCP/REST 경로와 Container Apps의 OBO를 비교합니다. Foundry portal의 AI gateway 자동 연계나 hosted agent + Toolbox 통합 배포를 수행한 예제는 아닙니다. 해당 자동 연계에는 별도의 v2 SKU 및 지원 범위가 적용됩니다. + +아래 명령을 한 단계씩 실행합니다. 사용이 끝나면 마지막의 Azure·Entra 정리 절차를 수행합니다. + +## 실습 구성 + +| 구성 요소 | 역할 | +|---|---| +| API Management Developer | 기존 REST operation을 MCP tool로 노출, Learn MCP 프록시 | +| Internal Container Apps | 공식 Azure MCP와 Python MCP 호스팅 | +| Microsoft Entra ID | MCP API access token, caller 제한, ARM OBO | +| AKS | 로컬 AKS MCP가 조회할 클러스터와 선택적 개발용 터널 | +| ACR / private DNS | `azd` remote build와 VNet 내 이름 해석 | + +Azure MCP와 Python MCP는 같은 internal Container Apps 환경에 배포합니다. APIM에는 inventory용 REST-to-MCP API와 Learn용 MCP 프록시 API를 각각 만듭니다. AKS MCP binary는 Azure에 원격 서버로 올리지 않고 개발 PC에서 실행합니다. + +### Access token과 OBO 흐름 + +| 연결 | 사용하는 인증 | +|---|---| +| 로컬 client → Python MCP / APIM | Custom MCP API용 access token, `Mcp.Access` scope | +| 로컬 client → 공식 Azure MCP | Native Azure MCP API용 access token, `Mcp.Tools.ReadWrite` scope | +| Azure·Python MCP → ARM | 사용자를 대신해 발급받은 ARM용 OBO access token | +| 로컬 client → GitHub MCP | GitHub OAuth 또는 PAT | +| Client / APIM → Learn MCP | 익명 upstream 호출 | + +OBO는 **MCP API용 사용자 token을 받아 ARM용 token을 별도로 발급받는 흐름**입니다. 원래 MCP token을 ARM·GitHub·Learn에 그대로 전달하지 않습니다. Native Azure MCP의 managed identity는 confidential client를 인증하는 데 사용하며, ARM에서는 OBO 사용자의 Azure RBAC가 적용됩니다. + +Entra 앱의 사전 승인과 caller 제한도 구분합니다. 사전 승인은 consent 설정이고, 허용할 client는 APIM 정책·Python API·Container Apps authentication에서 검사합니다. 로컬 PC에서 내부 MCP까지의 접속 경로와 private DNS는 3번 단계에서 설정합니다. + +### 예제에서 사용하는 버전 + +| 항목 | 사용하는 버전·방식 | +|---|---| +| MCP protocol | 날짜 기반 revision. Python 연결은 `2026-07-28`, APIM 연결은 응답으로 협상한 이전 revision 사용 | +| Python SDK | `mcp==2.2.0` — SDK 버전이며 protocol 이름 “MCP 2.0”과 다름 | +| 메시지 형식 | JSON-RPC `2.0` | +| 공식 Azure MCP | `2.0.5`, read-only tool과 remote OBO | +| AKS MCP | `0.0.20`, local stdio-only | + +`2026-07-28` 연결은 `server/discover`와 요청별 metadata를 사용합니다. APIM의 HTTP 호출에서는 호환되는 `initialize` 절차를 직접 실행하고, 반환된 `protocolVersion`을 다음 요청에 사용합니다. + +### 필요한 도구와 권한 + +- Azure CLI, Azure Developer CLI **1.29+**, Bicep. +- Node.js **22.19+**, npm, Python **3.13**, `curl`, `jq`. +- `kubectl`, `kubelogin`, GitHub CLI(`gh`). +- Azure resource group 생성·리소스 배포·role assignment 권한. +- Entra app registration과 해당 사용자의 ARM delegated consent를 설정할 권한. +- GitHub를 사용하는 단계에는 GitHub 로그인과 대상 repository 읽기 권한. + +명령은 **Bash** 기준입니다. 로컬 Docker는 필요하지 않습니다. `azd`가 ACR에 container build를 제출합니다. + +### 비용 + +기본 구성은 Korea Central의 APIM Developer 1개, AKS Free control plane과 `Standard_D4as_v5` 노드 1개, Container Apps Consumption workload profile, ACR Basic입니다. APIM과 AKS node는 도구를 호출하지 않는 동안에도 비용이 발생합니다. + +[Azure 가격 계산기](https://azure.microsoft.com/pricing/calculator/)에서 배포 region과 실제 실행 시간을 기준으로 산정합니다. APIM과 VM 외에 디스크·네트워크·ACR·Container Apps 비용도 포함합니다. Developer SKU와 단일 노드는 실습용 구성입니다. + +## 1. azd로 환경 배포 + +### 1-1. 소스와 로그인 + +```bash +git clone https://github.com/hellices/devguidesample.git +cd devguidesample/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab + +set -euo pipefail +umask 077 +az login +azd auth login +gh auth login +npm ci --no-audit --no-fund +``` + +PR을 검토 중이면 해당 PR branch를 checkout한 뒤 진행합니다. 이후 명령은 모두 **이 sample 디렉터리**에서 실행합니다. + +### 1-2. 새 환경 선택 + +```bash +azd env new mcpdemo01 +azd env set AZURE_SUBSCRIPTION_ID "$(az account show --query id -o tsv)" +azd env set AZURE_LOCATION koreacentral +``` + +`mcpdemo01` 대신 사용하지 않은 이름을 지정합니다. 이 sample은 소문자·숫자 6~10자로 된 환경 이름을 사용합니다. 만들어지는 기본 RG는 `rg-<환경 이름>`입니다. + +Azure CLI와 `azd`의 로그인·선택 구독은 따로 관리되므로 위와 같이 구독을 명시합니다. 기존 환경을 정리한 후 새로 시작하려면 마지막 정리 절차를 마치고 다른 이름을 사용하세요. + +### 1-3. Preview와 배포 + +```bash +python3 scripts/identity.py prepare +azd provision --preview +azd up +``` + +`azure.yaml`에는 실제 Container Apps service와 `docker.remoteBuild: true`가 선언되어 있습니다. `azd up`은 Bicep provisioning → ACR build → service deploy를 진행합니다. + +| 단계 | 확인할 내용 | +|---|---| +| Directory 준비 / `preup` hook | 이 환경의 Entra 앱 3개와 현재 사용자 ARM delegated consent 설정 | +| Bicep provisioning | 새 RG, APIM, Container Apps, AKS, ACR, private DNS 생성 | +| `postprovision` hook | Native Azure MCP의 managed identity와 confidential-client federation 연결 | +| Service deploy | Python image remote build, Container App의 실제 image·port·readiness 설정 | + +`azd`는 provisioning hook보다 먼저 Bicep 필수 입력을 읽습니다. 따라서 Preview 전에는 `identity.py prepare`를 명시적으로 실행합니다. 이 명령은 Entra 객체와 동의를 설정하며, `azd up`의 `preup` hook에서도 동일한 준비를 수행합니다. Hook은 ARM 리소스를 배포하는 별도 배포 스크립트가 아닙니다. + +RG 외에 AKS node RG와 Container Apps managed RG도 생성됩니다. Entra app은 RG에 속하지 않습니다. `.azure/`에는 실제 식별자와 실습 credential이 저장되므로 공유·캡처하지 않습니다. + +배포가 중단되면 실패한 단계의 오류를 해결한 후 같은 환경에서 다시 실행합니다. `azd provision`만 수행하면 Python service는 placeholder image 상태이며, `azd deploy mcp`가 실제 앱을 배포합니다. + +### 1-4. 배포 결과 확인 + +```bash +export AZURE_SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID)" +export RG="$(azd env get-value AZURE_RESOURCE_GROUP)" +export TENANT_ID="$(azd env get-value AZURE_TENANT_ID)" +export APIM_NAME="$(azd env get-value MCP_APIM_NAME)" +export AKS_NAME="$(azd env get-value MCP_AKS_NAME)" +export PYTHON_MCP_URL="$(azd env get-value MCP_PYTHON_URL)" +export AZURE_MCP_URL="$(azd env get-value MCP_AZURE_URL)" +export REST_MCP_URL="$(azd env get-value MCP_APIM_REST_URL)" +export LEARN_MCP_URL="$(azd env get-value MCP_APIM_LEARN_URL)" +export REST_URL="$(azd env get-value MCP_REST_URL)" +export PRIVATE="$PWD/.private/$(azd env get-value AZURE_ENV_NAME)" +mkdir -p "$PRIVATE" + +az resource list --subscription "$AZURE_SUBSCRIPTION_ID" \ + --resource-group "$RG" --query "[].{type:type,location:location}" -o table +``` + +목록에서 APIM, Container Apps environment, Container App 2개, AKS, ACR를 확인합니다. 전체 environment 설정을 출력하는 `azd env get-values`는 credential을 포함할 수 있으므로 화면 공유 때 사용하지 않습니다. + +2026-09-13에 실행한 같은 구성의 배포 출력입니다. `azd up`이 provisioning, ACR remote build, service deployment까지 수행했습니다. + +![azd up의 실제 provisioning과 service deployment 완료 출력 발췌](../../validation/images/azd-deployment.png) + +## 2. 로컬 MCP 서버 연결 + +![개발 PC의 Azure·AKS stdio 연결과 GitHub 자체 인증, 익명 Learn 연결 구성](../../hosting-reference/images/local-upstreams.svg) + +MCP Inspector는 한 번에 한 요청을 보내는 공식 MCP client입니다. 아래 명령은 서버에 연결해 tool 목록 또는 지정 tool의 결과를 표시합니다. + +### 2-1. Microsoft Learn + +```bash +npx --no-install mcp-inspector --cli \ + https://learn.microsoft.com/api/mcp --transport http --method tools/list +``` + +`microsoft_docs_search`, `microsoft_docs_fetch`, `microsoft_code_sample_search`를 확인합니다. Learn은 이 연결에 access token을 요구하지 않습니다. + +```bash +npx --no-install mcp-inspector --cli \ + https://learn.microsoft.com/api/mcp --transport http \ + --method tools/call --tool-name microsoft_docs_search \ + --tool-arg 'query=Azure API Management MCP' +``` + +응답에서 문서 제목·URL을 확인합니다. HTTP 연결이 성공해도 MCP 응답이 `isError: true`라면 호출은 실패한 것입니다. + +### 2-2. Azure MCP + +```bash +npx --no-install mcp-inspector --cli --config inspector.json \ + --server azure --method tools/list +``` + +`group_list`, `group_resource_list`가 표시됩니다. 이 경로는 로컬 Azure CLI credential을 사용합니다. 뒤에서 실행할 remote OBO 경로와 다릅니다. + +```bash +npx --no-install mcp-inspector --cli --config inspector.json \ + --server azure --method tools/call \ + --tool-name group_resource_list \ + --tool-arg "subscription=$AZURE_SUBSCRIPTION_ID" --tool-arg "resource-group=$RG" +``` + +`results.resources`에서 방금 배포한 RG의 리소스를 확인합니다. + +### 2-3. GitHub + +GitHub는 GitHub OAuth/PAT를 사용합니다. Entra access token을 GitHub에 전달하지 않습니다. 로그인한 `gh` credential을 비공개 MCP client 설정으로 저장합니다. + +```bash +gh auth token | jq -Rs '{ + mcpServers: {github: { + url: "https://api.githubcopilot.com/mcp/", + type: "streamable-http", + headers: { + Authorization: ("Bearer " + rtrimstr("\n")), + "X-MCP-Readonly": "true", + "X-MCP-Tools": "search_repositories" + } + }} +}' > "$PRIVATE/github.json" + +npx --no-install mcp-inspector --cli --config "$PRIVATE/github.json" \ + --server github --method tools/call --tool-name search_repositories \ + --tool-arg 'query=repo:hellices/devguidesample' +``` + +공개 repository 검색 결과를 확인합니다. 이 credential 파일은 Git에 추가하지 않으며 실습 후 삭제합니다. VS Code 연결은 [MCP 설정 예제](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/mcp.example.json)를 로컬 설정에 병합해 사용할 수 있습니다. + +## 3. 로컬 PC에서 private MCP endpoint에 접속 + +AKS MCP에서 사용할 kubeconfig를 이 환경의 비공개 디렉터리에 저장합니다. 기존 기본 kubeconfig는 변경하지 않습니다. + +```bash +export KUBECONFIG="$PRIVATE/kubeconfig" +az aks get-credentials --subscription "$AZURE_SUBSCRIPTION_ID" \ + --resource-group "$RG" --name "$AKS_NAME" --file "$KUBECONFIG" --overwrite-existing +kubelogin convert-kubeconfig --login azurecli --kubeconfig "$KUBECONFIG" +kubectl create namespace mcp-lab --dry-run=client -o yaml | kubectl apply -f - +``` + +### 3-1. VPN 경로가 있는 경우 + +회사 네트워크에서 Azure VNet까지 VPN/ExpressRoute 경로와 private DNS가 준비되어 있다면 endpoint에 직접 접속합니다. URL은 인증서가 발급된 hostname을 그대로 사용합니다. + +```bash +: > "$PRIVATE/transport.conf" +export MCP_PROXY="" +``` + +### 3-2. VPN 없이 개발용 터널을 사용하는 경우 + +이 선택지는 AKS API에 대한 권한으로 `kubectl port-forward`를 사용합니다. 전체 VNet을 공개하지 않고, pod의 loopback에서 실행하는 CONNECT listener를 로컬 PC에 연결합니다. + +```bash +kubectl -n mcp-lab create configmap mcp-tunnel-code \ + --from-file=connect_proxy.py=network/connect_proxy.py + +HOSTS="$(printf '%s\n' "$PYTHON_MCP_URL" "$AZURE_MCP_URL" "$REST_MCP_URL" \ + | sed -E 's#https://([^/]+).*#\1#' | sort -u | paste -sd, -)" +kubectl -n mcp-lab create configmap mcp-endpoints --from-literal="hosts=$HOSTS" +kubectl apply -f network/tunnel.yaml +kubectl -n mcp-lab wait --for=condition=Ready pod/mcp-tunnel --timeout=180s +``` + +원래 터미널에서 실습 kubeconfig의 절대 경로를 출력해 복사합니다. + +```bash +printf '%s\n' "${KUBECONFIG:?3번 단계에서 실습 kubeconfig를 먼저 준비하세요}" +``` + +새 터미널은 원래 터미널의 환경 변수를 상속하지 않습니다. **복사한 경로를 새 터미널에서 입력**한 뒤 터널을 유지합니다. 빈 값·상대 경로·존재하지 않는 파일이면 실행하지 않습니다. + +```bash +read -r -p "실습 kubeconfig의 절대 경로: " KUBECONFIG +if [[ "$KUBECONFIG" == /* && -f "$KUBECONFIG" ]]; then + export KUBECONFIG + kubectl --kubeconfig "$KUBECONFIG" -n mcp-lab port-forward \ + --address 127.0.0.1 pod/mcp-tunnel 18080:8080 +else + printf '%s\n' '존재하는 실습 kubeconfig 파일의 절대 경로를 입력하세요.' >&2 + false +fi +``` + +원래 터미널에서 curl에 사용할 proxy를 지정합니다. + +```bash +printf '%s\n' 'proxy = "http://127.0.0.1:18080"' > "$PRIVATE/transport.conf" +export MCP_PROXY="http://127.0.0.1:18080" +``` + +포트가 이미 사용 중이면 다른 로컬 포트를 선택하고 두 명령의 포트를 함께 바꿉니다. 이 설정은 curl에만 적용되며 VS Code의 네트워크 설정을 자동으로 변경하지 않습니다. + +### 3-3. AKS MCP로 pod 조회 + +공식 AKS MCP 0.0.20 release에서 OS/CPU에 맞는 executable을 다운로드하고 SHA-256을 확인합니다. 아래는 macOS arm64 예입니다. + +```bash +export AKS_MCP_BIN="$PRIVATE/aks-mcp" +gh release download v0.0.20 --repo Azure/aks-mcp \ + --pattern aks-mcp-darwin-arm64 --output "$AKS_MCP_BIN" +printf '%s %s\n' \ + '1104b6abfeec05de836b67f309c47bdfff4aaa1d21f78505a4a1c090a0ef5c61' \ + "$AKS_MCP_BIN" | shasum -a 256 -c - && + chmod 700 "$AKS_MCP_BIN" + +jq -n --arg binary "$AKS_MCP_BIN" --arg kubeconfig "$KUBECONFIG" '{ + mcpServers:{aks:{type:"stdio",command:$binary, + args:["--access-level","readonly","--enabled-components","az_cli,kubectl","--allow-namespaces","mcp-lab"], + env:{KUBECONFIG:$kubeconfig}}} +}' > "$PRIVATE/aks.json" +npx --no-install mcp-inspector --cli --config "$PRIVATE/aks.json" \ + --server aks --method tools/call --tool-name call_kubectl \ + --tool-arg 'command=kubectl get pods -n mcp-lab -o json' +``` + +`mcp-tunnel` pod가 표시됩니다. VPN 경로를 선택해 pod를 만들지 않았다면 namespace·pod 목록이 다른 것은 정상입니다. 이 버전의 AKS MCP는 **local stdio-only**입니다. + +다음은 실제 GitHub repository 검색, AKS pod 조회와 Learn 검색 응답의 발췌입니다. Learn의 APIM 경유 호출은 7번 단계에서 실행합니다. + +![GitHub repository 검색, AKS pod 조회, Learn 검색의 실제 응답 발췌](../../validation/images/mcp-upstreams.png) + +## 4. Entra access token 준비 + +### 4-1. Token 없이 접근 + +```bash +curl --silent --show-error --max-time 90 --config "$PRIVATE/transport.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --data-binary @requests/initialize.json \ + --suppress-connect-headers --dump-header "$PRIVATE/unauthorized.headers" --output "$PRIVATE/unauthorized.body" \ + "$REST_MCP_URL" + +head -n 1 "$PRIVATE/unauthorized.headers" +``` + +예상 결과는 **401**입니다. APIM이 반환한 `WWW-Authenticate`에서 `resource_metadata` URL을 확인할 수 있습니다. 해당 metadata는 공개되어도 되지만 MCP tool 실행에는 access token이 필요합니다. + +### 4-2. API별 token 발급 + +```bash +CUSTOM_ID="$(azd env get-value MCP_CUSTOM_API_CLIENT_ID)" +AZURE_API_ID="$(azd env get-value MCP_AZURE_API_CLIENT_ID)" + +az account get-access-token --tenant "$TENANT_ID" \ + --scope "api://$CUSTOM_ID/Mcp.Access" -o json \ + | jq -r '"header = \"Authorization: Bearer \(.accessToken)\""' \ + > "$PRIVATE/custom-auth.conf" + +az account get-access-token --tenant "$TENANT_ID" \ + --scope "api://$AZURE_API_ID/Mcp.Tools.ReadWrite" -o json \ + | jq -r '"header = \"Authorization: Bearer \(.accessToken)\""' \ + > "$PRIVATE/azure-auth.conf" +``` + +Token을 터미널에 출력하지 않고 curl 설정에 저장합니다. 이 두 token의 audience는 서로 다릅니다. 발급이 거부되면 API scope, consent, Conditional Access 요구사항을 확인하고 필요한 대화형 로그인을 수행합니다. + +이 절은 **token을 사전 발급하는 연결 방식**입니다. MCP client의 discovery·PKCE 자동 로그인을 확인하려면 [인증 단계별 확인](authentication-checks.md)을 따릅니다. + +## 5. REST API를 APIM MCP tool로 호출 + +![Entra로 보호된 APIM이 기존 REST operation을 getInventory MCP tool로 연결하는 아키텍처](../../hosting-reference/images/apim-rest-tools.svg) + +### 5-1. 원래 REST 응답 + +```bash +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" --config "$PRIVATE/custom-auth.conf" \ + "$REST_URL" | jq '{source, widgets, authorization_present}' +``` + +예상 응답은 가상 widget 3개, `source: inventory-rest`, `authorization_present: false`입니다. APIM은 사용자 token을 검증한 후 비민감 REST backend로 보낼 때 Authorization header를 제거합니다. + +### 5-2. MCP initialize + +```bash +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" --config "$PRIVATE/custom-auth.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --data-binary @requests/initialize.json --suppress-connect-headers --dump-header "$PRIVATE/init.headers" \ + --output "$PRIVATE/init.body" "$REST_MCP_URL" +``` + +응답은 JSON 또는 SSE의 `data:` 줄입니다. 다음 함수는 **출력 형식만 JSON으로 정리**하며, 요청 실행이나 성공 판정을 하지 않습니다. + +```bash +mcp_json() { + jq -Rrs 'if test("^\\s*\\{") then fromjson + else [split("\n")[] | select(startswith("data: ")) | + ltrimstr("data: ") | fromjson] | + if length == 1 then .[0] else error("응답 본문과 Content-Type을 확인하세요") end end' "$1" +} +mcp_json "$PRIVATE/init.body" | jq '.result | {protocolVersion, serverInfo}' + +VERSION="$(mcp_json "$PRIVATE/init.body" | jq -r .result.protocolVersion)" +SESSION="$(awk 'tolower($1)=="mcp-session-id:" {gsub("\r","",$2);print $2}' "$PRIVATE/init.headers")" +``` + +여기서는 이전 revision과 호환되는 `initialize` 절차를 사용합니다. 요청한 revision과 실제 반환된 `protocolVersion`이 다를 수 있으므로 응답 값을 다음 요청에 사용합니다. 최신 `2026-07-28`의 `server/discover`와 혼동하지 않습니다. + +### 5-3. Tool 목록 + +```bash +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" --config "$PRIVATE/custom-auth.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --header "MCP-Protocol-Version: $VERSION" --header "Mcp-Session-Id: $SESSION" \ + --data-binary @requests/initialized.json "$REST_MCP_URL" + +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" --config "$PRIVATE/custom-auth.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --header "MCP-Protocol-Version: $VERSION" --header "Mcp-Session-Id: $SESSION" \ + --data-binary @requests/tools-list.json --output "$PRIVATE/tools.body" "$REST_MCP_URL" +mcp_json "$PRIVATE/tools.body" | jq '.result.tools[] | {name,inputSchema}' +``` + +`getInventory`와 APIM이 만든 `inputSchema`를 확인합니다. 이 tool은 별도 MCP server가 아니라 APIM의 REST operation `get-inventory`에 연결됩니다. + +### 5-4. Tool 호출 + +```bash +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" --config "$PRIVATE/custom-auth.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --header "MCP-Protocol-Version: $VERSION" --header "Mcp-Session-Id: $SESSION" \ + --data-binary @requests/inventory.json --output "$PRIVATE/inventory.body" "$REST_MCP_URL" +mcp_json "$PRIVATE/inventory.body" | jq '.result' +``` + +`isError`가 `true`가 아니고 `content`에 가상 재고가 포함되는지 봅니다. 반환된 `invocation_id`는 REST backend에서 요청마다 생성합니다. **HTTP 200과 빈 tool 목록만으로 완료한 것으로 판단하지 않습니다.** + +2026-09-13에는 REST URL과 MCP tool 모두 같은 가상 widget 3개를 반환했습니다. `authorization_present: false`로 backend에 Entra token을 전달하지 않은 것도 확인했습니다. + +![직접 REST 호출과 APIM getInventory MCP tool 호출의 실제 응답 비교](../../validation/images/apim-rest-to-mcp.png) + +## 6. Azure MCP에서 OBO로 Azure 조회 + +![Internal Container Apps의 Azure MCP가 사용자 token과 managed identity federation을 사용해 ARM OBO token을 발급받는 흐름](../../hosting-reference/images/container-apps-obo.svg) + +### 6-1. Native Azure MCP + +MCP Inspector로 새 연결을 엽니다. Client가 initialize와 session 처리를 수행합니다. 이전 APIM session ID를 재사용하지 않습니다. + +```bash +az account get-access-token --tenant "$TENANT_ID" \ + --scope "api://$AZURE_API_ID/Mcp.Tools.ReadWrite" -o json \ + | jq --arg url "$AZURE_MCP_URL" '{mcpServers:{azure:{ + url:$url,type:"streamable-http",headers:{Authorization:("Bearer "+.accessToken)} + }}}' > "$PRIVATE/azure-remote.json" + +HTTPS_PROXY="$MCP_PROXY" HTTP_PROXY="$MCP_PROXY" \ + npx --no-install mcp-inspector --cli --config "$PRIVATE/azure-remote.json" \ + --server azure --method tools/list +``` + +`group_resource_list`가 보이면 해당 tool로 배포한 RG를 조회합니다. + +```bash +HTTPS_PROXY="$MCP_PROXY" HTTP_PROXY="$MCP_PROXY" \ + npx --no-install mcp-inspector --cli --config "$PRIVATE/azure-remote.json" \ + --server azure --method tools/call --tool-name group_resource_list \ + --tool-arg "subscription=$AZURE_SUBSCRIPTION_ID" --tool-arg "resource-group=$RG" +``` + +Native Azure MCP는 incoming user token을 검증하고, managed identity federation으로 confidential client를 인증한 뒤 ARM용 OBO token을 받습니다. 반환된 resource 목록은 호출한 사용자의 Azure RBAC를 따릅니다. + +### 6-2. Python MCP의 OBO tool + +Python MCP용 client 설정을 만들고 `read_lab_resource_group`을 호출합니다. + +```bash +az account get-access-token --tenant "$TENANT_ID" \ + --scope "api://$CUSTOM_ID/Mcp.Access" -o json \ + | jq --arg url "$PYTHON_MCP_URL" '{mcpServers:{python:{ + url:$url,type:"streamable-http",protocolEra:"modern",modernLogLevel:"off", + headers:{Authorization:("Bearer "+.accessToken)} + }}}' > "$PRIVATE/python-remote.json" + +HTTPS_PROXY="$MCP_PROXY" HTTP_PROXY="$MCP_PROXY" \ + npx --no-install mcp-inspector --cli --config "$PRIVATE/python-remote.json" \ + --server python --method tools/list + +HTTPS_PROXY="$MCP_PROXY" HTTP_PROXY="$MCP_PROXY" \ + npx --no-install mcp-inspector --cli --config "$PRIVATE/python-remote.json" \ + --server python --method tools/call --tool-name read_lab_resource_group +``` + +응답에는 `exists: true`, region, `provisioning_succeeded: true`가 포함됩니다. 이 tool은 배포 시 지정한 RG만 조회합니다. OBO 오류가 나면 delegated permission·consent·confidential-client credential을 확인하며, 관리 ID의 권한으로 호출을 대체하지 않습니다. + +Python 연결에는 Inspector의 `protocolEra: modern`을 지정했습니다. Python SDK 2.2.0 server의 `2026-07-28` 요청 방식을 사용하며, APIM과 native Azure MCP에서 실행한 이전 revision의 initialize/session 절차와 구분합니다. + +다음 실제 응답에서는 native Azure MCP의 ARM 조회 결과와 Python MCP의 `exists`, `region`, `provisioning_succeeded`를 비교할 수 있습니다. + +![공식 Azure MCP와 Python MCP의 실제 OBO 조회 응답](../../validation/images/mcp-obo.png) + +## 7. APIM을 통해 기존 Learn MCP 호출 + +![APIM이 Entra token을 검증하고 Authorization header를 제거한 뒤 익명 Learn MCP backend에 연결하는 흐름](../../hosting-reference/images/apim-existing-mcp.svg) + +이번에는 공개 Learn URL이 아니라 APIM의 `$LEARN_MCP_URL`에 연결합니다. + +```bash +az account get-access-token --tenant "$TENANT_ID" \ + --scope "api://$CUSTOM_ID/Mcp.Access" -o json \ + | jq --arg url "$LEARN_MCP_URL" '{mcpServers:{learn:{ + url:$url,type:"streamable-http",headers:{Authorization:("Bearer "+.accessToken)} + }}}' > "$PRIVATE/learn-proxy.json" + +HTTPS_PROXY="$MCP_PROXY" HTTP_PROXY="$MCP_PROXY" \ + npx --no-install mcp-inspector --cli --config "$PRIVATE/learn-proxy.json" \ + --server learn --method tools/list +``` + +세 Learn tool이 표시되면 검색 요청을 보냅니다. + +```bash +HTTPS_PROXY="$MCP_PROXY" HTTP_PROXY="$MCP_PROXY" \ + npx --no-install mcp-inspector --cli --config "$PRIVATE/learn-proxy.json" \ + --server learn --method tools/call --tool-name microsoft_docs_search \ + --tool-arg 'query=Azure API Management MCP' +``` + +APIM에서 Entra 인증을 수행하지만 upstream Learn 요청은 익명입니다. 이 구성은 REST operation을 tool로 만드는 5번 시나리오와 달리 **기존 MCP server를 프록시**합니다. + +## 8. 401과 403 확인 + +### 8-1. 다른 API용 token 제출 + +Native Azure MCP용 token으로 APIM에 요청합니다. + +```bash +curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" --config "$PRIVATE/azure-auth.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --data-binary @requests/initialize.json \ + --suppress-connect-headers --dump-header "$PRIVATE/wrong-audience.headers" \ + --output "$PRIVATE/wrong-audience.body" "$REST_MCP_URL" +head -n 1 "$PRIVATE/wrong-audience.headers" +``` + +예상 결과는 **401**입니다. 서명이 유효한 token이어도 대상 API의 audience와 다르면 APIM이 거부합니다. + +### 8-2. Native MCP의 caller 제한 + +App registration의 사전 승인은 caller ACL이 아닙니다. **이 개발 환경에서만** Azure CLI client를 잠시 제외해 403을 확인합니다. 아래 블록 전체를 한 번에 실행합니다. 원본 백업은 매번 새 파일에 보관하며, 설정 변경 전에 등록한 `EXIT` trap이 성공·오류·일반적인 중단 시 복구를 시도합니다. + +```bash +( + set -euo pipefail + APP_NAME="$(azd env get-value MCP_AZURE_APP_NAME)" + AUTH_API="https://management.azure.com/subscriptions/$AZURE_SUBSCRIPTION_ID/resourceGroups/$RG/providers/Microsoft.App/containerApps/$APP_NAME/authConfigs/current?api-version=2025-01-01" + ORIGINAL="$(mktemp "$PRIVATE/native-auth-original.XXXXXX")" + RESTRICTED="$(mktemp "$PRIVATE/native-auth-restricted.XXXXXX")" + + az rest --method get --url "$AUTH_API" --headers Accept=application/json \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + | jq -e 'if (.properties.identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications + | index("04b07795-8ddb-461a-bbee-02f9e1bf7b46")) == null + then error("Azure CLI client must be allowed before this scenario") + else {properties} end' > "$ORIGINAL" + jq -e '.properties.identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications + |= map(select(. != "04b07795-8ddb-461a-bbee-02f9e1bf7b46")) + | if (.properties.identityProviders.azureActiveDirectory.validation.defaultAuthorizationPolicy.allowedApplications | length) > 0 + then . else error("Keep at least one allowed client") end' \ + "$ORIGINAL" > "$RESTRICTED" + printf '원본 백업: %s\n' "$ORIGINAL" + + restore_native_auth() { + local status="$1" + local restore_status=0 + trap - EXIT + if az rest --method put --url "$AUTH_API" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --body @"$ORIGINAL" --output none; then + printf '%s\n' '원래 인증 설정을 복구했습니다.' + else + restore_status=$? + printf '복구 실패: 검증 종료 코드=%s, 복구 종료 코드=%s, 원본=%s\n' \ + "$status" "$restore_status" "$ORIGINAL" >&2 + fi + if [[ "$status" -ne 0 ]]; then + exit "$status" + fi + exit "$restore_status" + } + trap 'restore_native_auth "$?"' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + + az rest --method put --url "$AUTH_API" \ + --subscription "$AZURE_SUBSCRIPTION_ID" \ + --body @"$RESTRICTED" --output none + read -r -p "설정 반영을 기다린 뒤 Enter를 누르세요(기존 실증에서는 약 60초): " + STATUS="$(curl --silent --show-error --max-time 90 \ + --config "$PRIVATE/transport.conf" --config "$PRIVATE/azure-auth.conf" \ + --header 'Content-Type: application/json' \ + --header 'Accept: application/json, text/event-stream' \ + --data-binary @requests/initialize.json \ + --suppress-connect-headers --dump-header "$PRIVATE/client-denied.headers" \ + --output "$PRIVATE/client-denied.body" --write-out '%{http_code}' "$AZURE_MCP_URL")" + printf 'HTTP %s\n' "$STATUS" + if [[ "$STATUS" != 403 ]]; then + printf '%s\n' '403이 아닙니다. 복구 후 설정 반영 상태와 응답을 확인하세요.' >&2 + exit 1 + fi +) +``` + +복구 성공 메시지와 종료 상태를 확인합니다. 403이 아니라면 응답과 반영 시간을 확인한 뒤 **복구가 끝난 상태에서** 전체 블록을 다시 실행합니다. Timeout 등 검증 오류의 종료 코드는 유지하며, 복구 자체의 오류도 별도로 표시합니다. + +강제 종료나 네트워크 장애로 복구하지 못했다면, 같은 sample 디렉터리와 azd environment에서 아래 명령을 실행합니다. 출력해 둔 원본 백업 경로를 사용하며 기존 백업을 덮어쓰지 않습니다. + +```bash +( + set -euo pipefail + read -r -p "원본 백업 파일의 절대 경로: " ORIGINAL + [[ "$ORIGINAL" == /* && -s "$ORIGINAL" ]] || { + printf '%s\n' '원본 백업 파일의 절대 경로가 필요합니다.' >&2 + exit 1 + } + SUBSCRIPTION_ID="$(azd env get-value AZURE_SUBSCRIPTION_ID)" + GROUP="$(azd env get-value AZURE_RESOURCE_GROUP)" + APP_NAME="$(azd env get-value MCP_AZURE_APP_NAME)" + API_CLIENT_ID="$(azd env get-value MCP_AZURE_API_CLIENT_ID)" + if ! jq -e --arg id "$API_CLIENT_ID" \ + '.properties.identityProviders.azureActiveDirectory.registration.clientId == $id' \ + "$ORIGINAL" > /dev/null; then + printf '%s\n' '백업과 선택한 azd environment의 API 앱이 다릅니다.' >&2 + exit 1 + fi + AUTH_API="https://management.azure.com/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$GROUP/providers/Microsoft.App/containerApps/$APP_NAME/authConfigs/current?api-version=2025-01-01" + az rest --method put --url "$AUTH_API" --subscription "$SUBSCRIPTION_ID" \ + --body @"$ORIGINAL" --output none +) +``` + +6-1의 `tools/list`를 다시 실행하여 정상 연결을 확인합니다. 다른 사용자의 RBAC를 비교하려면 그 사용자의 scope consent와 Azure RBAC를 준비한 뒤 같은 tool을 호출합니다. + +2026-09-13에는 무인증·wrong audience 요청에서 401, 제외된 client에서 403을 확인했습니다. 원래 allowlist로 복구한 후에는 native Azure MCP의 도구가 다시 조회되었습니다. + +![401과 403 응답 및 caller allowlist 복구 후 실제 도구 목록](../../validation/images/mcp-authorization.png) + +## 9. 환경 정리 + +1. `kubectl port-forward` 터미널에서 `Ctrl+C`로 터널을 종료합니다. +2. 선택한 azd environment를 확인하고 ARM 리소스를 제거합니다. + +```bash +azd env list +azd down +``` + +3. Entra 앱은 별도로 제거합니다. + +```bash +python3 scripts/identity.py remove --confirm "$(azd env get-value AZURE_ENV_NAME)" +``` + +4. RG와 AKS·Container Apps managed RG가 삭제되었는지 확인한 뒤 `.private/`의 token·kubeconfig를 정리합니다. Directory cleanup을 끝내기 전에 `.azure/`를 삭제하지 않습니다. + +`expiresOn` tag는 자동 삭제 설정이 아닙니다. `azd down`이 실패하면 삭제 상태를 확인하고 마무리해야 비용이 멈춥니다. + +## 관련 자료 + +- [운영 아키텍처와 인증 설계](../../index.md) +- [2026-09-13 실행 기록](../../validation/index.md) +- [샘플 구성 파일과 주의사항](README.md) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/toolbox/.gitignore b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/.gitignore new file mode 100644 index 00000000..469f93fd --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/.gitignore @@ -0,0 +1,3 @@ +.azure/ +.private/ +node_modules/ diff --git a/docs/services/azure-architecture/mcp-configuration/samples/toolbox/README.md b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/README.md new file mode 100644 index 00000000..313c283d --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/README.md @@ -0,0 +1,131 @@ +# Microsoft Foundry Toolbox for MCP + +This example publishes Microsoft Learn MCP tools through a Foundry Toolbox. It has a normal `learn-tools` service and a separate `learn-tools-search` service that demonstrates deferred discovery. It does not provision API Management, a model deployment or a new Foundry project. + +Use the [MCP solution guide](../../index.md) for Toolbox aggregation, IQ tools, APIM governance and documented protocol/authentication support. The guide also links the Microsoft Learn token-comparison demo. This sample uses Learn tools and is not a token-savings benchmark. + +## Prerequisites + +- An existing Microsoft Foundry project and its project endpoint. +- The **Foundry User** role on that project for the developer and the appropriate consuming identity. +- Azure Developer CLI 1.29+, the `microsoft.foundry` extension, Azure CLI, Node.js 22.19+ and `jq`. +- Connectivity to the Foundry project endpoint. A private project requires its private route and DNS configuration. + +## Configure and publish + +Run from this sample directory. Use a separate environment from the Container Apps/APIM lab. + +```bash +azd extension install microsoft.foundry +azd auth login +az login +azd env new toolboxdemo +azd env set AZURE_SUBSCRIPTION_ID "$(az account show --query id -o tsv)" + +read -r -p "Foundry project endpoint: " PROJECT_ENDPOINT +azd env set FOUNDRY_PROJECT_ENDPOINT "$PROJECT_ENDPOINT" +azd ai toolbox list --project-endpoint "$PROJECT_ENDPOINT" +azd deploy learn-tools +``` + +Use an unused toolbox name. If `learn-tools` already belongs to another application, rename the service and corresponding commands before deploying; do not update or delete that shared toolbox. + +The declared `learn-tools` service is a managed Toolbox configuration, not a container-hosted MCP application. The external Learn MCP server remains the backend. `require_approval: never` is limited here to that read-only documentation source; choose an approval policy appropriate to other tools. + +## Connect an MCP client + +```bash +azd ai toolbox show learn-tools --project-endpoint "$PROJECT_ENDPOINT" --output json +export TOOLBOX_ENDPOINT="${PROJECT_ENDPOINT%/}/toolboxes/learn-tools/mcp?api-version=v1" + +umask 077 +mkdir -p .private +az account get-access-token --scope https://ai.azure.com/.default -o json \ + | jq --arg url "$TOOLBOX_ENDPOINT" '{mcpServers:{toolbox:{ + type:"streamable-http",url:$url,headers:{Authorization:("Bearer "+.accessToken)} + }}}' > .private/toolbox-client.json + +npx --yes @modelcontextprotocol/inspector@2.5.0 --cli \ + --config .private/toolbox-client.json --server toolbox --method tools/list +``` + +Use the actual tool name from `tools/list`; Toolbox can qualify names. Select the Learn search tool and call it: + +```bash +read -r -p "Learn search tool name from tools/list: " SEARCH_TOOL +npx --yes @modelcontextprotocol/inspector@2.5.0 --cli \ + --config .private/toolbox-client.json --server toolbox \ + --method tools/call --tool-name "$SEARCH_TOOL" \ + --tool-arg 'query=Azure MCP hosting options' +``` + +The consumer authenticates to Foundry. The downstream Learn request is anonymous. Do not pass the Foundry access token to an arbitrary downstream MCP server. + +## Compare normal discovery and Tool search + +Use an unused `learn-tools-search` name; check the project toolbox list before deploying. Deploy only the second service when you want this comparison: + +```bash +azd deploy learn-tools-search +export SEARCH_ENDPOINT="${PROJECT_ENDPOINT%/}/toolboxes/learn-tools-search/mcp?api-version=v1" +jq --arg url "$SEARCH_ENDPOINT" '.mcpServers.toolbox.url=$url' \ + .private/toolbox-client.json > .private/toolbox-search-client.json + +npx --yes @modelcontextprotocol/inspector@2.5.0 --cli \ + --config .private/toolbox-search-client.json --server toolbox --method tools/list +``` + +Look for `tool_search` and `call_tool`. Explicitly pinned or auto-pinned tools may also appear; do not require the result count to be exactly two. + +Search for the capability you need: + +```bash +npx --yes @modelcontextprotocol/inspector@2.5.0 --cli \ + --config .private/toolbox-search-client.json --server toolbox \ + --method tools/call --tool-name tool_search \ + --tool-arg 'query=Search official Microsoft documentation' --tool-arg limit=3 +``` + +Read the returned tool definitions. To invoke the selected tool, open the Inspector against this configuration, select `call_tool`, and fill its actual `inputSchema` with the selected tool name and arguments: + +```bash +npx --yes @modelcontextprotocol/inspector@2.5.0 \ + --config .private/toolbox-search-client.json +``` + +Learn has only a few tools, so this is a protocol/configuration demonstration, **not a token-savings benchmark**. For a business catalog, compare the same model, task set, permissions and toolbox version with and without Tool search. Record cumulative model input/output tokens, cache usage, search round-trips, task latency and correctness. Do not infer end-to-end savings from the smaller initial `tools/list` alone. + +Tool search uses metadata ranking; improve descriptions and `additional_search_text`, then pin only genuinely frequent tools. It does not replace backend authorization or API gateway policy. + +## Use the tools from a hosted agent + +Follow [hosted-agent.md](hosted-agent.md) for the maintained Foundry hosted-agent sample. That agent is a consumer of Toolbox; it is not the MCP backend server. + +## Versions and cleanup + +Use the version-specific endpoint before promoting a new default version: + +```text +/toolboxes/learn-tools/versions//mcp?api-version=v1 +``` + +```bash +azd ai toolbox versions list learn-tools --project-endpoint "$PROJECT_ENDPOINT" +read -r -p "Version to make default: " TOOLBOX_VERSION +azd ai toolbox publish learn-tools "$TOOLBOX_VERSION" --project-endpoint "$PROJECT_ENDPOINT" +``` + +Delete only this toolbox when finished: + +```bash +azd ai toolbox delete learn-tools --project-endpoint "$PROJECT_ENDPOINT" +azd ai toolbox delete learn-tools-search --project-endpoint "$PROJECT_ENDPOINT" +``` + +Delete `learn-tools-search` only if you created it. Do not delete a shared Foundry project or its resource group to remove this example. The local `.private/` client files contain short-lived bearer tokens and must not be committed. + +## Verification scope + +The configuration and commands were checked against the official Toolbox documentation and installed CLI help. This example is not included in the live Container Apps/APIM execution captures. + +Sources: [Toolbox overview](https://learn.microsoft.com/azure/foundry/agents/concepts/toolbox-overview), [create and manage a toolbox](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox), [Tool search](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/tool-search), [network isolation](https://learn.microsoft.com/azure/foundry/agents/how-to/tools/toolbox-network-isolation). diff --git a/docs/services/azure-architecture/mcp-configuration/samples/toolbox/azure.yaml b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/azure.yaml new file mode 100644 index 00000000..b6b01320 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/azure.yaml @@ -0,0 +1,21 @@ +name: mcp-toolbox +requiredVersions: + azd: ">=1.29.0" +services: + learn-tools: + host: azure.ai.toolbox + description: Shared read-only Microsoft Learn documentation tools + tools: + - type: mcp + server_label: microsoft_learn + server_url: https://learn.microsoft.com/api/mcp + require_approval: never + learn-tools-search: + host: azure.ai.toolbox + description: Microsoft Learn tools discovered through Toolbox Tool search + tools: + - type: mcp + server_label: microsoft_learn + server_url: https://learn.microsoft.com/api/mcp + require_approval: never + - type: toolbox_search diff --git a/docs/services/azure-architecture/mcp-configuration/samples/toolbox/hosted-agent.md b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/hosted-agent.md new file mode 100644 index 00000000..853b73fc --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/hosted-agent.md @@ -0,0 +1,79 @@ +# Foundry hosted agent에서 Toolbox 사용 + +이 문서는 Toolbox를 소비하는 agent 구현을 확인할 때 사용하는 선택적 sample 안내입니다. [MCP 솔루션 가이드](../../index.md)는 APIM·Toolbox·IQ와 인증을 설명하며 agent 배포는 다루지 않습니다. 아래는 agent가 model과 Toolbox를 호출하는 절차이지 MCP backend를 hosted agent에 옮기는 절차가 아닙니다. + +## 1. 필요한 환경 + +- Foundry project와 게시한 Toolbox consumer endpoint. +- Agent 배포자의 **Foundry Project Manager** 권한과 runtime identity의 필요한 project/tool 권한. +- Azure CLI, azd 및 `microsoft.foundry` 확장. +- 선택한 공식 sample manifest의 `requiredVersions`를 충족하는 도구 버전. + +모델·compute·스토리지 비용과 region 지원을 확인합니다. 사용자 OAuth tool을 쓰면 사용자 context·consent와 해당 backend 권한도 필요합니다. + +## 2. 공식 sample에서 agent 초기화 + +기존 작업 디렉터리를 덮어쓰지 않도록 새 폴더에서 실행합니다. + +```bash +mkdir toolbox-hosted-agent +cd toolbox-hosted-agent +azd extension install microsoft.foundry +azd auth login + +azd ai agent init -m https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox/azure.yaml +``` + +이 manifest는 실제 azd sample catalog에서 확인한 **Agent Framework + Toolbox + Responses** 예제입니다. 안내에 따라 Foundry project와 model을 선택합니다. 기존 project를 재사용하려면 실제 project를 선택하며 endpoint 문자열만으로 ARM resource ID를 추측하지 않습니다. + +## 3. 사용할 Toolbox 연결 + +```bash +read -r -p "Toolbox consumer endpoint: " TOOLBOX_ENDPOINT +azd env set TOOLBOX_ENDPOINT "$TOOLBOX_ENDPOINT" +``` + +이 저장소의 `learn-tools` 또는 `learn-tools-search` endpoint를 사용할 수 있습니다. 공식 sample의 여러 SaaS connection을 전부 만들 필요는 없습니다. 사용하지 않는 기본 tool과 model deployment가 포함되어 있는지 생성된 `azure.yaml`과 README를 확인합니다. + +공식 구현의 `FoundryToolbox`는 Foundry credential로 Toolbox에 인증하고 platform의 per-request call ID를 연결합니다. 사용자 identity header나 token cache를 임의로 만들어 사용자 OAuth를 대체하지 않습니다. + +## 4. Preview와 배포 + +```bash +azd provision --preview +azd up +``` + +이 sample의 manifest에는 project/model 배포가 포함될 수 있습니다. Preview에서 기존 리소스 재사용 여부와 새 비용을 확인한 뒤 진행합니다. Agent version이 active 상태가 되고 필요한 identity 권한이 적용되었는지 확인합니다. + +## 5. Agent 요청과 tool 호출 확인 + +```bash +azd ai agent invoke --new-session \ + "Microsoft Learn에서 MCP authentication 문서를 찾아 핵심 내용을 설명해줘." +``` + +답변만 보지 말고 agent의 tool 호출도 확인합니다. + +- 일반 Toolbox: 검색 도구가 직접 선택되는지 확인합니다. +- Tool search Toolbox: 필요한 경우 `tool_search`로 도구를 찾고 `call_tool`로 실행하는지 확인합니다. +- APIM으로 governance하는 MCP: Toolbox connection의 대상이 APIM인지, 실제 APIM HTTP 로그에 요청이 기록되는지 확인합니다. + +이 저장소의 기본 Learn Toolbox는 공개 Learn endpoint를 직접 호출합니다. APIM 경유를 확인하려면 별도의 MCP connection을 APIM URL과 그 API의 인증 방식으로 구성해야 합니다. Foundry의 preview 자동 gateway 연계는 모든 code-first tool이나 managed OAuth에 적용되는 기능이 아닙니다. + +## 6. 권한과 비용 확인 + +Agent identity로 Toolbox에 접근하는 권한과 backend가 사용자·agent 중 누구의 권한으로 실행되는지를 구분합니다. Tool search는 도구를 찾는 기능이지 권한 검사나 API rate limit가 아닙니다. + +토큰 비교는 같은 model·task·권한·버전 조건으로 업무 완료까지의 usage와 latency를 기록합니다. 이 문서는 실행 결과나 절감률을 제공하지 않습니다. + +## 7. 정리 + +생성한 agent와 전용 리소스만 해당 azd environment의 안내에 따라 제거합니다. 기존 Foundry project나 공유 Toolbox를 재사용했다면 그것까지 삭제하지 않습니다. 이 저장소의 Toolbox를 제거하는 절차는 [README](README.md#versions-and-cleanup)에 있습니다. + +## 공식 근거 + +- [What are hosted agents?](https://learn.microsoft.com/azure/foundry/agents/concepts/hosted-agents) +- [Deploy a hosted agent](https://learn.microsoft.com/azure/foundry/agents/how-to/deploy-hosted-agent) +- [공식 Agent Framework + Toolbox sample](https://github.com/microsoft-foundry/foundry-samples/tree/main/samples/python/hosted-agents/agent-framework/responses/04-foundry-toolbox) +- [Foundry Toolbox integration](https://learn.microsoft.com/agent-framework/integrations/by-component/tools/foundry-toolbox) diff --git a/docs/services/azure-architecture/mcp-configuration/samples/toolbox/sample.yml b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/sample.yml new file mode 100644 index 00000000..d16b251a --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/samples/toolbox/sample.yml @@ -0,0 +1,7 @@ +title: Foundry Toolbox and Tool search +description: Manual Toolbox configuration and discovery scenarios with documented verification limits. +kind: runnable +used_by: + - index + - authentication + - hosting-reference diff --git a/docs/services/azure-architecture/mcp-configuration/validation/images/apim-rest-to-mcp.png b/docs/services/azure-architecture/mcp-configuration/validation/images/apim-rest-to-mcp.png new file mode 100644 index 00000000..0b518081 Binary files /dev/null and b/docs/services/azure-architecture/mcp-configuration/validation/images/apim-rest-to-mcp.png differ diff --git a/docs/services/azure-architecture/mcp-configuration/validation/images/azd-deployment.png b/docs/services/azure-architecture/mcp-configuration/validation/images/azd-deployment.png new file mode 100644 index 00000000..e7749142 Binary files /dev/null and b/docs/services/azure-architecture/mcp-configuration/validation/images/azd-deployment.png differ diff --git a/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-authorization.png b/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-authorization.png new file mode 100644 index 00000000..c1efff31 Binary files /dev/null and b/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-authorization.png differ diff --git a/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-obo.png b/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-obo.png new file mode 100644 index 00000000..6edae2c7 Binary files /dev/null and b/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-obo.png differ diff --git a/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-upstreams.png b/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-upstreams.png new file mode 100644 index 00000000..38df2c15 Binary files /dev/null and b/docs/services/azure-architecture/mcp-configuration/validation/images/mcp-upstreams.png differ diff --git a/docs/services/azure-architecture/mcp-configuration/validation/index.md b/docs/services/azure-architecture/mcp-configuration/validation/index.md new file mode 100644 index 00000000..c0590a61 --- /dev/null +++ b/docs/services/azure-architecture/mcp-configuration/validation/index.md @@ -0,0 +1,236 @@ +--- +title: azd 배포와 MCP 호출 결과 — APIM REST-to-MCP, Azure OBO, Learn 프록시 +description: Azure Developer CLI로 배포한 MCP 구성에서 REST operation 호출, Entra OBO와 인증 오류 응답을 확인한 기록입니다. +document_type: case +topic_order: 3 +redirect_from: + - cases/azure-api-management/mcp-entra-validation/index.md +services: [azure-architecture, azure-api-management, azure-container-apps, azure-kubernetes-service, microsoft-entra-id] +technologies: [mcp, azure-cli, bicep, python, nodejs] +tags: [deploy, diagnose, ai-agents, identity] +status: resolved +verification_status: needs-review +sources_checked_at: 2026-09-13 +official_sources: + - title: Remote builds support with Azure Container Registry + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/remote-builds + - title: Azure Developer CLI schema reference + url: https://learn.microsoft.com/azure/developer/azure-developer-cli/azd-schema + - title: Expose REST API in API Management as an MCP server + url: https://learn.microsoft.com/azure/api-management/export-rest-mcp-server + - title: Manage MCP servers programmatically in API Management + url: https://learn.microsoft.com/azure/api-management/manage-mcp-servers-rest-api + - title: Deploy Azure MCP Server with on-behalf-of authentication + url: https://learn.microsoft.com/azure/developer/azure-mcp-server/how-to/deploy-remote-mcp-server-on-behalf-of + - title: Enable authentication and authorization in Azure Container Apps with Microsoft Entra ID + url: https://learn.microsoft.com/azure/container-apps/authentication-entra + - title: MCP Inspector CLI Client + url: https://github.com/modelcontextprotocol/inspector/blob/main/clients/cli/README.md + - title: Secure a Model Context Protocol (MCP) server with Microsoft Entra ID + url: https://learn.microsoft.com/entra/agent-id/secure-mcp-server-with-entra-id + - title: Microsoft identity platform and OAuth 2.0 authorization code flow + url: https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow + - title: Acquire tokens + url: https://learn.microsoft.com/entra/msal/python/getting-started/acquiring-tokens + - title: Authorization Security Considerations + url: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations +occurred_at: 2026-09-13 +resolved_at: 2026-09-13 +related_guides: + - ../index.md + - ../authentication/index.md +--- + +# azd 배포와 MCP 호출 결과 — APIM REST-to-MCP, Azure OBO, Learn 프록시 + +## 구성 + +운영 전략은 [Azure MCP 운영 아키텍처](../index.md), 구체적인 배포·호출은 [sample walkthrough](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md)를 사용합니다. 이 문서는 2026-09-13의 Container Apps·APIM 실행 이력과 관측값을 보관하며, Foundry hosted agent + Toolbox 통합 구성의 실행 결과는 포함하지 않습니다. + +| 항목 | 구성 | +|---|---| +| 배포 | Azure Developer CLI 1.29.0, `azure.yaml` + Bicep | +| Region | Korea Central | +| API Management | Developer, internal VNet | +| Container Apps | Internal workload-profiles environment, Consumption profile | +| MCP 서버 | Azure MCP 2.0.5, Python MCP SDK 2.2.0 | +| 로컬 client | MCP Inspector 2.5.0, Azure MCP 2.0.5, AKS MCP 0.0.20 | +| 접근 경로 | 로컬 PC → loopback port-forward → VNet의 MCP endpoint | + +APIM의 **REST-to-MCP**와 **기존 MCP 프록시**는 별도 API입니다. 전자는 기존 REST operation을 `getInventory` tool로 연결하고, 후자는 Microsoft Learn MCP backend에 연결합니다. + +## 배포 순서 + +기존 환경을 삭제하고 새로운 azd environment를 만들었습니다. Entra app과 service principal도 별도로 정리한 후 새로 등록했습니다. + +```bash +azd env new <새-환경-이름> +azd env set AZURE_SUBSCRIPTION_ID <구독-ID> +azd env set AZURE_LOCATION koreacentral +python3 scripts/identity.py prepare +azd provision --preview +azd up +``` + +`azd`가 provisioning, ACR remote build, Container App deployment를 수행합니다. Directory hook은 Entra app·delegated consent·federation만 관리합니다. 새 환경의 preview에는 RG, APIM, Container Apps, ACR, AKS, VNet 생성이 표시되었습니다. + +`azd up`은 **33분 26초**에 완료되었습니다. 출력에서 provisioning 32분 25초, service deployment 1분 1초를 확인했습니다. APIM 생성에는 약 28분이 걸렸으며, 이는 이번 실행의 관측값이지 배포 시간 보장은 아닙니다. + +![azd up으로 새 환경의 provisioning과 service deployment가 완료된 실제 CLI 출력 발췌](images/azd-deployment.png) + +아래 캡처는 실행한 CLI와 MCP 응답에서 필요한 부분을 발췌해 표시한 것입니다. Credential, tenant·subscription ID, 환경 hostname과 resource ID는 제외했습니다. [공개용 요청·응답 발췌 JSON](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-responses.json)에도 같은 내용을 보관했습니다. 원본 실행 로그와 로컬 상태는 공개 자료에 포함하지 않습니다. + +## 로컬 MCP 연결 + +### Microsoft Learn + +`tools/list` 응답에는 다음 세 tool이 있었습니다. + +```text +microsoft_docs_search +microsoft_docs_fetch +microsoft_code_sample_search +``` + +`microsoft_docs_search`에 `Azure API Management MCP`를 전달하면 관련 Microsoft Learn 문서 제목과 URL이 반환되었습니다. Access token 없이 호출했습니다. + +### GitHub + +GitHub credential과 read-only tool 설정을 사용했습니다. `search_repositories`에 `repo:hellices/devguidesample`을 전달한 응답은 다음과 같습니다. + +```json +{ + "total_count": 1, + "items": [ + { + "full_name": "hellices/devguidesample", + "private": false + } + ] +} +``` + +GitHub 연결에는 Entra token을 사용하지 않았습니다. + +### Azure MCP + +로컬 Azure MCP의 `tools/list`에는 `group_list`, `group_resource_list`가 표시되었습니다. 로컬 실행은 Azure CLI credential을 사용하며, remote Azure MCP의 OBO와는 인증 방식이 다릅니다. + +`group_resource_list`에 새 RG를 지정했을 때 `status: 200`과 그 RG의 resource 목록이 반환되었습니다. AKS MCP의 `call_kubectl`에서는 새 클러스터의 `mcp-lab` namespace에 있는 `mcp-tunnel` pod가 `Running`으로 표시되었습니다. + +![GitHub repository 검색, 새 AKS pod 조회, APIM을 통한 Learn 검색의 실제 응답 발췌](images/mcp-upstreams.png) + +## APIM REST-to-MCP 호출 + +먼저 REST URL을 직접 호출한 다음, `initialize` → `notifications/initialized` → `tools/list` → `tools/call` 순서로 같은 데이터를 조회했습니다. + +| 요청 | 응답 | +|---|---| +| `GET /rest/inventory` | 가상 widget 3개와 `authorization_present: false` | +| MCP `initialize` | `protocolVersion: 2025-06-18`, server `Azure API Management` | +| MCP `tools/list` | `getInventory`, 추가 입력이 없는 `inputSchema` | +| MCP `tools/call` | REST 응답과 같은 inventory 데이터 | + +![REST URL과 APIM getInventory tool을 각각 호출해 같은 inventory를 받은 응답](images/apim-rest-to-mcp.png) + +`getInventory`는 기존 REST operation에 연결된 native MCP tool입니다. 별도의 MCP 서버가 inventory를 대신 제공하는 구성은 아닙니다. 두 호출 모두 REST backend에 Entra Authorization header를 전달하지 않았습니다. + +## Azure와 Python MCP의 OBO + +공식 Azure MCP에는 `Mcp.Tools.ReadWrite`, Python MCP에는 `Mcp.Access` scope의 access token을 사용했습니다. + +| 요청 | 관측 | +|---|---| +| Native Azure MCP `group_resource_list` | `status: 200`, 새 RG에 속한 Azure 리소스 반환 | +| Python MCP 연결 | Protocol `2026-07-28`, server version `0.2.0` | +| Python MCP `read_lab_resource_group` | `exists: true`, `region: koreacentral`, `provisioning_succeeded: true` | +| Python MCP `learn_search` | 실제 Microsoft Learn 문서 제목 반환 | + +![Native Azure MCP의 ARM 조회 응답과 Python MCP의 최신 protocol 연결 및 OBO 결과](images/mcp-obo.png) + +Native Azure MCP는 managed identity federation으로 confidential client를 인증하고 사용자 token으로 OBO를 수행했습니다. Python 예제는 서버 측 client secret으로 confidential client를 인증했습니다. + +## 기존 Learn MCP 프록시 + +APIM의 Learn MCP URL에서 세 Learn tool이 조회되었고, `microsoft_docs_search`에 `Azure API Management MCP`를 전달한 결과 관련 문서 제목·URL이 반환되었습니다. 이 요청은 Entra로 APIM 접근을 인증한 뒤 공개 Learn backend를 익명으로 호출한 것입니다. + +## 인증 오류와 설정 복구 + +| 조건 | 응답 | +|---|---| +| APIM MCP에 access token 없이 요청 | `401 Unauthorized` | +| Native Azure MCP용 token을 APIM에 제출 | `401 Unauthorized` | +| Native MCP에서 Azure CLI client를 allowlist에서 제외 | `403` | +| 원래 allowlist 복구 후 `tools/list` | `group_list`, `group_resource_list` | + +![무인증과 wrong audience의 401, 제외된 client의 403, allowlist 복구 후 도구 목록](images/mcp-authorization.png) + +Client allowlist 변경 직후에는 이전 설정이 잠시 적용되었습니다. 이번 실행에서는 변경 후 60초를 기다린 요청에서 403을 확인했고, 원래 설정으로 복구한 뒤 다시 도구를 조회했습니다. 반영 시간은 환경에 따라 달라질 수 있습니다. + +CONNECT proxy를 사용할 때 curl의 header 파일에는 proxy의 `200 Connection Established`가 먼저 기록될 수 있습니다. 실습 명령의 `--suppress-connect-headers`는 이 응답을 제외하여 실제 MCP endpoint의 401·403을 읽도록 합니다. + +## OAuth 단계별 재확인 + +2026-09-13 13:35 UTC에 기존 RG·Entra 앱을 재사용해 HTTP 요청과 Azure CLI로 확인했습니다. 리소스나 인증 설정은 변경하지 않았습니다. + +| 단계 | 관측 | +|---|---| +| APIM·Python MCP challenge | 무인증 401, `resource_metadata` 있음. PRM 200, endpoint에 맞는 `resource`와 Entra authorization server 반환 | +| Native Azure MCP fallback | 401 header에 `resource_metadata` 없음. Root well-known은 200, `/mcp`를 붙인 metadata 경로는 401. 광고한 resource는 HTTPS server origin | +| Entra discovery | v2 OIDC metadata 200, authorization·token·JWKS endpoint 있음. 확인한 RFC 8414 후보 두 경로는 404 | +| PKCE 지원 선언 | OIDC 응답에 `code_challenge_methods_supported` 없음 | +| 앱 등록 | v2 token·등록 public client 사전 승인 확인. API 앱에는 `api://...`만 있고 HTTPS MCP identifier URI는 없음 | +| Canonical URL을 token 대상으로 요청 | Azure CLI에서 `AADSTS500011`. 기존 `api://.../Mcp.Access` scope 요청은 성공 | +| 정상·다른 audience token | APIM initialize 200, protocol `2025-06-18`; APIM·Python에서 wrong audience 401 | +| Python OBO 재호출 | `2026-07-28` 요청에서 `resultType: complete`, `isError: false`, RG 존재·region·provisioning 상태 반환 | + +**PKCE 기능과 metadata 선언은 다릅니다.** [Entra는 S256을 지원](https://learn.microsoft.com/entra/identity-platform/v2-oauth2-auth-code-flow#request-an-authorization-code)하고 [MSAL의 interactive flow는 PKCE를 자동 적용](https://learn.microsoft.com/entra/msal/python/getting-started/acquiring-tokens#acquire-token-interactive)합니다. 위 누락은 [MCP의 metadata 검증 규칙](https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization/security-considerations#authorization-code-protection)을 적용하는 client와의 상호운용성 관측입니다. **Entra의 PKCE 미지원이나 실제 MSAL 로그인 실패를 의미하지 않습니다.** MCP PRM은 APIM/서버가, OIDC metadata는 Entra가 제공했습니다. + +| 확인 완료 | 아직 완료로 볼 수 없는 것 | +|---|---| +| 사전 token 발급, challenge·PRM 조회, API token 검증, Python의 ARM OBO | MCP client의 discovery→authorization code+PKCE→token→호출 전체 흐름 | +| 앱의 현재 identifier URI와 canonical URL 대상 CLI 발급 결과 | `resource`·scope를 함께 사용하는 브라우저 PKCE 교환 전체 | +| 기존 APIM·Container Apps·Entra 환경 | 배포 대상이 없었던 Toolbox consumer 인증, token refresh 수명주기 | + +실행 명령은 [인증 확인 절차](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/authentication-checks.md), 공개용 결과는 [auth-stages JSON](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/assets/captures/2026-09-13-auth-stages.json)에 있습니다. Raw metadata·응답·오류는 `.private/`에만 저장했습니다. + +## 구성 과정에서 확인한 사항 + +### azd의 필수 입력과 directory 준비 + +`azd provision --preview`는 provisioning hook보다 먼저 Bicep 필수 입력을 읽습니다. 처음부터 app ID와 credential을 provisioning hook에서 준비하려고 하면 입력 누락으로 중단됩니다. + +이 sample은 preview 전에 directory 준비를 명시적으로 수행하고, `azd up`에는 `preup` hook을 사용합니다. Entra에서는 scope를 먼저 저장한 후 `preAuthorizedApplications`가 그 scope ID를 참조하도록 구성했습니다. + +### Container App의 최초 배포와 업데이트 + +최초 provisioning에는 placeholder image를 사용합니다. `azd deploy`가 실제 Python image를 배포할 때 ingress port와 readiness probe도 함께 맞춰야 합니다. + +`azure.yaml`의 service `module`을 `python-app`으로 지정하여 `azd`가 image뿐 아니라 해당 Bicep 구성을 함께 배포하도록 했습니다. 이후 `azd provision`에서도 azd가 저장한 service image를 사용합니다. + +### APIM MCP backend 설정 + +APIM의 native MCP proxy는 backend resource와 `backendId`로 연결했습니다. 실행 환경의 endpoint 형식은 다음과 같았습니다. + +```bicep +mcpProperties: { + transportType: 'streamable' + endpoints: { + message: { uriTemplate: '/mcp' } + } +} +``` + +일부 Learn 관리 예제와 Bicep type은 endpoint 배열을 표시하지만, 이 API 버전의 runtime과 [공식 AI-Gateway sample](https://github.com/Azure-Samples/AI-Gateway/blob/main/labs/mcp-from-graphql/src/mcp-api/api.bicep)은 keyed object를 사용합니다. 해당 필드의 type 차이는 코드 주석에 명시했습니다. + +### MCP Inspector와 VS Code 설정 형식 + +Inspector 예제에서는 `mcpServers` 아래에 `type: streamable-http`를 사용합니다. VS Code 예제의 `servers`·input 변수와는 파일 구조가 다르므로 구분해서 사용합니다. CLI로 server executable에 여러 flag를 전달할 때는 config 파일을 사용하면 client 옵션과 server 옵션을 명확히 나눌 수 있습니다. + +## 관련 자료 + +시나리오 확인 후 개발용 port-forward는 종료했습니다. 새 Azure 환경은 후속 실습을 위해 유지하며, 사용이 끝나면 [sample의 정리 절차](https://github.com/hellices/devguidesample/blob/main/docs/services/azure-architecture/mcp-configuration/samples/apim-entra-lab/walkthrough.md)로 ARM 리소스와 Entra 앱을 각각 제거합니다. + +- [MCP 관리·연결·인증 설계](../index.md) +- [Entra 인증과 OBO 가이드](../authentication/index.md) +- [아키텍처 SVG와 Azure 서비스 비교](../hosting-reference/index.md) diff --git a/scripts/docs/validate_public_safety.py b/scripts/docs/validate_public_safety.py index c11a52f2..ddb03989 100644 --- a/scripts/docs/validate_public_safety.py +++ b/scripts/docs/validate_public_safety.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path import re +import subprocess REPO_ROOT = Path(__file__).resolve().parents[2] @@ -99,18 +100,27 @@ def _is_example_azure_service_hostname(hostname: str) -> bool: def _text_files(root: Path): - for top_level in SCAN_ROOTS: - directory = root / top_level - if not directory.is_dir(): - continue - for path in sorted(directory.rglob("*")): - lowered_name = path.name.casefold() - if path.is_file() and ( - path.suffix.casefold() in TEXT_SUFFIXES - or lowered_name in TEXT_FILENAMES - or lowered_name.endswith(TEXT_COMPOUND_SUFFIXES) - ): - yield path + if (root / ".git").exists(): + # Local azd state is private; a force-added file must still be inspected. + listing = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z", "--cached", "--others", + "--exclude-standard", "--", *SCAN_ROOTS], + capture_output=True, text=True, check=True, + ) + paths = [root / name for name in sorted(set(listing.stdout.split("\0"))) if name] + else: + paths = [ + path for top_level in SCAN_ROOTS + for path in sorted((root / top_level).rglob("*")) + ] + for path in paths: + lowered_name = path.name.casefold() + if path.is_file() and ( + path.suffix.casefold() in TEXT_SUFFIXES + or lowered_name in TEXT_FILENAMES + or lowered_name.endswith(TEXT_COMPOUND_SUFFIXES) + ): + yield path def validate_repository(repo_root: Path | str) -> PublicSafetyResult: diff --git a/tests/docs/test_faceted_discovery.py b/tests/docs/test_faceted_discovery.py index 17a2f50a..549c95a1 100644 --- a/tests/docs/test_faceted_discovery.py +++ b/tests/docs/test_faceted_discovery.py @@ -160,11 +160,11 @@ def test_explore_has_exact_member_data_and_one_card_per_topic(taxonomy, catalog) assert PurePosixPath("explore/index.md") in pages page = pages[PurePosixPath("explore/index.md")] assert yaml.safe_load(page.split("---", 2)[1])["title"] == "글 찾기" - assert "40개 주제 · 62개 문서" in page + assert "41개 주제 · 66개 문서" in page parser = Elements(page) - assert len(parser.select("data-explore-topic")) == len(catalog.topics) == 40 + assert len(parser.select("data-explore-topic")) == len(catalog.topics) == 41 members = parser.select("data-explore-member") - assert len(members) == len(catalog.documents) == 62 + assert len(members) == len(catalog.documents) == 66 by_path = {row["data-explore-member"]: row for row in members} for doc in catalog.documents: row = by_path[doc.relative_path.as_posix()] @@ -230,7 +230,7 @@ def test_home_navigation_and_article_chips_use_explore(taxonomy, catalog): nav = yaml.safe_load((ROOT / "docs/.nav.yml").read_text())["nav"] assert [next(iter(item)) for item in nav if "glob" not in item] == ["홈", "서비스별 보기", "글 찾기", "기여하기"] home = build_home_page((ROOT / "docs/index.md").read_text(), catalog.documents, taxonomy, catalog=catalog) - assert "40개 주제 · 62개 문서" in home + assert "41개 주제 · 66개 문서" in home assert "전체 글" not in home and "태그별 보기" not in home assert "(explore/index.md)" in home doc = catalog.documents[0] @@ -253,7 +253,7 @@ def test_explore_assets_are_configured_and_accessible(): def test_service_discovery_reports_topics_and_documents(taxonomy, catalog): pages = build_index_pages(catalog.documents, taxonomy, catalog=catalog) - assert "40개 주제 · 62개 문서" in pages[PurePosixPath("services/index.md")] + assert "41개 주제 · 66개 문서" in pages[PurePosixPath("services/index.md")] documents = [doc for doc in catalog.documents if "azure-monitor" in doc.metadata["services"]] topics = {catalog.by_document[doc.relative_path].entry.relative_path for doc in documents} assert f"{len(topics)}개 주제 · {len(documents)}개 문서" in pages[PurePosixPath("services/azure-monitor/index.md")] diff --git a/tests/docs/test_public_safety.py b/tests/docs/test_public_safety.py index e0041c65..0eb5b2e9 100644 --- a/tests/docs/test_public_safety.py +++ b/tests/docs/test_public_safety.py @@ -9,6 +9,28 @@ from scripts.docs import validate_public_safety +def test_public_safety_ignores_local_azd_state_but_scans_force_added_files(tmp_path: Path) -> None: + root = make_repository(tmp_path) + subprocess.run(["git", "init", "-q", str(root)], check=True) + (root / ".gitignore").write_text(".azure/\nnode_modules/\n", encoding="utf-8") + (root / "docs" / "index.md").write_text("# Public page\n", encoding="utf-8") + local = ( + root / "docs" / "services" / "aks" / "example" + / "samples" / "example" / ".azure" / "demo" / ".env" + ) + local.parent.mkdir(parents=True) + local.write_text("ENDPOINT=https://private-service.azurecontainerapps.io\n", encoding="utf-8") + + result = validate_public_safety.validate_repository(root) + assert result.file_count == 1 + assert result.errors == [] + + subprocess.run(["git", "-C", str(root), "add", "--force", str(local)], check=True) + result = validate_public_safety.validate_repository(root) + assert result.file_count == 2 + assert any("non-example Azure service hostname" in error for error in result.errors) + + COMPLETED_PLAN_PATHS = ( "project/plans/2026-09-12-public-github-pages.md", "project/specs/2026-09-12-public-github-pages-design.md", diff --git a/tests/docs/test_topics.py b/tests/docs/test_topics.py index 069f290f..45e71e1f 100644 --- a/tests/docs/test_topics.py +++ b/tests/docs/test_topics.py @@ -1065,8 +1065,8 @@ def test_repository_uses_only_canonical_topic_packages() -> None: ROOT / "docs", load_taxonomy(ROOT / "docs-taxonomy.yml") ) - assert len(catalog.documents) == 62 - assert len(catalog.topics) == 40 + assert len(catalog.documents) == 66 + assert len(catalog.topics) == 41 assert not any( (ROOT / "docs" / name).exists() for name in ("cases", "guides", "labs", "research")