Submission requirements
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
- Configure an
mcp-servers: entry as above, staging $idToken to a mounted file at job start.
- Have the agent do more than ~5 minutes of non-Azure work first (read an issue, search the repo).
- Have the agent then issue its first Azure tool call.
- 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:
- 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.
- Write it to a gateway-owned file and bind-mount that file read-only into the server's container.
- Set
AZURE_CLIENT_ID, AZURE_TENANT_ID and AZURE_FEDERATED_TOKEN_FILE on that container —
declared and validated, not passthrough.
- 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.
Submission requirements
.github/agents/ado-aw.agent.md.githubnext/ado-aw.Problem summary
There is no first-class way for a user-defined
mcp-servers:entry to authenticate to Azure from anADO 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:
Their answer is ID Token Refresh — the running task re-requests an ID token once the old one has
expired — shipped in
TerraformTask@5for exactly this reason. There is no equivalent for an ado-awMCP server.
A user can work around this: background a loop that re-mints from
SYSTEM_OIDCREQUESTURIeveryfew minutes and rewrites the file, which works because
WorkloadIdentityCredentialre-reads thatfile 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$idTokenvariable,AZURE_FEDERATED_TOKEN_FILE, a mount pathoutside 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_envnever mapped usermcp-serversenv at all, sothe
-e VARflag 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 intoevery 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
Backed by an extra step I had to author and maintain myself:
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_FILEand performs the Entraexchange 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
mcp-servers:entry as above, staging$idTokento a mounted file at job start.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
AADSTS700024in one of my own runs. Every run in which a Kusto queryactually 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
0.47.0, AWF image0.27.32@azure/mcponnode:22-slim, stdio transport, launched by the MCP gatewayProposed next step
Proposed shape
When present, the compiler/gateway would:
(
SYSTEM_OIDCREQUESTURI, authenticated withSystem.AccessToken) — the same mechanism behindID Token Refresh and
AzurePipelinesCredential.AZURE_CLIENT_ID,AZURE_TENANT_IDandAZURE_FEDERATED_TOKEN_FILEon that container —declared and validated, not passthrough.
ID Token Refresh, applied to an MCP server's token file.
Why this design
refreshable credential.
exists for
TerraformTask@5and, in SDK form, asAzurePipelinesCredential. This asks for thesame idea where the consumer is a containerised MCP server rather than a task.
WorkloadIdentityCredentialalready re-reads the fileon 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.
the same environment contract.
rather than smuggled through ambient job state.
access token, and the exchange happens inside the server.
Please keep
addSpnToEnvironmentreachable (re: #1378)#1378 proposes unifying the compiler's
AzureCLI@2token-mint steps behind a sharedacquire_azure_token_step(service_connection, resource, out_var)helper, and suggests defaultingaddSpnToEnvironmentoff on the grounds thataz account get-access-tokendoes not need it.That is correct for access-token minting. But
addSpnToEnvironment: trueis precisely whatexposes
$idToken— the federated assertion this request is built on. If the shared helper drops itunconditionally, 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_TOKENand the BYOK provider bearertoken. 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.
McpOptionsgives HTTPservers
url:andheaders:, andheaders:values are static strings baked into the committedlock 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-oidchas the gateway mint an audience-bound JWT per request: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— Iverified this in the npm distribution, despite the docs implying HTTP is Docker-only. But its
outgoing auth strategies land back in the same place:
UseHostingEnvironmentIdentityfalls back to the ambient Azure credential chain — the sameenvironment variables and the same expiring token file this request is about. A hosted ADO agent
has no managed identity to substitute for it.
UseOnBehalfOfrequires an authenticated incoming Entra token for the server's own appregistration. A pipeline OIDC assertion is not that.
It would also mean self-hosting the server as a sidecar:
container:andurl:are alternatives, sothe 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
auth: type: github-oidc--transport http), butUseHostingEnvironmentIdentityreturns to the same ambient chain and expiring token file,UseOnBehalfOfneeds an Entra token a pipeline assertion is not, and it makes sidecar hosting the author's problem.AzurePipelinesCredentialin the MCP serverado-proxyto user serversenv: ""+ user-staged file)Secondary, lower priority
gh-aw supports
${{ secrets.NAME }}expressions inmcp-servers: env:. ado-aw has no expressionsyntax, so
""passthrough is the only option. Declared secrets would be a nice improvement, but itis strictly less important than the token-refresh gap above, and I would not want it to dilute this
request.
Related issues
env: ""passthrough never reaches the container. Problem 3 aboveis a consequence of it. This request would remove the need for that path for Azure auth
specifically, though Custom MCP container env passthrough never reaches the server (step env mapping missing) #945 still matters for every other credential.
addSpnToEnvironmentnote above before landing it.service-connectionfield and misleading\{\{ workspace }}marker description #23 / 📝 Documentation drift detected —toolsfield incorrectly marked unimplemented;service-connectionoption undocumented #25 (closed) —service-connection:previously existed onmcp-serversentries for 1ESservice-connection naming. It is not in
McpOptionson main today, so the name is free. Note thatservice-connection:is already the established spelling elsewhere in the schema(
permissions.read,supply-chain.feed/.registry), so a flatservice-connection:key on theMCP entry would fit existing conventions just as well as the nested
azure-auth:block proposedabove — happy either way.
Posted by Copilot CLI assistant.