From 4dcdb7080728a42dfc78b85b625412458cb2da6b Mon Sep 17 00:00:00 2001 From: Lili Xu Date: Sat, 13 Sep 2025 11:44:45 -0700 Subject: [PATCH] Gateway proxy to local & remote MCP servers --- README.md | 6 +- deployment/infra/azure-deployment.bicep | 26 +++- deployment/infra/azure-deployment.json | 33 ++++- deployment/k8s/cloud-deployment-template.yml | 13 +- .../src/Contracts/AdapterData.cs | 10 +- .../src/Contracts/AdapterResource.cs | 11 +- .../KubernetesAdapterDeploymentManager.cs | 5 +- .../AdapterReverseProxyController.cs | 2 +- mcp-example-server/requirements.txt | 2 +- mcp-proxy-server/Dockerfile | 47 +++++++ mcp-proxy-server/README.md | 119 ++++++++++++++++++ mcp-proxy-server/requirements.txt | 2 + mcp-proxy-server/src/main.py | 64 ++++++++++ 13 files changed, 323 insertions(+), 17 deletions(-) create mode 100644 mcp-proxy-server/Dockerfile create mode 100644 mcp-proxy-server/README.md create mode 100644 mcp-proxy-server/requirements.txt create mode 100644 mcp-proxy-server/src/main.py diff --git a/README.md b/README.md index e59241b..d2f55b2 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ ## Overview +#### *New*: Support for **Proxying Local & Remote MCP Servers**. See [examples and usage](mcp-proxy-server/README.md). + This project provides: - A data gateway for routing traffic to MCP servers with session affinity. @@ -146,7 +148,7 @@ kubectl port-forward -n adapter svc/mcpgateway-service 8000:8000 ``` ### 8. Test the API - MCP Server Access -- After deploying the MCP server, use a client like [VS Code](https://code.visualstudio.com/) to test the connection. Refer to the guide: [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). +- After deploying the MCP server, use a client like [VS Code](https://code.visualstudio.com/) to test the connection. Refer to the guide: [Use MCP servers in VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). > **Note:** Ensure VSCode is up to date to access the latest MCP features. - To connect to the deployed `mcp-example` server, use: @@ -288,7 +290,7 @@ az acr build -r "mgreg$resourceLabel" -f mcp-example-server/Dockerfile mcp-examp ### 6. Test the API - MCP Server Access -- After deploying the MCP server, use a client like [VS Code](https://code.visualstudio.com/) to test the connection. Refer to the guide: [Use MCP servers in VS Code (Preview)](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). +- After deploying the MCP server, use a client like [VS Code](https://code.visualstudio.com/) to test the connection. Refer to the guide: [Use MCP servers in VS Code](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). > **Note:** Ensure VSCode is up to date to access the latest MCP features. - To connect to the deployed `mcp-example` server, use: diff --git a/deployment/infra/azure-deployment.bicep b/deployment/infra/azure-deployment.bicep index 00eb191..a3b1768 100644 --- a/deployment/infra/azure-deployment.bicep +++ b/deployment/infra/azure-deployment.bicep @@ -280,12 +280,18 @@ resource uai 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { location: location } -// User Assigned Identity for amdin +// User Assigned Identity for admin resource uaiAdmin 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { name: '${userAssignedIdentityName}-admin' location: location } +// User Assigned Identity for server workload instance +resource uaiWorkload 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: '${userAssignedIdentityName}-workload' + location: location +} + resource uaiAdminContributorOnAks 'Microsoft.Authorization/roleAssignments@2022-04-01' = { name: guid(aks.name, uaiAdmin.name, 'AKSContributor') scope: aks @@ -319,6 +325,19 @@ resource federatedCred 'Microsoft.ManagedIdentity/userAssignedIdentities/federat } } +// Federated Credential +resource federatedCredWorkload 'Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials@2023-01-31' = { + parent: uaiWorkload + name: '${federatedCredName}-workload' + properties: { + audiences: [ + 'api://AzureADTokenExchange' + ] + issuer: aks.properties.oidcIssuerProfile.issuerURL + subject: 'system:serviceaccount:adapter:workload-sa' + } +} + // CosmosDB Account resource cosmosDb 'Microsoft.DocumentDB/databaseAccounts@2023-04-15' = { name: cosmosDbAccountName @@ -417,6 +436,7 @@ resource kubernetesDeployment 'Microsoft.Resources/deploymentScripts@2023-08-01' retentionInterval: 'P1D' scriptContent: ''' sed -i "s|\${AZURE_CLIENT_ID}|$AZURE_CLIENT_ID|g" cloud-deployment-template.yml + sed -i "s|\${WORKLOAD_CLIENT_ID}|$WORKLOAD_CLIENT_ID|g" cloud-deployment-template.yml sed -i "s|\${TENANT_ID}|$TENANT_ID|g" cloud-deployment-template.yml sed -i "s|\${CLIENT_ID}|$CLIENT_ID|g" cloud-deployment-template.yml sed -i "s|\${APPINSIGHTS_CONNECTION_STRING}|$APPINSIGHTS_CONNECTION_STRING|g" cloud-deployment-template.yml @@ -441,6 +461,10 @@ resource kubernetesDeployment 'Microsoft.Resources/deploymentScripts@2023-08-01' name: 'AZURE_CLIENT_ID' value: uai.properties.clientId } + { + name: 'WORKLOAD_CLIENT_ID' + value: uaiWorkload.properties.clientId + } { name: 'APPINSIGHTS_CONNECTION_STRING' value: appInsights.properties.ConnectionString diff --git a/deployment/infra/azure-deployment.json b/deployment/infra/azure-deployment.json index 0ddad78..7a99175 100644 --- a/deployment/infra/azure-deployment.json +++ b/deployment/infra/azure-deployment.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.36.1.42791", - "templateHash": "4470748714752194409" + "templateHash": "7651876796214077404" } }, "parameters": { @@ -323,6 +323,12 @@ "name": "[format('{0}-admin', variables('userAssignedIdentityName'))]", "location": "[parameters('location')]" }, + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities", + "apiVersion": "2023-01-31", + "name": "[format('{0}-workload', variables('userAssignedIdentityName'))]", + "location": "[parameters('location')]" + }, { "type": "Microsoft.Authorization/roleAssignments", "apiVersion": "2022-04-01", @@ -369,6 +375,22 @@ "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('userAssignedIdentityName'))]" ] }, + { + "type": "Microsoft.ManagedIdentity/userAssignedIdentities/federatedIdentityCredentials", + "apiVersion": "2023-01-31", + "name": "[format('{0}/{1}', format('{0}-workload', variables('userAssignedIdentityName')), format('{0}-workload', variables('federatedCredName')))]", + "properties": { + "audiences": [ + "api://AzureADTokenExchange" + ], + "issuer": "[reference(resourceId('Microsoft.ContainerService/managedClusters', variables('aksName')), '2023-04-01').oidcIssuerProfile.issuerURL]", + "subject": "system:serviceaccount:adapter:workload-sa" + }, + "dependsOn": [ + "[resourceId('Microsoft.ContainerService/managedClusters', variables('aksName'))]", + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-workload', variables('userAssignedIdentityName')))]" + ] + }, { "type": "Microsoft.DocumentDB/databaseAccounts", "apiVersion": "2023-04-15", @@ -481,7 +503,7 @@ "azCliVersion": "2.60.0", "timeout": "PT30M", "retentionInterval": "P1D", - "scriptContent": " sed -i \"s|\\${AZURE_CLIENT_ID}|$AZURE_CLIENT_ID|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${TENANT_ID}|$TENANT_ID|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${CLIENT_ID}|$CLIENT_ID|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${APPINSIGHTS_CONNECTION_STRING}|$APPINSIGHTS_CONNECTION_STRING|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${IDENTIFIER}|$IDENTIFIER|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${REGION}|$REGION|g\" cloud-deployment-template.yml\r\n\r\n az aks command invoke -g $ResourceGroupName -n mg-aks-\"$ResourceGroupName\" --command \"kubectl apply -f cloud-deployment-template.yml\" --file cloud-deployment-template.yml\r\n ", + "scriptContent": " sed -i \"s|\\${AZURE_CLIENT_ID}|$AZURE_CLIENT_ID|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${WORKLOAD_CLIENT_ID}|$WORKLOAD_CLIENT_ID|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${TENANT_ID}|$TENANT_ID|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${CLIENT_ID}|$CLIENT_ID|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${APPINSIGHTS_CONNECTION_STRING}|$APPINSIGHTS_CONNECTION_STRING|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${IDENTIFIER}|$IDENTIFIER|g\" cloud-deployment-template.yml\r\n sed -i \"s|\\${REGION}|$REGION|g\" cloud-deployment-template.yml\r\n\r\n az aks command invoke -g $ResourceGroupName -n mg-aks-\"$ResourceGroupName\" --command \"kubectl apply -f cloud-deployment-template.yml\" --file cloud-deployment-template.yml\r\n ", "supportingScriptUris": [ "https://raw.githubusercontent.com/microsoft/mcp-gateway/refs/heads/main/deployment/k8s/cloud-deployment-template.yml" ], @@ -498,6 +520,10 @@ "name": "AZURE_CLIENT_ID", "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('userAssignedIdentityName')), '2023-01-31').clientId]" }, + { + "name": "WORKLOAD_CLIENT_ID", + "value": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-workload', variables('userAssignedIdentityName'))), '2023-01-31').clientId]" + }, { "name": "APPINSIGHTS_CONNECTION_STRING", "value": "[reference(resourceId('Microsoft.Insights/components', variables('appInsightsName')), '2020-02-02').ConnectionString]" @@ -520,7 +546,8 @@ "[resourceId('Microsoft.ContainerService/managedClusters', variables('aksName'))]", "[resourceId('Microsoft.Insights/components', variables('appInsightsName'))]", "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('userAssignedIdentityName'))]", - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-admin', variables('userAssignedIdentityName')))]" + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-admin', variables('userAssignedIdentityName')))]", + "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', format('{0}-workload', variables('userAssignedIdentityName')))]" ] } ] diff --git a/deployment/k8s/cloud-deployment-template.yml b/deployment/k8s/cloud-deployment-template.yml index 12e1b74..8b97b44 100644 --- a/deployment/k8s/cloud-deployment-template.yml +++ b/deployment/k8s/cloud-deployment-template.yml @@ -57,6 +57,14 @@ metadata: annotations: azure.workload.identity/client-id: "${AZURE_CLIENT_ID}" --- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: workload-sa + namespace: adapter + annotations: + azure.workload.identity/client-id: "${WORKLOAD_CLIENT_ID}" +--- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: @@ -65,13 +73,16 @@ metadata: rules: - apiGroups: ["apps"] resources: ["statefulsets"] - verbs: ["get", "list", "create", "update", "delete"] + verbs: ["get", "list", "create", "update", "delete", "patch"] - apiGroups: [""] resources: ["services", "pods"] verbs: ["get", "list", "create", "update", "delete"] - apiGroups: [""] resources: ["pods/log"] verbs: ["get"] + - apiGroups: [""] + resources: ["pods"] + verbs: ["watch"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterData.cs b/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterData.cs index b5f23ea..fda9f88 100644 --- a/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterData.cs +++ b/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterData.cs @@ -60,6 +60,12 @@ public class AdapterData [JsonPropertyOrder(8)] public string Description { get; set; } = string.Empty; + /// + /// Indicates whether to use workload identity for the deployed adapter instance. Default is false. + /// + [JsonPropertyOrder(9)] + public bool UseWorkloadIdentity { get; set; } = false; + public AdapterData( string name, string imageName, @@ -68,7 +74,8 @@ public AdapterData( int? replicaCount = 1, string description = "", ServerProtocol protocol = ServerProtocol.MCP, - ConnectionType connectionType = ConnectionType.StreamableHttp) + ConnectionType connectionType = ConnectionType.StreamableHttp, + bool useWorkloadIdentity = false) { ArgumentException.ThrowIfNullOrEmpty(name); ArgumentException.ThrowIfNullOrEmpty(imageName); @@ -82,6 +89,7 @@ public AdapterData( EnvironmentVariables = environmentVariables ?? []; ReplicaCount = replicaCount ?? 1; Description = description; + UseWorkloadIdentity = useWorkloadIdentity; } public AdapterData() { } diff --git a/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs b/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs index cb3f3fa..d080c4d 100644 --- a/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs +++ b/dotnet/Microsoft.McpGateway.Management/src/Contracts/AdapterResource.cs @@ -14,23 +14,23 @@ public class AdapterResource : AdapterData /// /// The ID of the user who created the adapter. /// - [JsonPropertyOrder(9)] + [JsonPropertyOrder(30)] public required string CreatedBy { get; set; } /// /// The date and time when the adapter was created. /// - [JsonPropertyOrder(10)] + [JsonPropertyOrder(31)] public DateTimeOffset CreatedAt { get; set; } /// /// The date and time when the adapter was created. /// - [JsonPropertyOrder(11)] + [JsonPropertyOrder(32)] public DateTimeOffset LastUpdatedAt { get; set; } public AdapterResource(AdapterData adapterData, string createdBy, DateTimeOffset createdAt, DateTimeOffset lastUpdatedAt) - : base(adapterData.Name, adapterData.ImageName, adapterData.ImageVersion, adapterData.EnvironmentVariables, adapterData.ReplicaCount, adapterData.Description, adapterData.Protocol, adapterData.ConnectionType) + : base(adapterData.Name, adapterData.ImageName, adapterData.ImageVersion, adapterData.EnvironmentVariables, adapterData.ReplicaCount, adapterData.Description, adapterData.Protocol, adapterData.ConnectionType, adapterData.UseWorkloadIdentity) { CreatedBy = createdBy; CreatedAt = createdAt; @@ -51,7 +51,8 @@ public static AdapterResource Create(AdapterData data, string createdBy, DateTim ConnectionType = data.ConnectionType, CreatedBy = createdBy, CreatedAt = createdAt, - LastUpdatedAt = DateTime.UtcNow + LastUpdatedAt = DateTime.UtcNow, + UseWorkloadIdentity = data.UseWorkloadIdentity }; public AdapterResource() { } diff --git a/dotnet/Microsoft.McpGateway.Management/src/Deployment/KubernetesAdapterDeploymentManager.cs b/dotnet/Microsoft.McpGateway.Management/src/Deployment/KubernetesAdapterDeploymentManager.cs index f0b34b6..d3aac5c 100644 --- a/dotnet/Microsoft.McpGateway.Management/src/Deployment/KubernetesAdapterDeploymentManager.cs +++ b/dotnet/Microsoft.McpGateway.Management/src/Deployment/KubernetesAdapterDeploymentManager.cs @@ -32,7 +32,8 @@ public async Task CreateDeploymentAsync(AdapterData request, CancellationToken c var labels = new Dictionary { { $"{AdapterNamespace}/type", "mcp" }, - { $"{AdapterNamespace}/name", request.Name } + { $"{AdapterNamespace}/name", request.Name }, + { "azure.workload.identity/use", request.UseWorkloadIdentity.ToString().ToLowerInvariant() } }; var statefulSet = new V1StatefulSet @@ -48,6 +49,7 @@ public async Task CreateDeploymentAsync(AdapterData request, CancellationToken c Metadata = new V1ObjectMeta { Labels = labels }, Spec = new V1PodSpec { + ServiceAccountName = "workload-sa", SecurityContext = new V1PodSecurityContext { RunAsUser = 1100, @@ -72,7 +74,6 @@ public async Task CreateDeploymentAsync(AdapterData request, CancellationToken c SecurityContext = new V1SecurityContext { AllowPrivilegeEscalation = false, - ReadOnlyRootFilesystem = true, Capabilities = new V1Capabilities { Drop = ["ALL"] } }, Resources = new V1ResourceRequirements diff --git a/dotnet/Microsoft.McpGateway.Service/src/Controllers/AdapterReverseProxyController.cs b/dotnet/Microsoft.McpGateway.Service/src/Controllers/AdapterReverseProxyController.cs index e0e96ea..fc41540 100644 --- a/dotnet/Microsoft.McpGateway.Service/src/Controllers/AdapterReverseProxyController.cs +++ b/dotnet/Microsoft.McpGateway.Service/src/Controllers/AdapterReverseProxyController.cs @@ -155,7 +155,7 @@ private static Uri ReplaceUriAddress(Uri originalUri, string newAddress) var newBaseUri = new Uri(newAddress, UriKind.Absolute); var path = '/' + string.Join('/', segments.Skip(2)); - if (path.EndsWith("/messages") || path.EndsWith("/mcp")) + if (path.EndsWith("/messages")) path += "/"; var newUriBuilder = new UriBuilder(newBaseUri.Scheme, newBaseUri.Host, newBaseUri.Port) diff --git a/mcp-example-server/requirements.txt b/mcp-example-server/requirements.txt index 74cf909..76cb1af 100644 --- a/mcp-example-server/requirements.txt +++ b/mcp-example-server/requirements.txt @@ -1 +1 @@ -fastmcp==2.6.1 +fastmcp==2.11.3 diff --git a/mcp-proxy-server/Dockerfile b/mcp-proxy-server/Dockerfile new file mode 100644 index 0000000..28391c0 --- /dev/null +++ b/mcp-proxy-server/Dockerfile @@ -0,0 +1,47 @@ +FROM python:3.12-slim + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y curl ca-certificates gnupg \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | tee /etc/apt/keyrings/nodesource.gpg > /dev/null \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \ + | tee /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates tini dumb-init \ + && rm -rf /var/lib/apt/lists/* \ + && curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin UV_NO_MODIFY_PATH=1 sh + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/root/.local/bin:${PATH}" + +RUN python --version && node --version && npm --version && npx --version && uv --version + +# Create non-root user +RUN adduser --disabled-password --gecos '' --uid 1100 mcpuser + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/main.py /app/main.py + +# Change ownership to non-root user +RUN chown -R mcpuser:mcpuser /app \ + && chown -R mcpuser:mcpuser /home/mcpuser + +# Switch to non-root user +USER 1100 + +EXPOSE 8080 + +ENTRYPOINT ["python", "/app/main.py"] diff --git a/mcp-proxy-server/README.md b/mcp-proxy-server/README.md new file mode 100644 index 0000000..75e2d51 --- /dev/null +++ b/mcp-proxy-server/README.md @@ -0,0 +1,119 @@ +# Proxying Local & Remote MCP Servers + +## What’s New + +### Proxying Local Stdio MCP Server +- Spin up local MCP servers behind the gateway by specifying a command (`npx`, `uvx`, etc.). +- Expose them remotely via HTTP/Streamable so cloud agents can connect. +- Support workload identity for secure authentication with Azure resources. + +With this, you can transform **local-only MCP servers** into **cloud-accessible services** that plug directly into your AI workflows. + +### Proxying Remote HTTP MCP Server +- Forward requests from the gateway to an existing MCP server over Streamable HTTP. + + +## Instructions + +### Preparation + +- Make sure the cloud deployment has been done. +- Build the MCP proxy server image in ACR. + ```sh + az acr build -r "mgreg$resourceLabel" -f mcp-proxy-server/Dockerfile mcp-proxy-server -t "mgreg$resourceLabel.azurecr.io/mcp-proxy:1.0.0" + ``` + +- Configure permissions for the workload identity principal (If setting up a local mcp server) +`mg-identity--workload`. +This identity is created by deployment. The MCP server will use the workload identity for upstream resouce access. + +### Proxying Local Servers +For starting a local MCP server in stdio and proxying the traffice through gateway to it. +Set server startup command and arguments in environment variables: + - `MCP_COMMAND` + - `MCP_ARGS` + +Set `useWorkloadIdentity` to be true if need the server to use the workload identity. + + > **Note:** When using a bridged local server, certain system packages may be missing by default. To address this, you can install the required packages within a custom Dockerfile and build your own `mcp-proxy` image. + +### Proxying Remote Servers +For proxying another internal mcp server hosted in streamable HTTP. Set the target ednpoint in environment variable + - `MCP_PROXY_URL` + + +## Examples + +Example payloads to send to `mcp-gateway` using the `POST /adapters` endpoint to launch a mcp server remotely. + +#### Example 1: Bridged [Azure MCP Server](https://github.com/microsoft/mcp/tree/main/servers/Azure.Mcp.Server) +```json +{ + "name": "ado-remote", + "imageName": "mcp-proxy", + "imageVersion": "1.0.0", + "environmentVariables": { + "MCP_COMMAND": "npx", + "MCP_ARGS": "-y @azure/mcp@latest server start", + "AZURE_MCP_INCLUDE_PRODUCTION_CREDENTIALS": "true", + "DOTNET_SYSTEM_GLOBALIZATION_INVARIANT": "1" + }, + "description": "Bridged ADO local MCP server" +} +``` + +#### Example 2: Bridged [Azure AI Foundry MCP Server](https://github.com/azure-ai-foundry/mcp-foundry) +```json +{ + "name": "foundry", + "imageName": "mcp-proxy", + "imageVersion": "1.0.0", + "environmentVariables": { + "MCP_COMMAND": "uvx", + "MCP_ARGS": "--prerelease=allow --from git+https://github.com/azure-ai-foundry/mcp-foundry.git run-azure-ai-foundry-mcp" + }, + "useWorkloadIdentity": true, + "description": "Bridged Azure AI Foundry Local MCP Server" +} +``` + +#### Example 3: Bridged [Azure DevOps MCP Server](https://github.com/microsoft/azure-devops-mcp) +```json +{ + "name": "ado-remote", + "imageName": "mcp-proxy", + "imageVersion": "1.0.0", + "environmentVariables": { + "MCP_COMMAND": "npx", + "MCP_ARGS": "-y @azure-devops/mcp contoso", + "ADO_MCP_AZURE_TOKEN_CREDENTIALS": "WorkloadIdentityCredential", + "AZURE_TOKEN_CREDENTIALS": "WorkloadIdentityCredential" + }, + "useWorkloadIdentity": true, + "description": "Bridged ADO MCP Local Server" +} +``` + +> **Note:** Different MCP servers have different conventions for reading credentials from the environment for setting up `TokenCredential` and connect to upstream resources. You may need to adjust the environment variable names/values per server.
+Examples: +Some servers expect a general switch like `AZURE_TOKEN_CREDENTIALS=WorkloadIdentityCredential` +Others use service-specific variables (e.g., `ADO_MCP_AZURE_TOKEN_CREDENTIALS`) + +#### Example 4: Proxied Internal MCP Server (Streamable HTTP) +```json +{ + "name": "internal-mcp", + "imageName": "mcp-proxy", + "imageVersion": "1.0.0", + "environmentVariables": { + "MCP_PROXY_URL": "https://internal-mcp-server/mcp" + }, + "description": "Proxied Internal MCP Server" +} +``` + +## Security Considerations + +Before running in production +- Implement appropriate access controls on the gateway level to prevent users from exploiting the workload identity access through it. +- Always only register trusted MCP servers, enable network access policies on the server pods. diff --git a/mcp-proxy-server/requirements.txt b/mcp-proxy-server/requirements.txt new file mode 100644 index 0000000..8e064ac --- /dev/null +++ b/mcp-proxy-server/requirements.txt @@ -0,0 +1,2 @@ +fastmcp==2.11.3 +validators==0.35.0 diff --git a/mcp-proxy-server/src/main.py b/mcp-proxy-server/src/main.py new file mode 100644 index 0000000..824c5f4 --- /dev/null +++ b/mcp-proxy-server/src/main.py @@ -0,0 +1,64 @@ +from fastmcp import FastMCP +import os, sys, shlex +import validators + +MAX_ARGS = 64 +MAX_ARG_LEN = 512 + +def bad(msg: str): + print(f"[shim] {msg}", file=sys.stderr) + sys.exit(1) + +def safe(arg: str) -> bool: + return ( + isinstance(arg, str) + and 0 < len(arg) <= MAX_ARG_LEN + and "\x00" not in arg + and "\n" not in arg + and "\r" not in arg + ) + +def main(): + proxy_url = os.environ.get("MCP_PROXY_URL") + if proxy_url: + if not validators.url(proxy_url): + bad("Invalid MCP_PROXY_URL: must be http(s) and a well-formed URL") + config = { + "mcpServers": { + "default": { + "url": proxy_url, + "transport": "http" + } + } + } + else: + cmd = os.environ.get("MCP_COMMAND") + if not cmd: + bad("Must set either MCP_PROXY_URL or MCP_COMMAND environment variable") + raw_args = os.environ.get("MCP_ARGS", "") + args = shlex.split(raw_args) if raw_args else [] + + if not safe(cmd): + bad("Unsafe command") + if len(args) > MAX_ARGS: + bad("Too many args") + for a in args: + if not safe(a): + bad(f"Unsafe arg: {a!r}") + + config = { + "mcpServers": { + "default": { + "type": "stdio", + "command": cmd, + "args": args, + "env": dict(os.environ) + } + } + } + + app = FastMCP.as_proxy(config, name="MCP Proxy Server") + app.run(transport="streamable-http", host="0.0.0.0", port=8000) + +if __name__ == "__main__": + main()