Skip to content

MCP auth gate bypassed when ErrNoToken surfaces at tool-call time (per-request auth), not connection-establish #376

Description

@initializ-mk

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 requesttransport_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 ClientForestablishInitialize), 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions