Skip to content

Forbidden by the Spec, Documented by Microsoft: Agent Identity for a Custom MCP Tool in Foundry

Over the last few weeks I built a custom MCP server for a Foundry agent: a vulnerability-context tool that queries Microsoft Graph Advanced Hunting and gives the agent real exposure data to reason over. The server holds no credentials at all. No secret, no API key, no Graph token of its own. This post covers how that works, two failures that cost us days, and an honest look at whether the credential-free relay is actually good architecture or just convenient. The MCP specification and Microsoft’s own documentation point in opposite directions on this one, and I’ll link both.

Fair warning: this is not a tutorial with a happy path.

The setup

A Foundry agent (vuln) answers questions like “which internet-facing servers have exploitable CVEs?”. The tool behind it is a small FastMCP server in an Azure Container App that forwards requests to POST /security/runHuntingQuery in Microsoft Graph.

Authentication is the interesting part. The agent calls the tool with auth type Agent Identity (AgenticIdentityToken). Agent Service obtains a token from Entra for the agent identity, issued for the audience https://graph.microsoft.com, and sends it to my MCP server as a bearer token. The server reads the Authorization header and forwards the token to Graph, unchanged. That is all it does. Whether the query is allowed gets decided by Graph, based on the token’s roles claim and the permissions granted to the agent identity (ThreatHunting.Read.All).

Screenshot 1: Foundry tool configuration of the custom MCP tool. Auth type “Agent Identity”, audience https://graph.microsoft.com, no credential fields anywhere

For customer conversations this is the part I keep coming back to: the tool provider ships logic, the authorization boundary stays entirely on the customer’s side. Revoke the Graph permission on the identity and data access stops immediately. Nothing changes on the provider side, because the provider never had standing access to revoke. Governance follows the same line: least-privilege RBAC on the agent identity, Conditional Access on its blueprint, and every query shows up in the audit trail as that identity.

Hold that thought, though. There’s a section further down on why the MCP spec disagrees with this design, and where I ended up after sitting with that.

Gotcha 1: there are two agent identities, and Playground uses the “wrong” one

This one cost us a full evening. When you publish an agent, it gets its own distinct agent identity, bound to the agent application resource. According to Microsoft’s agent identity concepts (https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/agent-identity), published consumption (web app, Teams, API) uses exactly that identity. So far, so expected.

The Foundry Playground, however, keeps using the project’s shared project identity. Even after publishing. We had dutifully granted ThreatHunting.Read.All to the agent’s distinct identity and still got a constant Graph 403 in Playground: “Missing application roles… application roles: .” An empty roles claim, because the token simply came from a different identity that had no Graph permissions at all.

Screenshot 2: Graph 403 response with an empty roles claim from the Playground test

The fix: grant the permission to both identities if you want Playground testing and published consumption to work. The IDs live in two different places:

  • Shared project identity: Foundry project resource in the Azure portal, Overview, JSON View, agentIdentity.agentIdentityId. Careful: do not grab identity.principalId from the same JSON view. That is the resource’s system-assigned managed identity and has nothing to do with tool-call auth.
  • Distinct identity: JSON view of the agent application resource, same field. Or, much more comfortably, the agent’s Details tab under “Identity & access”.
Screenshot 3: Project resource JSON view. Green: agentIdentity.agentIdentityId, the shared project agent identity that Playground authenticates with. Red: identity.principalId, the resource’s own system-assigned managed identity, which has nothing to do with tool-call auth. The two agentIdentityBlueprint* fields right below are also not what you want.

I’ve come to read this as a deliberate governance boundary: one shared identity for every agent in development, one grant covers the whole project. Publishing creates a fresh identity, and the grant does not travel with it. A dev agent never silently goes to production with the same permissions. Explained as a feature, that makes sense. As an undocumented surprise at 10 pm, somewhat less.

Gotcha 2: Foundry caches tool schemas, and remove + re-add doesn’t clear it

After rebuilding the server with an expanded tool set (scan_exposed_critical became find_exploitable_devices, and find_attack_path_devices came in for attack-path queries over the Exposure Graph), the agents kept calling the old tool name and failed with “Tool … not found on remote server”. A direct tools/list call against the live server correctly showed only the three current tools.

The behavior reproduced on every agent we tried. Including a freshly created one that had never used the tool. And it survived removing and re-adding the connection under the same name.

Only one thing helped in the end: creating the tool connection under a new name. That cleared the schema instantly. Where exactly this cache lives (connection ID, agent version, something tenant-level?) we never found out. Note to self for the next schema change: don’t edit, don’t delete-and-recreate under the same name. Create fresh under a new name and clean up the old connection afterwards.

The plan: private networking. The outcome: not today.

The target picture was more precise than “make everything private”: the agent stays publicly reachable (Playground, published endpoint), but its egress to the MCP tool runs exclusively over a private VNet. Public in, private out, for this one tool call. That requires Foundry’s Standard Setup with agent outbound network injection (BYO VNet), plus a container app that is only reachable from inside the VNet. Here are both variants side by side, the public one we ended up demoing and the private one we were actually after:

Diagram: both variants of the architecture. Variant A with public egress and a working token relay, variant B with private egress over a BYO VNet and the exact point where the DataProxy fails

We rebuilt Microsoft’s validated reference architecture for this, down to the identical CLI commands from the 19-private-network-agent-tools sample (https://github.com/microsoft-foundry/foundry-samples/tree/main/infrastructure/infrastructure-setup-bicep/19-private-network-agent-tools) in the microsoft-foundry/foundry-samples repo. That sample matters: the neighboring private-networking templates (15 through 18) explicitly do not support agent tools behind a VNet and point to template 19 for exactly this scenario. And we failed deterministically at one spot: Foundry’s DataProxy reported, on every tool call, “the host name could not be resolved from the selected network path”.

Screenshot 4: The DataProxy error when calling the privately networked MCP tool. The message itself lists what to check: server URL, DNS record, capability host, VNet, DataProxy, private DNS zone, gateway, proxy, firewall. We verified every item on that list independently, and it still failed.

The frustrating part: we could prove every single layer healthy on its own. A test VM in the same VNet (different subnet) resolved the identical hostname to the correct private IP, completed a clean TLS handshake, and got a valid MCP initialize response back. DNS, private endpoint, VNet link, container health, server code, Graph permissions: each verified individually, via az vm run-command, direct curl probes against the MCP protocol, and ARM queries against actual resource state instead of trusting the portal UI.

Screenshot 5: in-VNet test from the Ubuntu VM. nslookup returns the private IP and curl gets a successful MCP initialize response

Along the way we found and fixed three genuine bugs in our own configuration: duplicate MCP connections resolving to the same URL, a stale private endpoint after toggling Public Network Access, and a wrong ingress traffic scope. None of them touched the core problem. Even rebuilding the topology exactly to Microsoft’s sample (–internal-only true on the Container Apps environment, dedicated MCP subnet) and recreating the capability host from scratch changed nothing. The ingress side of that difference is easy to underestimate in the portal, so here it is side by side:

Screenshot 6: Ingress configuration of both container apps side by side. Left: vulnmcp-app, the private deployment, set to “Limited to VNet”. Right: vulnmcp-app-demo, the public one, set to “Accepting traffic from anywhere”. Note the third option, “Limited to Container Apps Environment”, which sounds restrictive but is not the same thing.

We could also rule out the “Hyena cluster routing” limitation documented in the sample’s testing guide, with its roughly 50% failure rate: our failure hit 100% of the time, across two different agents, byte for byte identical.

My working hypothesis, unresolved: the DataProxy resolves DNS on a different path than “Azure-provided DNS from the injected subnet”, possibly from outside the customer VNet entirely, which would require an Azure DNS Private Resolver that the reference architecture never mentions. A Microsoft support case is the logical next step; the repro package is sitting there ready.

The pragmatic way out for the demo

Because a customer meeting doesn’t wait for a support case, the demo now runs on a second, fully public deployment: same container image, own environment without a VNet, minReplicas: 1 against cold starts, system-assigned identity for the ACR pull and nothing else. Exactly the configuration that had already worked reliably earlier in the project, from Playground request to a rendered risk table with real Advanced Hunting data.

Screenshot 7: The working demo: a Playground question answered from live Advanced Hunting data, with exposure, exploitable CVE count, max CVSS and a risk score per device. Note the tool connection in the left panel: it is called VulnMCP2 because the original name still carried the stale tool schema.

The auth model stays identical either way. The token relay works over the public internet the same as over a private network, because it assumes no network or tenant boundary at all. Private networking would have been defense in depth here, not the authorization boundary. That one lives entirely in Entra and Graph. Since the server has no credentials of its own, it also isn’t self-authenticating, so a public deployment still needs protection at the network layer, at minimum restrictive ipSecurityRestrictions on the ingress. Fine for a demo. For production I want the private variant back, and the next section explains why that “isn’t self-authenticating” sentence deserves more weight than I first gave it.

Is the token relay actually best practice?

Honest answer: the MCP specification explicitly forbids what my server does. The spec’s security best practices (https://modelcontextprotocol.io/specification/2025-06-18/basic/security_best_practices) call it “token passthrough”: an MCP server accepting tokens that were not issued for it and forwarding them to a downstream API. The authorization spec (https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) turned this into a MUST NOT with the 2025-06-18 revision, with a concrete risk list: the server cannot authenticate its callers, per-client rate limiting and monitoring get bypassed, trust boundaries blur, and with a stolen token the server makes a convenient exfiltration proxy.

At the same time, Microsoft’s agent identity documentation (https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/agent-identity) describes exactly this flow for MCP tools: the audience is the resource identifier of the downstream service, explicitly not the MCP server’s URL. The token gets passed to the MCP server, and the downstream resource validates it. So the platform vendor documents a pattern the protocol spec forbids. Both are right about something, and it’s worth untangling what.

What the relay gets right is attribution. In Graph’s audit log, every query appears as the agent identity, the actual caller, an identity in the customer’s own tenant. And the server cannot escalate anything, which defuses the main attack the spec worries about: the confused deputy. A confused deputy is a service that holds more authority than its caller and can be tricked into spending that authority on the caller’s behalf. The caller can’t do X, the deputy can, and a crafted request makes the deputy do X for them. Classic example: a service with a powerful standing API permission that any client can invoke. My relay is no such deputy. It holds no authority of its own and adds nothing to the token it receives, so an attacker who sends their own token to it gets exactly what that token already allowed and not one row more.

What the relay gets wrong is that the server has no perimeter of its own. It cannot tell a legitimate agent from anyone else on the internet holding some Graph-audience token, cannot rate-limit per caller, cannot reject traffic it was never meant to serve. The network-layer protection I mentioned above isn’t hardening on top of a sound design. It’s the auth design outsourcing a job the spec says the server should do itself.

Now the alternative that question usually implies: give the MCP server its own Graph permission and have it call as itself. That turns the server into a proper OAuth resource server. The audience becomes the server’s own App ID URI, incoming tokens get validated, the spec is satisfied. But note what this does to the server: it now is a deputy in the confused-deputy sense, a service with standing authority that has to be very careful about whom it serves. And look at what happens to the logs. Every Graph query now shows the server’s service principal. All callers collapse into one identity, and attribution has to be reconstructed from the server’s own logging, which the customer has to take on faith. On top of that, the vendor now holds standing access to customer data, the exact thing this design set out to avoid. For a vendor/customer boundary I consider that a clear step backwards, not forward.

The textbook resolution is a third option: validate incoming tokens against the server’s own audience, then obtain a new downstream token that preserves the caller, via on-behalf-of or RFC 8693 token exchange (https://www.rfc-editor.org/rfc/rfc8693). For delegated user tokens that flow exists and works today. For app-only agent identity tokens it’s murkier: classic Entra OBO handles user assertions, not app tokens. So the realistic choice with agent identity, right now, is between the relay (caller attribution downstream, no server perimeter) and server-owned permissions (server perimeter, attribution collapsed).

Where I land, concretely: for this scenario, a stateless read-only relay across a vendor/customer boundary, the relay plus strict network controls is the better trade, and I’d defend it in an architecture review. The customer keeps instant revocation and per-agent audit, and the server stays worthless to steal. But it is a deliberate deviation from the MCP spec and should be documented as one, with ingress restrictions treated as a hard requirement, not optional hardening. The day Entra offers a clean token-exchange path for agent identities, that becomes the migration target: server validates its own audience, exchanges for a Graph token that still carries the agent identity, and you get both properties at once.

What sticks

The agent identity pattern for custom MCP tools is the right fit for anything that means “the agent may do X, no matter who is chatting”. For the opposite pole, look at Microsoft’s own Sentinel MCP collections: they use OAuth identity passthrough, delegated, with a consent click from the signed-in analyst. Same protocol, same platform, a completely different authorization philosophy. That deserves its own post rather than a paragraph here.

And the open question to Microsoft stands: why does the DataProxy fail on a hostname that a VM in the same VNet resolves without issue? If any of you have private networking with MCP tools running stably in Foundry, get in touch. I’ll happily compare notes.

Sources

Published inFoundry

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *