feat(mcp-server): create approval request on action trigger when required by role#1708
feat(mcp-server): create approval request on action trigger when required by role#1708Scra3 wants to merge 15 commits into
Conversation
2 new issues
|
3ce5bee to
11b6da4
Compare
|
Coverage Impact ⬇️ Merging this pull request will decrease total coverage on Modified Files with Diff Coverage (10)
🛟 Help
|
Code reviewRan the standard passes plus a ponytail (over-engineering) pass and a skeptic pass. No blocking bugs. Skeptic note (flagged, then dismissed). Multiple passes flagged agent-nodejs/packages/agent-client/src/domains/action.ts Lines 121 to 128 in 9f90e14 Non-blocking:
agent-nodejs/packages/mcp-server/src/utils/agent-caller.ts Lines 38 to 44 in 9f90e14
agent-nodejs/packages/agent-client/src/domains/action.ts Lines 120 to 131 in 9f90e14
Ponytail: agent-nodejs/packages/agent-client/src/approval-request-creator.ts Lines 13 to 23 in 9f90e14 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
…ired by role
Triggering an action via the Forest MCP now goes through the approval
workflow when the user's role requires it, instead of executing directly.
The approval logic lives in agent-client `Action.execute()` so it is shared
by all prod consumers: on a 403 `CustomActionRequiresApprovalError`, an
injected `ApprovalRequestCreator` creates the approval request and execute()
returns `{ approvalRequested: true }`. The creator is wired only on the prod
client (`createRemoteAgentClient({ forestServer })`); test contexts
(agent-testing) omit it, so triggering an action there never creates an
approval request — execute() rethrows as before.
mcp-server reports "approval requested" to the LLM and keeps the activity log
marked completed (never failed).
fixes PRD-288
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Use forestadmin-server private-api `POST /api/action-approvals` with the correct payload (type `action-approvals`, `status: null`, `action_name`, `collection_name`, `record_ids`, `inputs`), the `forest-rendering-id` header, and the `forestServerToken` bearer (not the user token). The Forest server URL comes from FOREST_SERVER_URL, and renderingId/forestServerToken from the MCP auth context. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Use the configured forestServerUrl (exposed on ForestServerClient) instead of re-reading FOREST_SERVER_URL in agent-caller, so approval requests and activity logs hit the same host for self-hosted setups. - Replace the ApprovalRequestCreator class with a makeCreateApprovalRequest factory function (one method, one caller). - Default recordIds to [] in the approval payload (record_ids is required). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stServer shape
- execute() now returns `{ success; html? } | { approvalRequested: true }` so
callers are forced to handle the approval case (was all-optional fields).
- Rename forestServer fields to `{ serverUrl, serverToken, renderingId }` and
document the param: it is the shared Forest server connection (distinct from
the agent `url`), kept as one bag so future server-side features reuse it
rather than duplicating coords per feature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atures Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP ForestServerClientImpl constructor now requires the forest server URL; forward the agent's configured forestServerUrl when building it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Unit-test makeCreateApprovalRequest (asserts the /api/action-approvals POST payload, rendering-id header and server-token bearer). - Cover createRemoteAgentClient with forestServer wired. - Cover agent-caller building the forestServer connection when complete. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Forest server stores action approval `inputs` as a list of { name, value }
and reduces it back into a values map when generating the signed request
(generate-custom-action-request). Sending a plain values object made
`inputs.reduce` throw, 500-ing GET /api/action-approvals/:id from the front.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The front renders each approval input via a component requiring `type`
({ name, type, value } — see BaseActionInput). Build inputs from the form
field states (name + type + value) instead of a bare name/value list, so the
approval detail view no longer fails on a missing `inputValue.type`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Build approval inputs from the form fields (name/type/value) and pass the field type as-is. List fields expose an array type (['String'], ['Number']) which must reach the Forest server as an array, not a stringified one, to match its ActionInput shape; scalars stay plain strings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ertion The test only asserts the client is built; the approval wiring itself is verified in action.test and approval-request-creator.test. Renamed so the name no longer overclaims. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1544db6 to
23b2b0a
Compare
| const mapped = toActionError(error); | ||
|
|
||
| if (mapped instanceof ActionRequiresApprovalError && this.createApprovalRequest) { | ||
| const inputs = this.fieldsFormStates.getFields().map(field => ({ |
There was a problem hiding this comment.
Claude Opus 4.8 — [Should fix] Approval inputs are built from getFields(), which returns all fields including ones whose value is undefined, whereas the executed path (line 103) uses getFieldValues() that drops undefined. Confirm the server's /api/action-approvals validation accepts entries for unfilled fields — otherwise an action with optional, unfilled fields could have its approval request rejected. Mirror getFieldValues() semantics if so.
There was a problem hiding this comment.
Not changing: the server validator (forestadmin-server #8327) declares value: Joi.any() and explicitly tests "allow a list field type and a missing value", so unfilled fields are accepted (and JSON.stringify omits value: undefined on the wire anyway). Keeping getFields() also preserves the {name, type} of unfilled fields for the approver.
| value: field.getValue(), | ||
| })); | ||
|
|
||
| await this.createApprovalRequest({ |
There was a problem hiding this comment.
Claude Opus 4.8 — [Preferential] If this POST fails, the error propagates and the LLM sees only the transport error — the fact that the action is approval-gated is lost, and "couldn't file the approval request" becomes indistinguishable from "the action failed". Consider wrapping to disambiguate the two failure modes.
There was a problem hiding this comment.
Fixed in 106db88: the approval POST is now wrapped in ApprovalRequestCreationError (carries the original cause), so an approval-gate filing failure is distinguishable from the action itself failing.
| data: { | ||
| type: 'action-approvals', | ||
| attributes: { | ||
| status: null, |
There was a problem hiding this comment.
Claude Opus 4.8 — [Preferential] status: null is an unexplained literal. A one-line note on the server contract (e.g. the server assigns the pending status; null is required on create) would save a future reader the "is this needed?" question.
There was a problem hiding this comment.
Fixed in 106db88: added a comment on the create contract (the Forest server assigns the pending status itself; null is required on create).
| }, | ||
| }); | ||
|
|
||
| expect(client).toBeInstanceOf(RemoteAgentClient); |
There was a problem hiding this comment.
Claude Opus 4.8 — [Violates conventions] This test ("build a client when forestServer is provided") asserts only toBeInstanceOf(RemoteAgentClient) — identical to the tests above that pass no forestServer, so it passes even if the forestServer → createApprovalRequest wiring is dropped entirely. CLAUDE.md: test name = assertion / avoid weak assertions. Assert the wiring is observable (e.g. spy makeCreateApprovalRequest and verify it's called with the forestServer fields). Semantic — not linter-catchable.
There was a problem hiding this comment.
Fixed in 106db88: the test now mocks makeCreateApprovalRequest and asserts it is called with { forestServerUrl, forestServerToken, renderingId } when forestServer is provided, plus a negative test asserting it is not called when omitted.
|
|
||
| const result = await buildClientWithActions(request, mockForestServerClient); | ||
|
|
||
| expect(result.rpcClient).toBeDefined(); |
There was a problem hiding this comment.
Claude Opus 4.8 — [Violates conventions] Asserts only that rpcClient is defined — the same as the tests without forest-server fields — so it doesn't verify forestServer was actually passed, and there is no negative test for the guard (forestServerUrl && forestServerToken && renderingId != null). Mock createRemoteAgentClient and assert it's called with forestServer: { serverUrl, serverToken, renderingId } when complete, and undefined when a field is missing. (CLAUDE.md weak-assertion rule; semantic, not linter-catchable.)
There was a problem hiding this comment.
Fixed in 106db88: now asserts createRemoteAgentClient is called with forestServer: { serverUrl, serverToken, renderingId } when complete, and a negative test asserting forestServer: undefined when forestServerToken is missing (the guard's silent-disable branch).
| content: [{ type: 'text', text: expect.stringContaining('Approval requested') }], | ||
| }); | ||
| // It is not an error → the activity log is not marked failed. | ||
| expect((result as { isError?: boolean }).isError).toBeUndefined(); |
There was a problem hiding this comment.
Claude Opus 4.8 — [Preferential] isError is never set by either branch of the handler, and withActivityLog is mocked in this suite, so this assertion (and the "activity log is not marked failed" comment) is tautological — it cannot fail. The stringContaining('Approval requested') assertion above is the meaningful one. Drop this line, or assert the activity-log status against the real withActivityLog if the intent is to prove the log stays completed.
There was a problem hiding this comment.
Fixed in 106db88: dropped the tautological isError assertion and its misleading comment; the stringContaining('Approval requested') assertion remains.
- wrap approval-request POST failure in ApprovalRequestCreationError so an approval-gate failure is distinguishable from an action failure - document the status: null create contract - strengthen wiring tests: assert forestServer -> createApprovalRequest, and the agent-caller guard (incl. negative case) - drop the tautological isError assertion in execute-action approval test Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Context
Some custom actions are gated behind approval-by-role: a user whose role can't execute the action triggers it → an approval request is created → another user (with an approving role) reviews it on the front. Until now this guard existed only via the frontend — triggering the same action through the Forest MCP executed it directly, bypassing approval (compliance hole for refunds, exports, deletions…).
This PR makes an action triggered via MCP create an approval request instead of executing, and reports "approval requested" to the LLM.
fixes PRD-288
Approach — approval logic mutualized in
agent-clientThe approval handling lives in
agent-clientAction.execute()so every prod consumer (mcp-server, and later the workflow executor) gets it for free, with no duplicated catch+create per consumer:CustomActionRequiresApprovalError(mapped toActionRequiresApprovalErrorby the deterministic-action-steps base work),execute()calls an injectedApprovalRequestCreatorand returns{ approvalRequested: true }.createRemoteAgentClient({ forestServer: { url, bearerToken } })— no callback. Prod clients wire it;agent-testingomits it, so triggering an action in tests never creates an approval request (execute()rethrows as before).agent-testingis untouched.Changes
ApprovalRequestCreator(SaaS call);execute()approval branch; creator threadedRemoteAgentClient → Collection → Action;createRemoteAgentClientacceptsforestServer.agent-callerwiresforestServer;execute-actionreturns the "approval requested" message (caught inside the activity-log operation → log stayscompleted, neverfailed).ApprovalRequestCreator.create()carries aTODO(PRD-288)placeholder until confirmed.feature/workflow-deterministic-ai-steps(retarget tomainonce that merges).Tests
create()called with correct args +{ approvalRequested: true }; no creator → rethrows, nothing created.agent-client330 ✓ ·mcp-server545 ✓ · lint 0 error.🤖 Generated with Claude Code
Note
Create approval requests on action trigger when required by role in workflow executor
TriggerRecordActionStepExecutornow supports dynamic action forms: Manual mode pauses for user input with no AI prefill; AI-assisted mode pre-fills and pauses for review; Full AI mode iteratively fills required fields and executes, falling back to review if fields remain empty.CustomActionRequiresApprovalError, the executor callsmakeCreateApprovalRequestto POST to/api/action-approvalson the Forest server, returning{ approvalRequested: true }instead of throwing.AgentHttpErrorreplaces generic stringified errors throughoutagent-client, carryingstatus, parsedbody, and rawresponseTextfor structured downstream handling.preRecordedArgs.selectedRecordStepId(stable BPMN step id) replaces the previous index-basedselectedRecordStepIndexin bothTriggerActionStepDefinitionSchemaandLoadRelatedRecordStepDefinitionSchema.StepExecutionFormattersgains aformatTriggerActionpath that distinguishes pending-approval from executed submissions and highlights AI prefill vs. user edits.TriggerActionStepDefinitionSchemano longer coerces invalidexecutionTypevalues toAutomatedWithConfirmation; existing configs with unrecognized values will now fail validation.Changes since #1708 opened
ApprovalRequestCreationErrorclass and wrapped approval request creation inAction.executemethod with try/catch block to throw the new error type [106db88]createRemoteAgentClientto accept and passforestServerconnection details including url, token, and renderingId to wire approval request creation [106db88]ApprovalRequestCreationErrorthrowing behavior andforestServerconfiguration wiring [106db88]makeCreateApprovalRequestfactory explaining why status attribute is set to null on creation [106db88]Macroscope summarized 23b2b0a.