Skip to content

fix(agents): raise OAuth consent interrupt for skill-folded external MCP tools#477

Merged
philmerrell merged 1 commit into
developfrom
fix/skills-oauth-consent-fold
Jun 12, 2026
Merged

fix(agents): raise OAuth consent interrupt for skill-folded external MCP tools#477
philmerrell merged 1 commit into
developfrom
fix/skills-oauth-consent-fold

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

Problem

In skills mode (now the server default, agent_type="skill"), invoking a skill whose bound external MCP tool has no active OAuth authorization (e.g. "Gmail for employees" with no Gmail token in the vault) produced a text apology from the model instead of pausing the turn with the in-conversation consent prompt.

Root cause: OAuthConsentHook's provider_lookup only resolves when event.selected_tool is an MCPAgentTool. In skills mode the model calls the skill_executor meta-tool (a decorated function tool) — the real target only appears in the tool-use input ({skill_name, tool_name, ...}) — so the gate concluded "not OAuth-gated", the FoldedMCPTool ran call_tool_sync tokenless, and the auth error surfaced as tool text. No Strands interrupt → no pending_interrupt → no oauth_required SSE event.

A compounding bug: FoldedMCPTool.invoke swallowed all failures into success-status strings, so the hook's 401-retry heuristic (gated on status == "error") was also dead for folded tools — even the expired-token refresh path.

Fix

  • oauth_consent.py — optional tool_use_provider_lookup: a second-chance resolver receiving the raw tool_use dict, consulted by both the consent gate and the 401-retry handler when provider_lookup returns None. Plain chat wiring passes None → behavior unchanged.
  • skills/mcp_binding.pymake_folded_tool_provider_lookup(registry, provider_for_client): executor tool_use input → bound FoldedMCPTool → owning client → provider id. Gateway clients resolve to None (SigV4, not user OAuth). FoldedMCPTool.invoke now returns a ToolResult-shaped {"status": "error", ...} on failure (Strands' @tool decorator passes status+content dicts through), so error status survives the fold.
  • base_agent.py / skill_agent.py — new _build_tool_use_provider_lookup() hook point (base returns None); SkillAgent overrides it with its registry + the external MCP integration.

Resume path is unchanged: the interrupt is raised on the skill_executor call itself, so consent → interrupt_responses → token-cache warm → the client's lazy token provider injects the bearer on the actual MCP request.

Tests

  • TestOAuthConsentHookSkillFoldedTools — skill-bound tool with no token → InterruptException with the exact oauth_required reason; cached token proceeds; 401-through-the-fold clears cache and retries; no-lookup preserves old behavior.
  • TestFoldedToolProviderLookup — resolver against a real SkillRegistry (unknown skill/tool, local non-folded tools, unmapped clients, malformed input).
  • TestToolUseProviderLookupWiringSkillAgent hands the hook a working resolver.
  • FoldedMCPTool error-status preservation (exception + error-status MCP result).

Full backend suite: 3820 passed, 3 skipped (pytest is not run in CI; local suite is the gate).

Out of scope (flagged separately)

MCPExternalApprovalHook has the identical hole — needs_approval=True tools bound to a skill bypass the approval prompt. Tracked as a follow-up task using the same tool_use fallback pattern.

🤖 Generated with Claude Code

…MCP tools

In skills mode a skill-bound external MCP tool executes through the
skill_executor meta-tool, so OAuthConsentHook's provider_lookup (keyed on
the selected tool being an MCPAgentTool) resolved nothing and the consent
gate silently never fired: the tool ran tokenless, returned an auth error
as text, and the model apologized instead of the turn pausing with an
oauth_required interrupt.

- OAuthConsentHook: optional tool_use_provider_lookup second-chance
  resolver (gate + 401-retry handler) consulted when provider_lookup
  returns None
- mcp_binding: make_folded_tool_provider_lookup maps the executor's
  tool_use input (skill_name/tool_name) -> bound FoldedMCPTool -> owning
  client -> provider id; gateway clients resolve to None (SigV4, not
  user OAuth)
- FoldedMCPTool.invoke now returns a ToolResult-shaped error on failure
  so the error status survives the fold and the consent hook's 401-retry
  heuristic (gated on status == "error") can fire for folded tools
- SkillAgent wires the resolver over its registry via the new
  BaseAgent._build_tool_use_provider_lookup hook point (None for chat)

The resume path is unchanged: the interrupt is raised on the
skill_executor call itself, so consent -> interrupt_responses -> cache
warm -> the client's lazy token provider picks up the token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philmerrell philmerrell merged commit 33bc402 into develop Jun 12, 2026
1 check passed
@philmerrell philmerrell deleted the fix/skills-oauth-consent-fold branch June 12, 2026 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant