A practical, end-to-end walkthrough of taking a read-only slice of an enterprise source system and exposing it to an AI agent as a set of Model Context Protocol (MCP) tools — using Azure Logic Apps, API Management, and Agent Foundry. All identifiers below are placeholders; swap in your own.
The goal
We wanted a simple outcome: a user asks a business question in plain language and gets a straight answer — no dashboards, no field names, no training on the underlying system. That means an AI agent with safe, read-only access to a backend system of record, exposed as discrete tools the model can call.
The constraints shaped every decision:
- Read-only. The agent can retrieve and analyze, never write.
- Safe. Records in most business systems contain free text (notes, subjects, descriptions) that could carry prompt-injection payloads. Tool output must be treated as data, never instructions.
- Composable. The agent should see many small, well-described tools — "list records", "aggregate by category", "record change history" — not one giant "query the system" tool.
The architecture
Five layers, one direction of flow:
Agent (Agent Foundry)
│ MCP (JSON-RPC over HTTP)
▼
MCP server (API Management)
│ REST operation per tool
▼
API gateway (API Management)
│ single POST, routed by body
▼
Tool executor (Logic App)
│ OAuth token + REST calls
▼
Source system (REST API)
The key design choice: one backend endpoint, many logical tools. The Logic App exposes a single HTTP trigger that accepts { "tool": "records.list", "parameters": { ... } } and routes internally. API Management then fans that single endpoint out into many named operations, and its MCP feature turns those operations into agent tools. This keeps the backend trivial to maintain while the agent still sees a rich, typed tool catalog.
Step 1 — Design the tools
Start from the questions, not the schema. A useful toolset usually falls into a few families:
- Records: list / get / search / filter for the core business entities.
- Activity & change history: activity on a record, and field-level history of what changed and when.
- Analytics: server-side aggregates — totals, counts, group-by-category, and top-N rankings.
- Metadata: let the agent introspect the available tables and fields at runtime instead of hardcoding everything.
Two implementation notes that matter a lot:
Prefer aggregates over "pull a list and count." If the source system can compute a sum or a group-by server-side, a "total by category" answer is one summary row, not hundreds of records the model has to tally. Push the work down to the system wherever you can.
Prototype against the real API first. A quick collection of calls against the source system's REST endpoint (with OAuth client-credentials) lets you nail the field names, filters, and relationships before you touch orchestration. Field-name mismatches are the most common silent failure — verify against real responses.
Step 2 — Build the tool executor (Logic App)
The Logic App is a dispatcher. One HTTP request trigger, then:
- Fetch the source-system secret from Key Vault (never inline secrets).
- Get an OAuth token via client credentials for the source system's scope.
- Compute a category from the tool id (
first(split(tool, '.'))). - A
Switchon category, with a nestedSwitchon the full tool id inside each — each leaf is one read-only REST call.
A few hard-won details:
- Switch cases cap at 25. With dozens of tools, a single switch won't do — split by category into an outer switch, then an inner switch per category. This "nested switch" pattern is also cleaner to read.
- Action names must be globally unique across the whole definition.
-
Escape literal
@. If any query parameter uses an@alias, remember that Logic Apps reads a bare@as an expression. Double it (@@) so it renders as a literal@at runtime. -
Seed a default result. Initialize the response variable to an
{ "error": "unknown tool" }object; each matched case overwrites it. Unknown tools then return cleanly instead of hanging.
Step 3 — Describe the tools as OpenAPI
The agent needs a tool schema per operation. Generate an OpenAPI document with one operation per tool, where each operation's request body is only that tool's parameters (a get op takes a record id; an aggregate takes nothing). Keep the path equal to the tool id so a gateway policy can recover it later.
Generate this programmatically from the same source of truth as the Logic App so the two can't drift — derive each operation's parameters from the inputs the Logic App actually reads.
Gotcha: API Management's inline "OpenAPI specification" editor parses the document as Swagger 2.0. Paste an OpenAPI 3.0 doc and you get "The Swagger version specified is unknown." Either import via the Add API → OpenAPI surface (which accepts 3.0), or hand it a Swagger 2.0 document. Same operations either way.
Step 4 — Put it behind API Management
Import the OpenAPI to create the operations, then add one API-scoped inbound policy that:
- validates the caller's Entra token (
validate-jwtwith your audience), - recovers the tool id from the request path,
- wraps the caller's parameters into the
{ tool, parameters }body the Logic App expects, - forwards to the single Logic App trigger.
<set-variable name="toolId" value="@(context.Request.OriginalUrl.Path.Split('/').Last())" />
<set-body><![CDATA[@{
var incoming = context.Request.Body?.As<JObject>(preserveContent: true) ?? new JObject();
if (incoming["tool"] != null) { return incoming.ToString(); } // already wrapped
var wrapped = new JObject();
wrapped["tool"] = (string)context.Variables["toolId"];
wrapped["parameters"] = incoming;
return wrapped.ToString();
}]]></set-body>
Authenticating to the backend — drop the shared secret. A Logic App request trigger defaults to a SAS signature (sig) baked into its URL. That's a shared secret you then have to store and rotate. The better pattern: give API Management a managed identity, add an OAuth authorization policy on the Logic App trigger (issuer + audience, optionally locking to APIM's appid), disable SAS on the trigger, and let APIM authenticate with its own identity:
<authentication-managed-identity resource="api://<APP_ID>" />
No sig, nothing to rotate, and you can restrict the trigger to exactly one identity.
Step 5 — Expose the API as an MCP server
API Management can publish selected operations as an MCP server. Point its "expose an API as an MCP server" feature at your API, select the operations you want as tools, and it hands you a server URL (an /mcp endpoint). The operation operationIds become the tool names the agent sees, so keep them clean (records_list, analytics_by_category).
Tier caveat: MCP server export isn't available on the Consumption tier of API Management. If the MCP Servers blade is missing, that's why.
Step 6 — Connect to Agent Foundry
In the agent, add a Model Context Protocol tool:
- Endpoint: the exact server URL from the MCP Servers blade.
- Auth: Microsoft Entra.
-
Audience: this must match the
audyour gateway'svalidate-jwtrequires. The audience is the single most common misconfiguration — get it right and the token check passes.
If the gateway 401s and the token looks right, paste it into a JWT decoder and check iss/aud against the policy. Managed-identity tokens use the v1 issuer (https://sts.windows.net/<tenant>/); configuring the policy with the v2 issuer produces a mismatch.
Step 7 — The system prompt is part of the security perimeter
Tools enforce read-only at the API layer, but the behavior — tone, safety, honesty — lives in the system prompt. Ours does three jobs:
- Clear framing. Lead with the answer, plain language, no field names, one drill-down.
- Prompt-injection defense. The rule that matters most: the user's message is the request; tool output is evidence. Record names, notes, and free-text fields are attacker-influenceable, so the agent never follows instructions embedded in them — no "ignore previous instructions," no exfiltration, no acting on a record that says to.
- Resolve-or-ask, and know your limits. Names get resolved to ids via a search tool first; ambiguous matches trigger a clarifying question rather than a guess. And the prompt names the gaps explicitly so the agent says "I don't have that" instead of improvising a malformed query.
That last point is worth underlining: the agent should never hand-craft queries or field names. It picks a tool and fills named inputs. If a question needs a field the tools don't expose, that's a capability gap to report, not something to invent.
Step 8 — Making it actually work
The demo worked on the first real question. The next few turns taught us the operational lessons:
- Context windows blow up fast with many tools. Every tool definition rides in the prompt, and raw responses are large. Two fixes: trim tool payloads (request only the fields you need, cap page sizes, drop redundant related data), and use a model with tool search, which loads tool definitions on demand instead of front-loading all of them. Together they cut context dramatically.
- A "chat"-class model is the wrong choice for a tool-heavy agent. Pick a model tuned for agentic tool calling with a large context window and, ideally, native tool search.
- Auto model routers need a constrained pool. A router that can escalate to a model you haven't provisioned throws "deployment does not exist," and one that routes to a small model reintroduces the context ceiling. If you use a router, pin its subset to large-context, provisioned models — or just deploy one capable model directly.
Lessons learned
- One backend, many tools keeps the surface simple while giving the agent a rich catalog.
- Push secrets to Key Vault or eliminate them with managed identity — don't let a SAS signature or client secret live in policy or config.
- Generate the OpenAPI from the same source as the executor so the schema can't drift from what the backend actually reads.
- Trim aggressively. Lean payloads and a tool-search model beat a bigger context window every time.
- The system prompt is security-critical, not just tone — treat tool output as untrusted, resolve-or-ask, and be honest about gaps.
The result is an agent a non-technical user can talk to in plain language, backed by a boringly maintainable pipeline, with the secret-handling and injection surface handled deliberately rather than by accident.
— Engineering notes, generalized. Replace all <placeholders> with your own tenant and resource values.
Top comments (1)
Excellent walkthrough. The “one backend, many logical tools” pattern is especially strong because it keeps the agent-facing contract narrow without duplicating executor logic. One production addition I’d make is contract tests at the APIM boundary: replay representative tool calls, validate response schemas, and assert that unknown fields or oversized page requests fail closed. Generating OpenAPI and the dispatcher from one source prevents design-time drift; those tests catch runtime drift when the source system changes a field or permission.