├── .github ├── CODE_OF_CONDUCT.md ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── azure.yaml ├── infra ├── abbreviations.json ├── app │ ├── api.bicep │ ├── apim-mcp │ │ ├── mcp-api.bicep │ │ └── mcp-api.policy.xml │ ├── apim-oauth │ │ ├── authorize.policy.xml │ │ ├── consent.policy.xml │ │ ├── diagrams │ │ │ ├── diagrams.md │ │ │ ├── images │ │ │ │ └── mcp-client-auth.png │ │ │ └── mcp_client_auth.mmd │ │ ├── entra-app.bicep │ │ ├── oauth-callback.policy.xml │ │ ├── oauth.bicep │ │ ├── oauthmetadata-get.policy.xml │ │ ├── oauthmetadata-options.policy.xml │ │ ├── register-options.policy.xml │ │ ├── register.policy.xml │ │ └── token.policy.xml │ ├── storage-Access.bicep │ ├── storage-PrivateEndpoint.bicep │ └── vnet.bicep ├── bicepconfig.json ├── core │ ├── apim │ │ └── apim.bicep │ ├── host │ │ ├── appserviceplan.bicep │ │ └── functions-flexconsumption.bicep │ ├── identity │ │ └── userAssignedIdentity.bicep │ ├── monitor │ │ ├── appinsights-access.bicep │ │ ├── applicationinsights.bicep │ │ ├── loganalytics.bicep │ │ └── monitoring.bicep │ └── storage │ │ └── storage-account.bicep ├── main.bicep └── main.parameters.json ├── mcp-client-authorization.gif ├── overview.png ├── pyproject.toml └── src ├── .funcignore ├── .gitignore ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── function_app.py ├── host.json ├── local.settings.json └── requirements.txt /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | > Please provide us with the following information: 5 | > --------------------------------------------------------------- 6 | 7 | ### This issue is for a: (mark with an `x`) 8 | ``` 9 | - [ ] bug report -> please search issues before submitting 10 | - [ ] feature request 11 | - [ ] documentation issue or request 12 | - [ ] regression (a behavior that used to work and stopped in a new release) 13 | ``` 14 | 15 | ### Minimal steps to reproduce 16 | > 17 | 18 | ### Any log messages given by the failure 19 | > 20 | 21 | ### Expected/desired behavior 22 | > 23 | 24 | ### OS and Version? 25 | > Windows 7, 8 or 10. Linux (which distribution). macOS (Yosemite? El Capitan? Sierra?) 26 | 27 | ### Versions 28 | > 29 | 30 | ### Mention any other details that might be useful 31 | 32 | > --------------------------------------------------------------- 33 | > Thanks! We'll be in touch soon. 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | * ... 4 | 5 | ## Does this introduce a breaking change? 6 | 7 | ``` 8 | [ ] Yes 9 | [ ] No 10 | ``` 11 | 12 | ## Pull Request Type 13 | What kind of change does this Pull Request introduce? 14 | 15 | 16 | ``` 17 | [ ] Bugfix 18 | [ ] Feature 19 | [ ] Code style update (formatting, local variables) 20 | [ ] Refactoring (no functional changes, no api changes) 21 | [ ] Documentation content changes 22 | [ ] Other... Please describe: 23 | ``` 24 | 25 | ## How to Test 26 | * Get the code 27 | 28 | ``` 29 | git clone [repo-address] 30 | cd [repo-name] 31 | git checkout [branch-name] 32 | npm install 33 | ``` 34 | 35 | * Test the code 36 | 37 | ``` 38 | ``` 39 | 40 | ## What to Check 41 | Verify that the following are valid 42 | * ... 43 | 44 | ## Other Information 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-python.python" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Python Functions", 6 | "type": "debugpy", 7 | "request": "attach", 8 | "connect": { 9 | "host": "localhost", 10 | "port": 9091 11 | }, 12 | "preLaunchTask": "func: host start" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "src", 3 | "azureFunctions.scmDoBuildDuringDeployment": true, 4 | "azureFunctions.projectLanguage": "Python", 5 | "azureFunctions.projectRuntime": "~4", 6 | "debug.internalConsoleOptions": "neverOpen", 7 | "azureFunctions.projectLanguageModel": 2 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "func", 6 | "label": "func: host start", 7 | "command": "host start", 8 | "problemMatcher": "$func-python-watch", 9 | "isBackground": true, 10 | "options": { 11 | "cwd": "${workspaceFolder}/src" 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [project-title] Changelog 2 | 3 | 4 | # x.y.z (yyyy-mm-dd) 5 | 6 | *Features* 7 | * ... 8 | 9 | *Bug Fixes* 10 | * ... 11 | 12 | *Breaking Changes* 13 | * ... 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to [project-title] 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 4 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 5 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 6 | 7 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 8 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 9 | provided by the bot. You will only need to do this once across all repos using our CLA. 10 | 11 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 12 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 13 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 14 | 15 | - [Code of Conduct](#coc) 16 | - [Issues and Bugs](#issue) 17 | - [Feature Requests](#feature) 18 | - [Submission Guidelines](#submit) 19 | 20 | ## Code of Conduct 21 | Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 22 | 23 | ## Found an Issue? 24 | If you find a bug in the source code or a mistake in the documentation, you can help us by 25 | [submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can 26 | [submit a Pull Request](#submit-pr) with a fix. 27 | 28 | ## Want a Feature? 29 | You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub 30 | Repository. If you would like to *implement* a new feature, please submit an issue with 31 | a proposal for your work first, to be sure that we can use it. 32 | 33 | * **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr). 34 | 35 | ## Submission Guidelines 36 | 37 | ### Submitting an Issue 38 | Before you submit an issue, search the archive, maybe your question was already answered. 39 | 40 | If your issue appears to be a bug, and hasn't been reported, open a new issue. 41 | Help us to maximize the effort we can spend fixing issues and adding new 42 | features, by not reporting duplicate issues. Providing the following information will increase the 43 | chances of your issue being dealt with quickly: 44 | 45 | * **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps 46 | * **Version** - what version is affected (e.g. 0.1.2) 47 | * **Motivation for or Use Case** - explain what are you trying to do and why the current behavior is a bug for you 48 | * **Browsers and Operating System** - is this a problem with all browsers? 49 | * **Reproduce the Error** - provide a live example or a unambiguous set of steps 50 | * **Related Issues** - has a similar issue been reported before? 51 | * **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be 52 | causing the problem (line of code or commit) 53 | 54 | You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new]. 55 | 56 | ### Submitting a Pull Request (PR) 57 | Before you submit your Pull Request (PR) consider the following guidelines: 58 | 59 | * Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR 60 | that relates to your submission. You don't want to duplicate effort. 61 | 62 | * Make your changes in a new git fork: 63 | 64 | * Commit your changes using a descriptive commit message 65 | * Push your fork to GitHub: 66 | * In GitHub, create a pull request 67 | * If we suggest changes then: 68 | * Make the required updates. 69 | * Rebase your fork and force push to your GitHub repository (this will update your Pull Request): 70 | 71 | ```shell 72 | git rebase master -i 73 | git push -f 74 | ``` 75 | 76 | That's it! Thank you for your contribution! 77 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 17 | 18 | # Secure Remote MCP Servers using Azure API Management 19 | 20 | ![Diagram](mcp-client-authorization.gif) 21 | 22 | Azure API Management acts as the [AI Gateway](https://github.com/Azure-Samples/AI-Gateway) for MCP servers. 23 | 24 | This sample implements the latest [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization#2-10-third-party-authorization-flow) 25 | 26 | This is a [sequence diagram](infra/app/apim-oauth/diagrams/diagrams.md) to understand the flow. 27 | 28 | ## Deploy Remote MCP Server to Azure 29 | 30 | 1. Register `Microsoft.App` resource provider. 31 | * If you are using Azure CLI, run `az provider register --namespace Microsoft.App --wait`. 32 | * If you are using Azure PowerShell, run `Register-AzResourceProvider -ProviderNamespace Microsoft.App`. Then run `(Get-AzResourceProvider -ProviderNamespace Microsoft.App).RegistrationState` after some time to check if the registration is complete. 33 | 34 | 2. Run this [azd](https://aka.ms/azd) command to provision the api management service, function app(with code) and all other required Azure resources 35 | 36 | ```shell 37 | azd up 38 | ``` 39 | 40 | ### Test with MCP Inspector 41 | 42 | 1. In a **new terminal window**, install and run MCP Inspector 43 | 44 | ```shell 45 | npx @modelcontextprotocol/inspector 46 | ``` 47 | 48 | 1. CTRL click to load the MCP Inspector web app from the URL displayed by the app (e.g. http://127.0.0.1:6274/#resources) 49 | 1. Set the transport type to `SSE` 50 | 1. Set the URL to your running API Management SSE endpoint displayed after `azd up` and **Connect**: 51 | 52 | ```shell 53 | https://.azure-api.net/mcp/sse 54 | ``` 55 | 56 | 5. **List Tools**. Click on a tool and **Run Tool**. 57 | 58 | 59 | ## Technical Architecture Overview 60 | 61 | This solution deploys a secure MCP (Model Context Protocol) server infrastructure on Azure. The architecture implements a multi-layered security model with Azure API Management serving as an intelligent gateway that handles authentication, authorization, and request routing. 62 | 63 | ![overview diagram](overview.png) 64 | 65 | ### Deployed Azure Resources 66 | 67 | The infrastructure provisions the following Azure resources: 68 | 69 | #### Core Gateway Infrastructure 70 | - **Azure API Management (APIM)** - The central security gateway that exposes both OAuth and MCP APIs 71 | - **SKU**: BasicV2 (configurable) 72 | - **Identity**: System-assigned and user-assigned managed identities 73 | - **Purpose**: Handles authentication flows, request validation, and secure proxying to backend services 74 | 75 | #### Backend Compute 76 | - **Azure Function App** - Hosts the MCP server implementation 77 | - **Runtime**: Python 3.11 on Flex Consumption plan 78 | - **Authentication**: Function-level authentication with managed identity integration 79 | - **Purpose**: Executes MCP tools and operations (snippet management in this example) 80 | 81 | #### Storage and Data 82 | - **Azure Storage Account** - Provides multiple storage functions 83 | - **Function hosting**: Stores function app deployment packages 84 | - **Application data**: Blob container for snippet storage 85 | - **Security**: Configured with managed identity access and optional private endpoints 86 | 87 | #### Security and Identity 88 | - **User-Assigned Managed Identity** - Enables secure service-to-service authentication 89 | - **Purpose**: Allows Function App to access Storage and Application Insights without secrets 90 | - **Permissions**: Storage Blob Data Owner, Storage Queue Data Contributor, Monitoring Metrics Publisher 91 | 92 | - **Entra ID Application Registration** - OAuth2/OpenID Connect client for authentication 93 | - **Purpose**: Enables third-party authorization flow per MCP specification 94 | - **Configuration**: PKCE-enabled public client with custom redirect URIs 95 | 96 | #### Monitoring and Observability 97 | - **Application Insights** - Provides telemetry and monitoring 98 | - **Log Analytics Workspace** - Centralized logging and analytics 99 | 100 | #### Optional Network Security 101 | - **Virtual Network (VNet)** - When `vnetEnabled` is true 102 | - **Private Endpoints**: Secure connectivity to Storage Account 103 | - **Network Isolation**: Functions and storage communicate over private network 104 | 105 | ### Why These Resources? 106 | 107 | **Azure API Management** serves as the security perimeter, implementing: 108 | - OAuth 2.0/PKCE authentication flows per MCP specification 109 | - Session key encryption/decryption for secure API access 110 | - Request validation and header injection 111 | - Rate limiting and throttling capabilities 112 | - Centralized policy management 113 | 114 | **Azure Functions** provides: 115 | - Serverless, pay-per-use compute model 116 | - Native integration with Azure services 117 | - Automatic scaling based on demand 118 | - Built-in monitoring and diagnostics 119 | 120 | **Managed Identities** eliminate the need for: 121 | - Service credentials management 122 | - Secret rotation processes 123 | - Credential exposure risks 124 | 125 | ## Azure API Management Configuration Details 126 | 127 | The APIM instance is configured with two primary APIs that work together to implement the MCP authorization specification: 128 | 129 | ### OAuth API (`/oauth/*`) 130 | 131 | This API implements the complete OAuth 2.0 authorization server functionality required by the MCP specification: 132 | 133 | #### Endpoints and Operations 134 | 135 | **Authorization Endpoint** (`GET /authorize`) 136 | - **Purpose**: Initiates the OAuth 2.0/PKCE flow 137 | - **Policy Logic**: 138 | 1. Extracts PKCE parameters from MCP client request 139 | 2. Checks for existing user consent (via cookies) 140 | 3. Redirects to consent page if consent not granted 141 | 4. Generates new PKCE parameters for Entra ID communication 142 | 5. Stores authentication state in APIM cache 143 | 6. Redirects user to Entra ID for authentication 144 | 145 | **Consent Management** (`GET/POST /consent`) 146 | - **Purpose**: Handles user consent for MCP client access 147 | - **Features**: Consent persistence via secure cookies 148 | 149 | **OAuth Metadata Endpoint** (`GET /.well-known/oauth-authorization-server`) 150 | - **Purpose**: Publishes OAuth server configuration per RFC 8414 151 | - **Returns**: JSON metadata about supported endpoints, flows, and capabilities 152 | 153 | **Client Registration** (`POST /register`) 154 | - **Purpose**: Supports dynamic client registration per MCP specification 155 | 156 | **Token Endpoint** (`POST /token`) 157 | - **Purpose**: Exchanges authorization codes for access tokens 158 | - **Policy Logic**: 159 | 1. Validates authorization code and PKCE verifier from MCP client 160 | 2. Exchanges Entra ID authorization code for access tokens 161 | 3. Generates encrypted session key for MCP API access 162 | 4. Caches the access token with session key mapping 163 | 5. Returns encrypted session key to MCP client 164 | 165 | #### Named Values and Configuration 166 | 167 | The OAuth API uses several APIM Named Values for configuration: 168 | - `McpClientId` - The registered Entra ID application client ID 169 | - `EntraIDFicClientId` - Service identity client ID for token exchange 170 | - `APIMGatewayURL` - Base URL for callback and metadata endpoints 171 | - `OAuthScopes` - Requested OAuth scopes (`openid` + Microsoft Graph) 172 | - `EncryptionKey` / `EncryptionIV` - For session key encryption 173 | 174 | ### MCP API (`/mcp/*`) 175 | 176 | This API provides the actual MCP protocol endpoints with security enforcement: 177 | 178 | #### Endpoints and Operations 179 | 180 | **Server-Sent Events Endpoint** (`GET /sse`) 181 | - **Purpose**: Establishes real-time communication channel for MCP protocol 182 | - **Security**: Requires valid encrypted session token 183 | 184 | **Message Endpoint** (`POST /message`) 185 | - **Purpose**: Handles MCP protocol messages and tool invocations 186 | - **Security**: Requires valid encrypted session token 187 | 188 | #### Security Policy Implementation 189 | 190 | The MCP API applies a comprehensive security policy to all operations: 191 | 192 | 1. **Authorization Header Validation** 193 | ```xml 194 | 196 | ``` 197 | 198 | 2. **Session Key Decryption** 199 | - Extracts encrypted session key from Authorization header 200 | - Decrypts using AES with stored key and IV 201 | - Validates token format and structure 202 | 203 | 3. **Token Cache Lookup** 204 | ```xml 205 | 207 | ``` 208 | 209 | 4. **Access Token Validation** 210 | - Verifies cached access token exists and is valid 211 | - Returns 401 with proper WWW-Authenticate header if invalid 212 | 213 | 5. **Backend Authentication** 214 | ```xml 215 | 216 | {{function-host-key}} 217 | 218 | ``` 219 | 220 | ### Security Model 221 | 222 | The solution implements a sophisticated multi-layer security model: 223 | 224 | **Layer 1: OAuth 2.0/PKCE Authentication** 225 | - MCP clients must complete full OAuth flow with Entra ID 226 | - PKCE prevents authorization code interception attacks 227 | - User consent management with persistent preferences 228 | 229 | **Layer 2: Session Key Encryption** 230 | - Access tokens are never exposed to MCP clients 231 | - Encrypted session keys provide time-bounded access 232 | - AES encryption with secure key management in APIM 233 | 234 | **Layer 3: Function-Level Security** 235 | - Function host keys protect direct access to Azure Functions 236 | - Managed identity ensures secure service-to-service communication 237 | - Network isolation available via VNet integration 238 | 239 | **Layer 4: Azure Platform Security** 240 | - All traffic encrypted in transit (TLS) 241 | - Storage access via managed identities 242 | - Audit logging through Application Insights 243 | 244 | This layered approach ensures that even if one security boundary is compromised, multiple additional protections remain in place. 245 | 246 | 247 | 248 | -------------------------------------------------------------------------------- /azure.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json 2 | 3 | name: remote-mcp-functions-python 4 | metadata: 5 | template: remote-mcp-functions-python@1.0.0 6 | services: 7 | api: 8 | project: ./src/ 9 | language: python 10 | host: function 11 | -------------------------------------------------------------------------------- /infra/abbreviations.json: -------------------------------------------------------------------------------- 1 | { 2 | "analysisServicesServers": "as", 3 | "apiManagementService": "apim-", 4 | "appConfigurationConfigurationStores": "appcs-", 5 | "applications": "app-", 6 | "appManagedEnvironments": "cae-", 7 | "appContainerApps": "ca-", 8 | "authorizationPolicyDefinitions": "policy-", 9 | "automationAutomationAccounts": "aa-", 10 | "blueprintBlueprints": "bp-", 11 | "blueprintBlueprintsArtifacts": "bpa-", 12 | "cacheRedis": "redis-", 13 | "cdnProfiles": "cdnp-", 14 | "cdnProfilesEndpoints": "cdne-", 15 | "cognitiveServicesAccounts": "cog-", 16 | "cognitiveServicesFormRecognizer": "cog-fr-", 17 | "cognitiveServicesTextAnalytics": "cog-ta-", 18 | "computeAvailabilitySets": "avail-", 19 | "computeCloudServices": "cld-", 20 | "computeDiskEncryptionSets": "des", 21 | "computeDisks": "disk", 22 | "computeDisksOs": "osdisk", 23 | "computeGalleries": "gal", 24 | "computeSnapshots": "snap-", 25 | "computeVirtualMachines": "vm", 26 | "computeVirtualMachineScaleSets": "vmss-", 27 | "containerInstanceContainerGroups": "ci", 28 | "containerRegistryRegistries": "cr", 29 | "containerServiceManagedClusters": "aks-", 30 | "databricksWorkspaces": "dbw-", 31 | "dataFactoryFactories": "adf-", 32 | "dataLakeAnalyticsAccounts": "dla", 33 | "dataLakeStoreAccounts": "dls", 34 | "dataMigrationServices": "dms-", 35 | "dBforMySQLServers": "mysql-", 36 | "dBforPostgreSQLServers": "psql-", 37 | "devicesIotHubs": "iot-", 38 | "devicesProvisioningServices": "provs-", 39 | "devicesProvisioningServicesCertificates": "pcert-", 40 | "documentDBDatabaseAccounts": "cosmos-", 41 | "eventGridDomains": "evgd-", 42 | "eventGridDomainsTopics": "evgt-", 43 | "eventGridEventSubscriptions": "evgs-", 44 | "eventHubNamespaces": "evhns-", 45 | "eventHubNamespacesEventHubs": "evh-", 46 | "hdInsightClustersHadoop": "hadoop-", 47 | "hdInsightClustersHbase": "hbase-", 48 | "hdInsightClustersKafka": "kafka-", 49 | "hdInsightClustersMl": "mls-", 50 | "hdInsightClustersSpark": "spark-", 51 | "hdInsightClustersStorm": "storm-", 52 | "hybridComputeMachines": "arcs-", 53 | "insightsActionGroups": "ag-", 54 | "insightsComponents": "appi-", 55 | "keyVaultVaults": "kv-", 56 | "kubernetesConnectedClusters": "arck", 57 | "kustoClusters": "dec", 58 | "kustoClustersDatabases": "dedb", 59 | "logicIntegrationAccounts": "ia-", 60 | "logicWorkflows": "logic-", 61 | "machineLearningServicesWorkspaces": "mlw-", 62 | "managedIdentityUserAssignedIdentities": "id-", 63 | "managementManagementGroups": "mg-", 64 | "migrateAssessmentProjects": "migr-", 65 | "networkApplicationGateways": "agw-", 66 | "networkApplicationSecurityGroups": "asg-", 67 | "networkAzureFirewalls": "afw-", 68 | "networkBastionHosts": "bas-", 69 | "networkConnections": "con-", 70 | "networkDnsZones": "dnsz-", 71 | "networkExpressRouteCircuits": "erc-", 72 | "networkFirewallPolicies": "afwp-", 73 | "networkFirewallPoliciesWebApplication": "waf", 74 | "networkFirewallPoliciesRuleGroups": "wafrg", 75 | "networkFrontDoors": "fd-", 76 | "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", 77 | "networkLoadBalancersExternal": "lbe-", 78 | "networkLoadBalancersInternal": "lbi-", 79 | "networkLoadBalancersInboundNatRules": "rule-", 80 | "networkLocalNetworkGateways": "lgw-", 81 | "networkNatGateways": "ng-", 82 | "networkNetworkInterfaces": "nic-", 83 | "networkNetworkSecurityGroups": "nsg-", 84 | "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", 85 | "networkNetworkWatchers": "nw-", 86 | "networkPrivateDnsZones": "pdnsz-", 87 | "networkPrivateLinkServices": "pl-", 88 | "networkPublicIPAddresses": "pip-", 89 | "networkPublicIPPrefixes": "ippre-", 90 | "networkRouteFilters": "rf-", 91 | "networkRouteTables": "rt-", 92 | "networkRouteTablesRoutes": "udr-", 93 | "networkTrafficManagerProfiles": "traf-", 94 | "networkVirtualNetworkGateways": "vgw-", 95 | "networkVirtualNetworks": "vnet-", 96 | "networkVirtualNetworksSubnets": "snet-", 97 | "networkVirtualNetworksVirtualNetworkPeerings": "peer-", 98 | "networkVirtualWans": "vwan-", 99 | "networkVpnGateways": "vpng-", 100 | "networkVpnGatewaysVpnConnections": "vcn-", 101 | "networkVpnGatewaysVpnSites": "vst-", 102 | "notificationHubsNamespaces": "ntfns-", 103 | "notificationHubsNamespacesNotificationHubs": "ntf-", 104 | "operationalInsightsWorkspaces": "log-", 105 | "portalDashboards": "dash-", 106 | "powerBIDedicatedCapacities": "pbi-", 107 | "purviewAccounts": "pview-", 108 | "recoveryServicesVaults": "rsv-", 109 | "resourcesResourceGroups": "rg-", 110 | "searchSearchServices": "srch-", 111 | "serviceBusNamespaces": "sb-", 112 | "serviceBusNamespacesQueues": "sbq-", 113 | "serviceBusNamespacesTopics": "sbt-", 114 | "serviceEndPointPolicies": "se-", 115 | "serviceFabricClusters": "sf-", 116 | "signalRServiceSignalR": "sigr", 117 | "sqlManagedInstances": "sqlmi-", 118 | "sqlServers": "sql-", 119 | "sqlServersDataWarehouse": "sqldw-", 120 | "sqlServersDatabases": "sqldb-", 121 | "sqlServersDatabasesStretch": "sqlstrdb-", 122 | "storageStorageAccounts": "st", 123 | "storageStorageAccountsVm": "stvm", 124 | "storSimpleManagers": "ssimp", 125 | "streamAnalyticsCluster": "asa-", 126 | "synapseWorkspaces": "syn", 127 | "synapseWorkspacesAnalyticsWorkspaces": "synw", 128 | "synapseWorkspacesSqlPoolsDedicated": "syndp", 129 | "synapseWorkspacesSqlPoolsSpark": "synsp", 130 | "timeSeriesInsightsEnvironments": "tsi-", 131 | "webServerFarms": "plan-", 132 | "webSitesAppService": "app-", 133 | "webSitesAppServiceEnvironment": "ase-", 134 | "webSitesFunctions": "func-", 135 | "webStaticSites": "stapp-" 136 | } -------------------------------------------------------------------------------- /infra/app/api.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | param applicationInsightsName string = '' 5 | param appServicePlanId string 6 | param appSettings object = {} 7 | param runtimeName string 8 | param runtimeVersion string 9 | param serviceName string = 'api' 10 | param storageAccountName string 11 | param deploymentStorageContainerName string 12 | param virtualNetworkSubnetId string = '' 13 | param instanceMemoryMB int = 2048 14 | param maximumInstanceCount int = 100 15 | param identityId string = '' 16 | param identityClientId string = '' 17 | 18 | var applicationInsightsIdentity = 'ClientId=${identityClientId};Authorization=AAD' 19 | 20 | module api '../core/host/functions-flexconsumption.bicep' = { 21 | name: '${serviceName}-functions-module' 22 | params: { 23 | name: name 24 | location: location 25 | tags: union(tags, { 'azd-service-name': serviceName }) 26 | identityType: 'UserAssigned' 27 | identityId: identityId 28 | appSettings: union(appSettings, 29 | { 30 | AzureWebJobsStorage__clientId : identityClientId 31 | APPLICATIONINSIGHTS_AUTHENTICATION_STRING: applicationInsightsIdentity 32 | }) 33 | applicationInsightsName: applicationInsightsName 34 | appServicePlanId: appServicePlanId 35 | runtimeName: runtimeName 36 | runtimeVersion: runtimeVersion 37 | storageAccountName: storageAccountName 38 | deploymentStorageContainerName: deploymentStorageContainerName 39 | virtualNetworkSubnetId: virtualNetworkSubnetId 40 | instanceMemoryMB: instanceMemoryMB 41 | maximumInstanceCount: maximumInstanceCount 42 | } 43 | } 44 | 45 | output SERVICE_API_NAME string = api.outputs.name 46 | output SERVICE_API_IDENTITY_PRINCIPAL_ID string = api.outputs.identityPrincipalId 47 | -------------------------------------------------------------------------------- /infra/app/apim-mcp/mcp-api.bicep: -------------------------------------------------------------------------------- 1 | @description('The name of the API Management service') 2 | param apimServiceName string 3 | 4 | @description('The name of the Function App hosting the MCP endpoints') 5 | param functionAppName string 6 | 7 | // Get reference to the existing APIM service 8 | resource apimService 'Microsoft.ApiManagement/service@2023-05-01-preview' existing = { 9 | name: apimServiceName 10 | } 11 | 12 | // Get reference to the Function App 13 | resource functionApp 'Microsoft.Web/sites@2023-12-01' existing = { 14 | name: functionAppName 15 | } 16 | 17 | // Create a named value in APIM to store the function key 18 | resource functionHostKeyNamedValue 'Microsoft.ApiManagement/service/namedValues@2023-05-01-preview' = { 19 | parent: apimService 20 | name: 'function-host-key' 21 | properties: { 22 | displayName: 'function-host-key' 23 | secret: true 24 | value: listKeys('${functionApp.id}/host/default', functionApp.apiVersion).masterKey 25 | } 26 | } 27 | 28 | // Create the MCP API definition in APIM 29 | resource mcpApi 'Microsoft.ApiManagement/service/apis@2023-05-01-preview' = { 30 | parent: apimService 31 | name: 'mcp' 32 | properties: { 33 | displayName: 'MCP API' 34 | description: 'Model Context Protocol API endpoints' 35 | subscriptionRequired: false 36 | path: 'mcp' 37 | protocols: [ 38 | 'https' 39 | ] 40 | serviceUrl: 'https://${functionApp.properties.defaultHostName}/runtime/webhooks/mcp' 41 | } 42 | dependsOn: [ 43 | functionHostKeyNamedValue 44 | ] 45 | } 46 | 47 | // Apply policy at the API level for all operations 48 | resource mcpApiPolicy 'Microsoft.ApiManagement/service/apis/policies@2023-05-01-preview' = { 49 | parent: mcpApi 50 | name: 'policy' 51 | properties: { 52 | format: 'rawxml' 53 | value: loadTextContent('mcp-api.policy.xml') 54 | } 55 | } 56 | 57 | // Create the SSE endpoint operation 58 | resource mcpSseOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = { 59 | parent: mcpApi 60 | name: 'mcp-sse' 61 | properties: { 62 | displayName: 'MCP SSE Endpoint' 63 | method: 'GET' 64 | urlTemplate: '/sse' 65 | description: 'Server-Sent Events endpoint for MCP Server' 66 | } 67 | } 68 | 69 | // Create the message endpoint operation 70 | resource mcpMessageOperation 'Microsoft.ApiManagement/service/apis/operations@2023-05-01-preview' = { 71 | parent: mcpApi 72 | name: 'mcp-message' 73 | properties: { 74 | displayName: 'MCP Message Endpoint' 75 | method: 'POST' 76 | urlTemplate: '/message' 77 | description: 'Message endpoint for MCP Server' 78 | } 79 | } 80 | 81 | // Output the API ID for reference 82 | output apiId string = mcpApi.id 83 | -------------------------------------------------------------------------------- /infra/app/apim-mcp/mcp-api.policy.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | Bearer error="invalid_token" 35 | 36 | 37 | 38 | 39 | 40 | 41 | {{function-host-key}} 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/authorize.policy.xml: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ("clientId")}"; 28 | return context.Request.Headers.GetValueOrDefault("Cookie", "") 29 | .Split(';') 30 | .Select(c => c.Trim().Split('=')) 31 | .Where(c => c.Length == 2 && c[0] == cookieName) 32 | .Select(c => c[1]) 33 | .FirstOrDefault() ?? ""; 34 | }" /> 35 | 36 | 37 | 38 | ("consentCookie") == "granted" 41 | )"> 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | @{ 50 | string basePath = context.Request.OriginalUrl.Scheme + "://" + context.Request.OriginalUrl.Host + (context.Request.OriginalUrl.Port == 80 || context.Request.OriginalUrl.Port == 443 ? "" : ":" + context.Request.OriginalUrl.Port); 51 | return $"{basePath}/consent?client_id={context.Variables.GetValueOrDefault("clientId")}&redirect_uri={context.Variables.GetValueOrDefault("redirect_uri")}&state={context.Variables.GetValueOrDefault("currentState")}"; 52 | } 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 98 | 99 | 100 | 101 | 102 | 103 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 115 | 116 | 117 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | @(context.Variables.GetValueOrDefault("authUrl", "")) 140 | 141 | 142 | 143 | 144 | 145 | 146 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/consent.policy.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | Access Denied 26 | 29 | 30 | 31 | 37 | 38 | "; 39 | }" /> 40 | 41 | 43 | 44 | 45 | 46 | 47 | Client Not Found 48 | 51 | 52 | 53 | 63 | 64 | "; 65 | }" /> 66 | 67 | 72 | 73 | 74 | ("client_id")}")" variable-name="clientInfoJson" /> 75 | 76 | 77 | 78 | 79 | ("normalized_redirect_uri", ""); 83 | 84 | if (string.IsNullOrEmpty(clientId)) { 85 | return false; 86 | } 87 | 88 | // Get the client info from the variable set by cache-lookup-value 89 | string clientInfoJson = context.Variables.GetValueOrDefault("clientInfoJson"); 90 | if (string.IsNullOrEmpty(clientInfoJson)) { 91 | context.Trace($"Client info not found in cache for client_id: {clientId}"); 92 | return false; 93 | } 94 | 95 | // Parse client info 96 | JObject clientInfo = JObject.Parse(clientInfoJson); 97 | JArray redirectUris = clientInfo["redirect_uris"]?.ToObject(); 98 | 99 | // Check if the redirect URI is in the registered URIs 100 | if (redirectUris != null) { 101 | foreach (var uri in redirectUris) { 102 | // Normalize the URI from the cache for comparison 103 | string registeredUri = System.Net.WebUtility.UrlDecode(uri.ToString()); 104 | if (registeredUri == redirectUri) { 105 | return true; 106 | } 107 | } 108 | } 109 | 110 | context.Trace($"Redirect URI mismatch - URI: {redirectUri} not found in registered URIs"); 111 | return false; 112 | } 113 | catch (Exception ex) { 114 | context.Trace($"Error checking client registration: {ex.Message}"); 115 | return false; 116 | } 117 | }" /> 118 | ("clientInfoJson"); 128 | 129 | if (string.IsNullOrEmpty(clientInfoJson)) { 130 | return clientId; // Fall back to client ID if no name found 131 | } 132 | 133 | // Parse client info 134 | JObject clientInfo = JObject.Parse(clientInfoJson); 135 | string clientName = clientInfo["client_name"]?.ToString(); 136 | 137 | return string.IsNullOrEmpty(clientName) ? clientId : clientName; 138 | } 139 | catch (Exception ex) { 140 | context.Trace($"Error retrieving client name: {ex.Message}"); 141 | return context.Variables.GetValueOrDefault("client_id", "Unknown Application"); 142 | } 143 | }" /> 144 | 145 | 146 | 166 | 300 | 301 | 302 | 303 | Application Consent 304 | 316 | 317 | 318 | 351 | 352 | "; 353 | }" /> 354 | 355 | 356 | ("redirect_uri", ""))) { 360 | return false; 361 | } 362 | 363 | string clientId = context.Variables.GetValueOrDefault("client_id", ""); 364 | // Always use the normalized (decoded) redirect URI for consistency 365 | string redirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 366 | 367 | // Define a consistent cookie name for denials 368 | string DENIAL_COOKIE_NAME = "MCP_DENIED_CLIENTS"; 369 | 370 | context.Trace($"DEBUG: Starting denial cookie check for clientId: {clientId}, redirectUri: {redirectUri}"); 371 | 372 | // Check for cookie in request 373 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 374 | if (string.IsNullOrEmpty(cookieHeader)) { 375 | return false; 376 | } 377 | 378 | // Parse cookies 379 | string[] cookies = cookieHeader.Split(';'); 380 | foreach (string cookie in cookies) { 381 | string trimmedCookie = cookie.Trim(); 382 | if (trimmedCookie.StartsWith(DENIAL_COOKIE_NAME + "=")) { 383 | // Extract the cookie value 384 | string cookieValue = trimmedCookie.Substring(DENIAL_COOKIE_NAME.Length + 1); 385 | try { 386 | // Parse the base64-encoded JSON array of denied clients 387 | string decodedValue = System.Text.Encoding.UTF8.GetString( 388 | System.Convert.FromBase64String(cookieValue.Split('.')[0])); 389 | JArray deniedClients = JArray.Parse(decodedValue); 390 | 391 | // Create a unique identifier for this client/redirect combination 392 | // Use the normalized (decoded) redirect URI for consistency 393 | string clientKey = $"{clientId}:{redirectUri}"; 394 | 395 | // Check if this client/redirect is in the denied list 396 | context.Trace($"DEBUG: Checking cookie. Current clientKey: {clientKey}"); 397 | context.Trace($"DEBUG: Decoded cookie content: {decodedValue}"); 398 | 399 | foreach (var item in deniedClients) { 400 | string itemString = item.ToString(); 401 | context.Trace($"DEBUG: Comparing against denied entry: {itemString}"); 402 | 403 | // Direct comparison - no need for encoded version since we're using normalized URI 404 | if (itemString == clientKey) { 405 | context.Trace($"DEBUG: Direct match found! Setting has_denial_cookie to true"); 406 | return true; 407 | } 408 | 409 | // Handle URL-encoded redirectURI in cookie 410 | try { 411 | string storedValue = itemString; 412 | if (storedValue.Contains(':')) { 413 | string[] parts = storedValue.Split(new char[] {':'}, 2); 414 | if (parts.Length == 2) { 415 | string storedClientId = parts[0]; 416 | // Always decode the stored redirect URI once for comparison 417 | string storedRedirectUri = System.Net.WebUtility.UrlDecode(parts[1]); 418 | 419 | context.Trace($"DEBUG: Split values - storedClientId: {storedClientId}, decoded redirectUri: {storedRedirectUri}"); 420 | context.Trace($"DEBUG: Comparing against - clientId: {clientId}, normalized redirectUri: {redirectUri}"); 421 | 422 | // Simple direct comparison since both are now in decoded form 423 | if (storedClientId == clientId && storedRedirectUri == redirectUri) { 424 | context.Trace($"DEBUG: Match found! Setting has_denial_cookie to true"); 425 | return true; 426 | } 427 | } 428 | } 429 | } catch (Exception ex) { 430 | context.Trace($"Error comparing encoded values: {ex.Message}"); 431 | } 432 | } 433 | } catch (Exception ex) { 434 | // Log error but continue if cookie parsing fails 435 | context.Trace($"Error parsing denial cookie: {ex.Message}"); 436 | } 437 | } 438 | } 439 | 440 | return false; 441 | } catch (Exception ex) { 442 | context.Trace($"Error checking denial cookie: {ex.Message}"); 443 | return false; 444 | } 445 | }" /> 446 | 447 | 448 | ("redirect_uri", ""))) { 452 | return false; 453 | } 454 | 455 | string clientId = context.Variables.GetValueOrDefault("client_id", ""); 456 | // Always use the normalized (decoded) redirect URI for consistency 457 | string redirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 458 | 459 | // Define a consistent cookie name for approvals 460 | string APPROVAL_COOKIE_NAME = "MCP_APPROVED_CLIENTS"; 461 | 462 | // Check for cookie in request 463 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 464 | if (string.IsNullOrEmpty(cookieHeader)) { 465 | return false; 466 | } 467 | 468 | // Parse cookies 469 | string[] cookies = cookieHeader.Split(';'); 470 | foreach (string cookie in cookies) { 471 | string trimmedCookie = cookie.Trim(); 472 | if (trimmedCookie.StartsWith(APPROVAL_COOKIE_NAME + "=")) { 473 | // Extract the cookie value 474 | string cookieValue = trimmedCookie.Substring(APPROVAL_COOKIE_NAME.Length + 1); 475 | try { 476 | // Parse the base64-encoded JSON array of approved clients 477 | string decodedValue = System.Text.Encoding.UTF8.GetString( 478 | System.Convert.FromBase64String(cookieValue.Split('.')[0])); 479 | JArray approvedClients = JArray.Parse(decodedValue); 480 | 481 | // Create a unique identifier for this client/redirect combination 482 | // Always use the normalized redirect URI for consistency 483 | string clientKey = $"{clientId}:{redirectUri}"; 484 | 485 | // Check if this client/redirect is in the approved list 486 | context.Trace($"DEBUG: Checking approval cookie. Current clientKey: {clientKey}"); 487 | context.Trace($"DEBUG: Decoded approval cookie content: {decodedValue}"); 488 | 489 | foreach (var item in approvedClients) { 490 | string itemString = item.ToString(); 491 | context.Trace($"DEBUG: Comparing against approved entry: {itemString}"); 492 | 493 | // Direct comparison - no need for encoded version since we're using normalized URI 494 | if (itemString == clientKey) { 495 | context.Trace($"DEBUG: Direct match found! Setting has_approval_cookie to true"); 496 | return true; 497 | } 498 | 499 | // Handle URL-encoded redirectURI in cookie 500 | try { 501 | string storedValue = itemString; 502 | if (storedValue.Contains(':')) { 503 | string[] parts = storedValue.Split(new char[] {':'}, 2); 504 | if (parts.Length == 2) { 505 | string storedClientId = parts[0]; 506 | // Always decode the stored redirect URI once for comparison 507 | string storedRedirectUri = System.Net.WebUtility.UrlDecode(parts[1]); 508 | 509 | context.Trace($"DEBUG: Split approval values - storedClientId: {storedClientId}, decoded redirectUri: {storedRedirectUri}"); 510 | context.Trace($"DEBUG: Comparing against - clientId: {clientId}, normalized redirectUri: {redirectUri}"); 511 | 512 | // Simple direct comparison since both are now in decoded form 513 | if (storedClientId == clientId && storedRedirectUri == redirectUri) { 514 | context.Trace($"DEBUG: Approval match found! Setting has_approval_cookie to true"); 515 | return true; 516 | } 517 | } 518 | } 519 | } catch (Exception ex) { 520 | context.Trace($"Error comparing encoded values in approval: {ex.Message}"); 521 | } 522 | } 523 | } catch (Exception ex) { 524 | // Log error but continue if cookie parsing fails 525 | context.Trace($"Error parsing approval cookie: {ex.Message}"); 526 | } 527 | } 528 | } 529 | 530 | return false; 531 | } catch (Exception ex) { 532 | context.Trace($"Error checking approval cookie: {ex.Message}"); 533 | return false; 534 | } 535 | }" /> 536 | 537 | (preserveContent: true); 548 | string[] pairs = body.Split('&'); 549 | foreach (string pair in pairs) 550 | { 551 | string[] keyValue = pair.Split('='); 552 | if (keyValue.Length == 2 && keyValue[0] == "consent_action") 553 | { 554 | return System.Net.WebUtility.UrlDecode(keyValue[1]); 555 | } 556 | } 557 | } 558 | // Still try to extract from JSON if form parsing didn't work 559 | return context.Request.Body.As(preserveContent: true)["consent_action"]?.ToString() ?? ""; 560 | } 561 | return ""; 562 | }" /> 563 | 564 | 565 | 566 | 567 | 568 | 569 | 570 | 571 | (preserveContent: true); 580 | string[] pairs = body.Split('&'); 581 | foreach (string pair in pairs) 582 | { 583 | string[] keyValue = pair.Split('='); 584 | if (keyValue.Length == 2) 585 | { 586 | if (keyValue[0] == "client_id") 587 | { 588 | clientId = System.Net.WebUtility.UrlDecode(keyValue[1]); 589 | } 590 | else if (keyValue[0] == "redirect_uri") 591 | { 592 | redirectUri = System.Net.WebUtility.UrlDecode(keyValue[1]); 593 | } 594 | else if (keyValue[0] == "state") 595 | { 596 | state = System.Net.WebUtility.UrlDecode(keyValue[1]); 597 | } 598 | } 599 | } 600 | 601 | // If we couldn't extract the values from form data, fall back to context variables 602 | if (string.IsNullOrEmpty(clientId)) 603 | { 604 | clientId = context.Variables.GetValueOrDefault("client_id", ""); 605 | } 606 | 607 | if (string.IsNullOrEmpty(redirectUri)) 608 | { 609 | redirectUri = context.Variables.GetValueOrDefault("redirect_uri", ""); 610 | } 611 | 612 | if (string.IsNullOrEmpty(state)) 613 | { 614 | state = context.Variables.GetValueOrDefault("state", ""); 615 | } 616 | 617 | context.Trace($"DEBUG: Building redirect location with clientId: {clientId}, redirectUri: {redirectUri}, state: {state}"); 618 | 619 | return $"{baseUrl}/authorize?client_id={clientId}&redirect_uri={redirectUri}&state={state}&consent=granted"; 620 | }" /> 621 | 622 | (preserveContent: true); 631 | string[] pairs = body.Split('&'); 632 | foreach (string pair in pairs) 633 | { 634 | string[] keyValue = pair.Split('='); 635 | if (keyValue.Length == 2) 636 | { 637 | if (keyValue[0] == "client_id") 638 | { 639 | clientId = System.Net.WebUtility.UrlDecode(keyValue[1]); 640 | } 641 | else if (keyValue[0] == "redirect_uri") 642 | { 643 | // Important - decode the redirect_uri from form data 644 | redirectUri = System.Net.WebUtility.UrlDecode(keyValue[1]); 645 | } 646 | } 647 | } 648 | 649 | // If we couldn't get values from the form, fall back to context variables 650 | if (string.IsNullOrEmpty(clientId)) 651 | { 652 | clientId = context.Variables.GetValueOrDefault("client_id", ""); 653 | } 654 | 655 | if (string.IsNullOrEmpty(redirectUri)) 656 | { 657 | // Use the normalized version when falling back 658 | redirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 659 | } 660 | 661 | // Log for diagnosis 662 | context.Trace($"Setting approval cookie for client_id: {clientId}, redirect_uri: {redirectUri}"); 663 | 664 | // Create a unique identifier for this client/redirect combination 665 | string clientKey = $"{clientId}:{redirectUri}"; 666 | 667 | // Check for existing cookie 668 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 669 | JArray approvedClients = new JArray(); 670 | 671 | if (!string.IsNullOrEmpty(cookieHeader)) { 672 | // Parse cookies to find our approval cookie 673 | string[] cookies = cookieHeader.Split(';'); 674 | foreach (string cookie in cookies) { 675 | string trimmedCookie = cookie.Trim(); 676 | if (trimmedCookie.StartsWith(cookieName + "=")) { 677 | try { 678 | // Extract and parse the cookie value 679 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 680 | // Get the payload part (before the first dot if cookie is signed) 681 | string payload = cookieValue.Contains('.') ? 682 | cookieValue.Split('.')[0] : cookieValue; 683 | string decodedValue = System.Text.Encoding.UTF8.GetString( 684 | System.Convert.FromBase64String(payload)); 685 | approvedClients = JArray.Parse(decodedValue); 686 | } catch (Exception) { 687 | // If parsing fails, we'll just create a new cookie 688 | approvedClients = new JArray(); 689 | } 690 | break; 691 | } 692 | } 693 | } 694 | 695 | // Add the current client if not already in the list 696 | bool clientExists = false; 697 | foreach (var item in approvedClients) { 698 | if (item.ToString() == clientKey) { 699 | clientExists = true; 700 | break; 701 | } 702 | } 703 | 704 | if (!clientExists) { 705 | approvedClients.Add(clientKey); 706 | } 707 | 708 | // Base64 encode the client list 709 | string jsonClients = approvedClients.ToString(Newtonsoft.Json.Formatting.None); 710 | string encodedClients = System.Convert.ToBase64String( 711 | System.Text.Encoding.UTF8.GetBytes(jsonClients)); 712 | 713 | // Return the full cookie string with appropriate settings 714 | return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; 715 | }" /> 716 | 717 | 718 | 719 | 720 | 721 | 722 | 723 | 724 | 725 | @(context.Variables.GetValueOrDefault("response_redirect_location", "")) 726 | 727 | 728 | @(context.Variables.GetValueOrDefault("approval_cookie")) 729 | 730 | 731 | 732 | 733 | 734 | 735 | 736 | 737 | 738 | 739 | 740 | (preserveContent: true); 749 | string[] pairs = body.Split('&'); 750 | foreach (string pair in pairs) 751 | { 752 | string[] keyValue = pair.Split('='); 753 | if (keyValue.Length == 2) 754 | { 755 | if (keyValue[0] == "client_id") 756 | { 757 | clientId = System.Net.WebUtility.UrlDecode(keyValue[1]); 758 | } 759 | else if (keyValue[0] == "redirect_uri") 760 | { 761 | // Important - decode the redirect_uri from form data 762 | redirectUri = System.Net.WebUtility.UrlDecode(keyValue[1]); 763 | } 764 | } 765 | } 766 | 767 | // If we couldn't get values from the form, fall back to context variables 768 | if (string.IsNullOrEmpty(clientId)) 769 | { 770 | clientId = context.Variables.GetValueOrDefault("client_id", ""); 771 | } 772 | 773 | if (string.IsNullOrEmpty(redirectUri)) 774 | { 775 | // Use the normalized version when falling back 776 | redirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 777 | } 778 | 779 | // Log for diagnosis 780 | context.Trace($"Setting denial cookie for client_id: {clientId}, redirect_uri: {redirectUri}"); 781 | 782 | // Create a unique identifier for this client/redirect combination 783 | string clientKey = $"{clientId}:{redirectUri}"; 784 | 785 | // Check for existing cookie 786 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 787 | JArray deniedClients = new JArray(); 788 | 789 | if (!string.IsNullOrEmpty(cookieHeader)) { 790 | // Parse cookies to find our denial cookie 791 | string[] cookies = cookieHeader.Split(';'); 792 | foreach (string cookie in cookies) { 793 | string trimmedCookie = cookie.Trim(); 794 | if (trimmedCookie.StartsWith(cookieName + "=")) { 795 | try { 796 | // Extract and parse the cookie value 797 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 798 | // Get the payload part (before the first dot if cookie is signed) 799 | string payload = cookieValue.Contains('.') ? 800 | cookieValue.Split('.')[0] : cookieValue; 801 | string decodedValue = System.Text.Encoding.UTF8.GetString( 802 | System.Convert.FromBase64String(payload)); 803 | deniedClients = JArray.Parse(decodedValue); 804 | } catch (Exception) { 805 | // If parsing fails, we'll just create a new cookie 806 | deniedClients = new JArray(); 807 | } 808 | break; 809 | } 810 | } 811 | } 812 | 813 | // Add the current client if not already in the list 814 | bool clientExists = false; 815 | foreach (var item in deniedClients) { 816 | if (item.ToString() == clientKey) { 817 | clientExists = true; 818 | break; 819 | } 820 | } 821 | 822 | if (!clientExists) { 823 | deniedClients.Add(clientKey); 824 | } 825 | 826 | // Base64 encode the client list 827 | string jsonClients = deniedClients.ToString(Newtonsoft.Json.Formatting.None); 828 | string encodedClients = System.Convert.ToBase64String( 829 | System.Text.Encoding.UTF8.GetBytes(jsonClients)); 830 | 831 | // Return the full cookie string with appropriate settings 832 | return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; 833 | }" /> 834 | ("common_styles"); 837 | 838 | // Replace placeholders with actual content 839 | denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); 840 | denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", 841 | "You have denied authorization for this application against the MCP server."); 842 | 843 | return denialTemplate; 844 | }" /> 845 | 846 | 847 | 848 | 849 | 850 | 851 | 852 | 853 | 854 | text/html 855 | 856 | 857 | no-store, no-cache 858 | 859 | 860 | no-cache 861 | 862 | 863 | @(context.Variables.GetValueOrDefault("denial_cookie")) 864 | 865 | @(context.Variables.GetValueOrDefault("response_body", "")) 866 | 867 | 868 | 869 | 870 | 871 | 872 | text/html 873 | 874 | 875 | 876 | no-store, no-cache 877 | 878 | 879 | no-cache 880 | @{ 881 | string denialTemplate = context.Variables.GetValueOrDefault("access_denied_template"); 882 | string commonStyles = context.Variables.GetValueOrDefault("common_styles"); 883 | 884 | // Replace placeholders with actual content 885 | denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); 886 | denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", 887 | "You have previously denied authorization for this application against the MCP server."); 888 | 889 | return denialTemplate; 890 | } 891 | 892 | 893 | 894 | 895 | 896 | 897 | 898 | 899 | 900 | 901 | ("client_id", ""); 904 | // Use original redirect_uri as it's already properly encoded for URL 905 | string redirectUri = context.Variables.GetValueOrDefault("redirect_uri", ""); 906 | string state = context.Variables.GetValueOrDefault("state", ""); 907 | 908 | return $"{baseUrl}/authorize?client_id={clientId}&redirect_uri={redirectUri}&state={state}&consent=granted"; 909 | }" /> 910 | 911 | 912 | 913 | 914 | 915 | @(context.Variables.GetValueOrDefault("response_redirect_location", "")) 916 | 917 | 918 | 919 | 920 | 921 | 922 | 923 | 924 | 925 | text/html 926 | 927 | 928 | 929 | no-store, no-cache 930 | 931 | 932 | no-cache 933 | @{ 934 | string denialTemplate = context.Variables.GetValueOrDefault("access_denied_template"); 935 | string commonStyles = context.Variables.GetValueOrDefault("common_styles"); 936 | 937 | // Replace placeholders with actual content 938 | denialTemplate = denialTemplate.Replace("__COMMON_STYLES__", commonStyles); 939 | denialTemplate = denialTemplate.Replace("__DENIAL_MESSAGE__", 940 | "You have previously denied access to this application."); 941 | 942 | return denialTemplate; 943 | } 944 | 945 | 946 | 947 | 948 | 949 | 950 | 951 | 952 | 953 | 954 | 955 | text/html 956 | 957 | 958 | no-store, no-cache 959 | 960 | 961 | no-cache 962 | 963 | @{ 964 | string template = context.Variables.GetValueOrDefault("client_not_found_template"); 965 | string commonStyles = context.Variables.GetValueOrDefault("common_styles"); 966 | string clientId = context.Variables.GetValueOrDefault("client_id", ""); 967 | string redirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 968 | 969 | // Replace placeholders with actual content 970 | template = template.Replace("__COMMON_STYLES__", commonStyles); 971 | template = template.Replace("__CLIENT_ID__", clientId); 972 | template = template.Replace("__REDIRECT_URI__", redirectUri); 973 | 974 | return template; 975 | } 976 | 977 | 978 | 979 | 980 | ("common_styles"); 983 | 984 | // Use the service URL from APIM configuration 985 | string basePath = "{{APIMGatewayURL}}"; 986 | 987 | string clientId = context.Variables.GetValueOrDefault("client_id", ""); 988 | string clientName = context.Variables.GetValueOrDefault("client_name", "Unknown Application"); 989 | string clientUri = context.Variables.GetValueOrDefault("client_uri", "N/A"); 990 | string oauthScopes = context.Variables.GetValueOrDefault("oauth_scopes", ""); 991 | 992 | // Get the normalized (human-readable) redirect URI for display 993 | string normalizedRedirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 994 | context.Trace($"DEBUG: Normalized redirect URI for display: {normalizedRedirectUri}"); 995 | 996 | // Use the original encoded version for form submission 997 | string originalRedirectUri = context.Variables.GetValueOrDefault("redirect_uri", ""); 998 | context.Trace($"DEBUG: Original redirect URI for form: {originalRedirectUri}"); 999 | 1000 | string state = context.Variables.GetValueOrDefault("state", ""); 1001 | 1002 | // Create a temporary placeholder for the form fields 1003 | string FORM_FIELD_PLACEHOLDER = "___ENCODED_REDIRECT_URI___"; 1004 | 1005 | // Replace the styles first 1006 | template = template.Replace("__COMMON_STYLES__", commonStyles); 1007 | 1008 | // First, create a temporary placeholder for the form fields 1009 | template = template.Replace("value='__REDIRECT_URI__'", "value='" + FORM_FIELD_PLACEHOLDER + "'"); 1010 | // Now replace the display values with human-readable versions 1011 | template = template.Replace("__CLIENT_NAME__", clientName); 1012 | template = template.Replace("__CLIENT_URI__", clientUri); 1013 | template = template.Replace("__CLIENT_ID__", clientId); 1014 | template = template.Replace("__REDIRECT_URI__", normalizedRedirectUri); // Human-readable for display 1015 | template = template.Replace("__STATE__", state); 1016 | template = template.Replace("__CONSENT_ACTION_URL__", basePath + "/consent"); // Handle space-separated OAuth scopes and create individual list items 1017 | string[] scopeArray = oauthScopes.Split(new char[] {' '}, StringSplitOptions.RemoveEmptyEntries); 1018 | StringBuilder scopeList = new StringBuilder(); 1019 | 1020 | foreach (string scope in scopeArray) { 1021 | scopeList.AppendLine($"
  • {scope}
  • "); 1022 | } 1023 | 1024 | template = template.Replace("__OAUTH_SCOPES__", scopeList.ToString()); 1025 | 1026 | // Finally, replace our placeholder with the encoded URI for form submission 1027 | template = template.Replace(FORM_FIELD_PLACEHOLDER, originalRedirectUri); 1028 | 1029 | return template; 1030 | }" /> 1031 | 1032 | 1033 | 1034 | 1035 | 1036 | text/html 1037 | 1038 | @{ 1039 | return context.Variables.GetValueOrDefault("consent_page", ""); 1040 | } 1041 | 1042 |
    1043 |
    1044 |
    1045 |
    1046 |
    1047 |
    1048 |
    1049 | 1050 | 1051 | 1052 | 1053 | 1054 | 1055 | 1056 | @{ 1057 | return $"Outbound policy: consent_approved={context.Variables.GetValueOrDefault("consent_approved")}, consent_denied={context.Variables.GetValueOrDefault("consent_denied")}, cookie_name={context.Variables.GetValueOrDefault("cookie_name")}"; 1058 | } 1059 | 1060 | 1061 | 1062 | 1063 | 1064 | 1065 | 1066 | @{ 1067 | string cookieName = context.Variables.GetValueOrDefault("cookie_name", "MCP_APPROVED_CLIENTS"); 1068 | string clientId = context.Variables.GetValueOrDefault("client_id", ""); 1069 | // Use the normalized redirect URI for cookies 1070 | string redirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 1071 | 1072 | // Create a unique identifier for this client/redirect combination 1073 | string clientKey = $"{clientId}:{redirectUri}"; 1074 | 1075 | // Check for existing cookie 1076 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 1077 | JArray approvedClients = new JArray(); 1078 | 1079 | if (!string.IsNullOrEmpty(cookieHeader)) { 1080 | // Parse cookies to find our approval cookie 1081 | string[] cookies = cookieHeader.Split(';'); 1082 | foreach (string cookie in cookies) { 1083 | string trimmedCookie = cookie.Trim(); 1084 | if (trimmedCookie.StartsWith(cookieName + "=")) { 1085 | try { 1086 | // Extract and parse the cookie value 1087 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 1088 | // Get the payload part (before the first dot if cookie is signed) 1089 | string payload = cookieValue.Contains('.') ? 1090 | cookieValue.Split('.')[0] : cookieValue; 1091 | string decodedValue = System.Text.Encoding.UTF8.GetString( 1092 | System.Convert.FromBase64String(payload)); 1093 | approvedClients = JArray.Parse(decodedValue); 1094 | } catch (Exception) { 1095 | // If parsing fails, we'll just create a new cookie 1096 | approvedClients = new JArray(); 1097 | } 1098 | break; 1099 | } 1100 | } 1101 | } 1102 | 1103 | // Add the current client if not already in the list 1104 | bool clientExists = false; 1105 | foreach (var item in approvedClients) { 1106 | if (item.ToString() == clientKey) { 1107 | clientExists = true; 1108 | break; 1109 | } 1110 | } 1111 | 1112 | if (!clientExists) { 1113 | approvedClients.Add(clientKey); 1114 | } 1115 | 1116 | // Base64 encode the client list 1117 | string jsonClients = approvedClients.ToString(Newtonsoft.Json.Formatting.None); 1118 | string encodedClients = System.Convert.ToBase64String( 1119 | System.Text.Encoding.UTF8.GetBytes(jsonClients)); 1120 | 1121 | // Return the cookie with appropriate settings 1122 | return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; 1123 | } 1124 | 1125 | 1126 | 1127 | 1128 | 1129 | @(context.Variables.GetValueOrDefault("response_redirect_location", "")) 1130 | 1131 | 1132 | 1133 | 1134 | 1135 | 1136 | @{ 1137 | string cookieName = context.Variables.GetValueOrDefault("cookie_name", "MCP_DENIED_CLIENTS"); 1138 | string clientId = context.Variables.GetValueOrDefault("client_id", ""); 1139 | // Use the normalized redirect URI for cookies 1140 | string redirectUri = context.Variables.GetValueOrDefault("normalized_redirect_uri", ""); 1141 | 1142 | // Create a unique identifier for this client/redirect combination 1143 | string clientKey = $"{clientId}:{redirectUri}"; 1144 | 1145 | // Check for existing cookie 1146 | var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", ""); 1147 | JArray deniedClients = new JArray(); 1148 | 1149 | if (!string.IsNullOrEmpty(cookieHeader)) { 1150 | // Parse cookies to find our denial cookie 1151 | string[] cookies = cookieHeader.Split(';'); 1152 | foreach (string cookie in cookies) { 1153 | string trimmedCookie = cookie.Trim(); 1154 | if (trimmedCookie.StartsWith(cookieName + "=")) { 1155 | try { 1156 | // Extract and parse the cookie value 1157 | string cookieValue = trimmedCookie.Substring(cookieName.Length + 1); 1158 | // Get the payload part (before the first dot if cookie is signed) 1159 | string payload = cookieValue.Contains('.') ? 1160 | cookieValue.Split('.')[0] : cookieValue; 1161 | string decodedValue = System.Text.Encoding.UTF8.GetString( 1162 | System.Convert.FromBase64String(payload)); 1163 | deniedClients = JArray.Parse(decodedValue); 1164 | } catch (Exception) { 1165 | // If parsing fails, we'll just create a new cookie 1166 | deniedClients = new JArray(); 1167 | } 1168 | break; 1169 | } 1170 | } 1171 | } 1172 | 1173 | // Add the current client if not already in the list 1174 | bool clientExists = false; 1175 | foreach (var item in deniedClients) { 1176 | if (item.ToString() == clientKey) { 1177 | clientExists = true; 1178 | break; 1179 | } 1180 | } 1181 | 1182 | if (!clientExists) { 1183 | deniedClients.Add(clientKey); 1184 | } 1185 | 1186 | // Base64 encode the client list 1187 | string jsonClients = deniedClients.ToString(Newtonsoft.Json.Formatting.None); 1188 | string encodedClients = System.Convert.ToBase64String( 1189 | System.Text.Encoding.UTF8.GetBytes(jsonClients)); 1190 | 1191 | // Return the cookie with appropriate settings 1192 | return $"{cookieName}={encodedClients}; Max-Age=31536000; Path=/; Secure; HttpOnly; SameSite=Lax"; 1193 | } 1194 | 1195 | 1196 | 1197 | 1198 | 1199 | 1200 | 1201 |
    1202 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/diagrams/diagrams.md: -------------------------------------------------------------------------------- 1 | # Sequence Diagrams 2 | 3 | ## MCP Client Auth Flow 4 | 5 | ![MCP Client Authorization Flow](images/mcp-client-auth.png) 6 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/diagrams/images/mcp-client-auth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/remote-mcp-apim-functions-python/50e591db7c7cacb66f2721870b79b51a6e1e0dfa/infra/app/apim-oauth/diagrams/images/mcp-client-auth.png -------------------------------------------------------------------------------- /infra/app/apim-oauth/diagrams/mcp_client_auth.mmd: -------------------------------------------------------------------------------- 1 | sequenceDiagram 2 | participant UserAgent as User agent (browser) 3 | participant MCPClient as MCP Client (MCP Inspector) 4 | participant MCPGateway as MCP/AI Gateway (APIM) 5 | participant UpstreamServices as Entra (third-party IDP) 6 | participant MCPServer as MCP Server (Resource Server) 7 | 8 | UserAgent->>MCPClient: Open browser 9 | MCPClient->>MCPServer: Connect to MCP server with /sse protocol 10 | MCPServer->>MCPClient: Returns 401 Unauthorized 11 | MCPClient->>MCPGateway: GET /.well-known/oauth-authorization-server 12 | alt Server supports discovery endpoint 13 | MCPGateway->>MCPClient: Authorization server Metadata 14 | else No discovery 15 | MCPGateway->>MCPClient: 404 (use default endpoints) 16 | end 17 | MCPClient->>MCPGateway: POST /register endpoint 18 | Note over MCPGateway: Does dynamic client registration 19 | MCPGateway-->>MCPClient: Return client_credentials (client identifier and client_secret(if supported)) 20 | Note over MCPClient: Generate PKCE parameters (Code_Verifier, Code Challenge) and constructs the authorization URL 21 | MCPClient->>UserAgent: Open browser with authorization URL + code_challenge 22 | Note over UserAgent: Initiate authorization code flow using PKCE (Proof Key for Code Exchange) 23 | UserAgent->>MCPGateway: GET /authorize?response_type=code&client_id=client_id&redirect_uri=client_callback_url&code_challenge=code_challenge&code_challenge_method=S256 24 | Note over MCPGateway: Works as confidential client and
    1. Generate an mcpServerAuthCode for client
    2. generates the authorization URL for Entra with:
    scope and redirect_uri=mcpServer_callback_url 25 | MCPGateway-->>UpstreamServices: Redirect to upstream (Entra) authorization endpoint 26 | Note over UpstreamServices: User logs in and authorizes on upstream 27 | UpstreamServices-->>MCPGateway: Redirect with upstream (Entra) auth_code to mcpServer_callback_url (APIM /callback endpoint) 28 | MCPGateway-->>UpstreamServices: Exchange auth_code for upstream (Entra) access token

    POST /token 29 | UpstreamServices-->>MCPGateway: Upstream (Entra) access token 30 | Note over MCPGateway: Generate MCP specific token 31 | MCPGateway->>MCPClient: Redirect to mcpClient_callback URL with mcpServerAuthCode 32 | Note over MCPClient: Exchange mcpServerAuthCode + code_verifier for mcpServerAccessToken 33 | MCPClient->>MCPGateway: POST /token endpoint with mcpServerAuthCode + code_verifier 34 | Note over MCPGateway: Validate code_verifier and code_challenge 35 | MCPGateway->>MCPClient: Return Access Token if validation is successful 36 | Note over MCPClient: Caches mcpServerAccessToken 37 | MCPClient ->>MCPGateway: /sse endpoint with mcpServerAccessToken 38 | MCPGateway->>MCPServer: Forward request to MCP server with session ID 39 | MCPServer->>MCPClient: Return 200 OK with session ID 40 | Note over MCPClient,MCPServer: Begin standard MCP message exchange using session ID 41 | 42 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/entra-app.bicep: -------------------------------------------------------------------------------- 1 | extension microsoftGraphV1 2 | 3 | @description('The name of the Entra application') 4 | param entraAppUniqueName string 5 | 6 | @description('The display name of the Entra application') 7 | param entraAppDisplayName string 8 | 9 | @description('Tenant ID where the application is registered') 10 | param tenantId string = tenant().tenantId 11 | 12 | @description('The OAuth callback URL for the API Management service') 13 | param apimOauthCallback string 14 | 15 | @description('The principle id of the user-assigned managed identity') 16 | param userAssignedIdentityPrincipleId string 17 | 18 | var loginEndpoint = environment().authentication.loginEndpoint 19 | var issuer = '${loginEndpoint}${tenantId}/v2.0' 20 | 21 | resource entraApp 'Microsoft.Graph/applications@v1.0' = { 22 | displayName: entraAppDisplayName 23 | uniqueName: entraAppUniqueName 24 | web: { 25 | redirectUris: [ 26 | apimOauthCallback 27 | ] 28 | } 29 | requiredResourceAccess: [ 30 | { 31 | resourceAppId: '00000003-0000-0000-c000-000000000000' 32 | resourceAccess: [ 33 | { 34 | id: 'e1fe6dd8-ba31-4d61-89e7-88639da4683d' // User.Read 35 | type: 'Scope' 36 | } 37 | ] 38 | } 39 | ] 40 | 41 | resource fic 'federatedIdentityCredentials@v1.0' = { 42 | name: '${entraApp.uniqueName}/msiAsFic' 43 | description: 'Trust the user-assigned MI as a credential for the app' 44 | audiences: [ 45 | 'api://AzureADTokenExchange' 46 | ] 47 | issuer: issuer 48 | subject: userAssignedIdentityPrincipleId 49 | } 50 | } 51 | 52 | // Outputs 53 | output entraAppId string = entraApp.appId 54 | output entraAppTenantId string = tenantId 55 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauth-callback.policy.xml: -------------------------------------------------------------------------------- 1 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | POST 30 | 31 | application/x-www-form-urlencoded 32 | 33 | @{ 34 | return $"client_id={context.Variables.GetValueOrDefault("clientId")}&grant_type=authorization_code&code={context.Variables.GetValueOrDefault("authCode")}&redirect_uri={context.Variables.GetValueOrDefault("redirectUri")}&scope=https://graph.microsoft.com/.default&code_verifier={context.Variables.GetValueOrDefault("codeVerifier")}&client_assertion_type={context.Variables.GetValueOrDefault("clientAssertionType")}&client_assertion={context.Variables.GetValueOrDefault("ficToken")}"; 35 | } 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | @("Token response received: " + context.Response.Body.As(preserveContent: true)) 48 | 49 | 50 | 51 | 52 | 53 | 66 | 67 | 68 | 69 | 70 | 71 | 75 | 76 | 80 | 81 | 82 | 83 | 86 | 87 | 88 | 91 | 92 | 93 | 94 | 95 | 96 | @($"{context.Variables.GetValueOrDefault("callbackRedirectUri")}?code={context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}&state={context.Variables.GetValueOrDefault("mcpState")}&state_session=statesession123") 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauth.bicep: -------------------------------------------------------------------------------- 1 | @description('The name of the API Management service') 2 | param apimServiceName string 3 | 4 | @description('The Azure region for resources') 5 | param location string 6 | 7 | // Parameters for Named Values 8 | @description('The required scopes for authorization') 9 | param oauthScopes string 10 | 11 | @description('The principle id of the user-assigned managed identity for Entra app') 12 | param entraAppUserAssignedIdentityPrincipleId string 13 | 14 | @description('The client ID of the user-assigned managed identity for Entra app') 15 | param entraAppUserAssignedIdentityClientId string 16 | 17 | @description('The name of the Entra application') 18 | param entraAppUniqueName string 19 | 20 | @description('The display name of the Entra application') 21 | param entraAppDisplayName string 22 | 23 | @description('The name of the MCP Server to display in the consent page') 24 | param mcpServerName string = 'MCP Server' 25 | 26 | resource apimService 'Microsoft.ApiManagement/service@2021-08-01' existing = { 27 | name: apimServiceName 28 | } 29 | 30 | // Create user-assigned managed identity for crypto script 31 | resource cryptoScriptIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { 32 | name: '${apimServiceName}-crypto-script-identity' 33 | location: location 34 | } 35 | 36 | module entraApp './entra-app.bicep' = { 37 | name: 'entraApp' 38 | params:{ 39 | entraAppUniqueName: entraAppUniqueName 40 | entraAppDisplayName: entraAppDisplayName 41 | apimOauthCallback: '${apimService.properties.gatewayUrl}/oauth-callback' 42 | userAssignedIdentityPrincipleId: entraAppUserAssignedIdentityPrincipleId 43 | } 44 | } 45 | 46 | // Role assignment for the crypto script identity to manage APIM named values 47 | resource cryptoScriptApimRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { 48 | name: guid(resourceGroup().id, cryptoScriptIdentity.id, 'APIM Contributor') 49 | scope: apimService 50 | properties: { 51 | principalId: cryptoScriptIdentity.properties.principalId 52 | roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '312a565d-c81f-4fd8-895a-4e21e48d571c') 53 | principalType: 'ServicePrincipal' 54 | } 55 | } 56 | 57 | // Using a deployment script to generate cryptographically secure values for AES encryption 58 | // Key is 32 bytes (256-bit) and IV is 16 bytes (128-bit) 59 | resource cryptoValuesScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { 60 | name: 'generateCryptoValues' 61 | location: location 62 | kind: 'AzurePowerShell' 63 | identity: { 64 | type: 'UserAssigned' 65 | userAssignedIdentities: { 66 | '${cryptoScriptIdentity.id}': {} 67 | } 68 | } 69 | properties: { 70 | azPowerShellVersion: '7.0' 71 | timeout: 'PT30M' 72 | retentionInterval: 'P1D' 73 | environmentVariables: [ 74 | { 75 | name: 'APIM_NAME' 76 | value: apimServiceName 77 | } 78 | { 79 | name: 'RESOURCEGROUP_NAME' 80 | value: resourceGroup().name 81 | } 82 | ] 83 | scriptContent: ''' 84 | # Generate random 32 bytes (256-bit) key for AES-256 85 | $key = New-Object byte[] 32 86 | $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() 87 | $rng.GetBytes($key) 88 | $keyBase64 = [Convert]::ToBase64String($key) 89 | 90 | # Generate random 16 bytes (128-bit) IV 91 | $iv = New-Object byte[] 16 92 | $rng.GetBytes($iv) 93 | $ivBase64 = [Convert]::ToBase64String($iv) 94 | 95 | # Set the values in APIM named values 96 | New-AzApiManagementNamedValue -Context (New-AzApiManagementContext -ResourceGroupName $env:RESOURCEGROUP_NAME -ServiceName $env:APIM_NAME) -NamedValueId "EncryptionKey" -Name "EncryptionKey" -Value $keyBase64 -Secret 97 | New-AzApiManagementNamedValue -Context (New-AzApiManagementContext -ResourceGroupName $env:RESOURCEGROUP_NAME -ServiceName $env:APIM_NAME) -NamedValueId "EncryptionIV" -Name "EncryptionIV" -Value $ivBase64 -Secret 98 | ''' 99 | } 100 | } 101 | 102 | // Define the Named Values 103 | resource EntraIDTenantIdNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 104 | parent: apimService 105 | name: 'EntraIDTenantId' 106 | properties: { 107 | displayName: 'EntraIDTenantId' 108 | value: entraApp.outputs.entraAppTenantId 109 | secret: false 110 | } 111 | } 112 | 113 | resource EntraIDClientIdNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 114 | parent: apimService 115 | name: 'EntraIDClientId' 116 | properties: { 117 | displayName: 'EntraIDClientId' 118 | value: entraApp.outputs.entraAppId 119 | secret: false 120 | } 121 | } 122 | 123 | resource EntraIdFicClientIdNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 124 | parent: apimService 125 | name: 'EntraIDFicClientId' 126 | properties: { 127 | displayName: 'EntraIdFicClientId' 128 | value: entraAppUserAssignedIdentityClientId 129 | secret: false 130 | } 131 | } 132 | 133 | resource OAuthCallbackUriNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 134 | parent: apimService 135 | name: 'OAuthCallbackUri' 136 | properties: { 137 | displayName: 'OAuthCallbackUri' 138 | value: '${apimService.properties.gatewayUrl}/oauth-callback' 139 | secret: false 140 | } 141 | } 142 | 143 | resource OAuthScopesNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 144 | parent: apimService 145 | name: 'OAuthScopes' 146 | properties: { 147 | displayName: 'OAuthScopes' 148 | value: oauthScopes 149 | secret: false 150 | } 151 | } 152 | 153 | 154 | resource McpClientIdNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 155 | parent: apimService 156 | name: 'McpClientId' 157 | properties: { 158 | displayName: 'McpClientId' 159 | value: entraApp.outputs.entraAppId 160 | secret: false 161 | } 162 | } 163 | 164 | resource APIMGatewayURLNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 165 | parent: apimService 166 | name: 'APIMGatewayURL' 167 | properties: { 168 | displayName: 'APIMGatewayURL' 169 | value: apimService.properties.gatewayUrl 170 | secret: false 171 | } 172 | } 173 | 174 | resource MCPServerNamedValue 'Microsoft.ApiManagement/service/namedValues@2021-08-01' = { 175 | parent: apimService 176 | name: 'MCPServerName' 177 | properties: { 178 | displayName: 'MCPServerName' 179 | value: mcpServerName 180 | secret: false 181 | } 182 | } 183 | 184 | // Create the OAuth API 185 | resource oauthApi 'Microsoft.ApiManagement/service/apis@2021-08-01' = { 186 | parent: apimService 187 | name: 'oauth' 188 | properties: { 189 | displayName: 'OAuth' 190 | description: 'OAuth 2.0 Authentication API' 191 | subscriptionRequired: false 192 | path: '' 193 | protocols: [ 194 | 'https' 195 | ] 196 | serviceUrl: 'https://login.microsoftonline.com/${entraApp.outputs.entraAppTenantId}/oauth2/v2.0' 197 | } 198 | } 199 | 200 | // Add a GET operation for the authorization endpoint 201 | resource oauthAuthorizeOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 202 | parent: oauthApi 203 | name: 'authorize' 204 | properties: { 205 | displayName: 'Authorize' 206 | method: 'GET' 207 | urlTemplate: '/authorize' 208 | description: 'OAuth 2.0 authorization endpoint' 209 | } 210 | } 211 | 212 | // Add policy for the authorize operation 213 | resource oauthAuthorizePolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 214 | parent: oauthAuthorizeOperation 215 | name: 'policy' 216 | properties: { 217 | format: 'rawxml' 218 | value: loadTextContent('authorize.policy.xml') 219 | } 220 | } 221 | 222 | // Add a POST operation for the token endpoint 223 | resource oauthTokenOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 224 | parent: oauthApi 225 | name: 'token' 226 | properties: { 227 | displayName: 'Token' 228 | method: 'POST' 229 | urlTemplate: '/token' 230 | description: 'OAuth 2.0 token endpoint' 231 | } 232 | } 233 | 234 | // Add policy for the token operation 235 | resource oauthTokenPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 236 | parent: oauthTokenOperation 237 | name: 'policy' 238 | properties: { 239 | format: 'rawxml' 240 | value: loadTextContent('token.policy.xml') 241 | } 242 | } 243 | 244 | // Add a GET operation for the OAuth callback endpoint 245 | resource oauthCallbackOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 246 | parent: oauthApi 247 | name: 'oauth-callback' 248 | properties: { 249 | displayName: 'OAuth Callback' 250 | method: 'GET' 251 | urlTemplate: '/oauth-callback' 252 | description: 'OAuth 2.0 callback endpoint to handle authorization code flow' 253 | } 254 | } 255 | 256 | // Add policy for the OAuth callback operation 257 | resource oauthCallbackPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 258 | parent: oauthCallbackOperation 259 | name: 'policy' 260 | properties: { 261 | format: 'rawxml' 262 | value: loadTextContent('oauth-callback.policy.xml') 263 | } 264 | dependsOn: [ 265 | cryptoValuesScript 266 | ] 267 | } 268 | 269 | // Add a POST operation for the register endpoint 270 | resource oauthRegisterOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 271 | parent: oauthApi 272 | name: 'register' 273 | properties: { 274 | displayName: 'Register' 275 | method: 'POST' 276 | urlTemplate: '/register' 277 | description: 'OAuth 2.0 client registration endpoint' 278 | } 279 | } 280 | 281 | // Add policy for the register operation 282 | resource oauthRegisterPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 283 | parent: oauthRegisterOperation 284 | name: 'policy' 285 | properties: { 286 | format: 'rawxml' 287 | value: loadTextContent('register.policy.xml') 288 | } 289 | } 290 | 291 | // Add a OPTIONS operation for the register endpoint 292 | resource oauthRegisterOptionsOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 293 | parent: oauthApi 294 | name: 'register-options' 295 | properties: { 296 | displayName: 'Register Options' 297 | method: 'OPTIONS' 298 | urlTemplate: '/register' 299 | description: 'CORS preflight request handler for register endpoint' 300 | } 301 | } 302 | 303 | // Add policy for the register options operation 304 | resource oauthRegisterOptionsPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 305 | parent: oauthRegisterOptionsOperation 306 | name: 'policy' 307 | properties: { 308 | format: 'rawxml' 309 | value: loadTextContent('register-options.policy.xml') 310 | } 311 | } 312 | 313 | // Add a OPTIONS operation for the OAuth metadata endpoint 314 | resource oauthMetadataOptionsOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 315 | parent: oauthApi 316 | name: 'oauthmetadata-options' 317 | properties: { 318 | displayName: 'OAuth Metadata Options' 319 | method: 'OPTIONS' 320 | urlTemplate: '/.well-known/oauth-authorization-server' 321 | description: 'CORS preflight request handler for OAuth metadata endpoint' 322 | } 323 | } 324 | 325 | // Add policy for the OAuth metadata options operation 326 | resource oauthMetadataOptionsPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 327 | parent: oauthMetadataOptionsOperation 328 | name: 'policy' 329 | properties: { 330 | format: 'rawxml' 331 | value: loadTextContent('oauthmetadata-options.policy.xml') 332 | } 333 | } 334 | 335 | // Add a GET operation for the OAuth metadata endpoint 336 | resource oauthMetadataGetOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 337 | parent: oauthApi 338 | name: 'oauthmetadata-get' 339 | properties: { 340 | displayName: 'OAuth Metadata Get' 341 | method: 'GET' 342 | urlTemplate: '/.well-known/oauth-authorization-server' 343 | description: 'OAuth 2.0 metadata endpoint' 344 | } 345 | } 346 | 347 | // Add policy for the OAuth metadata get operation 348 | resource oauthMetadataGetPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 349 | parent: oauthMetadataGetOperation 350 | name: 'policy' 351 | properties: { 352 | format: 'rawxml' 353 | value: loadTextContent('oauthmetadata-get.policy.xml') 354 | } 355 | } 356 | 357 | // Add a GET operation for the consent endpoint 358 | resource oauthConsentGetOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 359 | parent: oauthApi 360 | name: 'consent-get' 361 | properties: { 362 | displayName: 'Consent Page' 363 | method: 'GET' 364 | urlTemplate: '/consent' 365 | description: 'Client consent page endpoint' 366 | } 367 | } 368 | 369 | // Add policy for the consent GET operation 370 | resource oauthConsentGetPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 371 | parent: oauthConsentGetOperation 372 | name: 'policy' 373 | properties: { 374 | format: 'rawxml' 375 | value: loadTextContent('consent.policy.xml') 376 | } 377 | } 378 | 379 | // Add a POST operation for the consent endpoint 380 | resource oauthConsentPostOperation 'Microsoft.ApiManagement/service/apis/operations@2021-08-01' = { 381 | parent: oauthApi 382 | name: 'consent-post' 383 | properties: { 384 | displayName: 'Consent Submission' 385 | method: 'POST' 386 | urlTemplate: '/consent' 387 | description: 'Client consent submission endpoint' 388 | } 389 | } 390 | 391 | // Add policy for the consent POST operation 392 | resource oauthConsentPostPolicy 'Microsoft.ApiManagement/service/apis/operations/policies@2021-08-01' = { 393 | parent: oauthConsentPostOperation 394 | name: 'policy' 395 | properties: { 396 | format: 'rawxml' 397 | value: loadTextContent('consent.policy.xml') 398 | } 399 | } 400 | 401 | output apiId string = oauthApi.id 402 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauthmetadata-get.policy.xml: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | application/json; charset=utf-8 12 | 13 | 14 | * 15 | 16 | 17 | { 18 | "issuer": "{{APIMGatewayURL}}", 19 | "service_documentation": "https://microsoft.com/", 20 | "authorization_endpoint": "{{APIMGatewayURL}}/authorize", 21 | "token_endpoint": "{{APIMGatewayURL}}/token", 22 | "revocation_endpoint": "{{APIMGatewayURL}}/revoke", 23 | "registration_endpoint": "{{APIMGatewayURL}}/register", 24 | "response_types_supported": [ 25 | "code" 26 | ], 27 | "code_challenge_methods_supported": [ 28 | "S256" 29 | ], 30 | "token_endpoint_auth_methods_supported": [ 31 | "none" 32 | ], 33 | "grant_types_supported": [ 34 | "authorization_code", 35 | "refresh_token" 36 | ], 37 | "revocation_endpoint_auth_methods_supported": [ 38 | "client_secret_post" 39 | ] 40 | } 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/oauthmetadata-options.policy.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | * 13 | 14 | 15 | GET, OPTIONS 16 | 17 | 18 | Content-Type, Authorization 19 | 20 | 21 | 86400 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/register-options.policy.xml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | * 13 | 14 | 15 | GET, OPTIONS 16 | 17 | 18 | Content-Type, Authorization 19 | 20 | 21 | 86400 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/register.policy.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | ("uniqueClientId")}")" 31 | value="@{ 32 | var requestBody = context.Variables.GetValueOrDefault("requestBody"); 33 | var clientInfo = new JObject(); 34 | clientInfo["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application"; 35 | clientInfo["client_uri"] = requestBody["client_uri"]?.ToString() ?? ""; 36 | clientInfo["redirect_uris"] = requestBody["redirect_uris"]; 37 | return clientInfo.ToString(); 38 | }" /> 39 | 40 | 41 | 42 | application/json 43 | 44 | 45 | 46 | 47 | 48 | 49 | * 50 | 51 | @{ 52 | var requestBody = context.Variables.GetValueOrDefault("requestBody"); 53 | 54 | // Generate timestamps dynamically 55 | // Current time in seconds since epoch (Unix timestamp) 56 | long currentTimeSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); 57 | 58 | // Client ID issued at current time 59 | long clientIdIssuedAt = currentTimeSeconds; 60 | 61 | // Client secret expires in 1 year (31536000 seconds = 365 days) 62 | long clientSecretExpiresAt = currentTimeSeconds + 31536000; 63 | 64 | // Use the generated client ID from earlier 65 | string uniqueClientId = context.Variables.GetValueOrDefault("uniqueClientId", Guid.NewGuid().ToString()); 66 | 67 | return new JObject 68 | { 69 | ["client_id"] = uniqueClientId, 70 | ["client_id_issued_at"] = clientIdIssuedAt, 71 | ["client_secret_expires_at"] = clientSecretExpiresAt, 72 | ["redirect_uris"] = requestBody["redirect_uris"]?.ToObject(), 73 | ["client_name"] = requestBody["client_name"]?.ToString() ?? "Unknown Application", 74 | ["client_uri"] = requestBody["client_uri"]?.ToString() ?? "" 75 | }.ToString(); 76 | } 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /infra/app/apim-oauth/token.policy.xml: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 39 | 40 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 79 | 80 | 81 | 116 | 117 | 118 | @("{\"error\": \"code_verifier does not match.\"}") 119 | 120 | 121 | 122 | 123 | 124 | 125 | 146 | 147 | 168 | 169 | 170 | 174 | 175 | 176 | ("client_id")}")" variable-name="clientInfoJson" /> 177 | 178 | 179 | ("normalized_redirect_uri", ""); 183 | 184 | if (string.IsNullOrEmpty(clientId)) { 185 | return false; 186 | } 187 | 188 | // Get the client info from the variable set by cache-lookup-value 189 | string clientInfoJson = context.Variables.GetValueOrDefault("clientInfoJson"); 190 | if (string.IsNullOrEmpty(clientInfoJson)) { 191 | context.Trace($"Client info not found in cache for client_id: {clientId}"); 192 | return false; 193 | } 194 | 195 | // Parse client info 196 | JObject clientInfo = JObject.Parse(clientInfoJson); 197 | JArray redirectUris = clientInfo["redirect_uris"]?.ToObject(); 198 | 199 | // Check if the redirect URI is in the registered URIs 200 | if (redirectUris != null) { 201 | foreach (var uri in redirectUris) { 202 | // Normalize the URI from the cache for comparison 203 | string registeredUri = System.Net.WebUtility.UrlDecode(uri.ToString()); 204 | if (registeredUri == redirectUri) { 205 | return true; 206 | } 207 | } 208 | } 209 | 210 | context.Trace($"Redirect URI mismatch - URI: {redirectUri} not found in registered URIs"); 211 | return false; 212 | } 213 | catch (Exception ex) { 214 | context.Trace($"Error checking client registration: {ex.Message}"); 215 | return false; 216 | } 217 | }" /> 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | application/json 227 | 228 | @{ 229 | var errorResponse = new JObject(); 230 | errorResponse["error"] = "invalid_client"; 231 | errorResponse["error_description"] = "Client not found or redirect URI is invalid."; 232 | return errorResponse.ToString(); 233 | } 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 257 | @{ 258 | return (string)context.Variables.GetValueOrDefault("jsonPayload", ""); 259 | } 260 | 261 | * 262 | 263 | 264 | 265 | 266 | 267 | -------------------------------------------------------------------------------- /infra/app/storage-Access.bicep: -------------------------------------------------------------------------------- 1 | param principalID string 2 | param roleDefinitionID string 3 | param storageAccountName string 4 | 5 | resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' existing = { 6 | name: storageAccountName 7 | } 8 | 9 | // Allow access from API to storage account using a managed identity and least priv Storage roles 10 | resource storageRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { 11 | name: guid(storageAccount.id, principalID, roleDefinitionID) 12 | scope: storageAccount 13 | properties: { 14 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID) 15 | principalId: principalID 16 | principalType: 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal 17 | } 18 | } 19 | 20 | output ROLE_ASSIGNMENT_NAME string = storageRoleAssignment.name 21 | -------------------------------------------------------------------------------- /infra/app/storage-PrivateEndpoint.bicep: -------------------------------------------------------------------------------- 1 | // Parameters 2 | @description('Specifies the name of the virtual network.') 3 | param virtualNetworkName string 4 | 5 | @description('Specifies the name of the subnet which contains the virtual machine.') 6 | param subnetName string 7 | 8 | @description('Specifies the resource name of the Storage resource with an endpoint.') 9 | param resourceName string 10 | 11 | @description('Specifies the location.') 12 | param location string = resourceGroup().location 13 | 14 | param tags object = {} 15 | 16 | // Virtual Network 17 | resource vnet 'Microsoft.Network/virtualNetworks@2021-08-01' existing = { 18 | name: virtualNetworkName 19 | } 20 | 21 | resource storageAccount 'Microsoft.Storage/storageAccounts@2021-09-01' existing = { 22 | name: resourceName 23 | } 24 | 25 | var blobPrivateDNSZoneName = format('privatelink.blob.{0}', environment().suffixes.storage) 26 | var blobPrivateDnsZoneVirtualNetworkLinkName = format('{0}-blob-link-{1}', resourceName, take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)) 27 | 28 | var queuePrivateDNSZoneName = format('privatelink.queue.{0}', environment().suffixes.storage) 29 | var queuePrivateDnsZoneVirtualNetworkLinkName = format('{0}-queue-link-{1}', resourceName, take(toLower(uniqueString(resourceName, virtualNetworkName)), 4)) 30 | 31 | // Private DNS Zones 32 | resource blobPrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { 33 | name: blobPrivateDNSZoneName 34 | location: 'global' 35 | tags: tags 36 | properties: {} 37 | dependsOn: [ 38 | vnet 39 | ] 40 | } 41 | 42 | resource queuePrivateDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = { 43 | name: queuePrivateDNSZoneName 44 | location: 'global' 45 | tags: tags 46 | properties: {} 47 | dependsOn: [ 48 | vnet 49 | ] 50 | } 51 | 52 | // Virtual Network Links 53 | resource blobPrivateDnsZoneVirtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { 54 | parent: blobPrivateDnsZone 55 | name: blobPrivateDnsZoneVirtualNetworkLinkName 56 | location: 'global' 57 | tags: tags 58 | properties: { 59 | registrationEnabled: false 60 | virtualNetwork: { 61 | id: vnet.id 62 | } 63 | } 64 | } 65 | 66 | resource queuePrivateDnsZoneVirtualNetworkLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = { 67 | parent: queuePrivateDnsZone 68 | name: queuePrivateDnsZoneVirtualNetworkLinkName 69 | location: 'global' 70 | tags: tags 71 | properties: { 72 | registrationEnabled: false 73 | virtualNetwork: { 74 | id: vnet.id 75 | } 76 | } 77 | } 78 | 79 | // Private Endpoints 80 | resource blobPrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-08-01' = { 81 | name: 'blob-private-endpoint' 82 | location: location 83 | tags: tags 84 | properties: { 85 | privateLinkServiceConnections: [ 86 | { 87 | name: 'blobPrivateLinkConnection' 88 | properties: { 89 | privateLinkServiceId: storageAccount.id 90 | groupIds: [ 91 | 'blob' 92 | ] 93 | } 94 | } 95 | ] 96 | subnet: { 97 | id: '${vnet.id}/subnets/${subnetName}' 98 | } 99 | } 100 | } 101 | 102 | resource blobPrivateDnsZoneGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2022-01-01' = { 103 | parent: blobPrivateEndpoint 104 | name: 'blobPrivateDnsZoneGroup' 105 | properties: { 106 | privateDnsZoneConfigs: [ 107 | { 108 | name: 'storageBlobARecord' 109 | properties: { 110 | privateDnsZoneId: blobPrivateDnsZone.id 111 | } 112 | } 113 | ] 114 | } 115 | } 116 | 117 | resource queuePrivateEndpoint 'Microsoft.Network/privateEndpoints@2021-08-01' = { 118 | name: 'queue-private-endpoint' 119 | location: location 120 | tags: tags 121 | properties: { 122 | privateLinkServiceConnections: [ 123 | { 124 | name: 'queuePrivateLinkConnection' 125 | properties: { 126 | privateLinkServiceId: storageAccount.id 127 | groupIds: [ 128 | 'queue' 129 | ] 130 | } 131 | } 132 | ] 133 | subnet: { 134 | id: '${vnet.id}/subnets/${subnetName}' 135 | } 136 | } 137 | } 138 | 139 | resource queuePrivateDnsZoneGroupName 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2022-01-01' = { 140 | parent: queuePrivateEndpoint 141 | name: 'queuePrivateDnsZoneGroup' 142 | properties: { 143 | privateDnsZoneConfigs: [ 144 | { 145 | name: 'storageQueueARecord' 146 | properties: { 147 | privateDnsZoneId: queuePrivateDnsZone.id 148 | } 149 | } 150 | ] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /infra/app/vnet.bicep: -------------------------------------------------------------------------------- 1 | @description('Specifies the name of the virtual network.') 2 | param vNetName string 3 | 4 | @description('Specifies the location.') 5 | param location string = resourceGroup().location 6 | 7 | @description('Specifies the name of the subnet for the Service Bus private endpoint.') 8 | param peSubnetName string = 'private-endpoints-subnet' 9 | 10 | @description('Specifies the name of the subnet for Function App virtual network integration.') 11 | param appSubnetName string = 'app' 12 | 13 | param tags object = {} 14 | 15 | resource virtualNetwork 'Microsoft.Network/virtualNetworks@2023-05-01' = { 16 | name: vNetName 17 | location: location 18 | tags: tags 19 | properties: { 20 | addressSpace: { 21 | addressPrefixes: [ 22 | '10.0.0.0/16' 23 | ] 24 | } 25 | encryption: { 26 | enabled: false 27 | enforcement: 'AllowUnencrypted' 28 | } 29 | subnets: [ 30 | { 31 | name: peSubnetName 32 | id: resourceId('Microsoft.Network/virtualNetworks/subnets', vNetName, 'private-endpoints-subnet') 33 | properties: { 34 | addressPrefixes: [ 35 | '10.0.1.0/24' 36 | ] 37 | delegations: [] 38 | privateEndpointNetworkPolicies: 'Disabled' 39 | privateLinkServiceNetworkPolicies: 'Enabled' 40 | } 41 | type: 'Microsoft.Network/virtualNetworks/subnets' 42 | } 43 | { 44 | name: appSubnetName 45 | id: resourceId('Microsoft.Network/virtualNetworks/subnets', vNetName, 'app') 46 | properties: { 47 | addressPrefixes: [ 48 | '10.0.2.0/24' 49 | ] 50 | delegations: [ 51 | { 52 | name: 'delegation' 53 | id: resourceId('Microsoft.Network/virtualNetworks/subnets/delegations', vNetName, 'app', 'delegation') 54 | properties: { 55 | //Microsoft.App/environments is the correct delegation for Flex Consumption VNet integration 56 | serviceName: 'Microsoft.App/environments' 57 | } 58 | type: 'Microsoft.Network/virtualNetworks/subnets/delegations' 59 | } 60 | ] 61 | privateEndpointNetworkPolicies: 'Disabled' 62 | privateLinkServiceNetworkPolicies: 'Enabled' 63 | } 64 | type: 'Microsoft.Network/virtualNetworks/subnets' 65 | } 66 | ] 67 | virtualNetworkPeerings: [] 68 | enableDdosProtection: false 69 | } 70 | } 71 | 72 | output peSubnetName string = virtualNetwork.properties.subnets[0].name 73 | output peSubnetID string = virtualNetwork.properties.subnets[0].id 74 | output appSubnetName string = virtualNetwork.properties.subnets[1].name 75 | output appSubnetID string = virtualNetwork.properties.subnets[1].id 76 | -------------------------------------------------------------------------------- /infra/bicepconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "experimentalFeaturesEnabled": { 3 | "extensibility": true 4 | }, 5 | // specify an alias for the version of the v1.0 dynamic types package you want to use 6 | "extensions": { 7 | "microsoftGraphV1": "br:mcr.microsoft.com/bicep/extensions/microsoftgraph/v1.0:0.2.0-preview" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /infra/core/apim/apim.bicep: -------------------------------------------------------------------------------- 1 | /** 2 | * @module apim-v1 3 | * @description This module defines the Azure API Management (APIM) resources using Bicep. 4 | * It includes configurations for creating and managing APIM instance. 5 | * This is version 1 (v1) of the APIM Bicep module. 6 | */ 7 | 8 | // ------------------ 9 | // PARAMETERS 10 | // ------------------ 11 | 12 | 13 | @description('The name of the API Management instance. Defaults to "apim-".') 14 | param apiManagementName string 15 | 16 | @description('The location of the API Management instance. Defaults to the resource group location.') 17 | param location string = resourceGroup().location 18 | 19 | @description('The email address of the publisher. Defaults to "noreply@microsoft.com".') 20 | param publisherEmail string = 'noreply@microsoft.com' 21 | 22 | @description('The name of the publisher. Defaults to "Microsoft".') 23 | param publisherName string = 'Microsoft' 24 | 25 | @description('Name of the APIM Logger') 26 | param apimLoggerName string = 'apim-logger' 27 | 28 | @description('Description of the APIM Logger') 29 | param apimLoggerDescription string = 'APIM Logger for OpenAI API' 30 | 31 | @description('The pricing tier of this API Management service') 32 | @allowed([ 33 | 'Consumption' 34 | 'Developer' 35 | 'Basic' 36 | 'Basicv2' 37 | 'Standard' 38 | 'Standardv2' 39 | 'Premium' 40 | ]) 41 | param apimSku string = 'Basicv2' 42 | 43 | @description('The instrumentation key for Application Insights') 44 | param appInsightsInstrumentationKey string = '' 45 | 46 | @description('The resource ID for Application Insights') 47 | param appInsightsId string = '' 48 | 49 | @description('The name of the user-assigned managed identity used as entra app FIC') 50 | param entraAppUserAssignedIdentityName string = 'entra-app-user-assigned-identity' 51 | 52 | // ------------------ 53 | // VARIABLES 54 | // ------------------ 55 | 56 | 57 | // ------------------ 58 | // RESOURCES 59 | // ------------------ 60 | 61 | // Create a user-assigned managed identity 62 | resource entraAppUserAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { 63 | name: entraAppUserAssignedIdentityName 64 | location: location 65 | } 66 | 67 | // https://learn.microsoft.com/azure/templates/microsoft.apimanagement/service 68 | resource apimService 'Microsoft.ApiManagement/service@2024-06-01-preview' = { 69 | name: apiManagementName 70 | location: location 71 | sku: { 72 | name: apimSku 73 | capacity: 1 74 | } 75 | properties: { 76 | publisherEmail: publisherEmail 77 | publisherName: publisherName 78 | } 79 | identity: { 80 | type: 'SystemAssigned, UserAssigned' 81 | userAssignedIdentities: { 82 | // BCP037: Not yet added to latest API: 83 | '${entraAppUserAssignedIdentity.id}': {} 84 | } 85 | } 86 | } 87 | 88 | // Create a logger only if we have an App Insights ID and instrumentation key. 89 | resource apimLogger 'Microsoft.ApiManagement/service/loggers@2021-12-01-preview' = if (!empty(appInsightsId) && !empty(appInsightsInstrumentationKey)) { 90 | name: apimLoggerName 91 | parent: apimService 92 | properties: { 93 | credentials: { 94 | instrumentationKey: appInsightsInstrumentationKey 95 | } 96 | description: apimLoggerDescription 97 | isBuffered: false 98 | loggerType: 'applicationInsights' 99 | resourceId: appInsightsId 100 | } 101 | } 102 | 103 | // ------------------ 104 | // OUTPUTS 105 | // ------------------ 106 | 107 | output id string = apimService.id 108 | output name string = apimService.name 109 | output principalId string = apimService.identity.principalId 110 | output gatewayUrl string = apimService.properties.gatewayUrl 111 | output entraAppUserAssignedIdentityPrincipleId string = entraAppUserAssignedIdentity.properties.principalId 112 | output entraAppUserAssignedIdentityClientId string = entraAppUserAssignedIdentity.properties.clientId 113 | -------------------------------------------------------------------------------- /infra/core/host/appserviceplan.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param kind string = '' 6 | param reserved bool = true 7 | param sku object 8 | 9 | resource appServicePlan 'Microsoft.Web/serverfarms@2023-12-01' = { 10 | name: name 11 | location: location 12 | tags: tags 13 | sku: sku 14 | kind: kind 15 | properties: { 16 | reserved: reserved 17 | } 18 | } 19 | 20 | output id string = appServicePlan.id 21 | -------------------------------------------------------------------------------- /infra/core/host/functions-flexconsumption.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | // Reference Properties 6 | param applicationInsightsName string = '' 7 | param appServicePlanId string 8 | param storageAccountName string 9 | param virtualNetworkSubnetId string = '' 10 | @allowed(['SystemAssigned', 'UserAssigned']) 11 | param identityType string 12 | @description('User assigned identity name') 13 | param identityId string 14 | 15 | // Runtime Properties 16 | @allowed([ 17 | 'dotnet-isolated', 'node', 'python', 'java', 'powershell', 'custom' 18 | ]) 19 | param runtimeName string 20 | @allowed(['3.10', '3.11', '7.4', '8.0', '10', '11', '17', '20']) 21 | param runtimeVersion string 22 | param kind string = 'functionapp,linux' 23 | 24 | // Microsoft.Web/sites/config 25 | param appSettings object = {} 26 | param instanceMemoryMB int = 2048 27 | param maximumInstanceCount int = 100 28 | param deploymentStorageContainerName string 29 | 30 | resource stg 'Microsoft.Storage/storageAccounts@2022-09-01' existing = { 31 | name: storageAccountName 32 | } 33 | 34 | resource functions 'Microsoft.Web/sites@2023-12-01' = { 35 | name: name 36 | location: location 37 | tags: tags 38 | kind: kind 39 | identity: { 40 | type: identityType 41 | userAssignedIdentities: { 42 | '${identityId}': {} 43 | } 44 | } 45 | properties: { 46 | serverFarmId: appServicePlanId 47 | functionAppConfig: { 48 | deployment: { 49 | storage: { 50 | type: 'blobContainer' 51 | value: '${stg.properties.primaryEndpoints.blob}${deploymentStorageContainerName}' 52 | authentication: { 53 | type: identityType == 'SystemAssigned' ? 'SystemAssignedIdentity' : 'UserAssignedIdentity' 54 | userAssignedIdentityResourceId: identityType == 'UserAssigned' ? identityId : '' 55 | } 56 | } 57 | } 58 | scaleAndConcurrency: { 59 | instanceMemoryMB: instanceMemoryMB 60 | maximumInstanceCount: maximumInstanceCount 61 | } 62 | runtime: { 63 | name: runtimeName 64 | version: runtimeVersion 65 | } 66 | } 67 | virtualNetworkSubnetId: !empty(virtualNetworkSubnetId) ? virtualNetworkSubnetId : null 68 | } 69 | 70 | resource configAppSettings 'config' = { 71 | name: 'appsettings' 72 | properties: union(appSettings, 73 | { 74 | AzureWebJobsStorage__accountName: stg.name 75 | AzureWebJobsStorage__credential : 'managedidentity' 76 | APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString 77 | }) 78 | } 79 | } 80 | 81 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = if (!empty(applicationInsightsName)) { 82 | name: applicationInsightsName 83 | } 84 | 85 | output name string = functions.name 86 | output uri string = 'https://${functions.properties.defaultHostName}' 87 | output identityPrincipalId string = identityType == 'SystemAssigned' ? functions.identity.principalId : '' 88 | -------------------------------------------------------------------------------- /infra/core/identity/userAssignedIdentity.bicep: -------------------------------------------------------------------------------- 1 | param identityName string 2 | param location string 3 | param tags object = {} 4 | 5 | resource userAssignedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = { 6 | name: identityName 7 | location: location 8 | tags: tags 9 | } 10 | 11 | output identityId string = userAssignedIdentity.id 12 | output identityName string = userAssignedIdentity.name 13 | output identityPrincipalId string = userAssignedIdentity.properties.principalId 14 | output identityClientId string = userAssignedIdentity.properties.clientId 15 | -------------------------------------------------------------------------------- /infra/core/monitor/appinsights-access.bicep: -------------------------------------------------------------------------------- 1 | param principalID string 2 | param roleDefinitionID string 3 | param appInsightsName string 4 | 5 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { 6 | name: appInsightsName 7 | } 8 | 9 | // Allow access from API to app insights using a managed identity and least priv role 10 | resource appInsightsRoleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { 11 | name: guid(applicationInsights.id, principalID, roleDefinitionID) 12 | scope: applicationInsights 13 | properties: { 14 | roleDefinitionId: resourceId('Microsoft.Authorization/roleDefinitions', roleDefinitionID) 15 | principalId: principalID 16 | principalType: 'ServicePrincipal' // Workaround for https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-template#new-service-principal 17 | } 18 | } 19 | 20 | output ROLE_ASSIGNMENT_NAME string = appInsightsRoleAssignment.name 21 | 22 | -------------------------------------------------------------------------------- /infra/core/monitor/applicationinsights.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param logAnalyticsWorkspaceId string 6 | param disableLocalAuth bool = false 7 | 8 | resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { 9 | name: name 10 | location: location 11 | tags: tags 12 | kind: 'web' 13 | properties: { 14 | Application_Type: 'web' 15 | WorkspaceResourceId: logAnalyticsWorkspaceId 16 | DisableLocalAuth: disableLocalAuth 17 | } 18 | } 19 | 20 | output connectionString string = applicationInsights.properties.ConnectionString 21 | output instrumentationKey string = applicationInsights.properties.InstrumentationKey 22 | output name string = applicationInsights.name 23 | -------------------------------------------------------------------------------- /infra/core/monitor/loganalytics.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { 6 | name: name 7 | location: location 8 | tags: tags 9 | properties: any({ 10 | retentionInDays: 30 11 | features: { 12 | searchVersion: 1 13 | } 14 | sku: { 15 | name: 'PerGB2018' 16 | } 17 | }) 18 | } 19 | 20 | output id string = logAnalytics.id 21 | output name string = logAnalytics.name 22 | -------------------------------------------------------------------------------- /infra/core/monitor/monitoring.bicep: -------------------------------------------------------------------------------- 1 | param logAnalyticsName string 2 | param applicationInsightsName string 3 | param location string = resourceGroup().location 4 | param tags object = {} 5 | param disableLocalAuth bool = false 6 | 7 | module logAnalytics 'loganalytics.bicep' = { 8 | name: 'loganalytics' 9 | params: { 10 | name: logAnalyticsName 11 | location: location 12 | tags: tags 13 | } 14 | } 15 | 16 | module applicationInsights 'applicationinsights.bicep' = { 17 | name: 'applicationinsights' 18 | params: { 19 | name: applicationInsightsName 20 | location: location 21 | tags: tags 22 | logAnalyticsWorkspaceId: logAnalytics.outputs.id 23 | disableLocalAuth: disableLocalAuth 24 | } 25 | } 26 | 27 | output applicationInsightsConnectionString string = applicationInsights.outputs.connectionString 28 | output applicationInsightsInstrumentationKey string = applicationInsights.outputs.instrumentationKey 29 | output applicationInsightsName string = applicationInsights.outputs.name 30 | output logAnalyticsWorkspaceId string = logAnalytics.outputs.id 31 | output logAnalyticsWorkspaceName string = logAnalytics.outputs.name 32 | -------------------------------------------------------------------------------- /infra/core/storage/storage-account.bicep: -------------------------------------------------------------------------------- 1 | param name string 2 | param location string = resourceGroup().location 3 | param tags object = {} 4 | 5 | param allowBlobPublicAccess bool = false 6 | @allowed(['Enabled', 'Disabled']) 7 | param publicNetworkAccess string = 'Enabled' 8 | param containers array = [] 9 | param kind string = 'StorageV2' 10 | param minimumTlsVersion string = 'TLS1_2' 11 | param sku object = { name: 'Standard_LRS' } 12 | param networkAcls object = { 13 | bypass: 'AzureServices' 14 | defaultAction: 'Allow' 15 | } 16 | 17 | resource storage 'Microsoft.Storage/storageAccounts@2023-01-01' = { 18 | name: name 19 | location: location 20 | tags: tags 21 | kind: kind 22 | sku: sku 23 | properties: { 24 | minimumTlsVersion: minimumTlsVersion 25 | allowBlobPublicAccess: allowBlobPublicAccess 26 | publicNetworkAccess: publicNetworkAccess 27 | allowSharedKeyAccess: false 28 | networkAcls: networkAcls 29 | } 30 | 31 | resource blobServices 'blobServices' = if (!empty(containers)) { 32 | name: 'default' 33 | resource container 'containers' = [for container in containers: { 34 | name: container.name 35 | properties: { 36 | publicAccess: container.?publicAccess ?? 'None' 37 | } 38 | }] 39 | } 40 | } 41 | 42 | output name string = storage.name 43 | output primaryEndpoints object = storage.properties.primaryEndpoints 44 | -------------------------------------------------------------------------------- /infra/main.bicep: -------------------------------------------------------------------------------- 1 | targetScope = 'subscription' 2 | 3 | @minLength(1) 4 | @maxLength(64) 5 | @description('Name of the the environment which is used to generate a short unique hash used in all resources.') 6 | param environmentName string 7 | 8 | 9 | @minLength(1) 10 | @description('Primary location for all resources') 11 | @allowed(['australiaeast', 'eastasia', 'eastus', 'eastus2', 'northeurope', 'southcentralus', 'southeastasia', 'swedencentral', 'uksouth', 'westus2', 'eastus2euap']) 12 | @metadata({ 13 | azd: { 14 | type: 'location' 15 | } 16 | }) 17 | param location string 18 | param vnetEnabled bool 19 | param apiServiceName string = '' 20 | param apiUserAssignedIdentityName string = '' 21 | param applicationInsightsName string = '' 22 | param appServicePlanName string = '' 23 | param logAnalyticsName string = '' 24 | param resourceGroupName string = '' 25 | param storageAccountName string = '' 26 | param vNetName string = '' 27 | param mcpEntraApplicationDisplayName string = '' 28 | param mcpEntraApplicationUniqueName string = '' 29 | param disableLocalAuth bool = true 30 | 31 | // MCP Client APIM gateway specific variables 32 | 33 | var oauth_scopes = 'openid https://graph.microsoft.com/.default' 34 | 35 | 36 | var abbrs = loadJsonContent('./abbreviations.json') 37 | var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) 38 | var tags = { 'azd-env-name': environmentName } 39 | var functionAppName = !empty(apiServiceName) ? apiServiceName : '${abbrs.webSitesFunctions}api-${resourceToken}' 40 | var deploymentStorageContainerName = 'app-package-${take(functionAppName, 32)}-${take(toLower(uniqueString(functionAppName, resourceToken)), 7)}' 41 | 42 | 43 | // Organize resources in a resource group 44 | resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = { 45 | name: !empty(resourceGroupName) ? resourceGroupName : '${abbrs.resourcesResourceGroups}${environmentName}' 46 | location: location 47 | tags: tags 48 | } 49 | 50 | var apimResourceToken = toLower(uniqueString(subscription().id, resourceGroupName, environmentName, location)) 51 | var apiManagementName = '${abbrs.apiManagementService}${apimResourceToken}' 52 | 53 | // apim service deployment 54 | module apimService './core/apim/apim.bicep' = { 55 | name: apiManagementName 56 | scope: rg 57 | params:{ 58 | apiManagementName: apiManagementName 59 | } 60 | } 61 | 62 | // MCP client oauth via APIM gateway 63 | module oauthAPIModule './app/apim-oauth/oauth.bicep' = { 64 | name: 'oauthAPIModule' 65 | scope: rg 66 | params: { 67 | location: location 68 | entraAppUniqueName: !empty(mcpEntraApplicationUniqueName) ? mcpEntraApplicationUniqueName : 'mcp-oauth-${abbrs.applications}${apimResourceToken}' 69 | entraAppDisplayName: !empty(mcpEntraApplicationDisplayName) ? mcpEntraApplicationDisplayName : 'MCP-OAuth-${abbrs.applications}${apimResourceToken}' 70 | apimServiceName: apimService.name 71 | oauthScopes: oauth_scopes 72 | entraAppUserAssignedIdentityPrincipleId: apimService.outputs.entraAppUserAssignedIdentityPrincipleId 73 | entraAppUserAssignedIdentityClientId: apimService.outputs.entraAppUserAssignedIdentityClientId 74 | } 75 | } 76 | 77 | // MCP server API endpoints 78 | module mcpApiModule './app/apim-mcp/mcp-api.bicep' = { 79 | name: 'mcpApiModule' 80 | scope: rg 81 | params: { 82 | apimServiceName: apimService.name 83 | functionAppName: functionAppName 84 | } 85 | dependsOn: [ 86 | api 87 | oauthAPIModule 88 | ] 89 | } 90 | 91 | 92 | // User assigned managed identity to be used by the function app to reach storage and service bus 93 | module apiUserAssignedIdentity './core/identity/userAssignedIdentity.bicep' = { 94 | name: 'apiUserAssignedIdentity' 95 | scope: rg 96 | params: { 97 | location: location 98 | tags: tags 99 | identityName: !empty(apiUserAssignedIdentityName) ? apiUserAssignedIdentityName : '${abbrs.managedIdentityUserAssignedIdentities}api-${resourceToken}' 100 | } 101 | } 102 | 103 | // The application backend is a function app 104 | module appServicePlan './core/host/appserviceplan.bicep' = { 105 | name: 'appserviceplan' 106 | scope: rg 107 | params: { 108 | name: !empty(appServicePlanName) ? appServicePlanName : '${abbrs.webServerFarms}${resourceToken}' 109 | location: location 110 | tags: tags 111 | sku: { 112 | name: 'FC1' 113 | tier: 'FlexConsumption' 114 | } 115 | } 116 | } 117 | 118 | module api './app/api.bicep' = { 119 | name: 'api' 120 | scope: rg 121 | params: { 122 | name: functionAppName 123 | location: location 124 | tags: tags 125 | applicationInsightsName: monitoring.outputs.applicationInsightsName 126 | appServicePlanId: appServicePlan.outputs.id 127 | runtimeName: 'python' 128 | runtimeVersion: '3.11' 129 | storageAccountName: storage.outputs.name 130 | deploymentStorageContainerName: deploymentStorageContainerName 131 | identityId: apiUserAssignedIdentity.outputs.identityId 132 | identityClientId: apiUserAssignedIdentity.outputs.identityClientId 133 | appSettings: { 134 | } 135 | virtualNetworkSubnetId: !vnetEnabled ? '' : serviceVirtualNetwork.outputs.appSubnetID 136 | } 137 | } 138 | 139 | // Backing storage for Azure functions api 140 | module storage './core/storage/storage-account.bicep' = { 141 | name: 'storage' 142 | scope: rg 143 | params: { 144 | name: !empty(storageAccountName) ? storageAccountName : '${abbrs.storageStorageAccounts}${resourceToken}' 145 | location: location 146 | tags: tags 147 | containers: [{name: deploymentStorageContainerName}, {name: 'snippets'}] 148 | publicNetworkAccess: vnetEnabled ? 'Disabled' : 'Enabled' 149 | networkAcls: !vnetEnabled ? {} : { 150 | defaultAction: 'Deny' 151 | } 152 | } 153 | } 154 | 155 | var StorageBlobDataOwner = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' 156 | var StorageQueueDataContributor = '974c5e8b-45b9-4653-ba55-5f855dd0fb88' 157 | 158 | // Allow access from api to blob storage using a managed identity 159 | module blobRoleAssignmentApi 'app/storage-Access.bicep' = { 160 | name: 'blobRoleAssignmentapi' 161 | scope: rg 162 | params: { 163 | storageAccountName: storage.outputs.name 164 | roleDefinitionID: StorageBlobDataOwner 165 | principalID: apiUserAssignedIdentity.outputs.identityPrincipalId 166 | } 167 | } 168 | 169 | // Allow access from api to queue storage using a managed identity 170 | module queueRoleAssignmentApi 'app/storage-Access.bicep' = { 171 | name: 'queueRoleAssignmentapi' 172 | scope: rg 173 | params: { 174 | storageAccountName: storage.outputs.name 175 | roleDefinitionID: StorageQueueDataContributor 176 | principalID: apiUserAssignedIdentity.outputs.identityPrincipalId 177 | } 178 | } 179 | 180 | // Virtual Network & private endpoint to blob storage 181 | module serviceVirtualNetwork 'app/vnet.bicep' = if (vnetEnabled) { 182 | name: 'serviceVirtualNetwork' 183 | scope: rg 184 | params: { 185 | location: location 186 | tags: tags 187 | vNetName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' 188 | } 189 | } 190 | 191 | module storagePrivateEndpoint 'app/storage-PrivateEndpoint.bicep' = if (vnetEnabled) { 192 | name: 'servicePrivateEndpoint' 193 | scope: rg 194 | params: { 195 | location: location 196 | tags: tags 197 | virtualNetworkName: !empty(vNetName) ? vNetName : '${abbrs.networkVirtualNetworks}${resourceToken}' 198 | subnetName: !vnetEnabled ? '' : serviceVirtualNetwork.outputs.peSubnetName 199 | resourceName: storage.outputs.name 200 | } 201 | } 202 | 203 | // Monitor application with Azure Monitor 204 | module monitoring './core/monitor/monitoring.bicep' = { 205 | name: 'monitoring' 206 | scope: rg 207 | params: { 208 | location: location 209 | tags: tags 210 | logAnalyticsName: !empty(logAnalyticsName) ? logAnalyticsName : '${abbrs.operationalInsightsWorkspaces}${resourceToken}' 211 | applicationInsightsName: !empty(applicationInsightsName) ? applicationInsightsName : '${abbrs.insightsComponents}${resourceToken}' 212 | disableLocalAuth: disableLocalAuth 213 | } 214 | } 215 | 216 | var monitoringRoleDefinitionId = '3913510d-42f4-4e42-8a64-420c390055eb' // Monitoring Metrics Publisher role ID 217 | 218 | // Allow access from api to application insights using a managed identity 219 | module appInsightsRoleAssignmentApi './core/monitor/appinsights-access.bicep' = { 220 | name: 'appInsightsRoleAssignmentapi' 221 | scope: rg 222 | params: { 223 | appInsightsName: monitoring.outputs.applicationInsightsName 224 | roleDefinitionID: monitoringRoleDefinitionId 225 | principalID: apiUserAssignedIdentity.outputs.identityPrincipalId 226 | } 227 | } 228 | 229 | 230 | 231 | // App outputs 232 | output APPLICATIONINSIGHTS_CONNECTION_STRING string = monitoring.outputs.applicationInsightsConnectionString 233 | output AZURE_LOCATION string = location 234 | output AZURE_TENANT_ID string = tenant().tenantId 235 | output SERVICE_API_NAME string = api.outputs.SERVICE_API_NAME 236 | output AZURE_FUNCTION_NAME string = api.outputs.SERVICE_API_NAME 237 | output SERVICE_API_ENDPOINTS array = [ '${apimService.outputs.gatewayUrl}/mcp/sse' ] 238 | -------------------------------------------------------------------------------- /infra/main.parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "environmentName": { 6 | "value": "${AZURE_ENV_NAME}" 7 | }, 8 | "location": { 9 | "value": "${AZURE_LOCATION}" 10 | }, 11 | "vnetEnabled": { 12 | "value": "${VNET_ENABLED=true}" 13 | }, 14 | "apimSku": { 15 | "value": "Basicv2" 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /mcp-client-authorization.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/remote-mcp-apim-functions-python/50e591db7c7cacb66f2721870b79b51a6e1e0dfa/mcp-client-authorization.gif -------------------------------------------------------------------------------- /overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azure-Samples/remote-mcp-apim-functions-python/50e591db7c7cacb66f2721870b79b51a6e1e0dfa/overview.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | line-length = 120 3 | target-version = "py311" 4 | lint.select = ["E", "F", "I", "UP", "A"] 5 | lint.ignore = ["D203"] 6 | -------------------------------------------------------------------------------- /src/.funcignore: -------------------------------------------------------------------------------- 1 | .venv -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | obj 3 | csx 4 | .vs 5 | edge 6 | Publish 7 | 8 | *.user 9 | *.suo 10 | *.cscfg 11 | *.Cache 12 | project.lock.json 13 | 14 | /packages 15 | /TestResults 16 | 17 | /tools/NuGet.exe 18 | /App_Data 19 | /secrets 20 | /data 21 | .secrets 22 | appsettings.json 23 | 24 | node_modules 25 | dist 26 | 27 | # Local python packages 28 | .python_packages/ 29 | 30 | # Python Environments 31 | .env 32 | .venv 33 | env/ 34 | venv/ 35 | ENV/ 36 | env.bak/ 37 | venv.bak/ 38 | 39 | # Byte-compiled / optimized / DLL files 40 | __pycache__/ 41 | *.py[cod] 42 | *$py.class 43 | 44 | # Azurite artifacts 45 | __blobstorage__ 46 | __queuestorage__ 47 | __azurite_db*__.json -------------------------------------------------------------------------------- /src/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-azuretools.vscode-azurefunctions", 4 | "ms-python.python" 5 | ] 6 | } -------------------------------------------------------------------------------- /src/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Attach to Python Functions", 6 | "type": "debugpy", 7 | "request": "attach", 8 | "connect": { 9 | "host": "localhost", 10 | "port": 9091 11 | }, 12 | "preLaunchTask": "func: host start" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /src/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "obj": true, 4 | "bin": true 5 | }, 6 | "azureFunctions.deploySubpath": ".", 7 | "azureFunctions.scmDoBuildDuringDeployment": true, 8 | "azureFunctions.pythonVenv": ".venv", 9 | "azureFunctions.projectLanguage": "Python", 10 | "azureFunctions.projectRuntime": "~4", 11 | "debug.internalConsoleOptions": "neverOpen", 12 | "azureFunctions.projectLanguageModel": 2, 13 | "azureFunctions.preDeployTask": "func: extensions install" 14 | } -------------------------------------------------------------------------------- /src/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "pip install (functions)", 6 | "type": "shell", 7 | "osx": { 8 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" 9 | }, 10 | "windows": { 11 | "command": "${config:azureFunctions.pythonVenv}\\Scripts\\python -m pip install -r requirements.txt" 12 | }, 13 | "linux": { 14 | "command": "${config:azureFunctions.pythonVenv}/bin/python -m pip install -r requirements.txt" 15 | }, 16 | "problemMatcher": [] 17 | }, 18 | { 19 | "type": "func", 20 | "label": "func: host start", 21 | "command": "host start", 22 | "problemMatcher": "$func-python-watch", 23 | "isBackground": true, 24 | "dependsOn": "func: extensions install" 25 | }, 26 | { 27 | "type": "func", 28 | "command": "extensions install", 29 | "dependsOn": "pip install (functions)", 30 | "problemMatcher": [] 31 | } 32 | ] 33 | } -------------------------------------------------------------------------------- /src/function_app.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import json 3 | import logging 4 | 5 | import azure.functions as func 6 | 7 | app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) 8 | 9 | # Constants for the Azure Blob Storage container, file, and blob path 10 | _SNIPPET_NAME_PROPERTY_NAME = "snippetname" 11 | _SNIPPET_PROPERTY_NAME = "snippet" 12 | _BLOB_PATH = "snippets/{mcptoolargs." + _SNIPPET_NAME_PROPERTY_NAME + "}.json" 13 | 14 | 15 | @dataclass 16 | class ToolProperty: 17 | propertyName: str 18 | propertyType: str 19 | description: str 20 | 21 | 22 | # Define the tool properties using the ToolProperty class 23 | tool_properties_save_snippets_object = [ 24 | ToolProperty(_SNIPPET_NAME_PROPERTY_NAME, "string", "The name of the snippet."), 25 | ToolProperty(_SNIPPET_PROPERTY_NAME, "string", "The content of the snippet."), 26 | ] 27 | 28 | tool_properties_get_snippets_object = [ToolProperty(_SNIPPET_NAME_PROPERTY_NAME, "string", "The name of the snippet.")] 29 | 30 | # Convert the tool properties to JSON 31 | tool_properties_save_snippets_json = json.dumps([prop.__dict__ for prop in tool_properties_save_snippets_object]) 32 | tool_properties_get_snippets_json = json.dumps([prop.__dict__ for prop in tool_properties_get_snippets_object]) 33 | 34 | 35 | @app.generic_trigger( 36 | arg_name="context", 37 | type="mcpToolTrigger", 38 | toolName="hello_mcp", 39 | description="Hello world.", 40 | toolProperties="[]", 41 | ) 42 | def hello_mcp(context) -> str: 43 | """ 44 | A simple function that returns a greeting message. 45 | 46 | Args: 47 | context: The trigger context (not used in this function). 48 | 49 | Returns: 50 | str: A greeting message. 51 | """ 52 | return "Hello I am MCPTool!" 53 | 54 | 55 | @app.generic_trigger( 56 | arg_name="context", 57 | type="mcpToolTrigger", 58 | toolName="get_snippet", 59 | description="Retrieve a snippet by name.", 60 | toolProperties=tool_properties_get_snippets_json, 61 | ) 62 | @app.generic_input_binding(arg_name="file", type="blob", connection="AzureWebJobsStorage", path=_BLOB_PATH) 63 | def get_snippet(file: func.InputStream, context) -> str: 64 | """ 65 | Retrieves a snippet by name from Azure Blob Storage. 66 | 67 | Args: 68 | file (func.InputStream): The input binding to read the snippet from Azure Blob Storage. 69 | context: The trigger context containing the input arguments. 70 | 71 | Returns: 72 | str: The content of the snippet or an error message. 73 | """ 74 | snippet_content = file.read().decode("utf-8") 75 | logging.info("Retrieved snippet: %s", snippet_content) 76 | return snippet_content 77 | 78 | 79 | @app.generic_trigger( 80 | arg_name="context", 81 | type="mcpToolTrigger", 82 | toolName="save_snippet", 83 | description="Save a snippet with a name.", 84 | toolProperties=tool_properties_save_snippets_json, 85 | ) 86 | @app.generic_output_binding(arg_name="file", type="blob", connection="AzureWebJobsStorage", path=_BLOB_PATH) 87 | def save_snippet(file: func.Out[str], context) -> str: 88 | content = json.loads(context) 89 | if "arguments" not in content: 90 | return "No arguments provided" 91 | 92 | snippet_name_from_args = content["arguments"].get(_SNIPPET_NAME_PROPERTY_NAME) 93 | snippet_content_from_args = content["arguments"].get(_SNIPPET_PROPERTY_NAME) 94 | 95 | if not snippet_name_from_args: 96 | return "No snippet name provided" 97 | 98 | if not snippet_content_from_args: 99 | return "No snippet content provided" 100 | 101 | file.set(snippet_content_from_args) 102 | logging.info("Saved snippet: %s", snippet_content_from_args) 103 | return f"Snippet '{snippet_content_from_args}' saved successfully" 104 | -------------------------------------------------------------------------------- /src/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle.Experimental", 13 | "version": "[4.*, 5.0.0)" 14 | } 15 | } -------------------------------------------------------------------------------- /src/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_RUNTIME": "python", 5 | "AzureWebJobsStorage": "UseDevelopmentStorage=true" 6 | } 7 | } -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | # Do not include azure-functions-worker in this file 2 | # The Python Worker is managed by the Azure Functions platform 3 | # Manually managing azure-functions-worker may cause unexpected issues 4 | 5 | azure-functions 6 | --------------------------------------------------------------------------------