This proof of concept demonstrates Azure API Management (APIM) privately calling an API that lives "on-premises" — specifically, an on-prem Data API Builder (DAB) MCP server exposed through APIM as a passthrough Model Context Protocol (MCP) server. The on-prem network is simulated as a second Azure VNet joined to the Azure VNet by an IPsec VNet-to-VNet (Site-to-Site style) VPN. Everything is authored in Bicep and driven from your machine with the Azure CLI.
📐 See
docs/ARCHITECTURE.mdfor the full architecture, a per‑module Bicep reference, and Azure‑icon Mermaid diagrams (validated withmmdc).
Deploy the entire POC into your own subscription — no clone, no CLI, no scripts. Click the button, pick a subscription/region, and select Create:
The button opens the Azure Portal's custom-deployment form driven by
azuredeploy.json (a compiled ARM template) and
createUiDefinition.json. A random VPN pre-shared key is
generated automatically. The VPN gateways take ~30–45 minutes to provision (the
long pole); APIM Standard v2 and the ACI backend come up in a few minutes.
Before you click — the deployment is fully self-contained (the dummy MCP server is embedded in the template, so no clone/build/registry is needed), but a few subscription-level things are outside the template's control:
- Region: pick one that supports APIM Standard v2 and zone-redundant VPN gateways
(e.g.
swedencentral,eastus). The form filters obviously-unsupported regions, but SKU availability isn't fully filterable. - Resource providers:
Microsoft.Network,Microsoft.ContainerInstance, andMicrosoft.ApiManagementare auto-registered by the portal.Microsoft.Web(needed for the APIM subnet delegation) usually is already registered; if the deploy fails withMissingSubscriptionRegistration, runaz provider register --namespace Microsoft.Webonce. - Container image: the on-prem dummy MCP server runs on
python:3.12-slim(Docker Hub). If your subscription blocks Docker Hub, set Backend container image in the form's Advanced tab to an MCR image such asmcr.microsoft.com/azurelinux/base/python:3.12.
The button requires the template to be reachable at a public URL, which it is because this repository is public. To deploy from a fork, replace
AlahmadiQ8/apim-onprem-vnet-pocandmainin the two links above with yourowner/repoand branch.
flowchart LR
client["Client<br/>curl / browser"]
subgraph AZ["Azure VNet · vnet-azure · 10.0.0.0/16"]
direction TB
subgraph AZAPIM["snet-apim · 10.0.1.0/24"]
apim@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/api_management_services_48_color.svg", label: "APIM Standard v2 · MCP passthrough", w: 60, h: 60, pos: "b", constraint: "on" }
nsgA@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/network_security_groups_48_color.svg", label: "nsg-apim", w: 42, h: 42, pos: "b", constraint: "on" }
end
subgraph AZGW["GatewaySubnet · 10.0.0.0/27"]
gwA@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/virtual_network_gateways_48_color.svg", label: "vpngw-azure (VpnGw1AZ)", w: 60, h: 60, pos: "b", constraint: "on" }
pipA@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/public_ip_addresses_48_color.svg", label: "pip-vpngw-azure", w: 42, h: 42, pos: "b", constraint: "on" }
end
end
subgraph OP["On-prem VNet (simulated) · vnet-onprem · 10.100.0.0/16"]
direction TB
subgraph OPGW["GatewaySubnet · 10.100.0.0/27"]
gwO@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/virtual_network_gateways_48_color.svg", label: "vpngw-onprem (VpnGw1AZ)", w: 60, h: 60, pos: "b", constraint: "on" }
pipO@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/public_ip_addresses_48_color.svg", label: "pip-vpngw-onprem", w: 42, h: 42, pos: "b", constraint: "on" }
end
subgraph OPACI["snet-aci · 10.100.1.0/24"]
aci@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/container_instances_48_color.svg", label: "on-prem MCP (dummy) · 10.100.1.4:5000/mcp", w: 60, h: 60, pos: "b", constraint: "on" }
nsgO@{ img: "https://raw.githubusercontent.com/AlahmadiQ8/icons/main/icons/network_security_groups_48_color.svg", label: "nsg-aci", w: 42, h: 42, pos: "b", constraint: "on" }
end
end
client -->|"HTTPS · MCP · public gateway"| apim
apim -->|"outbound VNet integration"| gwA
gwA <-->|"IPsec VNet-to-VNet tunnel"| gwO
gwO -->|"private IP 10.100.1.4:5000"| aci
nsgA -.->|protects| apim
nsgO -.->|protects| aci
pipA -.-> gwA
pipO -.-> gwO
classDef vnet fill:#eef5ff,stroke:#3b82f6,color:#1e3a8a;
classDef subnet fill:#f8fafc,stroke:#94a3b8,color:#334155;
class AZ,OP vnet;
class AZAPIM,AZGW,OPGW,OPACI subnet;
Request path: you -> APIM public gateway (MCP) -> APIM VNet integration (snet-apim) ->
vpngw-azure -> IPsec tunnel -> vpngw-onprem -> snet-aci -> on-prem DAB MCP, and back.
The client-to-APIM hop is public (Standard v2 integration); the APIM-to-backend hop
is fully private — which is what "expose an on-prem MCP server over a private connection"
means. APIM runs in passthrough MCP mode (streamable HTTP), proxying MCP calls straight
through to DAB's /mcp endpoint rather than modeling it as a REST API.
| Decision | Choice | Notes |
|---|---|---|
| On-prem simulation | 2nd Azure VNet + its own VPN gateway | Fully reproducible, no physical hardware |
| Connectivity | VNet-to-VNet IPsec VPN | Same IPsec/IKE tunnels as a real Site-to-Site link |
| APIM | Standard v2 + VNet integration | Officially supports "external access to private and on-premises backends"; gateway stays public, backend reached privately |
| APIM subnet | delegated Microsoft.Web/serverFarms, /27+, NSG required |
v2 integration requirement |
| API shape | Passthrough MCP server (type: mcp, streamable HTTP) |
DAB already speaks MCP, so APIM proxies MCP; requires API version 2025-09-01-preview and backend MCP 2025-06-18+ |
| Backend | Azure Container Instances (private IP, port 5000) | Runs a dummy MCP server (infra/mcp-dummy/server.py, streamable HTTP, tools echo/add) so the path is testable now; swap the image/command for real DAB when ready |
| VPN SKU | VpnGw1AZ |
Non-AZ SKUs (VpnGw1) are no longer accepted for new gateways |
- Azure CLI logged in (
az login) with rights to create the resources. - Bicep CLI (
az bicep), and the resource providersMicrosoft.Network,Microsoft.Web,Microsoft.ContainerInstance,Microsoft.ApiManagement(the deploy script registers them).
Two ways to deploy the same thing — both use the single orchestrator
infra/main.bicep:
A. One click (recommended for customers). Use the Deploy to Azure button above.
B. Command line.
./deploy.shdeploy.sh submits infra/main.bicep as one resource-group deployment and prints the MCP
endpoint plus ready-to-run test commands and the VPN status command at the end. Optional
overrides via environment variables: RG, LOCATION, PUBLISHER_EMAIL, PUBLISHER_NAME,
GATEWAY_SKU, API_PATH, BACKEND_PORT (the on-prem DAB port, default 5000).
ARM provisions the independent modules (VPN, APIM, ACI) concurrently; VPN gateway
provisioning is the long pole (~30–45 min).
You can also deploy the template directly without the script:
az group create -n rg-apim-onprem-poc -l swedencentral
az deployment group create -g rg-apim-onprem-poc -f infra/main.bicep-
VPN tunnel connected (both directions):
az network vpn-connection show -g rg-apim-onprem-poc -n conn-azure-to-onprem --query connectionStatus -o tsv az network vpn-connection show -g rg-apim-onprem-poc -n conn-onprem-to-azure --query connectionStatus -o tsv
Expect
Connected. -
End-to-end through APIM (client -> public gateway -> VNet integration -> VPN -> on-prem DAB). APIM exposes the MCP server at
https://<apim>.azure-api.net/<apiPath>/mcp(default pathonprem).APIM=$(az apim list -g rg-apim-onprem-poc --query "[0].name" -o tsv) SUB=$(az account show --query id -o tsv) KEY=$(az rest --method post \ --url "https://management.azure.com/subscriptions/$SUB/resourceGroups/rg-apim-onprem-poc/providers/Microsoft.ApiManagement/service/$APIM/subscriptions/master/listSecrets?api-version=2024-05-01" \ --query primaryKey -o tsv) # Point any MCP client at the endpoint, passing the subscription key as a header: npx -y @modelcontextprotocol/inspector \ --header "Ocp-Apim-Subscription-Key: $KEY" \ "https://$APIM.azure-api.net/onprem/mcp" # ...or send a raw MCP initialize handshake with curl: curl -s "https://$APIM.azure-api.net/onprem/mcp" \ -H "Ocp-Apim-Subscription-Key: $KEY" \ -H "Content-Type: application/json" \ -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
A successful MCP
initializeresponse (server info + capabilities) proves the private path. Then list and call the dummy server's tools (echo,add):curl -s "https://$APIM.azure-api.net/onprem/mcp" \ -H "Ocp-Apim-Subscription-Key: $KEY" \ -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello over the VPN"}}}'
Note: the on-prem ACI runs the dummy MCP server in
infra/mcp-dummy/server.py, soinitialize,tools/list, andtools/callall return real responses. To use a real backend, swapbackend-aci.bicep'simage+commandfor a Data API Builder container (with itsdab-config.json+ database), or point APIM'sdabBaseUrlat your actual on-prem DAB. -
(Optional) Isolate the VPN path from APIM — deploy a throwaway container in the Azure VNet
snet-testsubnet and curl the on-prem MCP private IP directly across the VPN.
infra/main.bicep # one-deployment orchestrator (wires the 5 modules below)
infra/network.bicep # 2 VNets, subnets, delegations, NSGs
infra/vpn.bicep # 2 public IPs, 2 VpnGw1AZ gateways, 2 Vnet2Vnet connections
infra/backend-aci.bicep # on-prem dummy MCP server on ACI (private IP :5000), outputs the IP
infra/mcp-dummy/server.py # the dummy MCP server (stdlib-only, streamable HTTP, embedded via loadFileAsBase64)
infra/apim.bicep # APIM Standard v2 + outbound VNet integration
infra/apim-mcp.bicep # passthrough MCP server (type: mcp, serviceUrl = on-prem MCP private IP:port)
azuredeploy.json # compiled ARM template (target of the Deploy to Azure button)
createUiDefinition.json # portal form for the Deploy to Azure button
deploy.sh # thin CLI wrapper around infra/main.bicep
teardown.sh # deletes the resource group
azuredeploy.jsonis generated frominfra/main.bicep. After editing any Bicep, regenerate it withaz bicep build -f infra/main.bicep --outfile azuredeploy.jsonand commit the result so the Deploy to Azure button stays in sync.
./teardown.shVPN gateways and APIM Standard v2 accrue hourly cost, so tear down when finished.
- Data API Builder MCP: DAB (1.7+, use 2.0) natively hosts an MCP server over
streamable HTTP at
/mcp, protocol version2025-06-18. APIM's "expose an existing MCP server" (passthrough) mode requires backend MCP2025-06-18+ and API version2025-09-01-preview— both satisfied here. - Don't buffer MCP responses: never read
context.Response.Bodyin any APIM policy on the MCP API — it forces response buffering and breaks MCP streaming. Rate-limit / auth policies are fine. Avoid aresponse-timeoutshorter than your longest tool call. - Backend port:
nsg-aciopens inbound TCP on the DAB port (BACKEND_PORT, default5000) from theVirtualNetworktag. If you run DAB on a different port, setBACKEND_PORTso the NSG rule, the ACI container, and the APIM backend URL stay aligned. - Making the client-to-APIM hop private too is an optional extension: add an APIM inbound private endpoint and call the gateway from inside a VNet.
- If APIM can't reach the backend: confirm the VPN connections are
Connected, that the APIM subnet keeps default gateway route propagation (no route table disabling BGP propagation), and thatnsg-aciallows inbound TCP on the DAB port from theVirtualNetworktag. Internalvirtual network type is not supported on Standard v2 (that needs Premium v2 injection); this POC usesExternalintegration by design.
