Build an MCP server on Azure, govern its access, and call one of its tools end to end.
The connector an AI is only as safe as
An MCP server is a small web service that exposes tools — functions an AI client like Claude or GitHub Copilot can discover and call — over a standard protocol. That is enormously useful and exactly why it is dangerous: the moment your inventory system, your ticketing queue, or your customer records are reachable as an MCP tool, an AI can act on them. So the job is never just "stand up an MCP server"; it is "stand up an MCP server that only the right caller can reach, that touches downstream systems without a stored password, and that leaves a log." This lab builds that governed version end to end, on infrastructure cheap enough to run for the price of a coffee if you tear it down after.
You will write a Python MCP server over the modern streamable HTTP transport, deploy it to Azure Container Apps, prove it works by connecting a client that lists and calls its tool, then add the two governance layers real deployments require: a managed identity for secret-free outbound access, and Entra ID built-in authentication that turns anonymous callers away at the door.
An MCP tool is a door into a business system. This lab is about who holds the key.
You need a free Azure account, the Azure CLI (az) and Python 3, then be signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs (winget / brew / apt), and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash), preinstalled and already signed in.
Just Azure Cloud Shell (Bash) — it has az, Python, and Docker-free cloud builds. All source is in github.com/kloudcaptain/campux-labs under lab-mcp-server-azure. Container Apps scales to zero and a Basic container registry is pennies a day, so the whole lab is effectively free if you run the teardown at the end — this is the one lab that provisions a container, so do not skip it.
Write the MCP server
Make a folder and three small files. The server uses FastMCP from the official Python SDK; the @mcp.tool() decorator turns a plain function into a callable tool, and streamable-http serves it at the conventional /mcp path on port 8080.
# Windows/Git Bash: stop it mangling /subscriptions/... arguments (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 mkdir -p ~/campux-mcp && cd ~/campux-mcp cat > server.py <<'EOF' from mcp.server.fastmcp import FastMCP # bind to all interfaces on 8080 so Container Apps ingress can reach it mcp = FastMCP("campux-inventory", host="0.0.0.0", port=8080) # stand-in for an enterprise system the AI is allowed to read STOCK = { "Camden": {"oat-milk": 42, "espresso-beans": 130, "napkins": 8}, "Shoreditch": {"oat-milk": 5, "espresso-beans": 76, "napkins": 240}, } @mcp.tool() def get_inventory(store: str) -> str: """Return current stock levels for a Campux Retail store.""" if store not in STOCK: return f"Unknown store '{store}'. Known: {', '.join(STOCK)}." lines = [f"{k}: {v}" for k, v in STOCK[store].items()] return f"Stock at {store} - " + "; ".join(lines) if __name__ == "__main__": mcp.run(transport="streamable-http") EOF echo 'mcp' > requirements.txt cat > Dockerfile <<'EOF' FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY server.py . EXPOSE 8080 CMD ["python", "server.py"] EOF
~/campux-mcp: server.py (one tool, streamable HTTP), requirements.txt (the mcp SDK), and a Dockerfile. The get_inventory tool stands in for a real backend — the governance you add later is identical whether the tool reads a dictionary or a banking core.Deploy to Container Apps
One command builds the image in the cloud and deploys it with external HTTPS ingress. Container Apps terminates TLS and forwards to your container's port 8080; the MCP endpoint is then at https://<fqdn>/mcp.
RG="campux-lab-mcp-rg" az group create -n "$RG" -l eastus az containerapp up \ --name campux-mcp \ --resource-group "$RG" \ --environment campux-mcp-env \ --source . \ --ingress external \ --target-port 8080 FQDN=$(az containerapp show -n campux-mcp -g "$RG" \ --query properties.configuration.ingress.fqdn -o tsv) echo "MCP endpoint: https://$FQDN/mcp"
az containerapp up finishes with a URL and your echo prints the MCP endpoint. The first run takes a few minutes because it builds the container image. Your MCP server is now live on the public internet — which is precisely why the next steps lock it down.Prove it: connect a client
Don't take the URL's word for it — speak MCP to it. This tiny client initialises a session, lists the tools the server advertises, and calls one. Run it in Cloud Shell.
pip install --quiet mcp
cat > client.py <<'EOF'
import asyncio, sys
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
async def main(url):
async with streamablehttp_client(url) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
print("tools:", [t.name for t in tools.tools])
out = await session.call_tool("get_inventory", {"store": "Camden"})
print("result:", out.content[0].text)
asyncio.run(main(sys.argv[1]))
EOF
python client.py "https://$FQDN/mcp"
tools: ['get_inventory'] and result: Stock at Camden - oat-milk: 42; espresso-beans: 130; napkins: 8. That is a full MCP round trip — initialise, discover, invoke — against a server you built and deployed. This is the sentence that makes an interviewer lean in.Govern outbound: a managed identity
Right now the tool reads a dictionary, but a real one reads Key Vault, Storage, or a database — and it must do so without a secret baked into the container. Give the container app a system-assigned managed identity; that identity is what you would grant a least-privilege role to reach downstream Azure services.
az containerapp identity assign -n campux-mcp -g "$RG" --system-assigned PRINCIPAL=$(az containerapp show -n campux-mcp -g "$RG" \ --query identity.principalId -o tsv) echo "managed identity principal: $PRINCIPAL" # in production you would now scope it, e.g. Key Vault secrets read: # az role assignment create --assignee "$PRINCIPAL" \ # --role "Key Vault Secrets User" --scope <your-key-vault-id>
Govern inbound: shut the anonymous door
The server is still open to anyone who knows the URL. Container Apps has built-in authentication — an Entra ID gate in front of your container that you turn on without changing a line of server code. First confirm the door is currently open, then close it.
# before: the endpoint answers anyone (2xx / a normal MCP response) curl -s -o /dev/null -w "before auth: %{http_code}\n" "https://$FQDN/mcp" # register an Entra app to represent the protected server APP_ID=$(az ad app create --display-name "campux-mcp-guard" --query appId -o tsv) TENANT=$(az account show --query tenantId -o tsv) # turn on the Entra provider and refuse unauthenticated callers az containerapp auth microsoft update -n campux-mcp -g "$RG" \ --client-id "$APP_ID" \ --issuer "https://sts.windows.net/$TENANT/" \ --yes az containerapp auth update -n campux-mcp -g "$RG" \ --unauthenticated-client-action Return401
curl -s -o /dev/null -w "after auth: %{http_code}\n" "https://$FQDN/mcp"
You should see before auth: 200 (or a normal MCP status) flip to after auth: 401. The server did not change; the platform now demands a valid Entra token before any request reaches your tool. An AI client would present that token; an anonymous scanner gets the door shut in its face.Tear it down
This lab created a container app, its environment, and a registry — so do not skip this. Deleting the resource group removes them all; also delete the guard app registration.
az ad app delete --id "$APP_ID"
az group delete -n campux-lab-mcp-rg --yes
az group exists -n campux-lab-mcp-rg # -> false
az group exists returns false and the app registration is gone. Because Container Apps scales to zero, an idle server barely costs anything — but a registry and environment left behind do add up, which is why the teardown here matters more than in the free labs.What you can now honestly claim
You built a Model Context Protocol server, deployed it to Azure Container Apps over the streamable HTTP transport, proved it with a real MCP client that listed and called your tool, then governed it on both sides — a managed identity for secret-free outbound access and Entra ID built-in auth that returns 401 to anonymous callers. That is not a toy: it is the exact shape of "administer and govern MCP integrations, ensuring access controls and data-handling boundaries" that senior 2026 postings ask for, and almost no candidate has done it. Pair it with the RAG lab and you can speak to the two halves of enterprise AI platform work — grounding models in data, and governing how they reach it.
- MCP's streamable HTTP transport (protocol revision 2025-03-26) supersedes the older HTTP+SSE transport: a single endpoint, conventionally
/mcp, handles POST and GET and can upgrade a response to a server-sent-events stream when the server needs to push. FastMCP'sstreamable-httpmode serves exactly this, which is why no reverse-proxy gymnastics are needed on Container Apps. - Container Apps built-in authentication runs as a sidecar in front of your container, so the Entra gate is enforced before a request ever reaches your code — the reason you could add real authorization without touching
server.py. For interactive AI clients you typically keep at least one replica warm and complete the Entra app's redirect/scope configuration; this lab proves the gate with the simplest possible "anonymous returns 401" check.