Chapter 2 — MCP in practice: wiring agents to real infrastructure safely
2026-07-036 min readAzure, AI agents, MCP, DevOps
In chapter 1 we built a simple mental model for agents: policy + LLM + memory + tools. Now we’ll make that concrete. In this chapter I’ll show you how to use the Model Context Protocol (MCP) to give an agent safe, auditable access to real Azure infrastructure—and how to keep that access on a tight leash. In chapter 3 we’ll reuse this plumbing to stand up a production-ready agent.
MCP basics: servers, tools, resources, prompts
Here’s the short version I keep in my head:
- An MCP server is a process that advertises capabilities to an agent client.
- Capabilities come in two flavors:
- Tools: callable operations with JSON-schema’d inputs and outputs (think “functions”).
- Resources: read-only, addressable artifacts the agent can “open” (think “files/URIs”—e.g., a KQL snippet, a policy file, a deployment template).
- Prompts: reusable prompt snippets the server exposes for grounding (instructions, examples, system prompts).
- Discovery: the client asks the server “what tools/resources/prompts do you have?” and gets a structured list back before making calls. That list is the contract the LLM can plan against.
Why this matters: capability discovery and schema’d I/O make it realistic to put guardrails around what the agent can do. Instead of “let the model run shell,” you advertise exactly “list resource groups” with a strongly typed output.
A minimal MCP server
To keep examples runnable, I’ll use TypeScript and the Azure SDK. This server exposes one read-only tool to list resource groups and one resource that renders a small README. It authenticates to Azure with Managed Identity or your developer credentials.
# prereqs: Node 20+, Azure CLI logged in, or Managed Identity in cloud
mkdir mcp-azure-demo && cd mcp-azure-demo
npm init -y
npm i typescript ts-node @types/node @azure/identity @azure/arm-resources @modelcontextprotocol/sdk
npx tsc --init// src/server.ts
import { DefaultAzureCredential } from "@azure/identity";
import { ResourceManagementClient } from "@azure/arm-resources";
import {
Server,
Tool,
ToolInputSchema,
Resource as McpResource,
} from "@modelcontextprotocol/sdk/server";
const server = new Server({
name: "azure-mcp",
version: "0.1.0",
});
// Tool: listResourceGroups
const listRgsInput: ToolInputSchema = {
type: "object",
properties: {
subscriptionId: { type: "string", description: "Azure subscription ID" },
tagFilter: {
type: "string",
description: "Optional tag filter in key=value form",
nullable: true,
},
},
required: ["subscriptionId"],
};
const listRgsTool: Tool = {
name: "listResourceGroups",
description:
"Return resource groups in a subscription. Optional tag filter key=value narrows results. Read-only.",
inputSchema: listRgsInput,
run: async (args) => {
const { subscriptionId, tagFilter } = args as {
subscriptionId: string;
tagFilter?: string | null;
};
const cred = new DefaultAzureCredential();
const client = new ResourceManagementClient(cred, subscriptionId);
const [tagKey, tagVal] = (tagFilter ?? "").split("=");
const results = [];
for await (const rg of client.resourceGroups.list()) {
if (tagFilter) {
const tags = rg.tags ?? {};
if (tags[tagKey] !== tagVal) continue;
}
results.push({
name: rg.name,
location: rg.location,
tags: rg.tags ?? {},
id: rg.id,
});
}
return { ok: true, content: results };
},
};
// Resource: a simple, read-only grounding document
const readme: McpResource = {
uri: "mcp://azure-mcp/README",
name: "Server README",
description:
"How to use the azure-mcp server safely. Read-only; do not treat as policy.",
mimeType: "text/markdown",
getContent: async () =>
Buffer.from(`# azure-mcp
- Tools: listResourceGroups(subscriptionId, tagFilter?)
- Principle: read-only unless explicitly marked mutating
`),
};
// Optional: prompts for grounding
server.addPrompt({
name: "azure-safety",
description: "Safety policy for Azure operations",
text: `You must request human approval before any mutating action. Prefer read-only tools. Provide a dry-run plan first.`,
});
server.addTool(listRgsTool);
server.addResource(readme);
const port = parseInt(process.env.MCP_PORT ?? "9000", 10);
server.listen({ port }, () => {
// eslint-disable-next-line no-console
console.log(`MCP server listening on :${port}`);
});npx ts-node src/server.ts
# Server will advertise its tool/resource/prompt set to any MCP client that connects (e.g., your agent).That’s the core shape: the client discovers listResourceGroups and can call it with structured inputs. No blanket shell access; just the capability you chose to expose.
Running and composing MCP servers
In practice you’ll compose servers with different concerns:
- Filesystem server: expose read-only IaC (Bicep/Terraform) and policy files as resources.
- Shell server: carefully scoped, often read-only by default (e.g.,
kubectl get), with explicit allowlist. - Database server: parameterized, read-only SELECTs for inventory or reports.
A simple way to orchestrate locally is a process manager and a client that connects to multiple servers. For quick demos, I run each server with a different port and let the agent client aggregate discovery.
# Run three servers on different ports
# 1) Azure read-only (above)
MCP_PORT=9000 npx ts-node src/server.ts
# 2) Filesystem read-only server (example using a toy one-liner via node)
# This is illustrative; in production, use a proper MCP FS server package.
node -e "require('http').createServer((_,res)=>res.end('MCP FS placeholder')).listen(9001)"
# 3) Postgres read-only server (wrap your connection with an MCP tool that only allows SELECT)
docker run --rm -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:16Mapping to Azure services is just HTTP+Auth under the hood. For example, you can expose a tool that queries Azure Resource Graph via REST instead of using the SDK. I’ll show a simple “read-only shell” tool that lets the agent run a pinned az command we predefine.
// add to server: a safe, allowlisted shell tool
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
server.addTool({
name: "azResourceGraphQuery",
description:
"Run a fixed Azure Resource Graph query (read-only). You may only pass the subscription and KQL body.",
inputSchema: {
type: "object",
properties: {
subscriptionId: { type: "string" },
kql: { type: "string", description: "KQL query for Resource Graph" },
},
required: ["subscriptionId", "kql"],
},
run: async (args) => {
const { subscriptionId, kql } = args as {
subscriptionId: string;
kql: string;
};
// Allowlist: az graph query only
const { stdout } = await execFileAsync(
"az",
["graph", "query", "-q", kql, "--subscriptions", subscriptionId, "-o", "json"],
{ env: process.env }
);
return { ok: true, content: JSON.parse(stdout) };
},
});This is still read-only. If you later add mutating tools, separate them into a different server and require a human approval step.
Security first: Managed Identity, RBAC, network isolation, audit
The fastest way to keep agent access sane is the same way you keep your apps sane: managed identities, least-privileged role assignments, private network paths, and logs everywhere.
1) Scope with Managed Identity and Azure RBAC
Give your MCP server a user-assigned managed identity (UAMI) and only the roles it needs (e.g., Reader on a single subscription for inventory).
# Variables
SUBSCRIPTION_ID="<your-subscription-id>"
RG="rg-mcp-demo"
LOCATION="eastus"
MI_NAME="mcp-server-mi"
az group create -n $RG -l $LOCATION
# Create a user-assigned managed identity
az identity create -g $RG -n $MI_NAME
MI_PRINCIPAL_ID=$(az identity show -g $RG -n $MI_NAME -o tsv --query principalId)
MI_ID=$(az identity show -g $RG -n $MI_NAME -o tsv --query id)
# Assign least-privileged role (Reader) scoped to a single subscription
az role assignment create \
--assignee-object-id $MI_PRINCIPAL_ID \
--assignee-principal-type ServicePrincipal \
--role "Reader" \
--scope "/subscriptions/$SUBSCRIPTION_ID"In code, the DefaultAzureCredential will pick up this identity automatically when the server runs on Azure services that support managed identity (Container Apps, VMs, Functions, etc.).
2) Network isolation
- Host your server in Azure Container Apps or Functions with VNet integration.
- If your tools call Azure control plane, prefer private endpoints for APIs that support them, or route egress through a firewall with FQDN tag rules.
- Lock inbound to your agent only (e.g., the agent calls servers over an internal endpoint).
Here’s a minimal Bicep snippet to deploy Container Apps with UAMI and VNet integration (trimmed to essentials; adjust to your environment):
param name string = 'mcp-azure'
param location string = resourceGroup().location
param userAssignedIdentityId string
param containerImage string
param vnetResourceId string
param subnetName string
resource caEnv 'Microsoft.App/managedEnvironments@2024-03-01' = {
name: '${name}-env'
location: location
properties: {
appLogsConfiguration: { destination: 'log-analytics' }
vnetConfiguration: {
infrastructureSubnetId: '${vnetResourceId}/subnets/${subnetName}'
internal: true
}
}
}
resource app 'Microsoft.App/containerApps@2024-03-01' = {
name: name
location: location
identity: {
type: 'UserAssigned'
userAssignedIdentities: {
'${userAssignedIdentityId}': {}
}
}
properties: {
managedEnvironmentId: caEnv.id
configuration: {
ingress: {
external: false
targetPort: 9000
}
}
template: {
containers: [
{
name: 'mcp'
image: containerImage
env: [
{ name: 'MCP_PORT', value: '9000' }
]
}
]
}
}
}Deploy with the identity you created earlier. Internal ingress means only your VNet can reach it.
3) Audit trails
- Emit structured logs for every tool call: who (agent run id), what (tool name, inputs hash), when, result, and latency.
- Send to Application Insights or Log Analytics and wire up alerts.
A tiny middleware around server.addTool can do this:
// logging wrapper
function withAudit(tool: Tool): Tool {
return {
...tool,
run: async (args, meta) => {
const started = Date.now();
try {
const res = await tool.run(args, meta);
console.log(
JSON.stringify({
event: "tool_success",
tool: tool.name,
argsHash: Buffer.from(JSON.stringify(args)).toString("base64").slice(0, 16),
runId: meta?.request?.requestId ?? "n/a",
ms: Date.now() - started,
})
);
return res;
} catch (e: any) {
console.error(
JSON.stringify({
event: "tool_error",
tool: tool.name,
message: e?.message ?? String(e),
runId: meta?.request?.requestId ?? "n/a",
ms: Date.now() - started,
})
);
throw e;
}
},
};
}
// apply
server.addTool(withAudit(listRgsTool));Design patterns for agent-safe actions
- Read-only first: split servers by mutation level. Make the default server read-only; publish a separate “-write” server for changes. Route “-write” through a human approval gate.
- Idempotency: require a client-provided requestId and use it to de-duplicate mutating calls. Return 409 or the previous result if the same requestId appears again.
- Retries: apply exponential backoff on transient Azure errors (HTTP 429/5xx). Keep max attempts low (e.g., 3) and surface partial results.
- Timeouts: set per-tool timeouts so the LLM doesn’t block forever. Fail fast with a actionable error message.
- Dry run pattern: for destructive operations, expose two tools—
planX(pure read) andapplyX(planId, signed)—so the agent can show a plan for human sign-off. - Parameter allowlists: for shell-like tools, enumerate permitted verbs and arguments; reject anything else.
- Output shaping: return small, typed summaries by default (e.g., top 100 items), and require an explicit flag for “full dump” to control token/latency costs.
Here’s an idempotent, time-bounded “create resource group” example (mutating; keep this in a separate server):
import { ResourceManagementClient } from "@azure/arm-resources";
server.addTool({
name: "createResourceGroup",
description:
"Create a resource group. Idempotent on (subscriptionId, name). Requires explicit approval flag.",
inputSchema: {
type: "object",
properties: {
subscriptionId: { type: "string" },
name: { type: "string" },
location: { type: "string" },
approved: { type: "boolean", description: "Must be true to proceed" },
requestId: { type: "string", description: "Client-provided idempotency key" },
},
required: ["subscriptionId", "name", "location", "approved", "requestId"],
},
run: async (args, meta) => {
const { subscriptionId, name, location, approved, requestId } = args as any;
if (!approved) {
throw new Error("Approval required. Set 'approved': true after human review.");
}
const cred = new DefaultAzureCredential();
const client = new ResourceManagementClient(cred, subscriptionId);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000); // 15s
try {
// Use tags to record idempotency key for easy lookup
const existing = await client.resourceGroups.checkExistence(name);
if (existing.body) {
const rg = await client.resourceGroups.get(name);
if ((rg.tags ?? {})["mcp-request-id"] === requestId) {
return { ok: true, content: rg };
}
// Same name, different requestId: treat as idempotent “already exists”
return { ok: true, content: rg, note: "Already existed with different requestId" };
}
const rg = await client.resourceGroups.createOrUpdate(
name,
{ location, tags: { "mcp-request-id": requestId } },
{ abortSignal: controller.signal }
);
return { ok: true, content: rg };
} finally {
clearTimeout(timeout);
}
},
});Local dev to cloud: connecting MCP to an agent via Azure AI Foundry/Prompt Flow
There are two common ways I wire MCP into an Azure-hosted agent:
- The agent code embeds an MCP client and calls servers directly (my go-to for Container Apps).
- With Prompt Flow in Azure AI Foundry, I add a custom Python/Node tool node that acts as a small MCP client bridge and exposes the servers to the flow.
Option A: Containerized agent with Azure AI Foundry as the model backend
- Your agent runs in Container Apps with the UAMI from earlier.
- It calls Azure AI Foundry for chat completions.
- It connects to MCP servers over the internal VNet.
Here’s a trimmed Node agent loop that:
- Calls Azure AI Foundry chat completions.
- When it decides to call a tool, it invokes the MCP server’s tool by name.
// src/agent.ts
import fetch from "node-fetch";
import { Client } from "@modelcontextprotocol/sdk/client";
import { DefaultAzureCredential, getBearerTokenProvider } from "@azure/identity";
const AZURE_OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT!;
const AZURE_OPENAI_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT!;
async function chat(messages: any[]) {
// Managed identity auth for Azure AI Foundry
const cred = new DefaultAzureCredential();
const tokenProvider = getBearerTokenProvider(cred, "https://cognitiveservices.azure.com/.default");
const token = await tokenProvider();
const res = await fetch(`${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=2024-02-15-preview`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token.token}`,
},
body: JSON.stringify({
messages,
tools: [
// Advertise MCP tools to the model by name and schema if you want toolcalling
// (You can synthesize schema from MCP discovery)
],
temperature: 0,
}),
});
return res.json();
}
async function main() {
// Connect to MCP server
const mcp = new Client({ name: "agent-client" });
await mcp.connectHttp({ url: "http://mcp-azure.internal:9000" }); // internal DNS in your VNet
const discovery = await mcp.discover();
console.log("Discovered tools:", discovery.tools.map(t => t.name));
// Seed a simple goal
const messages = [
{ role: "system", content: "You are a cloud agent. Prefer read-only tools. Ask before mutations." },
{ role: "user", content: "List RGs in subscription X with tag env=prod." },
];
const completion = await chat(messages);
// In a real agent, parse tool calls and dispatch to MCP dynamically.
// For demo, call MCP directly:
const result = await mcp.callTool("listResourceGroups", {
subscriptionId: "<SUBSCRIPTION_ID>",
tagFilter: "env=prod",
});
console.log(result.content);
}
main().catch(err => {
console.error(err);
process.exit(1);
});Run locally by exporting AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT, then ts-node src/agent.ts. In cloud, the DefaultAzureCredential will use your managed identity.
Option B: Prompt Flow custom tool as an MCP bridge
Prompt Flow lets you add custom tool nodes. I create a tiny Python tool that connects to the MCP server and calls a tool by name. Then I wire that node into a flow that drives the LLM.
Tool script:
# tools/mcp_invoke.py
# inputs: server_url, tool_name, args_json
import json, requests, sys
def main():
server_url = sys.argv[1]
tool_name = sys.argv[2]
args_json = sys.argv[3]
payload = {"tool": tool_name, "args": json.loads(args_json)}
# Your MCP server can expose a simple HTTP endpoint for tool invocation
r = requests.post(f"{server_url}/invoke", json=payload, timeout=15)
r.raise_for_status()
print(r.text)
if __name__ == "__main__":
main()A simple HTTP shim on your MCP server to support this:
// src/http-shim.ts
import express from "express";
import bodyParser from "body-parser";
import { Client } from "@modelcontextprotocol/sdk/client";
const app = express();
app.use(bodyParser.json());
const mcp = new Client({ name: "shim" });
await mcp.connectHttp({ url: "http://mcp-azure.internal:9000" });
app.post("/invoke", async (req, res) => {
const { tool, args } = req.body;
try {
const out = await mcp.callTool(tool, args);
res.json(out);
} catch (e: any) {
res.status(400).json({ error: e?.message ?? "invoke failed" });
}
});
app.listen(8080, () => console.log("MCP HTTP shim on :8080"));Then, in your Prompt Flow, add a tool node that shells out:
# flow.dag.yaml (excerpt)
nodes:
- name: invoke_mcp
type: python
source:
path: tools/mcp_invoke.py
inputs:
server_url: ${env.MCP_SHIM_URL}
tool_name: listResourceGroups
args_json: |
{"subscriptionId": "${env.SUBSCRIPTION_ID}", "tagFilter": "env=prod"}With this, your flow can call MCP tools as part of its graph. Lock the shim behind VNet/private ingress.
Testing MCP tools: golden prompts, mock servers, failure injection
Treat tools like APIs and prompts like code.
- Golden prompts: capture a set of “ask → expected plan → expected tool calls → expected summary.” Keep them under version control. Run them in CI.
- Mock servers: implement a lightweight MCP server that returns canned results for each tool. Point the agent at the mock for deterministic tests.
- Failure injection: force timeouts, 429s, and partial data to make sure the agent recovers gracefully.
Here’s a tiny test harness using Node’s assert and a mock server.
// test/golden.test.ts
import assert from "node:assert";
import { Client } from "@modelcontextprotocol/sdk/client";
async function run() {
const mcp = new Client({ name: "test" });
// Connect to a mock server that has the same tool schema
await mcp.connectHttp({ url: "http://localhost:9100" });
const res = await mcp.callTool("listResourceGroups", {
subscriptionId: "sub-123",
tagFilter: "env=prod",
});
// Golden expectation: two groups with prod tag
assert.strictEqual(Array.isArray(res.content), true);
const names = (res.content as any[]).map(r => r.name);
assert.deepStrictEqual(names.sort(), ["rg-app-prod", "rg-data-prod"].sort());
console.log("golden passed");
}
run().catch(e => { console.error(e); process.exit(1); });A toy mock server that sometimes fails to test retries/timeouts:
// test/mock-server.ts
import { Server } from "@modelcontextprotocol/sdk/server";
const server = new Server({ name: "mock", version: "0.0.1" });
let failNext = true;
server.addTool({
name: "listResourceGroups",
description: "Mocked RG list",
inputSchema: { type: "object", properties: { subscriptionId: { type: "string" }, tagFilter: { type: "string", nullable: true } }, required: ["subscriptionId"] },
run: async () => {
if (failNext) {
failNext = false;
throw new Error("Injected failure");
}
return {
ok: true,
content: [
{ name: "rg-app-prod", location: "eastus" },
{ name: "rg-data-prod", location: "eastus2" },
],
};
},
});
server.listen({ port: 9100 }, () => console.log("mock on :9100"));Run the mock, then the test:
npx ts-node test/mock-server.ts &
npx ts-node test/golden.test.tsFor prompts, I keep a folder like prompt_tests/ with JSON fixtures:
{
"name": "inventory-prod-rgs",
"input": "List all prod resource groups and count them.",
"expectedTools": ["listResourceGroups"],
"maxLatencyMs": 5000
}Wire a small runner that feeds the model a system prompt plus the input and verifies it selects the right tool before producing the summary.
Pulling it together: a secure workflow you can ship
- Start with read-only MCP servers exposing exactly the capabilities you want (inventory, health, policy checks).
- Put them behind managed identity and least-privileged RBAC.
- Log every call.
- Teach your agent to plan with “plan/apply” and idempotency for any write path.
- Move from local to VNet-hosted servers, and integrate the agent via Azure AI Foundry or Prompt Flow.
- Build golden tests and mocks so changes to prompts or tools don’t surprise prod.
That’s MCP in practice: a capability discovery layer that makes agents predictable rather than magical. In chapter 3 we’ll build a production-ready Azure agent: containerized MCP servers, an agent wired to Azure AI Foundry, API Management as the gate, Managed Identity for auth, and end-to-end telemetry with Application Insights.