Skip to content

[agent-issue]: Gateway-managed, auto-refreshed Azure federated token file for user-defined MCP servers #1964

Description

@JamesBurnside

Submission requirements

  • I generated this issue with an agent that used .github/agents/ado-aw.agent.md.
  • I reviewed the generated issue and confirm it is being filed directly in githubnext/ado-aw.

Produced by Copilot CLI following .github/agents/ado-aw.agent.md → the debug route
(prompts/debug-ado-agentic-workflow.md), reviewed and approved by the repository owner before
filing. Classification: product-bug / capability gap, confidence medium — the failure mode
below is documented by Microsoft and my configuration is structurally exposed to it, but I have
not yet captured it in one of my own runs. See the honesty note under Reproduction details.

Problem summary

There is no first-class way for a user-defined mcp-servers: entry to authenticate to Azure from an
ADO service connection. There is a workaround (below) that I have running in production against
Azure Data Explorer, but it cannot refresh its credential — and the credential it depends on lives
for about five minutes. I may well have missed something.

1. A five-minute token-lifetime gap.

Azure DevOps service-connection ID tokens have a lifespan of ~5 minutes, by design
(Introducing Azure DevOps ID Token Refresh).
The only available staging pattern writes one assertion, once, at job start, into a static
file.

The consequence is not "the credential expires eventually." It is that the agent's first Azure
tool call must land within ~5 minutes of job start
, or the exchange fails — and stays failed for
the rest of the job.

That is a poor fit for agentic workloads in particular. An agent reads the issue, greps the
repository, forms a hypothesis, and only then reaches for telemetry. The Azure call is rarely in
the first five minutes.

Microsoft documents both the failure mode and its symptom:

"There are many cases where you can have breaks or long running jobs that may need to request a
new access token. In this scenario a new ID Token is required as the old one will have expired and
cannot be used."

"An error you may see when a token times out without ID Token Refresh is
AADSTS700024: Client assertion is not within its valid time range."

Their answer is ID Token Refresh — the running task re-requests an ID token once the old one has
expired — shipped in TerraformTask@5 for exactly this reason. There is no equivalent for an ado-aw
MCP server.

A user can work around this: background a loop that re-mints from SYSTEM_OIDCREQUESTURI every
few minutes and rewrites the file, which works because WorkloadIdentityCredential re-reads that
file on every token request. But that means anyone wanting Azure access from an MCP server ends up
running a bespoke credential-refresh daemon beside their agent — which is squarely the platform's
job, especially as the compiler is already the component minting tokens from service connections.

2. Every user reinvents the staging step, and it is hard to discover in the first place.
addSpnToEnvironment: true, the $idToken variable, AZURE_FEDERATED_TOKEN_FILE, a mount path
outside the sandbox's reachable paths, and the "ids must be non-secret variables because ADO does
not export secret variables to the step environment" constraint — all of it is undocumented tribal
knowledge, all of it is load-bearing, and it took me a long time to piece together.

3. The wiring is ambient, and its declared path is a known-open bug. env: NAME: "" means
"read whatever happens to be in the job environment at gateway launch." It is not declared, not
validated at compile time, and if the variable is missing the result is a silently unauthenticated
server
— no compile error, no runtime error, just a model that cannot reach Azure and cannot
explain why.

That is #945, still open: generate_mcpg_step_env never mapped user mcp-servers env at all, so
the -e VAR flag forwards an empty value. My setup only works because it routes around that path
##vso[task.setvariable] publishes non-secret pipeline variables, and ADO injects those into
every later step's environment on its own. It also means the instinctive hardening (marking the ids
secret) fails silently, because ADO does not export secret variables to the step environment.

Reproduction details

What already works today

mcp-servers:
  kusto:
    container: "node:22-slim"
    entrypoint: "sh"
    entrypoint-args:
      - "-c"
      - >-
        apt-get update >&2 &&
        apt-get install -y --no-install-recommends ca-certificates libicu72 libssl3 >&2 &&
        exec npx -y @azure/mcp@latest server start --namespace kusto
    mounts:
      - "$(Agent.TempDirectory)/wif:/wif:ro"
    env:
      AZURE_CLIENT_ID: ""
      AZURE_TENANT_ID: ""
      AZURE_FEDERATED_TOKEN_FILE: "/wif/token"
      AZURE_TOKEN_CREDENTIALS: "prod"

Backed by an extra step I had to author and maintain myself:

- task: AzureCLI@2
  inputs:
    azureSubscription: <my-service-connection>
    scriptType: bash
    scriptLocation: inlineScript
    addSpnToEnvironment: true          # exposes $idToken / $servicePrincipalId / $tenantId
    inlineScript: |
      set -euo pipefail
      mkdir -p "$(Agent.TempDirectory)/wif"
      printf '%s' "$idToken" > "$(Agent.TempDirectory)/wif/token"
      echo "##vso[task.setvariable variable=AZURE_CLIENT_ID]$servicePrincipalId"
      echo "##vso[task.setvariable variable=AZURE_TENANT_ID]$tenantId"

This passes a federated assertion, not an access token. The Azure Identity chain inside the MCP
server reads the assertion from the path in AZURE_FEDERATED_TOKEN_FILE and performs the Entra
exchange itself. No access token is ever staged on the agent, and the model never sees any of it.
That property is good and I would like to keep it.

Steps to reproduce the gap

  1. Configure an mcp-servers: entry as above, staging $idToken to a mounted file at job start.
  2. Have the agent do more than ~5 minutes of non-Azure work first (read an issue, search the repo).
  3. Have the agent then issue its first Azure tool call.
  4. The Entra exchange fails; expected symptom AADSTS700024: Client assertion is not within its valid time range. Nothing re-mints the assertion, so the failure persists for the rest of the job.

Honesty note on evidence

I have not yet captured AADSTS700024 in one of my own runs. Every run in which a Kusto query
actually succeeded happened to issue it within seconds of gateway start. I am reporting a documented
failure mode my configuration is structurally exposed to — not an outage I can show you logs for.

Environment

  • ado-aw compiler 0.47.0, AWF image 0.27.32
  • Azure DevOps, ARM service connection using workload identity federation
  • MCP server: @azure/mcp on node:22-slim, stdio transport, launched by the MCP gateway

Proposed next step

Proposed shape

mcp-servers:
  kusto:
    container: "node:22-slim"
    azure-auth:
      service-connection: my-arm-service-connection
      # optional: mount-path, default /var/run/ado-aw/azure

When present, the compiler/gateway would:

  1. Mint a federated assertion for that service connection from the job's OIDC endpoint
    (SYSTEM_OIDCREQUESTURI, authenticated with System.AccessToken) — the same mechanism behind
    ID Token Refresh and AzurePipelinesCredential.
  2. Write it to a gateway-owned file and bind-mount that file read-only into the server's container.
  3. Set AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_FEDERATED_TOKEN_FILE on that container —
    declared and validated, not passthrough.
  4. Re-mint and rewrite the file ahead of each ~5-minute expiry, for as long as the job runs
    ID Token Refresh, applied to an MCP server's token file.

Why this design

  • It works for stdio, without forcing the author to self-host an HTTP sidecar just to obtain a
    refreshable credential.
  • It is the platform's own documented pattern, not a new invention. ID Token Refresh already
    exists for TerraformTask@5 and, in SDK form, as AzurePipelinesCredential. This asks for the
    same idea where the consumer is a containerised MCP server rather than a task.
  • No MCP-server-side change is required. WorkloadIdentityCredential already re-reads the file
    on every token request — that is precisely how AKS projected-token rotation works. Step 4 is
    therefore sufficient on its own; the credential library does its half already.
  • It closes the lifetime gap, which is the part users cannot fix themselves.
  • It generalises. Any Azure-SDK-based MCP server picks this up for free, because they all honour
    the same environment contract.
  • It is declarative. The service connection is named in YAML and checkable at compile time,
    rather than smuggled through ambient job state.
  • It keeps the good property of the current design: an assertion crosses the boundary, not an
    access token, and the exchange happens inside the server.

Please keep addSpnToEnvironment reachable (re: #1378)

#1378 proposes unifying the compiler's AzureCLI@2 token-mint steps behind a shared
acquire_azure_token_step(service_connection, resource, out_var) helper, and suggests defaulting
addSpnToEnvironment off on the grounds that az account get-access-token does not need it.

That is correct for access-token minting. But addSpnToEnvironment: true is precisely what
exposes $idToken — the federated assertion this request is built on. If the shared helper drops it
unconditionally, it forecloses this feature. Worth keeping available as an opt-in.

That helper also looks like the natural place for this to live: the compiler already mints Azure
tokens from service connections for SC_READ_TOKEN / SC_WRITE_TOKEN and the BYOK provider bearer
token. This asks for one more variant of an idiom you already own — an assertion written to a file
rather than an access token written to a variable.

Why HTTP transport and the gh-aw pattern do not solve this

The obvious counter is "run the MCP server over HTTP and inject a bearer token per request." I
checked that path. It does not work here, for three separate reasons.

ado-aw has no per-request credential mechanism on either transport. McpOptions gives HTTP
servers url: and headers:, and headers: values are static strings baked into the committed
lock file — no expression syntax, no minting. The gap is therefore not stdio-specific: neither
transport can present a freshly-minted credential.

gh-aw's equivalent is HTTP-only, and would not authenticate Azure anyway. Their
mcp-servers: auth: type: github-oidc has the gateway mint an audience-bound JWT per request:

"The auth.type: github-oidc field is only valid on HTTP servers. The MCP server is
responsible for validating the token; the gateway acts as a token forwarder."
docs/src/content/docs/guides/mcps.md

Good design, but it forwards a GitHub token. Azure MCP Server's HTTP mode expects either its own
hosting identity or an Entra token it can exchange on-behalf-of, and a GitHub OIDC assertion is
neither. So I am deliberately not asking you to port that feature.

Switching Azure MCP Server to HTTP does not help either. It does support --transport http — I
verified this in the npm distribution, despite the docs implying HTTP is Docker-only. But its
outgoing auth strategies land back in the same place:

  • UseHostingEnvironmentIdentity falls back to the ambient Azure credential chain — the same
    environment variables and the same expiring token file this request is about. A hosted ADO agent
    has no managed identity to substitute for it.
  • UseOnBehalfOf requires an authenticated incoming Entra token for the server's own app
    registration. A pipeline OIDC assertion is not that.

It would also mean self-hosting the server as a sidecar: container: and url: are alternatives, so
the gateway either launches a stdio container or connects to a remote endpoint. Ports, lifecycle and
health would become the workflow author's problem — to arrive back at the same credential question.

Alternatives considered

Alternative Why not
Port gh-aw's auth: type: github-oidc HTTP-only, and it forwards a GitHub token — which Azure MCP Server cannot use for either of its HTTP auth strategies.
Run the MCP server over HTTP instead Supported (--transport http), but UseHostingEnvironmentIdentity returns to the same ambient chain and expiring token file, UseOnBehalfOf needs an Entra token a pipeline assertion is not, and it makes sidecar hosting the author's problem.
AzurePipelinesCredential in the MCP server Not in Azure MCP Server's credential chain, and would need adopting by every server individually. Wrong layer.
Pre-mint an access token and pass it in Tempting, since an access token lives ~1 hour rather than ~5 minutes and would paper over the gap. But Azure MCP Server has no input for one — every entry in its credential chain is a type that acquires tokens itself, and there is no static-token credential. It also only moves the cliff: a job outlasting the access token fails the same way, just later.
Extend ado-proxy to user servers The docs scope it to first-party servers. #1652 is pursuing a proxy/broker posture for ADO reads with the same underlying goal — keep the credential out of the agent. A refreshed token file is the stdio-shaped version of that idea, not a competing one.
Status quo (env: "" + user-staged file) Works, but every user reimplements it — and closing the 5-minute gap means each of them also runs a bespoke token-refresh daemon next to their agent.

Secondary, lower priority

gh-aw supports ${{ secrets.NAME }} expressions in mcp-servers: env:. ado-aw has no expression
syntax, so "" passthrough is the only option. Declared secrets would be a nice improvement, but it
is strictly less important than the token-refresh gap above, and I would not want it to dilute this
request.

Related issues


Posted by Copilot CLI assistant.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions