Symptom
A delegated (type=user) MCP agent whose user has no grant yet fails the tool call hard instead of parking on the auth gate and driving consent. No mcp_auth_required event, task completes "successfully", the LLM narrates the raw error.
Field log (Atlassian, agt-1785462707114259917, forge next-SNAPSHOT-2018586):
tool error tool=atlassian-write__searchJiraIssuesUsingJql
mcp atlassian-write/searchJiraIssuesUsingJql: mcp: no stored token — login required:
no platform grant for subject "mk@initializ.io" on server "mcp.atlassian" yet —
awaiting the delegated consent flow (#317)
mcp_tool_result ok=false reason=no_token duration_ms=65
session_end state=completed # ← no mcp_auth_required, no park
Root cause
The auth gate (#330) is consulted only for resolveClient (connection-establish) errors. In forge-core/tools/adapters/mcp_tool.go::Execute:
client, err := m.resolveClient(ctx)
if err != nil && m.authGate != nil && errors.Is(err, mcp.ErrNoToken) {
// park + re-resolve ← gate consulted HERE only
}
if err != nil { ... return }
res, err := client.CallTool(ctx, m.descriptor.Name, args)
if err != nil {
// classify + return ← NO gate consultation
}
But the HTTP transport attaches the bearer per request — transport_http.go:129, authFn(ctx) runs inside Send on every frame, including tools/call. A streamable-HTTP MCP server (Atlassian, and most OAuth MCP servers) initializes the session fine and only 403s on the actual tools/call, so the delegated token func's ErrNoToken surfaces from client.CallTool, not from resolveClient.
The decisive evidence it came from CallTool, not establish: the error carries no initialize:/connect: phase prefix. server.go::establish wraps every failure in a phasedError (withPhase("initialize", …)), and the chain is errors.Is-transparent (phasedError.Unwrap), so an establish-time ErrNoToken would (a) be phase-prefixed and (b) trip the existing gate branch. Neither happened → the error bypassed establish entirely.
Why the gate design missed this
The parking design (#330) assumed the per-user token is resolved at connection establish (resolveClient → pool ClientFor → establish → Initialize), which holds for transports that authenticate at initialize. It's false for per-request-auth transports that authenticate after an unauthenticated initialize — the token error then appears at call time, where the gate is never consulted.
Fix
Route an ErrNoToken from client.CallTool through the same gate-and-retry the establish path already uses: park via m.authGate.Await, and on a granted resume re-resolve + re-call. Extract the resolve→call sequence into a small retry helper so both the establish-time and call-time ErrNoToken trip the gate exactly once, with the same bounded/one-shot semantics.
Test
A CallTool that returns ErrNoToken:
- calls
AuthGate.Await (previously never invoked on this path),
- on
Await → granted, re-calls the tool and succeeds,
- on
Await → timeout/cancel/no-subject, classifies no_token as today (no regression),
- and a non-
ErrNoToken CallTool error still returns immediately (no spurious park).
Impact / related
Symptom
A delegated (type=user) MCP agent whose user has no grant yet fails the tool call hard instead of parking on the auth gate and driving consent. No
mcp_auth_requiredevent, task completes "successfully", the LLM narrates the raw error.Field log (Atlassian,
agt-1785462707114259917, forgenext-SNAPSHOT-2018586):Root cause
The auth gate (#330) is consulted only for
resolveClient(connection-establish) errors. Inforge-core/tools/adapters/mcp_tool.go::Execute:But the HTTP transport attaches the bearer per request —
transport_http.go:129,authFn(ctx)runs insideSendon every frame, includingtools/call. A streamable-HTTP MCP server (Atlassian, and most OAuth MCP servers) initializes the session fine and only 403s on the actualtools/call, so the delegated token func'sErrNoTokensurfaces fromclient.CallTool, not fromresolveClient.The decisive evidence it came from CallTool, not establish: the error carries no
initialize:/connect:phase prefix.server.go::establishwraps every failure in aphasedError(withPhase("initialize", …)), and the chain iserrors.Is-transparent (phasedError.Unwrap), so an establish-timeErrNoTokenwould (a) be phase-prefixed and (b) trip the existing gate branch. Neither happened → the error bypassed establish entirely.Why the gate design missed this
The parking design (#330) assumed the per-user token is resolved at connection establish (
resolveClient→ poolClientFor→establish→Initialize), which holds for transports that authenticate atinitialize. It's false for per-request-auth transports that authenticate after an unauthenticatedinitialize— the token error then appears at call time, where the gate is never consulted.Fix
Route an
ErrNoTokenfromclient.CallToolthrough the same gate-and-retry the establish path already uses: park viam.authGate.Await, and on a granted resume re-resolve + re-call. Extract the resolve→call sequence into a small retry helper so both the establish-time and call-timeErrNoTokentrip the gate exactly once, with the same bounded/one-shot semantics.Test
A
CallToolthat returnsErrNoToken:AuthGate.Await(previously never invoked on this path),Await→ granted, re-calls the tool and succeeds,Await→ timeout/cancel/no-subject, classifiesno_tokenas today (no regression),ErrNoTokenCallTool error still returns immediately (no spurious park).Impact / related
mcp_auth_required; without it the delegated flow can't auto-drive. initializ/console-next#67's proactive "connect your account" banner partially mitigates (it keys on user-mode refs, not the gate event), but the clean park + auto-resume path is what this restores.