MCP series: Hosting Your MCP Server on Azure

This blogpost is the second of a series about building your MCP server, hosting it on Azure and finally implementing it in a M365 Copilot agent

  • Part 1Building your first MCP server
  • Part 2 — Hosting the MCP server on Azure (Azure Container Apps)
  • Part 3 — Connecting the MCP server to a Microsoft 365 Copilot agent

In part one we built a fully functional MCP server in plain JavaScript that exposes three project management tools and supports both STDIO (for local development) and HTTP (for remote clients). Now it’s time to get it off your laptop and into Azure.

Azure Container Apps is the sweet spot for hosting an MCP server: serverless, scales to zero, HTTPS out of the box, and straightforward to deploy.


1. Containerize the Server

Note

This step requires Docker Desktop. Download and install it from docker.com/products/docker-desktop. After installation, start Docker Desktop and wait until the whale icon in the system tray shows “Docker Desktop is running” before running any docker commands.

Create a Dockerfile in the project root:

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js .
EXPOSE 3000
CMD ["node", "server.js"]

Create a .dockerignore:

node_modules
.env

Build and test locally:

docker build -t my-mcp-server .
docker run -p 3000:3000 my-mcp-server

2. Push to Azure Container Registry

Note

Make sure the Azure CLI is installed — download it from learn.microsoft.com/cli/azure/install-azure-cli and run az login before continuing.

ACR names are globally unique across all of Azure. If az acr create returns an AlreadyInUse error, someone else has already claimed that name. Pick something distinctive . Add your name, company, or a random suffix, e.g. acrmcpnico.

$RESOURCE_GROUP = "rg-mcp-demo"
$LOCATION = "westeurope"
$ACR_NAME = "acrmcpyourname"   # Must be globally unique across all of Azure — pick something distinctive
$APP_ENV = "mcp-env"
$APP_NAME = "mcp-project-server"

az group create --name $RESOURCE_GROUP --location $LOCATION

# Register the Container Registry provider if not already done
az provider register --namespace Microsoft.ContainerRegistry

# Wait until registration is complete before continuing
az provider show --namespace Microsoft.ContainerRegistry --query "registrationState"
# Re-run the above until you see "Registered"

az acr create `
  --resource-group $RESOURCE_GROUP `
  --name $ACR_NAME `
  --sku Basic `
  --admin-enabled true

az acr login --name $ACR_NAME
docker tag my-mcp-server "$ACR_NAME.azurecr.io/my-mcp-server:latest"
docker push "$ACR_NAME.azurecr.io/my-mcp-server:latest"

3. Deploy to Azure Container Apps

az extension add --name containerapp --upgrade
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.OperationalInsights

az containerapp env create `
  --name $APP_ENV `
  --resource-group $RESOURCE_GROUP `
  --location $LOCATION

$ACR_PASSWORD = az acr credential show `
  --name $ACR_NAME `
  --query "passwords[0].value" -o tsv

az containerapp create `
  --name $APP_NAME `
  --resource-group $RESOURCE_GROUP `
  --environment $APP_ENV `
  --image "$ACR_NAME.azurecr.io/my-mcp-server:latest" `
  --registry-server "$ACR_NAME.azurecr.io" `
  --registry-username $ACR_NAME `
  --registry-password $ACR_PASSWORD `
  --target-port 3000 `
  --ingress external `
  --min-replicas 0 `
  --max-replicas 3 `
  --cpu 0.25 `
  --memory 0.5Gi `
  --env-vars NODE_ENV=production

Get your public URL:

az containerapp show `
  --name $APP_NAME `
  --resource-group $RESOURCE_GROUP `
  --query "properties.configuration.ingress.fqdn" -o tsv

Your MCP endpoint is now live at https://<your-fqdn>/mcp.


Testing the Hosted Endpoint

Note

The /mcp endpoint only accepts POST requests. Opening the URL in a browser triggers a GET and returns Cannot GET /mcp, that’s expected. Use the /health endpoint to verify the container is running: https://<your-fqdn>/health should return {"status":"ok"}.

To test the MCP endpoint itself, use an API client like Bruno (open source) or Postman.

Create a new POST request with the following settings:

  • URL: https://<your-fqdn>/mcp
  • Headers:
    • Content-Type: application/json
    • Accept: application/json, text/event-stream
  • Body (JSON):
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {}
}

A successful response returns your three tools in SSE format. You can also call a specific tool:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "name": "listProjects",
    "arguments": {}
  }
}


Security Considerations

By default, your MCP endpoint is publicly accessible - anyone with the URL can call your tools. For any real deployment you’ll want to protect it with at least an API key.

Step 1: Update server.js

Add the API key check to the MCP route. The key is read from an environment variable so it never ends up hardcoded in your source:

app.post("/mcp", async (req, res) => {
  const apiKey = req.headers["x-api-key"];

  if (!apiKey || apiKey !== process.env.MCP_API_KEY) {
    res.status(401).json({ error: "Unauthorized" });
    return;
  }

  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
  res.on("finish", () => transport.close());
});

Step 2 — Test locally

Create a .env file in your project root (add it to .gitignore!):

MCP_API_KEY=my-local-secret

Install dotenv to load it automatically:

npm install dotenv

Add this at the top of server.js:

import "dotenv/config";

Restart the server and verify authentication works:

Without key: should return 401:

curl -N -X POST http://localhost:3000/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

With correct key: should return the tools list:

curl -N -X POST http://localhost:3000/mcp -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "x-api-key: my-local-secret" -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'

In Bruno, add a header to your request:

HeaderValue
x-api-keymy-local-secret

Step 3 — Store the secret in Azure Container Apps

Never pass secrets as plain environment variables. Use Container Apps’ built-in secret store instead:

az containerapp secret set `
  --name $APP_NAME `
  --resource-group $RESOURCE_GROUP `
  --secrets mcp-api-key=<your-production-secret>

az containerapp update `
  --name $APP_NAME `
  --resource-group $RESOURCE_GROUP `
  --set-env-vars MCP_API_KEY=secretref:mcp-api-key

The secret is stored encrypted in Azure and injected into the container at runtime - it never appears in deployment logs or az containerapp show output.

Note

For enterprise scenarios, consider placing Azure API Management in front of your Container App. APIM gives you OAuth2/Entra ID authentication, rate limiting, and full observability - all without touching your server code.