From 0823a524009211acca1dd3aa03905ef3085f4a92 Mon Sep 17 00:00:00 2001 From: Lili Xu Date: Thu, 26 Jun 2025 09:21:02 -0700 Subject: [PATCH 1/3] Cloud Deployment --- .github/workflows/image.yml | 38 ++ README.md | 221 ++++++++--- deployment/azure-deploy.ps1 | 31 ++ deployment/infra/azure-deployment.bicep | 369 ++++++++++++++++++ deployment/k8s/cloud-deployment-template.yml | 107 +++++ .../k8s/local-deployment.yml | 0 .../src/Contracts/AdapterResource.cs | 1 + .../src/Store/CosmosAdapterResourceStore.cs | 14 +- .../src/Microsoft.McpGateway.Service.csproj | 2 +- .../src/Program.cs | 9 +- .../Properties/PublishProfiles/github.pubxml | 17 + .../src/appsettings.json | 2 +- infra-diagram.png | Bin 0 -> 44891 bytes openapi/mcp-gateway.openapi.json | 4 + 14 files changed, 756 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/image.yml create mode 100644 deployment/azure-deploy.ps1 create mode 100644 deployment/infra/azure-deployment.bicep create mode 100644 deployment/k8s/cloud-deployment-template.yml rename k8s/deployment.yml => deployment/k8s/local-deployment.yml (100%) create mode 100644 dotnet/Microsoft.McpGateway.Service/src/Properties/PublishProfiles/github.pubxml create mode 100644 infra-diagram.png diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml new file mode 100644 index 0000000..af2a769 --- /dev/null +++ b/.github/workflows/image.yml @@ -0,0 +1,38 @@ +name: Build and Publish Gateway Container Image + +on: + workflow_dispatch: + push: + branches: [main] + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + attestations: write + id-token: write + + steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup .NET SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '8.x' + + - name: Restore dependencies + run: dotnet restore dotnet/Microsoft.McpGateway.sln --runtime linux-x64 + + - name: Publish and push the container image + run: dotnet publish dotnet/Microsoft.McpGateway.Service/src/Microsoft.McpGateway.Service.csproj --configuration Release --no-restore /p:PublishProfile=github.pubxml /p:ContainerRepository=${{ github.repository }} diff --git a/README.md b/README.md index bfc350e..91594c2 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,15 @@ **MCP Gateway** is a reverse proxy and management layer for [Model Context Protocol (MCP)](https://modelcontextprotocol.io/introduction) servers, enabling scalable, session-aware routing and lifecycle management of MCP servers in Kubernetes environments. +## Table of Contents + +- [Overview](#overview) +- [Key Concepts](#key-concepts) +- [Architecture](#architecture) +- [Features](#features) +- [Getting Started – Local Deployment](#getting-started---local-deployment) +- [Getting Started – Cloud Deployment (Azure)](#getting-started---cloud-deployment-azure) + ## Overview This project provides: @@ -80,73 +89,191 @@ flowchart LR - Stateless reverse proxy with a distributed session store (production mode). - Kubernetes-native deployment using StatefulSets and headless services. -## 🚀 Getting Started +## Getting Started - Local Deployment -1. **Prepare Local Development Environment** - - [Install .NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) - - [Install Docker Desktop](https://docs.docker.com/desktop/) - - [Install and turn on Kubernetes](https://docs.docker.com/desktop/features/kubernetes/#install-and-turn-on-kubernetes) +### 1. **Prepare Local Development Environment** +- [Install .NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) +- [Install Docker Desktop](https://docs.docker.com/desktop/) +- [Install and turn on Kubernetes](https://docs.docker.com/desktop/features/kubernetes/#install-and-turn-on-kubernetes) -2. **Run Local Docker Registry** +### 2. **Run Local Docker Registry** ```sh docker run -d -p 5000:5000 --name registry registry:2.7 ``` -3. **Build & Publish MCP Server Images** - Build and push the MCP server images to your local registry (`localhost:5000`). - ```sh - docker build -f mcp-example-server/Dockerfile mcp-example-server -t localhost:5000/mcp-example:1.0.0 - docker push localhost:5000/mcp-example:1.0.0 - ``` +### 3. **Build & Publish MCP Server Images** +Build and push the MCP server images to your local registry (`localhost:5000`). +```sh +docker build -f mcp-example-server/Dockerfile mcp-example-server -t localhost:5000/mcp-example:1.0.0 +docker push localhost:5000/mcp-example:1.0.0 +``` -4. **Build & Publish MCP Gateway** - (Optional) Open `dotnet/Microsoft.McpGateway.sln` with Visual Studio. +### 4. **Build & Publish MCP Gateway** +(Optional) Open `dotnet/Microsoft.McpGateway.sln` with Visual Studio. - Publish the MCP Gateway image by right-clicking `Publish` on `Microsoft.McpGateway.Service` in Visual Studio, or run: - ```sh - dotnet publish dotnet/Microsoft.McpGateway.Service/src/Microsoft.McpGateway.Service.csproj -c Release /p:PublishProfile=localhost_5000.pubxml - ``` +Publish the MCP Gateway image by right-clicking `Publish` on `Microsoft.McpGateway.Service` in Visual Studio, or run: +```sh +dotnet publish dotnet/Microsoft.McpGateway.Service/src/Microsoft.McpGateway.Service.csproj -c Release /p:PublishProfile=localhost_5000.pubxml +``` -5. **Deploy MCP Gateway to Kubernetes Cluster** - Apply the deployment manifests: - ```sh - kubectl apply -f k8s/deployment.yml +### 5. **Deploy MCP Gateway to Kubernetes Cluster** +Apply the deployment manifests: +```sh +kubectl apply -f deployment/k8s/local-deployment.yml +``` + +### 6. **Enable Port Forwarding** +Forward the gateway service port: +```sh +kubectl port-forward -n adapter svc/mcpgateway-service 8000:8000 +``` + +### 7. **Test the API** + +- Import the OpenAPI definition from `openapi/mcp-gateway.openapi.json` into tools like [Postman](https://www.postman.com/), [Bruno](https://www.usebruno.com/), or [Swagger Editor](https://editor.swagger.io/). + +- Send a request to create a new adapter resource: + ```http + POST http://localhost:8000/adapters + Content-Type: application/json + ``` + ```json + { + "name": "mcp-example", + "imageName": "mcp-example", + "imageVersion": "1.0.0", + "description": "test" + } ``` -6. **Enable Port Forwarding** - Forward the gateway service port: +- After deploying the MCP server, use a client like [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to test the connection. + + To connect to the deployed `mcp-example` server, use: + - `http://localhost:8000/adapters/mcp-example/mcp` (Streamable HTTP) + + For other servers: + - `http://localhost:8000/adapters/{name}/mcp` (Streamable HTTP) + - `http://localhost:8000/adapters/{name}/sse` (SSE) + +### 8. **Clean the Environment** + To remove all deployed resources, delete the Kubernetes namespace: ```sh - kubectl port-forward -n adapter svc/mcpgateway-service 8000:8000 + kubectl delete namespace adapter ``` -7. **Test the API** +## Getting Started - Cloud Deployment (Azure) - - Import the OpenAPI definition from `openapi/mcp-gateway.openapi.json` into tools like [Postman](https://www.postman.com/), [Bruno](https://www.usebruno.com/), or [Swagger Editor](https://editor.swagger.io/). +### Cloud Infrastructure +![Architecture Diagram](infra-diagram.png) - - Send a POST request to `http://localhost:8000/adapters` to create a new adapter resource: - ```json - { - "name": "mcp-example", - "imageName": "mcp-example", - "imageVersion": "1.0.0", - "description": "test" - } - ``` +### 1. Prepare Cloud Development Environment +- An active [Azure subscription](https://azure.microsoft.com) with **Owner** access +- [Install Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) +- [Install Docker Desktop](https://docs.docker.com/desktop/) - - After deploying the MCP server, use a client like [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to test the connection. +### 2. Setup Entra ID (Azure Active Directory) +The cloud-deployed service needs authentication. Here we configure the basic bearer token authentication using Azure Entra ID. +- Go to [App Registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) +- Create a **single-tenant** app registration +- Under **Redirect URIs**, add: `http://localhost` +- Copy the **Application (client) ID** from the overview page - To connect to the deployed `mcp-example` server, use: - - `http://localhost:8000/adapters/mcp-example/mcp` (Streamable HTTP) +### 3. Deploy Infrastructure Resources - For other servers: - - `http://localhost:8000/adapters/{name}/mcp` (Streamable HTTP) - - `http://localhost:8000/adapters/{name}/sse` (SSE) +This script will: +- Create a resource group named `` +- Deploy Azure infrastructure via Bicep templates -8. **Clean up the environment** - To remove all deployed resources, delete the Kubernetes namespace: - ```sh - kubectl delete namespace adapter - ``` + | Resource Name | Resource Type | + |-----------------------------------|-----------------------------| + | acr\ | Container Registry | + | cosmos\ | Azure Cosmos DB Account | + | mg-aag-\ | Application Gateway | + | mg-ai-\ | Application Insights | + | mg-aks-\ | Kubernetes Service (AKS) | + | mg-identity-\ | Managed Identity | + | mg-pip-\ | Public IP Address | + | mg-vnet-\ | Virtual Network | + +- Deploy Kubernetes resources (including `mcp-gateway`) to the provisioned AKS cluster + +> **Note:** It's recommended to use Managed Identity for credential-less authentication. This deployment follows that design. + +Run the deployment script: + +```sh +deployment/azure-deploy.ps1 -ResourceGroupName -ClientId -Location +``` + +**Parameters:** + +| Name | Description | +|--------------------|-------------------------------------------------------| +| `ResourceGroupName`| All lowercase, letters and numbers only | +| `ClientId` | Client ID from your app registration | +| `Location` | Azure region (default: `westus3`) | + +### 4. Build & Publish MCP Server Images +The gateway service pulls the MCP server image from the newly provisioned Azure Container Registry (ACR) during deployment. + +Build and push the MCP server image to ACR: + +```sh +az acr login -n acr +docker build -f mcp-example-server/Dockerfile mcp-example-server -t acr.azurecr.io/mcp-example:1.0.0 +docker push acr.azurecr.io/mcp-example:1.0.0 +``` + +### 5. Test the API + +- Import the OpenAPI spec from `openapi/mcp-gateway.openapi.json` into [Postman](https://www.postman.com/), [Bruno](https://www.usebruno.com/), or [Swagger Editor](https://editor.swagger.io/) + +- Acquire a bearer token using this python script locally: + ```python + from azure.identity import InteractiveBrowserCredential + tenant_id = "" + client_id = "" + credential = InteractiveBrowserCredential(tenant_id=tenant_id, client_id=client_id) + credential.get_token(f"{client_id}/.default").token + ``` + +- Send a POST request to create an adapter resource: + ```http + POST http://..cloudapp.azure.com/adapters + Authorization: Bearer + Content-Type: application/json + ``` + ```json + { + "name": "mcp-example", + "imageName": "mcp-example", + "imageVersion": "1.0.0", + "description": "test" + } + ``` + +- After deploying the MCP server, use a client like [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) to test the connection. + > **Note:** A valid bearer token is still required in the Authorization header when connecting to the server. + + - To connect to the deployed `mcp-example` server, use: + - `http://..cloudapp.azure.com/adapters/mcp-example/mcp` (Streamable HTTP) + + - For other servers: + - `http://..cloudapp.azure.com/adapters/{name}/mcp` (Streamable HTTP) + - `http://..cloudapp.azure.com/adapters/{name}/sse` (SSE) + +### 6. Clean the Environment +To remove all deployed resources, delete the Azure resource group: +```sh +az group delete --name --yes +``` + +### 7. Production Onboarding (Follow-up) +- Configure TLS Certificates – Set up secure HTTPS communication on AAG listener using valid TLS certificates. +- Apply Network Policies – Restrict incoming traffic within the virtual network and configure Private Endpoints to enhance network security. +- Enable Advanced Telemetry – Integrate more detailed monitoring, metrics for observability and alert in production. +- Configure Server Scaling – Adjust scaling for `mcp-gateway` services and MCP servers based on expected load. +- Set Up Authentication & Authorization – Configure Oauth2.0 authentication with Azure Entra ID (AAD). Apply fine-grained access control to `adapters` via RBAC or custom ACLs. ## Contributing diff --git a/deployment/azure-deploy.ps1 b/deployment/azure-deploy.ps1 new file mode 100644 index 0000000..0475ab3 --- /dev/null +++ b/deployment/azure-deploy.ps1 @@ -0,0 +1,31 @@ +param( + [Parameter(Mandatory=$true)] + [string]$ResourceGroupName, + + [Parameter(Mandatory=$true)] + [string]$ClientId, + + [string]$Location = "westus3" +) + +$TenantId = az account show --query tenantId --output tsv + +# Create Resource Group +az group create --name "$ResourceGroupName" --location "$Location" + +# Deploy Bicep +$deployment = az deployment group create ` + --resource-group $ResourceGroupName ` + --template-file deployment/infra/azure-deployment.bicep | ConvertFrom-Json + +# Set cloud-deployment.yml +(Get-Content -Raw "deployment/k8s/cloud-deployment-template.yml") ` + -replace "{IDENTIFIER}", $ResourceGroupName ` + -replace "{TENANT_ID}", $TenantId ` + -replace "{CLIENT_ID}", $ClientId ` + -replace "{AZURE_CLIENT_ID}", $deployment.properties.outputs.identityClientId.value ` + -replace "{APPINSIGHTS_CONNECTION_STRING}", $deployment.properties.outputs.applicationInsightConnectionString.value ` + | Set-Content "deployment/k8s/cloud-deployment.yml" + +# Deploy to AKS +az aks command invoke -g $ResourceGroupName -n mg-aks-"$ResourceGroupName" --command "kubectl apply -f cloud-deployment.yml" --file deployment/k8s/cloud-deployment.yml \ No newline at end of file diff --git a/deployment/infra/azure-deployment.bicep b/deployment/infra/azure-deployment.bicep new file mode 100644 index 0000000..c06c5e5 --- /dev/null +++ b/deployment/infra/azure-deployment.bicep @@ -0,0 +1,369 @@ +// Parameters +param identifer string = resourceGroup().name +param location string = resourceGroup().location +param aksName string = 'mg-aks-${identifer}' +param acrName string = 'acr${identifer}' +param cosmosDbAccountName string = 'cosmos${identifer}' +param userAssignedIdentityName string = 'mg-identity-${identifer}' +param appInsightsName string = 'mg-ai-${identifer}' +param vnetName string = 'mg-vnet-${identifer}' +param aksSubnetName string = 'aks-subnet-${identifer}' +param appGwSubnetName string = 'mg-subnet-${identifer}' +param appGwName string = 'mg-aag-${identifer}' +param domainNameLabel string = identifer + +// VNet +resource vnet 'Microsoft.Network/virtualNetworks@2022-07-01' = { + name: vnetName + location: location + properties: { + addressSpace: { + addressPrefixes: [ + '10.0.0.0/16' + ] + } + subnets: [ + { + name: aksSubnetName + properties: { + addressPrefix: '10.0.1.0/24' + } + } + { + name: appGwSubnetName + properties: { + addressPrefix: '10.0.2.0/24' + } + } + ] + } +} + +resource networkContributorRA 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(vnet.name, aks.name, aksSubnetName, 'network-contributor') + scope: vnet + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4d97b98b-1d4f-4787-a291-c67834d212e7') // Network Contributor + principalId: aks.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// ACR +resource acr 'Microsoft.ContainerRegistry/registries@2023-01-01-preview' = { + name: acrName + location: location + sku: { + name: 'Standard' + } + properties: { + policies: { + quarantinePolicy: { + status: 'disabled' + } + } + publicNetworkAccess: 'Enabled' + anonymousPullEnabled: false + networkRuleBypassOptions: 'AzureServices' + dataEndpointEnabled: false + adminUserEnabled: false + } +} + +// AKS Cluster +resource aks 'Microsoft.ContainerService/managedClusters@2023-04-01' = { + name: aksName + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + dnsPrefix: aksName + agentPoolProfiles: [ + { + name: 'nodepool1' + count: 2 + vmSize: 'Standard_D4ds_v5' + osType: 'Linux' + mode: 'System' + osSKU: 'Ubuntu' + vnetSubnetID: resourceId('Microsoft.Network/virtualNetworks/subnets', vnetName, aksSubnetName) + } + ] + enableRBAC: true + aadProfile: { + managed: true + enableAzureRBAC: true + } + networkProfile: { + networkPlugin: 'azure' + loadBalancerSku: 'standard' + serviceCidr: '192.168.0.0/16' + dnsServiceIP: '192.168.0.10' + } + addonProfiles: {} + apiServerAccessProfile: { + enablePrivateCluster: false + } + oidcIssuerProfile: { + enabled: true + } + securityProfile: { + workloadIdentity: { + enabled: true + } + } + } + dependsOn: [vnet] +} + +// Attach ACR to AKS +resource acrRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(aks.id, acr.id, 'acrpull') + scope: acr + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') // AcrPull + principalId: aks.properties.identityProfile.kubeletidentity.objectId + principalType: 'ServicePrincipal' + } +} + +// Public IP for App Gateway +resource appGwPublicIp 'Microsoft.Network/publicIPAddresses@2022-05-01' = { + name: 'mg-pip-${identifer}' + location: location + sku: { + name: 'Standard' + } + properties: { + publicIPAllocationMethod: 'Static' + dnsSettings: { + domainNameLabel: domainNameLabel + } + } +} + +// Application Gateway +resource appGw 'Microsoft.Network/applicationGateways@2022-09-01' = { + name: appGwName + location: location + properties: { + sku: { + name: 'Standard_v2' + tier: 'Standard_v2' + capacity: 1 + } + gatewayIPConfigurations: [ + { + name: 'appGwIpConfig' + properties: { + subnet: { + id: resourceId('Microsoft.Network/virtualNetworks/subnets', vnetName, appGwSubnetName) + } + } + } + ] + frontendIPConfigurations: [ + { + name: 'appGwFrontendIP' + properties: { + publicIPAddress: { + id: appGwPublicIp.id + } + } + } + ] + frontendPorts: [ + { + name: 'httpPort' + properties: { + port: 80 + } + } + ] + backendAddressPools: [ + { + name: 'aksBackendPool' + properties: { + backendAddresses: [ + { + ipAddress: '10.0.1.100' + } + ] + } + } + ] + backendHttpSettingsCollection: [ + { + name: 'httpSettings' + properties: { + port: 8000 + protocol: 'Http' + pickHostNameFromBackendAddress: false + requestTimeout: 20 + probe: { + id: resourceId('Microsoft.Network/applicationGateways/probes', appGwName, 'mcpgateway-probe') + } + } + } + ] + httpListeners: [ + { + name: 'httpListener' + properties: { + frontendIPConfiguration: { + id: resourceId('Microsoft.Network/applicationGateways/frontendIPConfigurations', appGwName, 'appGwFrontendIP') + } + frontendPort: { + id: resourceId('Microsoft.Network/applicationGateways/frontendPorts', appGwName, 'httpPort') + } + protocol: 'Http' + } + } + ] + requestRoutingRules: [ + { + name: 'rule1' + properties: { + ruleType: 'Basic' + httpListener: { + id: resourceId('Microsoft.Network/applicationGateways/httpListeners', appGwName, 'httpListener') + } + backendAddressPool: { + id: resourceId('Microsoft.Network/applicationGateways/backendAddressPools', appGwName, 'aksBackendPool') + } + backendHttpSettings: { + id: resourceId('Microsoft.Network/applicationGateways/backendHttpSettingsCollection', appGwName, 'httpSettings') + } + priority: 100 + } + } + ] + probes: [ + { + name: 'mcpgateway-probe' + properties: { + protocol: 'Http' + host: '10.0.1.100' + path: '/ping' + interval: 30 + timeout: 30 + unhealthyThreshold: 3 + pickHostNameFromBackendHttpSettings: false + minServers: 0 + match: { + statusCodes: [ + '200-399' + ] + } + } + } + ] + } + dependsOn: [vnet] +} + + +// User Assigned Identity +resource uai 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: userAssignedIdentityName + location: location +} + +// Federated Credential +resource federatedCred 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-01-31' = { + parent: uai + name: 'mg-sa-federation-${identifer}' + properties: { + audiences: [ + 'api://AzureADTokenExchange' + ] + issuer: aks.properties.oidcIssuerProfile.issuerURL + subject: 'system:serviceaccount:adapter:mcpgateway-sa' + } +} + +// CosmosDB Account +resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = { + name: cosmosDbAccountName + location: location + kind: 'GlobalDocumentDB' + properties: { + databaseAccountOfferType: 'Standard' + locations: [ + { + locationName: location + failoverPriority: 0 + } + ] + capabilities: [] + consistencyPolicy: { + defaultConsistencyLevel: 'Session' + } + enableFreeTier: false + } +} + +// Cosmos DB SQL Database +resource cosmosDbSqlDb 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-04-15' = { + parent: cosmosDb + name: 'McpGatewayDb' + properties: { + resource: { + id: 'McpGatewayDb' + } + } +} + +// Cosmos DB SQL Containers +resource adapterContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = { + name: 'AdapterContainer' + parent: cosmosDbSqlDb + properties: { + resource: { + id: 'AdapterContainer' + partitionKey: { + paths: ['/id'] + kind: 'Hash' + } + } + } +} + +resource cacheContainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-04-15' = { + name: 'CacheContainer' + parent: cosmosDbSqlDb + properties: { + resource: { + id: 'CacheContainer' + partitionKey: { + paths: ['/id'] + kind: 'Hash' + } + } + } +} + +// Cosmos DB Data Contributor Role Assignment to UAI +resource cosmosDbRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2022-11-15' = { + parent: cosmosDb + name: guid(cosmosDb.name, uai.id, 'data-contributor') + properties: { + roleDefinitionId: resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', cosmosDb.name, '00000000-0000-0000-0000-000000000002') + principalId: uai.properties.principalId + scope: cosmosDb.id + } +} + +// Application Insights +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: appInsightsName + location: location + kind: 'web' + properties: { + Application_Type: 'web' + } +} + +output identityClientId string = uai.properties.clientId +output applicationInsightConnectionString string = appInsights.properties.ConnectionString diff --git a/deployment/k8s/cloud-deployment-template.yml b/deployment/k8s/cloud-deployment-template.yml new file mode 100644 index 0000000..01501e1 --- /dev/null +++ b/deployment/k8s/cloud-deployment-template.yml @@ -0,0 +1,107 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: adapter +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mcpgateway + namespace: adapter +spec: + replicas: 1 + selector: + matchLabels: + app: mcpgateway + template: + metadata: + labels: + app: mcpgateway + azure.workload.identity/use: "true" + spec: + serviceAccountName: mcpgateway-sa + containers: + - name: mcpgateway-container + image: ghcr.io/microsoft/mcp-gateway:latest + ports: + - containerPort: 8000 + env: + - name: ASPNETCORE_ENVIRONMENT + value: "Production" + envFrom: + - configMapRef: + name: app-config +--- +apiVersion: v1 +kind: Service +metadata: + name: mcpgateway-service + namespace: adapter + annotations: + service.beta.kubernetes.io/azure-load-balancer-internal: "true" +spec: + type: LoadBalancer + loadBalancerIP: 10.0.1.100 + selector: + app: mcpgateway + ports: + - protocol: TCP + port: 8000 + targetPort: 8000 +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mcpgateway-sa + namespace: adapter + annotations: + azure.workload.identity/client-id: "{AZURE_CLIENT_ID}" +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: app-manager + namespace: adapter +rules: + - apiGroups: ["apps"] + resources: ["statefulsets"] + verbs: ["get", "list", "create", "update", "delete"] + - apiGroups: [""] + resources: ["services", "pods"] + verbs: ["get", "list", "create", "update", "delete"] + - apiGroups: [""] + resources: ["pods/log"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: app-manager-binding + namespace: adapter +subjects: + - kind: ServiceAccount + name: mcpgateway-sa + namespace: adapter +roleRef: + kind: Role + name: app-manager + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: app-config + namespace: adapter +data: + AzureAd__Instance: "https://login.microsoftonline.com/" + AzureAd__TenantId: "{TENANT_ID}" + AzureAd__ClientId: "{CLIENT_ID}" + AzureAd__Audience: "{CLIENT_ID}" + + CosmosSettings__AccountEndpoint: "https://cosmos{IDENTIFIER}.documents.azure.com:443/" + CosmosSettings__DatabaseName: "McpGatewayDb" + CosmosSettings__ConnectionString: "" + + ApplicationInsights__ConnectionString: "{APPINSIGHTS_CONNECTION_STRING}" + + ContainerRegistrySettings__Endpoint: "acr{IDENTIFIER}.azurecr.io" diff --git a/k8s/deployment.yml b/deployment/k8s/local-deployment.yml similarity index 100% rename from k8s/deployment.yml rename to deployment/k8s/local-deployment.yml diff --git a/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs b/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs index 46493f1..cb3f3fa 100644 --- a/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs +++ b/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs @@ -8,6 +8,7 @@ namespace Microsoft.McpGateway.Management.Contracts public class AdapterResource : AdapterData { [JsonPropertyOrder(-1)] + [JsonPropertyName("id")] public required string Id { get; set; } /// diff --git a/dotnet/Microsoft.McpGateway.Management/src/Store/CosmosAdapterResourceStore.cs b/dotnet/Microsoft.McpGateway.Management/src/Store/CosmosAdapterResourceStore.cs index 0b02ef6..1f6086b 100644 --- a/dotnet/Microsoft.McpGateway.Management/src/Store/CosmosAdapterResourceStore.cs +++ b/dotnet/Microsoft.McpGateway.Management/src/Store/CosmosAdapterResourceStore.cs @@ -50,7 +50,7 @@ await db.Database.CreateContainerIfNotExistsAsync( var response = await _container.ReadItemAsync( id: name, partitionKey: new PartitionKey(name), - cancellationToken: cancellationToken); + cancellationToken: cancellationToken).ConfigureAwait(false); return response.Resource; } @@ -64,11 +64,13 @@ await db.Database.CreateContainerIfNotExistsAsync( public async Task UpsertAsync(AdapterResource adapter, CancellationToken cancellationToken) { using var stream = new MemoryStream(); - await JsonSerializer.SerializeAsync(stream, adapter, cancellationToken: cancellationToken); - await _container.UpsertItemStreamAsync( + await JsonSerializer.SerializeAsync(stream, adapter, cancellationToken: cancellationToken).ConfigureAwait(false); + stream.Position = 0; + var response = await _container.UpsertItemStreamAsync( stream, partitionKey: new PartitionKey(adapter.Id), - cancellationToken: cancellationToken); + cancellationToken: cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); } public async Task DeleteAsync(string name, CancellationToken cancellationToken) @@ -78,7 +80,7 @@ public async Task DeleteAsync(string name, CancellationToken cancellationToken) await _container.DeleteItemAsync( id: name, partitionKey: new PartitionKey(name), - cancellationToken: cancellationToken); + cancellationToken: cancellationToken).ConfigureAwait(false); } catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { @@ -93,7 +95,7 @@ public async Task> ListAsync(CancellationToken canc while (query.HasMoreResults) { - var response = await query.ReadNextAsync(cancellationToken); + var response = await query.ReadNextAsync(cancellationToken).ConfigureAwait(false); results.AddRange(response.Resource); } diff --git a/dotnet/Microsoft.McpGateway.Service/src/Microsoft.McpGateway.Service.csproj b/dotnet/Microsoft.McpGateway.Service/src/Microsoft.McpGateway.Service.csproj index 4a75a64..8035576 100644 --- a/dotnet/Microsoft.McpGateway.Service/src/Microsoft.McpGateway.Service.csproj +++ b/dotnet/Microsoft.McpGateway.Service/src/Microsoft.McpGateway.Service.csproj @@ -1,4 +1,4 @@ - + net8.0 diff --git a/dotnet/Microsoft.McpGateway.Service/src/Program.cs b/dotnet/Microsoft.McpGateway.Service/src/Program.cs index 901e29e..f4ecad8 100644 --- a/dotnet/Microsoft.McpGateway.Service/src/Program.cs +++ b/dotnet/Microsoft.McpGateway.Service/src/Program.cs @@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.Azure.Cosmos; using Microsoft.Azure.Cosmos.Fluent; -using Microsoft.Extensions.Caching.Cosmos; using Microsoft.Identity.Web; using Microsoft.McpGateway.Management.Deployment; using Microsoft.McpGateway.Management.Service; @@ -15,8 +14,10 @@ using Microsoft.McpGateway.Service.Session; var builder = WebApplication.CreateBuilder(args); +var credential = new DefaultAzureCredential(); builder.Services.AddApplicationInsightsTelemetry(); +builder.Services.AddLogging(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -37,10 +38,10 @@ { var config = builder.Configuration.GetSection("CosmosSettings"); var connectionString = config["ConnectionString"]; - var client = string.IsNullOrEmpty(connectionString) ? new CosmosClient(config["AccountEndpoint"], new DefaultAzureCredential()) : new CosmosClient(connectionString); + var client = string.IsNullOrEmpty(connectionString) ? new CosmosClient(config["AccountEndpoint"], credential) : new CosmosClient(connectionString); return new CosmosAdapterResourceStore(client, config["DatabaseName"]!, "AdapterContainer", c.GetRequiredService>()); }); - builder.Services.AddCosmosCache((CosmosCacheOptions options) => + builder.Services.AddCosmosCache(options => { var config = builder.Configuration.GetSection("CosmosSettings"); var endpoint = config["AccountEndpoint"]; @@ -50,7 +51,7 @@ options.DatabaseName = config["DatabaseName"]!; options.CreateIfNotExists = true; - options.ClientBuilder = string.IsNullOrEmpty(connectionString) ? new CosmosClientBuilder(endpoint, new DefaultAzureCredential()) : new CosmosClientBuilder(connectionString); + options.ClientBuilder = string.IsNullOrEmpty(connectionString) ? new CosmosClientBuilder(endpoint, credential) : new CosmosClientBuilder(connectionString); }); } diff --git a/dotnet/Microsoft.McpGateway.Service/src/Properties/PublishProfiles/github.pubxml b/dotnet/Microsoft.McpGateway.Service/src/Properties/PublishProfiles/github.pubxml new file mode 100644 index 0000000..13fd4cb --- /dev/null +++ b/dotnet/Microsoft.McpGateway.Service/src/Properties/PublishProfiles/github.pubxml @@ -0,0 +1,17 @@ + + + + + Container + NetSdk + ghcr.io + + latest + ContainerRegistry + Release + x64 + linux-x64 + a014200e-7f7d-a05f-7168-e3d6969022fe + <_TargetId>NetSdkCustomContainerRegistry + + \ No newline at end of file diff --git a/dotnet/Microsoft.McpGateway.Service/src/appsettings.json b/dotnet/Microsoft.McpGateway.Service/src/appsettings.json index b7dd04f..c38a802 100644 --- a/dotnet/Microsoft.McpGateway.Service/src/appsettings.json +++ b/dotnet/Microsoft.McpGateway.Service/src/appsettings.json @@ -14,7 +14,7 @@ }, "CosmosSettings": { "AccountEndpoint": "https://.documents.azure.com:443/", - "ConnectionString": "", + "ConnectionString": "", "DatabaseName": "McpGatewayDb" }, "ApplicationInsights": { diff --git a/infra-diagram.png b/infra-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..874ef01ff8591018293463952306c7b45b0a3423 GIT binary patch literal 44891 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 X{neK2co=z#0sOpB(p0RGw+Q<`n;-Ca literal 0 HcmV?d00001 diff --git a/openapi/mcp-gateway.openapi.json b/openapi/mcp-gateway.openapi.json index d45fb88..5d35b1f 100644 --- a/openapi/mcp-gateway.openapi.json +++ b/openapi/mcp-gateway.openapi.json @@ -6,6 +6,10 @@ "description": "RESTful APIs for MCP Server Management" }, "servers": [ + { + "url": "http://..cloudapp.azure.com", + "description": "Azure Deployment" + }, { "url": "http://localhost:8000", "description": "Local development" From 81bd6cd75f5c9aaf014ef3b05487f8c9e568fdae Mon Sep 17 00:00:00 2001 From: Lili Xu Date: Thu, 26 Jun 2025 16:26:07 -0700 Subject: [PATCH 2/3] Update doc --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 91594c2..b16a355 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ The cloud-deployed service needs authentication. Here we configure the basic bea ### 3. Deploy Infrastructure Resources This script will: -- Create a resource group named `` +- Create a resource group named `` - Deploy Azure infrastructure via Bicep templates | Resource Name | Resource Type | From a5f410915dea59815a865f525668d1fa3e2913a5 Mon Sep 17 00:00:00 2001 From: Lili Xu Date: Fri, 27 Jun 2025 12:06:23 -0700 Subject: [PATCH 3/3] update doc #2 --- README.md | 61 ++++++++++++++++++++++++------------- deployment/azure-deploy.ps1 | 16 +++++++++- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index b16a355..0834b0a 100644 --- a/README.md +++ b/README.md @@ -175,11 +175,26 @@ kubectl port-forward -n adapter svc/mcpgateway-service 8000:8000 The cloud-deployed service needs authentication. Here we configure the basic bearer token authentication using Azure Entra ID. - Go to [App Registrations](https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade) - Create a **single-tenant** app registration +- Add a platform - Mobile and desktop applications - Under **Redirect URIs**, add: `http://localhost` -- Copy the **Application (client) ID** from the overview page +- Copy the **Application (client) ID** and **Directory (tenant) ID** from the overview page ### 3. Deploy Infrastructure Resources +Run the deployment script: + +```sh +deployment/azure-deploy.ps1 -ResourceGroupName -ClientId -Location +``` + +**Parameters:** + +| Name | Description | +|--------------------|-------------------------------------------------------| +| `ResourceGroupName`| All lowercase, letters and numbers only | +| `ClientId` | Client ID from your app registration | +| `Location` | Azure region (default: `westus3`) | + This script will: - Create a resource group named `` - Deploy Azure infrastructure via Bicep templates @@ -199,24 +214,11 @@ This script will: > **Note:** It's recommended to use Managed Identity for credential-less authentication. This deployment follows that design. -Run the deployment script: - -```sh -deployment/azure-deploy.ps1 -ResourceGroupName -ClientId -Location -``` - -**Parameters:** - -| Name | Description | -|--------------------|-------------------------------------------------------| -| `ResourceGroupName`| All lowercase, letters and numbers only | -| `ClientId` | Client ID from your app registration | -| `Location` | Azure region (default: `westus3`) | - ### 4. Build & Publish MCP Server Images The gateway service pulls the MCP server image from the newly provisioned Azure Container Registry (ACR) during deployment. Build and push the MCP server image to ACR: +> **Note:** Ensure that Docker Engine is running before proceeding. ```sh az acr login -n acr @@ -229,12 +231,16 @@ docker push acr.azurecr.io/mcp-example:1.0.0 - Import the OpenAPI spec from `openapi/mcp-gateway.openapi.json` into [Postman](https://www.postman.com/), [Bruno](https://www.usebruno.com/), or [Swagger Editor](https://editor.swagger.io/) - Acquire a bearer token using this python script locally: + ```sh + pip install azure-identity + ``` ```python from azure.identity import InteractiveBrowserCredential tenant_id = "" client_id = "" credential = InteractiveBrowserCredential(tenant_id=tenant_id, client_id=client_id) - credential.get_token(f"{client_id}/.default").token + access_token = credential.get_token(f"{client_id}/.default").token + print(access_token) ``` - Send a POST request to create an adapter resource: @@ -268,12 +274,23 @@ To remove all deployed resources, delete the Azure resource group: az group delete --name --yes ``` -### 7. Production Onboarding (Follow-up) -- Configure TLS Certificates – Set up secure HTTPS communication on AAG listener using valid TLS certificates. -- Apply Network Policies – Restrict incoming traffic within the virtual network and configure Private Endpoints to enhance network security. -- Enable Advanced Telemetry – Integrate more detailed monitoring, metrics for observability and alert in production. -- Configure Server Scaling – Adjust scaling for `mcp-gateway` services and MCP servers based on expected load. -- Set Up Authentication & Authorization – Configure Oauth2.0 authentication with Azure Entra ID (AAD). Apply fine-grained access control to `adapters` via RBAC or custom ACLs. +## 7. Production Onboarding (Follow-up) + +- **TLS Configuration** + Set up HTTPS on Azure Application Gateway (AAG) listener using valid TLS certificates. + +- **Network Security** + Restrict incoming traffic within the virtual network and configure Private Endpoints for enhanced network security. + +- **Telemetry** + Enable advanced telemetry, detailed metrics, and alerts to support monitoring and troubleshooting in production. + +- **Scaling** + Adjust scaling for `mcp-gateway` services and MCP servers based on expected load. + +- **Authentication & Authorization** + Set up OAuth 2.0 with Azure Entra ID (AAD) for authentication. + Implement fine-grained access control using RBAC or custom ACLs for `adapter` level permissions. ## Contributing diff --git a/deployment/azure-deploy.ps1 b/deployment/azure-deploy.ps1 index 0475ab3..40cb74c 100644 --- a/deployment/azure-deploy.ps1 +++ b/deployment/azure-deploy.ps1 @@ -1,8 +1,22 @@ param( [Parameter(Mandatory=$true)] + [ValidateScript({ + if ($_ -match '^[a-z0-9]+$') { + $true + } else { + throw "ResourceGroupName must contain only lowercase letters and numbers (no spaces or special characters)." + } + })] [string]$ResourceGroupName, [Parameter(Mandatory=$true)] + [ValidateScript({ + if ($_ -match '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') { + $true + } else { + throw "ClientId must be a valid GUID in the format 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'." + } + })] [string]$ClientId, [string]$Location = "westus3" @@ -28,4 +42,4 @@ $deployment = az deployment group create ` | Set-Content "deployment/k8s/cloud-deployment.yml" # Deploy to AKS -az aks command invoke -g $ResourceGroupName -n mg-aks-"$ResourceGroupName" --command "kubectl apply -f cloud-deployment.yml" --file deployment/k8s/cloud-deployment.yml \ No newline at end of file +az aks command invoke -g $ResourceGroupName -n mg-aks-"$ResourceGroupName" --command "kubectl apply -f cloud-deployment.yml" --file deployment/k8s/cloud-deployment.yml