Skip to content

feat(mcp-server): create approval request on action trigger when required by role#1708

Open
Scra3 wants to merge 15 commits into
mainfrom
feature/prd-288-create-approval-request-on-action-trigger-when-required-by
Open

feat(mcp-server): create approval request on action trigger when required by role#1708
Scra3 wants to merge 15 commits into
mainfrom
feature/prd-288-create-approval-request-on-action-trigger-when-required-by

Conversation

@Scra3

@Scra3 Scra3 commented Jun 25, 2026

Copy link
Copy Markdown
Member

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-client

The approval handling lives in agent-client Action.execute() so every prod consumer (mcp-server, and later the workflow executor) gets it for free, with no duplicated catch+create per consumer:

  • On a 403 CustomActionRequiresApprovalError (mapped to ActionRequiresApprovalError by the deterministic-action-steps base work), execute() calls an injected ApprovalRequestCreator and returns { approvalRequested: true }.
  • The creator is data-injected via createRemoteAgentClient({ forestServer: { url, bearerToken } }) — no callback. Prod clients wire it; agent-testing omits it, so triggering an action in tests never creates an approval request (execute() rethrows as before). agent-testing is untouched.

Changes

  • agent-client: new ApprovalRequestCreator (SaaS call); execute() approval branch; creator threaded RemoteAgentClient → Collection → Action; createRemoteAgentClient accepts forestServer.
  • mcp-server: agent-caller wires forestServer; execute-action returns the "approval requested" message (caught inside the activity-log operation → log stays completed, never failed).

⚠️ Pending before merge

  1. SaaS endpoint spec (path / payload / auth) for creating an approval request — used by the front today. ApprovalRequestCreator.create() carries a TODO(PRD-288) placeholder until confirmed.
  2. Based on feature/workflow-deterministic-ai-steps (retarget to main once that merges).

Tests

  • agent-client: creator wired → create() called with correct args + { approvalRequested: true }; no creator → rethrows, nothing created.
  • mcp-server: approval path → LLM "approval requested" message, not an error.
  • agent-client 330 ✓ · mcp-server 545 ✓ · lint 0 error.

🤖 Generated with Claude Code

Note

Create approval requests on action trigger when required by role in workflow executor

  • TriggerRecordActionStepExecutor now 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.
  • When the agent rejects execution with CustomActionRequiresApprovalError, the executor calls makeCreateApprovalRequest to POST to /api/action-approvals on the Forest server, returning { approvalRequested: true } instead of throwing.
  • AgentHttpError replaces generic stringified errors throughout agent-client, carrying status, parsed body, and raw responseText for structured downstream handling.
  • preRecordedArgs.selectedRecordStepId (stable BPMN step id) replaces the previous index-based selectedRecordStepIndex in both TriggerActionStepDefinitionSchema and LoadRelatedRecordStepDefinitionSchema.
  • StepExecutionFormatters gains a formatTriggerAction path that distinguishes pending-approval from executed submissions and highlights AI prefill vs. user edits.
  • Risk: TriggerActionStepDefinitionSchema no longer coerces invalid executionType values to AutomatedWithConfirmation; existing configs with unrecognized values will now fail validation.

Changes since #1708 opened

  • Added ApprovalRequestCreationError class and wrapped approval request creation in Action.execute method with try/catch block to throw the new error type [106db88]
  • Configured createRemoteAgentClient to accept and pass forestServer connection details including url, token, and renderingId to wire approval request creation [106db88]
  • Added test coverage for ApprovalRequestCreationError throwing behavior and forestServer configuration wiring [106db88]
  • Added inline comment in makeCreateApprovalRequest factory explaining why status attribute is set to null on creation [106db88]

Macroscope summarized 23b2b0a.

@linear-code

linear-code Bot commented Jun 25, 2026

Copy link
Copy Markdown

PRD-288

@qltysh

qltysh Bot commented Jun 25, 2026

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Function with many parameters (count = 8): constructor 2

@Scra3 Scra3 force-pushed the feature/prd-288-create-approval-request-on-action-trigger-when-required-by branch from 3ce5bee to 11b6da4 Compare June 25, 2026 09:43
Comment thread packages/agent-client/src/approval-request-creator.ts Outdated
Comment thread packages/mcp-server/src/utils/agent-caller.ts
@qltysh

qltysh Bot commented Jun 25, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬇️ Merging this pull request will decrease total coverage on main by 0.07%.

Modified Files with Diff Coverage (10)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/mcp-server/src/tools/execute-action.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/http-client/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/utils/agent-caller.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/domains/collection.ts100.0%
Coverage rating: A Coverage rating: A
packages/mcp-server/src/http-client/mcp-http-client.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/errors.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/domains/action.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/domains/remote-agent-client.ts100.0%
Coverage rating: A Coverage rating: A
packages/agent-client/src/index.ts100.0%
New Coverage rating: A
packages/agent-client/src/approval-request-creator.ts100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

Comment thread packages/mcp-server/src/utils/agent-caller.ts
@Scra3

Scra3 commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Code review

Ran the standard passes plus a ponytail (over-engineering) pass and a skeptic pass. No blocking bugs.

Skeptic note (flagged, then dismissed). Multiple passes flagged recordIds: this.ids as possibly undefined for global actions, while record_ids is Joi.array().required() server-side. False positive: Collection.action() is the only production path and always builds ids as an array ([] at minimum), so record_ids: [] is sent and validates.

await this.approvalRequestCreator.create({
collectionName: this.collectionName,
actionName: this.actionName,
recordIds: this.ids,
values: this.fieldsFormStates.getFieldValues(),
});
return { approvalRequested: true };

Non-blocking:

  1. The Forest server URL is re-derived from process.env.FOREST_SERVER_URL here, duplicating cli.ts and bypassing a forestServerUrl configured via server options. For self-hosted hosts the approval POST could hit a different host than the activity-log calls.

const forestServer =
forestServerToken && renderingId != null
? {
url: process.env.FOREST_SERVER_URL || 'https://api.forestadmin.com',
forestServerToken,
renderingId,
}

  1. approvalRequestCreator.create() is not wrapped, so a SaaS network/5xx failure escapes execute() as a raw error (outside the typed ActionRequiresApprovalError contract). Acceptable, but the failure path is undocumented.

if (mapped instanceof ActionRequiresApprovalError && this.approvalRequestCreator) {
await this.approvalRequestCreator.create({
collectionName: this.collectionName,
actionName: this.actionName,
recordIds: this.ids,
values: this.fieldsFormStates.getFieldValues(),
});
return { approvalRequested: true };
}
throw mapped;

  1. Layering: a prior review on these files (#1403) set the convention "agent-client throws, mcp-server shapes the result". Here execute() swallows the approval error and returns { approvalRequested: true }, gated on whether a creator was wired. Deliberate (mutualizing for the future workflow-executor), but the dual contract for the same 403 is worth a short comment.

Ponytail: ApprovalRequestCreator is a class with a single method and a single caller; a plain createApprovalRequest(options, payload) function would do the same with less surface. Minor.

export default class ApprovalRequestCreator {
constructor(
private readonly options: {
forestServerUrl: string;
forestServerToken: string;
renderingId: number | string;
},
) {}
async create(payload: ApprovalRequestPayload): Promise<void> {
await ServerUtils.queryWithBearerToken({

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Comment thread packages/agent-client/src/domains/collection.ts
Base automatically changed from feature/workflow-deterministic-ai-steps to main June 25, 2026 12:22
alban bertolini and others added 14 commits June 25, 2026 14:27
…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>
@Scra3 Scra3 force-pushed the feature/prd-288-create-approval-request-on-action-trigger-when-required-by branch from 1544db6 to 23b2b0a Compare June 25, 2026 12:28
const mapped = toActionError(error);

if (mapped instanceof ActionRequiresApprovalError && this.createApprovalRequest) {
const inputs = this.fieldsFormStates.getFields().map(field => ({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 forestServercreateApprovalRequest 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Comment thread packages/agent-client/src/domains/action.ts
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.

2 participants