What I expected
When an FSM action calls request_tool_access(toolName, reason) and the user clicks Allow, the LLM should be able to call toolName for the rest of that action.
What actually happens
The block is real, but the granted boolean has no path to actually unlock the tool. The LLM gets { ok: true, granted: true }, then tries to call the underlying tool — the Anthropic API rejects it because the tool isn't in the action's registered tools array. The grant record exists in storage, but nothing reads it except request_tool_access itself.
Trace
-
buildTools runs once at action start (packages/fsm-engine/fsm-engine.ts:3200-3356). It assembles the LLM's tool array from action.tools, workspace MCP servers, and the atlas-platform ambient set, then hands that fixed array to streamText. The toolset doesn't change for the rest of the action.
-
request_tool_access blocks the run via waitForTerminalElicitation (packages/mcp-server/src/tools/permissions/request-tool-access.ts). That pause is real. The pause is the only thing about this tool that genuinely affects the action.
-
On allow_always, ToolAccessGrants.grantAlways persists (workspaceId, toolName) to storage. But ToolAccessGrants.hasGrant is only consulted from inside request_tool_access itself (to short-circuit duplicate elicitations). I grepped fsm-engine, agent-server, agent-sdk, core/delegate, core/agent-conversion — no other consumer. So the grant is a cache for future elicitations, not a runtime permission gate.
-
The only mechanism that actually expands the LLM's callable surface is permissions.dangerouslySkipAllowlist at the job/workspace/daemon-env level (fsm-engine.ts:3322-3329). When that's on, buildTools skips per-agent narrowing and the LLM gets every platform-allowlisted tool — and request_tool_access returns bypass as a no-op formality.
So what does request_tool_access actually do?
- Blocks the action until the user answers.
- Returns a
granted boolean the LLM can use as a signal — to decide whether to route around (delegate to a sub-agent that does have the tool, fail-step gracefully, etc.). The LLM cannot use the granted response to invoke the originally-requested tool in the same action.
- For
allow_always, primes the grant cache so the next request_tool_access for the same tool short-circuits. The LLM still needs the tool in the action's tools array to actually use it.
Why this matters
The tool reads like a permission gate ("request access"), and the workspace-jobs SKILL.md describes the allow path as "the same action continues" — easy to read as "the LLM can now call the tool." It cannot. Authors who declare tools: ['fs_read_file', 'request_tool_access'] expecting the LLM to gain fs_write_file after an allow will be confused when the next tool call fails.
Possible directions
Pick one or some combination:
-
Union declared tools with prior allow_always grants when buildTools runs. Cross-action only — once the user has allow-always-ed a tool, every future action in that workspace sees it. Cheapest to ship. Doesn't help allow_once.
-
Mid-action toolset rebuild on grant. Interrupt streamText, rebuild the toolset including the newly-granted tool, resume. Closest match to the tool's name, but mechanically fiddly with current ai-sdk semantics.
-
Dynamic dispatch tool. Add a call_tool(name, args) meta-tool the LLM can use to invoke any granted-this-session tool. Sidesteps the static-toolset constraint, but introduces an indirection layer.
-
Tighten the framing. Rename or re-document the tool as an "approval signal" rather than an access grant. Update the skill text to say the LLM has to route around, not retry. Cheapest but doesn't fix the underlying gap.
Files touched in this trace
packages/fsm-engine/fsm-engine.ts (buildTools)
packages/mcp-server/src/tools/permissions/request-tool-access.ts
packages/core/src/elicitations/tool-access-grants.ts
packages/core/src/agent-conversion/agent-tool-filters.ts (wrapPlatformToolsWithScope — no permission gate)
packages/system/skills/writing-workspace-jobs/SKILL.md (the misleading "action continues" wording)
What I expected
When an FSM action calls
request_tool_access(toolName, reason)and the user clicks Allow, the LLM should be able to calltoolNamefor the rest of that action.What actually happens
The block is real, but the granted boolean has no path to actually unlock the tool. The LLM gets
{ ok: true, granted: true }, then tries to call the underlying tool — the Anthropic API rejects it because the tool isn't in the action's registeredtoolsarray. The grant record exists in storage, but nothing reads it exceptrequest_tool_accessitself.Trace
buildToolsruns once at action start (packages/fsm-engine/fsm-engine.ts:3200-3356). It assembles the LLM's tool array fromaction.tools, workspace MCP servers, and theatlas-platformambient set, then hands that fixed array tostreamText. The toolset doesn't change for the rest of the action.request_tool_accessblocks the run viawaitForTerminalElicitation(packages/mcp-server/src/tools/permissions/request-tool-access.ts). That pause is real. The pause is the only thing about this tool that genuinely affects the action.On
allow_always,ToolAccessGrants.grantAlwayspersists(workspaceId, toolName)to storage. ButToolAccessGrants.hasGrantis only consulted from insiderequest_tool_accessitself (to short-circuit duplicate elicitations). I greppedfsm-engine,agent-server,agent-sdk,core/delegate,core/agent-conversion— no other consumer. So the grant is a cache for future elicitations, not a runtime permission gate.The only mechanism that actually expands the LLM's callable surface is
permissions.dangerouslySkipAllowlistat the job/workspace/daemon-env level (fsm-engine.ts:3322-3329). When that's on,buildToolsskips per-agent narrowing and the LLM gets every platform-allowlisted tool — andrequest_tool_accessreturnsbypassas a no-op formality.So what does request_tool_access actually do?
grantedboolean the LLM can use as a signal — to decide whether to route around (delegate to a sub-agent that does have the tool, fail-step gracefully, etc.). The LLM cannot use the granted response to invoke the originally-requested tool in the same action.allow_always, primes the grant cache so the nextrequest_tool_accessfor the same tool short-circuits. The LLM still needs the tool in the action'stoolsarray to actually use it.Why this matters
The tool reads like a permission gate ("request access"), and the workspace-jobs SKILL.md describes the allow path as "the same action continues" — easy to read as "the LLM can now call the tool." It cannot. Authors who declare
tools: ['fs_read_file', 'request_tool_access']expecting the LLM to gainfs_write_fileafter an allow will be confused when the next tool call fails.Possible directions
Pick one or some combination:
Union declared tools with prior
allow_alwaysgrants whenbuildToolsruns. Cross-action only — once the user has allow-always-ed a tool, every future action in that workspace sees it. Cheapest to ship. Doesn't helpallow_once.Mid-action toolset rebuild on grant. Interrupt
streamText, rebuild the toolset including the newly-granted tool, resume. Closest match to the tool's name, but mechanically fiddly with current ai-sdk semantics.Dynamic dispatch tool. Add a
call_tool(name, args)meta-tool the LLM can use to invoke any granted-this-session tool. Sidesteps the static-toolset constraint, but introduces an indirection layer.Tighten the framing. Rename or re-document the tool as an "approval signal" rather than an access grant. Update the skill text to say the LLM has to route around, not retry. Cheapest but doesn't fix the underlying gap.
Files touched in this trace
packages/fsm-engine/fsm-engine.ts(buildTools)packages/mcp-server/src/tools/permissions/request-tool-access.tspackages/core/src/elicitations/tool-access-grants.tspackages/core/src/agent-conversion/agent-tool-filters.ts(wrapPlatformToolsWithScope— no permission gate)packages/system/skills/writing-workspace-jobs/SKILL.md(the misleading "action continues" wording)