16. Azure target: API Management in front of Container Apps, provisioned with Terraform
16. Azure target: API Management in front of Container Apps, provisioned with Terraform
Date: 2026-10-22
Status
Proposed
This ADR records the target hosting for a client deployment.
Nothing in it is built in this version: the demo runs
the server locally behind a dev tunnel
(0015-dev-tunnel-for-the-agent-demo.md).
Problem
For a client, the MCP server needs a host that is always reachable by Copilot, starts fast enough for the roughly 10 second action timeout (MCP and API plugins), keeps the OBO credential out of configuration files, ships audit events to a store with retention, and is reproducible per client tenant. The owner also asked for an API gateway in front and for infrastructure as code.
The inbound side has a constraint that rules out the usual "make it private" answer. The caller is Copilot itself. We work from the premise that a declarative agent's plugin call comes from Microsoft's cloud over the public internet, not from the client's network; the pages read for the research document no fixed IP range for it. A private endpoint would make the server unreachable to its only caller. Network injection, which keeps agent traffic inside a customer virtual network, is a feature of Foundry Agent Service (agents injected into a delegated subnet), not of Microsoft 365 declarative agents (Foundry agent networking). So the edge stays public and the protection moves to identity and the gateway.
Decision
Target topology, all of it described in Terraform (one module set, one state per client environment):
- API Management is the only public entry. An inbound
validate-jwt(orvalidate-azure-ad-token) policy checks issuer and audience before anything reaches the server (validate-jwt, protect an API with Entra ID), and a per-user rate limit keyed on the token'soidcaps runaway loops. The server keeps validating the token itself; the gateway is defence in depth, not a replacement. - Azure Container Apps runs the server as a container
image built from the same build output (
lib/,package.json, production dependencies) and the same configuration variables as the local run. The image does not exist yet: locally,compose.ymlruns the genericm365pnp/spfxdevelopment image with the source mounted. A Dockerfile for a slim Node 22 image is work for the target (see Consequences). Ingress allows only API Management (IP restriction rules on the gateway's outbound addresses, IP restrictions).minReplicasis 1, so there is no cold start inside the action timeout. - A managed identity on the Container App pulls the image from the registry and reads Key Vault, which holds the OBO client secret or, preferably, a certificate. No secret in app settings.
- Log Analytics receives container logs and a
dedicated audit table for the per-tool-call events
(
0014-three-layer-audit-for-agent-actions.md), with its own retention.
flowchart LR
CP["Copilot Chat<br/>Microsoft cloud, public internet"] -->|"HTTPS, Entra SSO token"| APIM
subgraph AZ["Azure subscription, provisioned with Terraform"]
APIM["API Management<br/>validate-jwt: issuer, audience<br/>rate limit per user"] -->|"forward /mcp"| ACA
ACA["Container App: MCP server image<br/>ingress allows APIM only<br/>min replicas 1"]
MI["Managed identity"] -.-> ACA
KV["Key Vault<br/>OBO client secret or certificate"]
LA["Log Analytics<br/>diagnostics and audit table"]
ACR["Container registry"]
ACA -->|"managed identity"| KV
ACA -->|"audit events"| LA
ACR -->|"image pull, managed identity"| ACA
APIM -->|"gateway logs"| LA
end
ACA -->|"OBO token, SharePoint REST"| SP[("SharePoint Online")]
ACA -->|"OBO exchange"| ENTRA["Microsoft Entra ID"]
Considered Options
Compute
From the research's hosting comparison (choose an Azure service for MCP):
| Option | Summary | Pros | Cons |
|---|---|---|---|
| ✅ Azure Container Apps, minReplicas 1 | A container image of our server, Express + MCP SDK unchanged | Same build output and configuration as the local Podman run; logs to Log Analytics out of the box; ingress IP restrictions; plain HTTP app, portable to App Service or AKS | One small replica always billed |
| Azure Functions Flex Consumption | Functions host with MCP extension or self-hosted SDK handler | Official MCP hosting template (host MCP SDK servers) | Second toolchain (Functions Core Tools); always-ready instances billed to avoid cold start (Functions scale) |
| App Service | Container on a plan | Familiar | Plan sizing; fewer per-app ingress controls than Container Apps |
| AKS | Kubernetes | Full control | Cluster operations for one small service |
Edge
| Option | Summary | Pros | Cons |
|---|---|---|---|
| ✅ API Management in front | Gateway with token validation and rate limit | Rejects bad tokens before our code; throttling per user; one place for headers and logging; a stable host name across server moves | Cost of an APIM tier; one more hop inside the timeout |
| Container Apps ingress only, built-in auth | Easy Auth on the app | Cheaper, fewer parts | No rate limiting; less control over token rules |
| Private endpoint or internal environment | No public endpoint | Strong network isolation | Copilot cannot reach it: the caller comes from Microsoft's cloud over the public internet |
| Front Door with WAF | Global edge | WAF rules | Adds cost and complexity; JWT validation still needed behind it |
Infrastructure as code
| Option | Pros | Cons |
|---|---|---|
| ✅ Terraform | Owner's choice (O6); same tool across Azure and Entra (azuread provider) for app registration, Key Vault and APIM; state per client | State storage to manage |
| Bicep | Azure-native, no state file | Entra objects need Graph Bicep extension or scripts; the owner chose Terraform |
Agents Toolkit m365agents.yml |
Already used for the agent package | Covers the Microsoft 365 app and SSO registration, not Azure infrastructure |
Consequences
Positive:
- No code change between demo and target: the build output,
AUTH_MODE=entraand the environment variables are the same; only the URL inMCP_SERVER_URLchanges. - A client tenant is reproducible from Terraform plus the setup script.
- Audit events land in a queryable table with retention chosen by the client.
Negative / Trade-offs:
- Work still to do for the target: a Dockerfile for the server (Node
22 slim base,
npm ci --omit=devfor the workspace, builtshared-coreand serverlib/, non-root user), an image build and push to the registry, and the Terraform modules. None of it exists in this version. - Running cost: one warm replica, an APIM tier, Log Analytics ingestion.
- APIM adds latency inside the 10 second budget; to measure on the target.
- API Management's outbound addresses must be kept in the Container App's allow rules if the tier or region changes.
Risks: | Risk ID | Description | Impact (E/H/M/L) |
Mitigation | |---------|-------------|------------------|------------| |
R-AGT-9 | Cold path (APIM, OBO, SharePoint batch) exceeds the action
timeout | M | minReplicas: 1; measure a five-day
book_days on the dev tenant first (verification log) | |
R-AGT-10 | A future Microsoft feature publishes fixed egress ranges and
clients ask for IP allow-listing at APIM | L | Add an APIM
ip-filter policy then; the design does not depend on it | |
R-AGT-11 | Client has no Azure subscription for this (spec section 9,
open item 2) | M | Functions Flex or App Service under an existing
subscription; the server is portable |
Related
- Related ADRs:
0011-custom-mcp-server-in-typescript.md,0012-declarative-agent-entra-sso-obo.md,0015-dev-tunnel-for-the-agent-demo.md,0014-three-layer-audit-for-agent-actions.md, ADR 0002 (Podman, no Node on the host) - Related documents: the contract agent design document of 2026-10-22
(not published) (sections 4 O6, 6 item 4, 9),
docs/research/2026-10-22-sharepoint-contract-agent.md("Hosting", decision D5),architecture/05-phase-d-technology-architecture.md - TOGAF phase: D — Technology Architecture
- Decision maker(s): owner
- Stakeholders: client IT and security; tenant administrators; builder