feat(mcp-server): expose workflow tools in Forest MCP server (PRD-49) - #1792
feat(mcp-server): expose workflow tools in Forest MCP server (PRD-49)#1792christophebrun-forest wants to merge 41 commits into
Conversation
5 new issues
|
Expose MCP-enabled workflows to LLM clients via a new listWorkflows tool, calling the Forest server MS3 endpoint (GET /api/workflow-orchestrator/workflows) over the HTTP contract with the caller's forestServerToken + renderingId. - forestadmin-client: WorkflowsService + ForestHttpApi.listMcpEnabledWorkflows - mcp-server: listWorkflows tool, http-client wiring, shared getAuthContext util Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat(mcp-server): add triggerWorkflow tool (PRD-738)
Expose the triggerWorkflow MCP tool so an LLM can start a run on a
specific record and get a runId back. Non-blocking by design: the run
continues server-side and status is observed via getWorkflowRun (MS8).
- tool args { workflowId, recordId }; identity from the OAuth auth
context (forestServerToken + renderingId), wrapped in withActivityLog
so MCP-triggered runs are audited locally under the caller.
- forestadmin-client: WorkflowsService.triggerMcpWorkflow calls the
MCP-dedicated start endpoint over HTTP
(POST /api/workflow-orchestrator/workflows/:workflowId/start), no
private-api internals imported.
- collectionId is derived server-side from the workflow (MS5), so the
tool contract stays { workflowId, recordId } — consistent with the
webhook trigger.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…32) (#1786) MCP-triggered runs carry triggerType='mcp', but the executor only recognized manual|webhook, so AvailableStepExecutionSchema.parse rejected every MCP run at step 0 with a DomainValidationError before executing. triggerType is informational only (logged in runner.ts, no logic branches on it), so a run was aborted purely over an unrecognized logged value. Add 'mcp' to TriggerType and ServerWorkflowTriggerType so MCP runs map to a valid AvailableStepExecution and execute. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(mcp-server): add getWorkflowRun tool (PRD-740) Expose the getWorkflowRun polling tool so the LLM can observe a run's status, closing the discover -> trigger -> poll loop. Report-only in v1: human-gated runs report waitingForHumanInput but cannot be resumed via MCP (tracked in PRD-441). Threads a getMcpWorkflowRun call through forestadmin-client (types, HTTP api, workflows service) to the MS7 read endpoint, and registers a read-only getWorkflowRun MCP tool scoped to the caller. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
e87448d to
71eea4a
Compare
|
WorkflowRunTriggerResult.runId was typed number while getMcpWorkflowRun expects a string runId, so the trigger result could not be fed back into the run polling without conversion. The orchestrator's numeric id is now normalized at the HTTP boundary and the contract uses string end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lows (PRD-831) (#1805) perf(mcp-server): trigger workflow by id instead of listing all workflows triggerWorkflow no longer calls listMcpWorkflows before every trigger just to resolve the name/collection for the audit label. It now starts the run directly and reads workflowName/collectionName from the (enriched) start response, falling back to the workflowId when an older server omits them. A server 404 (unknown or MCP-disabled workflow) is mapped back to the existing "is not an MCP-enabled workflow" message so the LLM-facing contract is unchanged. The audit log is recorded after the run starts and is best-effort — the run is already ongoing, so a logging hiccup no longer fails the tool. fixes PRD-831 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
PMerlet
left a comment
There was a problem hiding this comment.
Adversarial review of the branch (head a4d3949). Overall solid: URL injection is properly mitigated (encodeURIComponent + tests), no stack/token/internal-URL leakage in error paths (404/403/409/500 all verified), the enum addition is non-breaking within the monorepo, and test coverage goes well beyond happy path. One cross-repo blocker inherited from the server branch, one audit-trail decision to make explicit — details inline.
| * The normalized status of a workflow run, as exposed for external (MCP) consumption. | ||
| * `result` is the terminal output when finished; `error` the failure detail otherwise. | ||
| */ | ||
| export interface WorkflowRunStatus { |
There was a problem hiding this comment.
Blocker (cross-repo) — this contract does not exist server-side.
WorkflowRunStatus (currentStep, waitingForHumanInput, result, error) is only a TypeScript cast: ForestHttpApi.getMcpWorkflowRun does queryWithBearerToken<WorkflowRunStatus> with no runtime mapping, and on forestadmin-server#8418 (head 6b9c421) the normalized status mapper was removed after review — GET /mcp-workflows/runs/:runId deliberately returns the full hydrated run ({ runState, triggerType, workflowId, collectionId, workflowHistory: [{ stepName, done, context, stepDefinition… }] }).
Tests pass because they mock the normalized shape. In production, getWorkflowRun will stringify the hydrated run and waitingForHumanInput — the pivot of the documented human-gated flow — will never appear.
Either the server re-lands the mapper, or this package should derive the normalized status from workflowHistory client-side (or embrace the hydrated shape in the types, the tool description, and docs#21). Needs to be settled before merge.
There was a problem hiding this comment.
Sorry, friday demo --> I forgot to push
There was a problem hiding this comment.
Resolved (de2872b) — the normalized WorkflowRunStatus cast was dropped and getWorkflowRun now returns the full hydrated run (HydratedWorkflowRun: runState + workflowHistory[] with each step's resolved definition and per-step context), matching what the orchestrator actually sends. The human-gated pivot is no longer a waitingForHumanInput flag but is derived from workflowHistory (a step with done: false and an escalationState/awaitingInputReason context). Types, the tool description and the PR body are updated accordingly.
| ); | ||
|
|
||
| markActivityLogAsSucceeded({ forestServerClient, request: extra, activityLog, logger }); | ||
| } catch (error) { |
There was a problem hiding this comment.
Important — audit trail is fail-open here, unlike every other write tool.
withActivityLog awaits createPendingActivityLog before the operation, outside the try/catch — create/update/delete/executeAction are all fail-closed (no audit log → the action is not executed). Here the log is written entirely post-hoc and any failure is swallowed with a warn: with the activity-log service down, a workflow with real side effects gets triggered with zero audit trail.
Fail-open may well be the right call (the run is already started server-side; failing the tool would mislead the LLM into retrying → 409). But then the pending log should at least be created before the trigger, like everywhere else, so intent is captured even if the trigger itself fails. Either way this deserves an explicit decision from the audit/compliance side rather than being an implementation side effect.
There was a problem hiding this comment.
Addressed — went fail-closed, as suggested. Latest commit (0ecd716a) reworks triggerWorkflow:
- The pending activity log is now written before the trigger, via the shared
withActivityLogwrapper — same fail-closed contract ascreate/update/delete. Intent is captured even when the trigger itself fails: a trigger-time 404/409 marks the logfailedinstead of leaving no trace. - To keep the label rich (workflow name + collection) without the O(n) listing @EnkiP flagged, it first resolves the workflow via a new O(1) by-id endpoint
GET /api/workflow-orchestrator/mcp-workflows/:workflowId(forestadmin-server, PRD-49) returning{ name, collectionName, mcpEnabled }. So we get fail-closed and a named label and O(1). - Unknown or MCP-disabled workflows are rejected up front — no run started, no log written.
Tests assert the log-before-trigger ordering and the failed marking on 404/409. Deployment note (also added to the PR description): the server-side by-id endpoint must ship first.
| }, | ||
| ); | ||
|
|
||
| markActivityLogAsSucceeded({ forestServerClient, request: extra, activityLog, logger }); |
There was a problem hiding this comment.
Minor: the log is marked succeeded as soon as POST /start returns, but the run is asynchronous and can still abort later. The label ("triggered the workflow …") technically covers it, yet an auditor reading a completed entry may assume the workflow succeeded. Worth a note in the label or a distinct status.
There was a problem hiding this comment.
Accepted for v1 — this activity log records the trigger call (triggerWorkflow), not the run's terminal state: completed here means the trigger was accepted; the run's actual outcome is read via getWorkflowRun. The label reflects that (triggered the workflow "…", not "completed"). Making the audit mirror the run's terminal state would need server-side status reconciliation (the activity-log API has no async-pending status today) — captured as a follow-up rather than v1. Noted in the PR's "Rollout & release notes" section.
| 'is not validated at trigger time: an invalid record surfaces later via getWorkflowRun. ' + | ||
| 'Discover triggerable workflows with listWorkflows first.', | ||
| inputSchema: { | ||
| workflowId: z.string().describe(WORKFLOW_ID_DESCRIPTION), |
There was a problem hiding this comment.
Minor: workflowId/recordId (and runId in getWorkflowRun) accept empty strings — z.string() without .min(1). An empty runId even turns the request path into a different endpoint (…/mcp-workflows/runs/); the server 404s, but it is a pointless round-trip. .min(1) closes the gap cheaply. No test covers empty-string args.
There was a problem hiding this comment.
Fixed in 1e988e28 — workflowId/recordId (triggerWorkflow) and runId (getWorkflowRun) now use z.string().min(1), so an empty string is rejected client-side instead of hitting the server (or, for an empty runId, a different endpoint). Added empty-string assertions to the tool tests.
| annotations: { readOnlyHint: true }, | ||
| title: 'Get a workflow run status', | ||
| description: | ||
| 'Poll the status of a workflow run started with triggerWorkflow. Returns runState, the ' + |
There was a problem hiding this comment.
Minor: the description says "Poll the status…" with no interval/backoff guidance, and there is no dedicated rate limiter on the server routes (docs#21 states it explicitly). An LLM can hammer this in a tight loop on a long or human-gated run (which never resolves via MCP in v1). One sentence — "wait at least N seconds between calls" — is free mitigation.
There was a problem hiding this comment.
Done in 1e988e28 — the description now tells the LLM to poll at a reasonable interval (wait at least a few seconds between calls) and not busy-loop on a long-running or human-gated run that never resolves via MCP in v1. Still no server-side rate limiter, so this is guidance only.
| export enum TriggerType { | ||
| Manual = 'manual', | ||
| Webhook = 'webhook', | ||
| Mcp = 'mcp', |
There was a problem hiding this comment.
Deployment-order constraint worth calling out in the epic/release notes: an executor running a pre-PR version validates triggerType with a z.nativeEnum that lacks mcp → DomainValidationError/MalformedRunError on the first MCP run it picks up. Executors must be upgraded before the orchestrator starts routing MCP-triggered runs. (Within the monorepo the addition is clean — no non-exhaustive switch, run-to-available-step-mapper handles it.)
There was a problem hiding this comment.
Documented in the PR's "Rollout & release notes": executors must be on the PRD-832 release before the orchestrator routes MCP-triggered runs, otherwise an older executor rejects triggerType='mcp' at validation. Note the server already gates oauth2 MCP steps on executor ≥ 1.14.0, but that's a separate axis from the triggerType enum — we'll confirm the routing/rollout sequencing with the deploy owner (the orchestrator already tracks executor versions via reportExecutorVersion, so version-gating the assignment is a possible follow-up).
| { name: 'getActionForm', register: () => declareGetActionFormTool(mcpServer, ctx) }, | ||
| { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, | ||
| { name: 'listWorkflows', register: () => declareListWorkflowsTool(mcpServer, ctx) }, | ||
| { name: 'triggerWorkflow', register: () => declareTriggerWorkflowTool(mcpServer, ctx) }, |
There was a problem hiding this comment.
Release-notes callout: the three tools join allToolNames, so any integration mounting the MCP server without an explicit enabledTools silently gains triggerWorkflow — a side-effectful tool — on upgrade. Consistent with create/update/delete/executeAction being default-on, so no change requested; just make it prominent in the changelog. (Per-workflow opt-in server-side still gates actual exposure.)
There was a problem hiding this comment.
Acknowledged — no code change (consistent with create/update/delete being default-on). Documented in the PR's "Rollout & release notes": the three tools are default-on, integrations pinning enabledTools are unaffected, and triggerWorkflow is inert until an admin enables the mcp trigger on a workflow — the server rejects a trigger on a non-opted-in workflow (WorkflowMcpTriggerNotEnabledError).
…wRun (PRD-49) Realign the MCP consumer to the server contract: GET mcp-workflows/runs/:runId now returns the full HydratedWorkflowRun (runState + complete workflowHistory with resolved step definitions and per-step context) instead of the dropped normalized WorkflowRunStatus. - forestadmin-client: replace WorkflowRunStatus/WorkflowRunStep with the HydratedWorkflowRun type hierarchy and re-export it - align ForestServerClient method names with the client (listMcpEnabledWorkflows, triggerMcpWorkflow, getMcpWorkflowRun) - update the getWorkflowRun tool description to the hydrated shape - update the agent-testing mock Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atedWorkflowRun (PRD-49)
The createMockForestServerClient helper still returned the dropped
{ runState, currentStep, waitingForHumanInput } shape by default; the
`as jest.Mocked<>` cast hid the mismatch from tsc. Return a valid
HydratedWorkflowRun so tests relying on the default get the real contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kflow lookup (PRD-49) Resolve the workflow by id before triggering so the activity log is written (pending) BEFORE the run starts, like create/update/delete — instead of the previous post-hoc, failure-swallowed audit. The new by-id lookup keeps this O(1) (no full workflow listing) while still labelling the log with the workflow name and collection. - forestadmin-client: add getMcpWorkflowById (ForestHttpApi + WorkflowsService), McpWorkflowLookup + GetMcpWorkflowByIdParams types, wired through the server interface and public exports. - mcp-server: expose getMcpWorkflowById on ForestServerClient; rewrite the triggerWorkflow tool to pre-check the workflow (unknown / mcpEnabled:false => rejected without a run or log) then wrap the trigger in withActivityLog (fail-closed). A trigger-time 404/409 now marks the log as failed. - Update mocks/factories and tests across agent-testing, agent, forestadmin-client and mcp-server. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(PRD-49) - workflowId/recordId (triggerWorkflow) and runId (getWorkflowRun) now use z.string().min(1), so an empty string is rejected client-side instead of producing a pointless server round-trip (an empty runId even hits a different endpoint). - getWorkflowRun description now tells the LLM to poll at a reasonable interval and not busy-loop on a long-running or human-gated run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@EnkiP done — the pre-check is O(1) now. |
PMerlet
left a comment
There was a problem hiding this comment.
Adversarial review of the MCP workflow tools. Two points worth addressing before merge: the fail-closed audit has a hole (a run can be triggered with no audit log when the audit store is down), and the ForestAdminClientWithCache constructor change is a silent breaking change for external consumers. Plus a minor note on tool annotations. Details inline.
| return { forestServerToken, renderingId: String(renderingId) }; | ||
| } | ||
|
|
||
| export default async function createPendingActivityLog( |
There was a problem hiding this comment.
The fail-closed guarantee has a hole: a workflow can be triggered with no audit log.
withActivityLog correctly awaits this before triggering, so a rejection blocks the trigger. But createPendingActivityLog never inspects the returned id, and the server route returns HTTP 200 with { data: { id: null, attributes: {} } } (not an error) whenever ActivityLogCreator.create() returns null — which happens in two real cases:
- Elasticsearch write failure (
activity-log-creator.ts→catch { logger.error; return null }) — precisely the "audit store down" scenario fail-closed is meant to cover. collectionName: null— a workflow whose collection was renamed/deleted (the by-id lookup returnscollectionName: nullvia LEFT JOIN).checkAuthorizationthen returnsnull(no collection and no dashboard/workspace/inbox).
In both cases this resolves with { id: null }, the trigger fires anyway, and the only trace is a later PATCH /.../null/status that 404s after the run already started. That directly contradicts the PR's claim that "a run with side effects is never started without an audit trail."
Suggested fix: reject in createPendingActivityLog when the returned id is null/undefined (so withActivityLog blocks the trigger), and add a test for the 200-with-null-id response. (The server's 200-on-null is a pre-existing backwards-compat behavior shared with the other tools, but only triggerWorkflow claims fail-closed, so the guard belongs here.)
There was a problem hiding this comment.
Fixed in 29a4cf1a — createPendingActivityLog now rejects when the 200 response carries a null/undefined id, so withActivityLog blocks the operation before the trigger fires and no PATCH .../null/status is ever issued. The guard lives in the shared creator, so every write tool going through withActivityLog gets the same protection against a dropped audit write (Elasticsearch failure or collectionName: null), not just triggerWorkflow.
Tests added: unit tests on the null-id and undefined-id responses, plus a triggerWorkflow test asserting that a 200-with-{ id: null } response blocks the run — triggerMcpWorkflow and updateActivityLogStatus are never called. The PR's rollout section now mentions this case explicitly.
There was a problem hiding this comment.
Follow-up hardening on your case 2 (collectionName: null) in 1c30c768: instead of letting it surface as the generic "no activity log id" fail-closed error, triggerWorkflow now rejects it up front — before any activity-log write — with an explicit message: Workflow "<id>" cannot be triggered via MCP because its collection is unavailable. Check the workflow's configuration in Forest. Covered by a test asserting none of the three HTTP calls fire (no pending log, no trigger, no status update).
While there, 5027916c adds a real 409 transport test (nock): a genuine HTTP 409 carrying errors[0].detail is proven to surface through ServerUtils.handleResponseError with the exact message the tool relays — the previous tool-level test only simulated it with a plain Error.
| protected readonly ipWhitelistService: IpWhiteListService, | ||
| public readonly schemaService: SchemaService, | ||
| public readonly activityLogsService: ActivityLogsService, | ||
| public readonly workflowsService: WorkflowsService, |
There was a problem hiding this comment.
Silent breaking change for external consumers — the new param is inserted mid-constructor.
ForestAdminClientWithCache is exported publicly (src/index.ts), and workflowsService is added as the 9th positional argument, before authService. A JS consumer constructing this class directly with the old signature gets a silent argument shift at runtime (workflowsService receives what used to be authService, etc.) — no error, just a corrupted client. Appending at the end of the parameter list would be non-breaking.
Also: the public ForestAdminClient interface gains a required readonly workflowsService, so any external implementation breaks at compile time. The commits are feat/fix → semantic-release cuts a minor, but this is breaking in the strict sense. Either move the param to the end of the constructor, or treat it as a major. (Inside the monorepo versions are pinned exact, so no internal skew.)
There was a problem hiding this comment.
Fixed in 7f60f458 — workflowsService is now appended as the last constructor parameter; params 1–13 are byte-for-byte the pre-branch main order, so positional construction with the old signature no longer shifts. All construction sites are aligned (the createForestAdminClient factory in src/index.ts is the only non-test site in the monorepo; no subclass or super(...) exists), and a new constructor-wiring test asserts by identity that each positional service lands on its matching property — a direct regression guard for this silent-shift class.
The interface's required readonly workflowsService stays as-is: every member of ForestAdminClient is required, and a lone optional member would misrepresent it as sometimes-absent while the factory always wires it. The compile-time-breaking addition for external implementations is now called out in the PR's "Rollout & release notes".
| mcpServer, | ||
| 'triggerWorkflow', | ||
| { | ||
| title: 'Trigger a workflow', |
There was a problem hiding this comment.
Minor: triggerWorkflow is a side-effectful, default-on tool with no MCP annotations.
resolveEnabledTools defaults to all tools, so an integration calling mountAiMcpServer() without an enabledTools allowlist silently gains this write tool on upgrade. That's mitigated server-side (the trigger 404s until an admin opts a workflow into the mcp trigger), but the tool itself carries no destructiveHint/idempotentHint annotation, so MCP clients can't tell it apart from the read tools. Consistent with create/update/delete today, but worth considering for a tool an LLM can invoke autonomously.
While here: the comment at L25-28 ("The server answers 404 both for an unknown workflow and for one whose MCP trigger is disabled") is inaccurate — the by-id lookup returns 200 with mcpEnabled: false for a disabled workflow (the code handles that via the !workflow.mcpEnabled check just below); only /start 404s both. The code is correct; the comment misleads.
There was a problem hiding this comment.
Done in 29a4cf1a — registerToolWithLogging already forwards annotations to the SDK's registerTool (the read tools already carry readOnlyHint: true), so triggerWorkflow now declares readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true — open-world because a triggered workflow can reach external systems (emails, webhooks, integrations). No registration refactor needed; the tool test asserts the exact annotation set.
The misleading L25-28 comment is rewritten to match the actual behavior: the by-id lookup returns 200 with mcpEnabled: false for a disabled workflow (handled by the !workflow.mcpEnabled check), only /start 404s both cases.
…ggerWorkflow (PRD-49) The activity-log route answers HTTP 200 with a null id when the audit write is dropped (audit store down, or a collection that no longer exists). createPendingActivityLog now rejects in that case so withActivityLog blocks the operation instead of triggering a workflow with no audit trail. Also adds MCP annotations (readOnlyHint/destructiveHint/idempotentHint/ openWorldHint) to triggerWorkflow and fixes the stale comment about the by-id lookup 404 behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ient constructor (PRD-49) workflowsService was inserted as the 9th positional argument of ForestAdminClientWithCache, before authService, silently shifting every following argument for external JS consumers constructing the class with the pre-existing signature. The parameter now comes last so the new signature is a strict append. A constructor-wiring test guards against reintroducing a positional shift. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e (PRD-49) A renamed/deleted collection leaves the by-id lookup's collectionName null. The MCP activity log would then be dropped server-side and the fail-closed guard would block the trigger with a misleading 'no activity log id' message. Reject up front with a clear error before any audit write, so no run is started and no log is written. Also drops the stale O(1) claim from the lookup comment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ookup docstring (PRD-49) Reword to what is actually guaranteed: the match is resolved inside Postgres, so the client never receives or deserializes the full workflow list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tils (PRD-49) Add a nock-backed test proving a genuine HTTP 409 carrying body.errors[0].detail surfaces as an HttpError with that detail as message and status 409 — the message the MCP triggerWorkflow tool relays for an already-running run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ons (PRD-49) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PMerlet
left a comment
There was a problem hiding this comment.
Adversarial review — MCP side (PRD-49)
Read against the server branch (ForestAdmin/forestadmin-server#8418) and the existing tool/audit plumbing.
What holds up: the fail-closed ordering in triggerWorkflow is right and well tested (log before trigger, unknown/disabled/unavailable-collection rejected with no run and no log, trigger-time 404 race mapped, 409 passed through). Error mapping is correct (ServerUtils → NotFoundError/ForbiddenError). Extracting getAuthContext was the right call. WorkflowRunState matches the server enum exactly.
One blocking ask: the fail-closed guard was written for triggerWorkflow but lives in createPendingActivityLog, so it changes behavior for all nine existing tools — including read-only ones — and turns an audit-store outage into a full MCP outage. That is the "fail policy" question from the audit-trail QA list being answered implicitly, for everything. Details inline.
Also: a single MCP trigger now produces two activity-log rows (this one, plus the orchestrator's via MCP one), and listWorkflows/getWorkflowRun produce none.
| label: extra?.label, | ||
| }); | ||
|
|
||
| // Fail-closed: the server answers HTTP 200 with a null id when the audit write is dropped |
There was a problem hiding this comment.
This guard is not scoped to triggerWorkflow — it changes behavior for every tool, including read-only ones.
createPendingActivityLog is what withActivityLog calls for all nine existing tools: list, listRelated, describeCollection, create, update, delete, associate, dissociate, executeAction. So this throw applies to all of them.
Server-side, the null-id response is not hypothetical: activityLogCreator.create returns null when the Elasticsearch write throws (services/activity-logs/activity-log-creator.ts:218-226 → catch → return null), and the route then answers 200 with id: null. Which means an audit-store outage now hard-fails the entire MCP surface, where before it degraded to "operation proceeds, log lost".
For writes, fail-closed is defensible and I would keep it. For list and describeCollection, blocking a read because Elastic is down is a different trade-off, and it is not in the release notes — which present this as a triggerWorkflow change.
Concretely, one of:
- gate on action type (
ACTION_TO_TYPE[action] === 'write'→ fail-closed,'read'→ log a warning and proceed); or - keep it global, but say so explicitly in the release notes as an availability trade-off for all tools.
Either way this should be a conscious decision rather than a side effect of the workflow epic — it is exactly the open fail policy item from the audit-trail QA findings.
There was a problem hiding this comment.
Fixed in bdc82084 — the guard is now gated on action type, exactly as suggested: write actions stay fail-closed (no audit → the operation is blocked), read actions are fail-open — a dropped audit write logs a warning and the read proceeds, skipping status tracking so no PATCH .../null/status is ever issued. An audit-store outage no longer takes down the read surface.
The policy is also stated explicitly in the PR's Rollout & release notes, so it's a documented decision rather than a side effect of the workflow epic.
| ).resolves.not.toThrow(); | ||
| }); | ||
|
|
||
| it('should reject when the server returns a 200 with a null id (fail-closed)', async () => { |
There was a problem hiding this comment.
Both new fail-closed tests use 'triggerWorkflow', so the suite documents the guarantee only for the new action — while the change actually applies to the eight pre-existing tools too (see my comment on activity-logs-creator.ts).
Whichever policy you land on, please pin it here with a case on a read action (e.g. 'browse'): either rejects (global fail-closed, deliberate) or resolves (read stays fail-open). Right now a future change in either direction breaks nothing in CI.
There was a problem hiding this comment.
Done in bdc82084 — the policy is pinned in both directions: an it.each over the five write actions (action, create, update, delete, triggerWorkflow) asserts rejects on a null-id response, and one over the five read actions (index, search, filter, listRelatedData, describeCollection) asserts resolves to null (fail-open). The withActivityLog suite additionally pins the read path end-to-end: warning logged, operation still runs (result returned / error rethrown), and no status tracking in either case. A future change in either direction now breaks CI.
| context: { | ||
| collectionName: workflow.collectionName, | ||
| recordId: args.recordId, | ||
| label: `triggered the workflow "${workflow.name}"`, |
There was a problem hiding this comment.
This label produces a second Activity Log row for one trigger, and it is the row that does not say via MCP.
The orchestrator independently writes its own audit in createAndStartRun (buildTriggerAuditLabel → triggered the workflow "X" via MCP, attached to the run). This one is written first, attached to the collection, with applicationSource: MCP but no channel in the label.
Net effect: two rows per MCP trigger (webhook has one), and the MCP-originated row reads like a manual trigger at a glance. ForestAdmin/docs#21 tells readers MCP runs are "labelled via MCP", which is only true of the row this PR does not write.
The duplication is defensible — the early row is what makes the trigger fail-closed, and it carries no runId because it cannot — but please align this label (… via MCP) and document the two complementary entries.
There was a problem hiding this comment.
Fixed in ecb3125f — the MCP-side row is now labelled triggered the workflow "X" via MCP, aligned with the orchestrator's. The duplication is kept deliberately (the early row is what makes the trigger fail-closed, and it cannot carry a runId); the two complementary entries are now documented in the PR's Rollout & release notes, and docs#21 gets a line describing them so an auditor knows what the two rows mean.
| { | ||
| annotations: { | ||
| readOnlyHint: false, | ||
| destructiveHint: true, |
There was a problem hiding this comment.
Mismatch with the PR description, which states destructiveHint: false. The code (and its test, should annotate the tool as a non-read-only, destructive, non-idempotent write) says true. true looks right to me for a tool that starts a workflow with side effects — so it is the description that needs fixing, but it is worth fixing: reviewers and release notes are reading false.
There was a problem hiding this comment.
Fixed — the PR description now reads destructiveHint: true, matching the code and its test. Thanks for catching the reviewer-facing drift.
| async (args: GetWorkflowRunArgument, extra) => { | ||
| const { forestServerToken, renderingId } = getAuthContext(extra); | ||
|
|
||
| const runStatus = await forestServerClient.getMcpWorkflowRun({ |
There was a problem hiding this comment.
No activity log on this read.
Every other read tool is audited — list, listRelated, describeCollection all go through withActivityLog with a 'read'-typed action. This one hands the full hydrated run (all steps, per-step context, selectedRecordId) to a third-party LLM and leaves no trace at all.
That also breaks a documented promise: get-started/expose-to-ai-agents.mdx says "Every operation … is logged just like a UI action", and ForestAdmin/docs#21 adds the workflow bullet directly above that line.
Either wrap it like the other reads (a new 'read' action, or reuse an existing one), or state in the docs that discovery and polling are not audited — but the current silent asymmetry is the worst of the three options.
There was a problem hiding this comment.
Settled as your option 2 (docs qualifier), with the real fix tracked. Wrapping this read would not produce a persisted audit today: the MCP activity-log route requires a resolvable resource (collection/dashboard/workspace/inbox) and silently drops a log without one, and this tool has no collection in hand — resolving one would add a by-id lookup per poll on exactly the path flagged for polling volume. Under the read-fail-open policy (bdc82084) the wrap would send a pending write the server drops anyway: an extra round trip per poll and still no audit row.
So v1 documents the asymmetry instead of hiding it: docs#21's "logged just like a UI action" claim is being qualified to state-changing operations (per your suggestion there), and server-side support for a workflow/run resource on MCP activity logs — after which both reads get wrapped like the other read tools — is tracked in PRD-967 (child of PRD-49).
| async (args: ListWorkflowsArgument, extra) => { | ||
| const { forestServerToken, renderingId } = getAuthContext(extra); | ||
|
|
||
| const workflows = await forestServerClient.listMcpEnabledWorkflows({ |
There was a problem hiding this comment.
Same as getWorkflowRun: no activity log, while every other read tool writes one. Less sensitive than the run history, but it is the discovery step of a side-effectful flow, so having it absent from the audit trail makes the trigger row harder to explain after the fact.
There was a problem hiding this comment.
Same resolution as the getWorkflowRun thread just above: not wrapped in v1 because the audit route silently drops resource-less logs — and listWorkflows often has no collection at all (the collectionName filter is optional) — documented via the docs#21 qualifier, and tracked for a real fix (server-side workflow/run resource support) in PRD-967.
| * The outcome of starting a workflow run: the run continues asynchronously server-side. | ||
| * `runId` is normalized to a string so it can be fed back to `getMcpWorkflowRun` as-is. | ||
| * `workflowName`/`collectionName` are still echoed by the start endpoint; the audit label is now | ||
| * resolved up front via `getMcpWorkflowById`, so they are optional and only kept for compatibility. |
There was a problem hiding this comment.
Worth going one step further than "kept for compatibility": nothing has ever consumed these two fields — this contract ships in the same epic as the endpoint that returns them.
On the server they are the only reason getWorkflowMetadata grew a correlated subquery over renderings.collections, now paid by the manual and webhook start paths too (see my comment on ForestAdmin/forestadmin-server#8418). Dropping them here and there removes the compat wart and the regression at once.
There was a problem hiding this comment.
Done in 0cdbcce4 — WorkflowRunTriggerResult is now exactly { runId, runState }, and the HTTP layer projects the response explicitly so nothing the server echoes leaks back into the contract. The server-side half (dropping the fields from the start response and reverting the getWorkflowMetadata subquery) is queued on ForestAdmin/forestadmin-server#8418 per your comments there.
| * It exposes the whole run — including internal fields (userId, bpmnVersion, collectionId, | ||
| * step indices, per-step context) — so the LLM has maximum context about where the run is | ||
| * and what each step does. The orchestrator holds no customer record data (that lives in | ||
| * the executor), so the full run is safe to surface. |
There was a problem hiding this comment.
"The orchestrator holds no customer record data … so the full run is safe to surface" is a bit stronger than what the shape guarantees, and this sentence is being repeated verbatim into the server comments and the public docs.
Two counter-examples in this very interface: selectedRecordId is a customer record identifier, and WorkflowHistoryStepContext.error is free-form text reported by the executor — a failing get-data/update-data step can easily embed values from the customer's database in it. The docs even instruct readers to read context.error to diagnose a bad record.
Suggest hedging to something like "carries no record payload; identifiers and executor-reported error strings may still contain customer data" — and mirroring that in the docs rather than a flat guarantee.
There was a problem hiding this comment.
Fixed in 1ce50dce with your wording — the docstring now reads: carries no record payload (records live in the executor), but identifiers (selectedRecordId) and executor-reported error strings (context.error) may still contain customer data. The two other copies of the flat guarantee (the server-side service comment on #8418 and the docs#21 Security section) get the same hedge on their respective PRs.
|
|
||
| if (!this.forestAdminServerInterface.getMcpWorkflowById) { | ||
| throw new Error( | ||
| 'The configured Forest server transport does not support getMcpWorkflowById.', |
There was a problem hiding this comment.
Four near-identical if (!this.forestAdminServerInterface.X) throw new Error('… does not support X.') blocks. Making the interface methods optional to avoid breaking external implementations is the right call, but the guard could be one small helper (assertSupported('getMcpWorkflowById') or a resolve(name) returning the bound method) — four copies of the same string template is the kind of thing that drifts.
There was a problem hiding this comment.
Done in ade72f8d — the four guards are now a single resolveTransportMethod(name) helper returning the bound transport method (one message template, no drift), plus an httpOptions(token) builder for the repeated options literal. The existing error-message assertions pass unchanged.
| * Extracts the caller's Forest identity from the MCP request auth context. | ||
| * Populated by the OAuth provider's `verifyAccessToken` (see `forest-oauth-provider.ts`). | ||
| */ | ||
| export default function getAuthContext( |
There was a problem hiding this comment.
New file with no test/utils/auth-context.test.ts. It is covered indirectly through the tool suites, but it is now the single choke point through which every tool derives the caller's identity — the two throw branches (missing/non-string token, null-or-undefined renderingId) and the number→string renderingId coercion deserve to be pinned directly, since a regression here is an authentication-scoping bug rather than a tool bug.
There was a problem hiding this comment.
Added in d8ceca49 — test/utils/auth-context.test.ts pins the extraction directly: happy path, both throw branches (missing and non-string forestServerToken; missing and null renderingId), the number→string renderingId coercion, and the absent-authInfo case.
…PRD-49) The null-activity-log-id guard added for triggerWorkflow lived in createPendingActivityLog, so it applied to all nine tools: an audit-store outage would have hard-failed the whole MCP surface, reads included. Write actions stay fail-closed (no audit -> operation blocked). Read actions are now fail-open: the tool proceeds with a warning and skips status tracking, so no PATCH .../null/status is ever issued. The policy is pinned by tests on both action types. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One MCP trigger writes two complementary Activity Log rows: this fail-closed one (no runId yet) and the orchestrator's (attached to the run). Only the orchestrator's said "via MCP", so the MCP-originated row read like a manual start. Both labels now carry the channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he trigger contract (PRD-49) Nothing ever consumed these two fields: the audit label is resolved up front via getMcpWorkflowById, and the tool returns only runId/runState. The HTTP layer now projects the response to exactly that contract. This also lets the server revert the getWorkflowMetadata subquery that existed only to feed them (forestadmin-server#8418). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(PRD-49) "Holds no customer record data" overstated the guarantee: the shape carries no record payload, but selectedRecordId is a record identifier and context.error is free-form executor-reported text that can embed values from the customer's database. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…RD-49) Four copies of the same "does not support X" guard and http-options literal are now one resolveTransportMethod helper plus one httpOptions builder, so the message template cannot drift between methods. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
getAuthContext is the single choke point through which every tool derives the caller's identity; its throw branches and the number-to- string renderingId coercion were only covered indirectly through the tool suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| @@ -45,15 +45,26 @@ export default async function withActivityLog<T>(options: WithActivityLogOptions | |||
|
|
|||
| const activityLog = await createPendingActivityLog(forestServerClient, request, action, context); | |||
There was a problem hiding this comment.
Blocker — the "reads fail open" contract is only half implemented.
createPendingActivityLog is awaited outside the try, so the fail-open path added in d8ceca4 only covers one case: the server answering 200 with a null log id. If the call rejects — 5xx, timeout, ECONNREFUSED, or the 400 that createFromMcp returns when collectionModelName / label don't validate — the rejection propagates from this line and the tool fails, type: 'read' included.
That takes down every read tool routed through this wrapper (list, describeCollection, listRelatedData), which is exactly what the PR description says cannot happen:
read actions fail open (a dropped audit write logs a warning and the read proceeds, without status tracking) — so an audit-store outage never takes down the read surface.
An audit-store outage is the far more likely failure mode than a 200-with-null-id, and it is the one case the guard doesn't reach.
The file also carries both policies at once — the comment two lines above still states the old one:
// We want to create the activity log before executing the operation
// If activity log creation fails, we must prevent the execution of the operationEither way out works:
- Make the code match the claim — wrap the creation call and apply to a thrown error the same
type === 'write'arbitration thatactivity-logs-creator.tsalready applies to the null id: writes rethrow, reads log the warning and continue withactivityLog = null. The downstreamif (activityLog)guards already handle that shape, so it is a local change. - Make the claim match the code — narrow the PR description and
mcp-server.mdx(Identity, auditing, and limits) to "a dropped audit write acknowledgement", so we stop documenting a guarantee the code doesn't give.
Worth a test in both cases: createMcpActivityLog rejecting should let a read through and still block a write. Today only the null-id variant is covered.
There was a problem hiding this comment.
Fixed in e97b498 — and you were right that this was the case that mattered, not the one the guard covered.
createPendingActivityLog now wraps the transport call, and a rejection goes through the same ACTION_TO_TYPE arbitration as the null id: writes rethrow, reads log the warning and continue with activityLog = null. The whole policy lives in one place next to the null-id guard, so there is a single spot where the decision is made. getAuthContext stays outside it — a missing token is a caller bug, not an audit outage, and your existing tests pinned that.
Two things I found while doing it, both of which make your point stronger than the thread states:
- The stale comment was worse than a leftover — a test pinned the contradiction.
activity-logs-creator.test.ts:239-249, "should propagate error when createMcpActivityLog fails", used the'index'action — a read — and assertedrejects.toThrow. So CI was actively defending the behaviour the release notes said was impossible. That test is now the write-action case, and the read matrix asserts the opposite. - The case the guard did cover is the least likely of the three. On
/mcp,collectionModelNameisJoi.string().required()→ 400, and an unresolvable modelName → 404. Both are rejections. The 200-with-null-id only happens on an Elasticsearch write failure. So the guard was covering the rare path and missing the common ones — which also means my own justification on thegetWorkflowRunthread ("the server silently drops it") was wrong; corrected there.
Tests: it.each over the five write actions asserting the rejection propagates, it.each over the five read actions asserting it resolves to null, the three failure modes named individually (400 unresolvable collection, 5xx, ECONNREFUSED), a case proving a read still throws on an invalid auth context without calling the server, and a withActivityLog test asserting a rejected creation never runs a write operation. The comment now describes the policy it implements, and the PR body says which two paths it covers.
PMerlet
left a comment
There was a problem hiding this comment.
Adversarial pass on the remaining surface, after the fail-closed audit and the by-id lookup landed. The blocker is in a separate thread on with-activity-log.ts; these four are the rest of what I found on this side.
| async (args: GetWorkflowRunArgument, extra) => { | ||
| const { forestServerToken, renderingId } = getAuthContext(extra); | ||
|
|
||
| const runStatus = await forestServerClient.getMcpWorkflowRun({ |
There was a problem hiding this comment.
Important — the two new read tools are the only ones on the MCP server that leave no audit trail.
Neither get-workflow-run.ts nor list-workflows.ts imports withActivityLog. Every other read tool does — list, describeCollection, listRelatedData all route through it with a type: 'read' entry.
That matters most here, because getWorkflowRun returns the heaviest payload of the three: selectedRecordId, the executor-reported context.error strings, and the full resolved step definitions. The PR description and mcp-server.mdx both acknowledge that payload may carry customer data — and reading it is currently untraceable. Same for getMcpWorkflowById, which triggerWorkflow calls before anything is logged: a caller can probe workflow ids, names and collections without leaving a row.
The tell is in the docs PR: expose-to-ai-agents.mdx goes from "Every operation […] is logged" to "every state-changing operation is logged". A global promise was weakened to fit a local gap — and the new sentence now under-describes what the pre-existing read tools actually do.
Two coherent options:
- Wrap both tools in
withActivityLogwithtype: 'read'(ACTION_TO_TYPEalready has the shape for it), which restores parity and lets the docs keep the general sentence. - Keep them unaudited as a deliberate v1 call — but then say so explicitly in
mcp-server.mdx(Identity, auditing, and limits) rather than weakening the global claim, and restore the original wording onexpose-to-ai-agents.mdx.
Option 1 is the smaller diff and the one that doesn't cost a documented guarantee.
There was a problem hiding this comment.
Settling this as your option 2, but rewritten — because both my earlier rationale and the thread's premise turn out to be wrong.
My "the server silently drops it" justification was false. On /mcp, collectionModelName is Joi.string().required() → 400, and an unresolvable modelName → 404. The silent drop exists in activity-log-creator.ts but is unreachable through this route (only an Elasticsearch failure gets there). The conclusion holds — no persisted row, one extra round trip per poll — but the mechanism is a rejection, which is exactly the path you flagged as uncovered in with-activity-log.ts. The two threads were the same defect seen from opposite ends; that one is fixed in e97b498.
And these are not the only unaudited tools. getActionForm is a read tool that doesn't import withActivityLog either, and it predates this epic. So "Every operation […] is logged" was already inaccurate before PRD-49 — which changes the right fix.
So instead of weakening the global claim, 19fe126 restores it and carves out the exception where it belongs, naming all three tools under Identity, auditing, and limits, plus the fail policy in one line. expose-to-ai-agents.mdx points there rather than hedging, and the two other global claims on the page (:19, the security bullet list) are qualified the same way instead of being left to contradict it.
Your sub-point about getMcpWorkflowById letting a caller probe ids before anything is logged is real and unchanged — it's part of what PRD-967 now covers, along with getActionForm.
| workflowId: args.workflowId, | ||
| }); | ||
| } catch (error) { | ||
| if (error instanceof NotFoundError) { |
There was a problem hiding this comment.
Important — a server rollback turns this into a silent loop for the assistant.
getMcpWorkflowById hits GET /api/workflow-orchestrator/mcp-workflows/:workflowId, which only exists once MS8 is deployed. If the orchestrator is older — or gets rolled back after an incident — that route 404s, ServerUtils maps it to NotFoundError, and this branch answers:
Workflow "X" is not an MCP-enabled workflow you can access. Use listWorkflows to discover triggerable workflows.
Meanwhile listWorkflows (MS3, already deployed) keeps working and keeps returning that exact workflow. So the assistant lists, picks an id, triggers, is told to list again — and loops. Every trigger in the rendering is dead, with a message that actively points the model back at the call that produced the id.
The "deploy the server side first" note covers the forward order, not the rollback, and rollback is the case where nobody is watching the deploy notes.
Worth distinguishing route absent from workflow unknown — the orchestrator's 404s carry a structured error body while an unmatched route doesn't, so the discriminator is cheap — and logging a real Error server-side when it's the former, so the failure is visible to an operator instead of only to the model.
There was a problem hiding this comment.
Fixed in 7052c54, both halves.
The lookup's catch now always logs an Error before translating, so an orchestrator that predates or was rolled back before the by-id endpoint is visible to an operator instead of only to the model. And the tool error ends with "If listWorkflows just returned this id, do not retry — report it to your Forest administrator instead." — which breaks the loop without telling the caller whether the workflow exists, so the uniform 404 contract holds.
I didn't take the structured-body discriminator. It needs ServerUtils to carry that information through HttpError, which is shared code well outside this epic, and comparing error.message against the generic sentinel would be a magic-string hack. Logging unconditionally gets the operator visibility, which was the part nothing provided.
Two tests pin it: the exact message text, and the exact log line on a lookup failure.
While in here I also found a second loop with no deploy dependency at all: listWorkflows returned workflows whose collectionName is null, which triggerWorkflow rejects up front — so list → pick → rejected → list, reproducible today. Fixed in 9ec10b78 by filtering them out, with a warning naming how many were hidden.
Rollback is now in the PR body as its own bullet, next to the forward order, since that was the gap.
| throw new Error(notMcpEnabledMessage(args.workflowId)); | ||
| } | ||
|
|
||
| // A renamed/deleted collection leaves collectionName null. The activity log would be dropped |
There was a problem hiding this comment.
Minor — the guard is right, the stated reason isn't.
The comment says the activity log "would be dropped server-side". It wouldn't: createFromMcp validates collectionModelName: Joi.string().required() (packages/private-api/src/validators/routes/activity-logs-requests.js), so a null collection is a 400, which then propagates out of createPendingActivityLog — a hard failure, not a silent drop. Same outcome for the caller, different mechanism, and the comment will send the next reader looking for a drop path that doesn't exist.
While here: recordId is z.string().min(1) with no upper bound, while the server caps selectedRecordId at 255. A 256-char id gets past this guard, writes the pending audit row, and only then takes a 400 on the trigger. Adding .max(255) to the zod schema makes the rejection actually happen up front — which is what the docs error table already claims (mcp-server.mdx, Errors: "rejected up front, nothing starts").
There was a problem hiding this comment.
(a) Right, and corrected in 7052c54. The comment claimed the log "would be dropped server-side"; the audit route validates collectionModelName as required, so a null collection is a 400 — a hard failure, not a drop. It now says the route rejects it and that the up-front check exists to replace a misleading fail-closed message with one that names the actual problem. Same family as the McpWorkflowLookup docstring, corrected in 2be4504.
(b) .max(255) added in 2be4504, with a schema test asserting 255 passes and 256 doesn't. Worth recording what it does and doesn't buy: I checked the server side, and selectedRecordId is VARCHAR(255) with the Joi validator already capping at 255 — so an over-long id was a clean 400 before any INSERT, never a 500. The gain is avoiding a round trip and a pending audit row marked failed, not closing a risk.
On the docs contradiction: I couldn't find it. The page states the record isn't validated at trigger time, and the tool description repeats it. The line that was imprecise is the Errors-table row, which lumped the empty and out-of-bounds cases together — split on docs#21, where the >255 case is now accurate for the right reason (the tool rejects it, so "nothing starts" is true).
Related, from Macroscope on the server PR and worth knowing here: a numeric recordId past MAX_SAFE_INTEGER was silently rounded by JSON.parse before String(recordId) persisted it — the run started on a different record. Rejected now (6205c9e0f). The MCP tool always sends a string, so it was never exposed, but the endpoint was.
|
|
||
| export function createListWorkflowsArgumentShape(collectionNames: string[]) { | ||
| const collectionName = | ||
| collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(); |
There was a problem hiding this comment.
Minor — this enum is built from the wrong side of the boundary.
ctx.collectionNames comes from the agent's Forest schema, but the server filters on coll."modelName" read out of the rendering layout's collections blob (layout-workflows-store.ts, the coll CTE). They normally agree; when they drift — a collection renamed in the layout, or present in one and not the other — the enum rejects a value the server would have accepted, and the assistant has no way to express the filter.
Since the argument is optional and the server already handles an unknown collectionName by returning an empty list, z.string() would be the safer type here; the enum buys autocomplete at the cost of a hard failure on drift. Not blocking, but worth a deliberate call rather than inheriting the pattern from the record-level tools, where collectionNames is the authoritative source.
There was a problem hiding this comment.
Traced it, and the mechanism you describe doesn't exist — but there is a real problem next to it, so thank you for the pull.
coll."modelName" and the agent schema's name are the same identifier by construction. The layout builder writes modelName: model.name (layout-builder.ts:99-104), and models.name is the collection's id from the apimap — the same value the schema exposes as name. They are not two independently-maintained fields. And modelName is not mutable: make-layout-patch-patterns.ts exposes PATCH patterns for displayName and displayNamePlural, none for modelName. Renaming a collection in the UI touches the display name. So no drift by rename, in either direction.
(For completeness: the three namespaces I mentioned earlier — "legacy integer, uuid or modelName" — are about collection.id, which is the LEFT JOIN key, a different axis. That one is the dedup question, now fixed with DISTINCT ON.)
The real risk is staleness, not disagreement. The enum is frozen at startup from a schema cache with a 24-hour TTL (schema-fetcher.ts, ONE_DAY_MS + a module-level schemaCache). A collection added after boot is rejected by zod for up to a day, even though the server would accept it. And the degraded path already falls back to z.string() when the schema fetch fails, so both behaviours coexist in production today.
I'm keeping the enum: it matches the eight other tools and the package's documented convention, and it gives the model autocomplete on the overwhelmingly common case. But say the word if you think a 24-hour window justifies z.string() here — the argument is optional and the server returns an empty list for an unknown name, so the downside of loosening it is small. Happy to flip it.
The read/write fail policy only covered a 200 carrying a null log id. A rejection - 5xx, timeout, ECONNREFUSED, or the 400/404 the audit route returns for a missing or unresolvable collection - propagated out of createPendingActivityLog and failed the tool, reads included. Those rejections are the likely failure modes; the null id is the rare one. The whole policy now lives in createPendingActivityLog, next to the null-id guard, so writes stay fail-closed and reads proceed with a warning. getAuthContext stays outside it: a missing token is a caller bug, not an audit outage. Tests pin both directions on a rejection, name the three failure modes, and assert a rejected creation never runs a write operation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt (PRD-49) An orchestrator that predates - or was rolled back before - the by-id endpoint 404s every lookup while listWorkflows keeps returning the same ids, so the assistant listed, triggered, was told to list again, and looped. Nothing reached the agent logs. The lookup failure is now always logged, and the tool error tells the caller not to retry an id listWorkflows just returned. Also corrects the null-collection comment: that log is refused with a 400, not dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-workflow-tools-in-forest-mcp-server #1832 added requestActionFileUpload to the same four tool lists this branch extends with the workflow tools, so every conflict was additive: both sides are kept. The workflow tools stay unconditional and the file upload tool keeps its fileUploads gate; the imports keep import/order. Without this the PR could not be checked at all - GitHub skips pull_request workflows on a conflicting PR, so no run was triggered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-49) A workflow whose collection was renamed or removed came back from the listing with collectionName null, and triggerWorkflow rejects exactly that up front - so the assistant listed it, picked it, was rejected, and listed again. Reachable with no deploy skew, and covered by no test. The listing now drops them and warns the operator, who is the only one who can fix the configuration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…routes (PRD-49) Forest-Application-Source: MCP was set from the caller's service options, which only the standalone server passes. The embedded mountAiMcpServer path builds its services from the shared client options, which carry no headers, so an agent-hosted MCP trigger reached the server unlabelled - indistinguishable from a UI start for audit attribution and rate limiting. activityLogsService had the same gap. /api/activity-logs-requests/mcp and the four mcp-workflows routes exist only for this transport, so the header belongs to the call rather than to the configuration. Callers can still override it. Also projects the hydrated run onto its declared contract instead of forwarding the response as-is: the tool stringifies it straight into an LLM's context, and the orchestrator builds a second shape of the same run carrying a userProfile with a live Forest serverToken. The MCP route uses a different builder today, but the two types are mutually assignable, so the whitelist is the guardrail rather than the annotation. stepDefinition is passed through whole - it is the org's own workflow config. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
recordId was min(1) with no upper bound while the server caps selectedRecordId at 255, so an over-long id was rejected only after the pending audit row had been written and marked failed. The server answers a clean 400, so this is a wasted round trip rather than a risk. Also corrects the McpWorkflowLookup docstring: it claimed mcpEnabled exists so the caller can label a fail-closed audit log, while the only caller throws before writing any log. The field distinguishes unknown from disabled; name is what makes the label possible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Two `toHaveBeenCalled()` assertions in the trigger-workflow suite now
assert the arguments, per the repo's own test guidance: the tests are
named after the audit label and were not checking it.
- The constructor-wiring test pins all 14 positions instead of the 9
public ones. It exists to catch a silent argument shift, and two
adjacent pairs of same-shaped services would have swapped unnoticed.
- `updateActivityLogStatus` no longer falls back to an empty Bearer when
the auth context has no token: it logged nothing, 401'd, and then got
retried five times on the 404 branch. It now logs and skips.
- The two workflow read tools spell out all four MCP annotations. The
spec defaults an omitted `destructiveHint` to true, so a client reading
that field alone treated these reads as destructive.
- `createListWorkflowsArgumentShape` and its inferred type are local
again - nothing outside the file used them.
`McpWorkflowLookup.workflowId` is kept: it looked like a dead field, but
the server does send it (`return { workflowId, ...workflow }`), so
dropping it would make the type stop describing the payload. Documented
as an echo of the requested id instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(PRD-49) The guard added with the review nits returns early when the auth context carries no usable token, and nothing exercised that branch - the qlty coverage gate caught it at 97.7% against a 98% threshold. Three cases: absent, non-string, and empty. Each asserts the client is never called and that the reason is logged, which is the whole point of the guard: an empty Bearer would 401 and then be retried five times on the 404 branch, silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(PRD-49) The fail policy arbitrated on the action type alone, so a 401/403 from the audit route was downgraded to a warning and the read proceeded. An authorization refusal is not an audit-store outage: the caller's identity was rejected, so the read it is about to perform is not authorized either. Fail-open exists so a broken audit store cannot take down the read surface, not to swallow a refusal. Also report the cause. Every fail-open read logged the same fixed sentence, which left an operator unable to tell a validation refusal (act now) from a transient outage (wait) from a connection error. The three "named failure modes" now use real HttpErrors with a status, so their labels describe modes the code actually distinguishes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ontext (PRD-49) The projection guarding the run stopped at two boundaries that matter. listMcpEnabledWorkflows was the only MCP route returning its response as-is, and it is the one whose result the listWorkflows tool stringifies straight into a model's context: a column added to the server query would have reached a prompt with no change here. Per-step `context` was forwarded by reference. It is a closed interface on this side but an open bag server-side, so the type promised a fence the code did not build — nothing would have caught an orchestrator field arriving in a third-party model's context. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…D-49) Any lookup failure other than a 404 was rethrown verbatim, so a 5xx, a timeout or an ECONNREFUSED handed the model the Forest server URL and an internal host:port. The uniform-404 contract next to it was carefully written never to reveal whether a workflow exists; this branch revealed the topology. The new message also distinguishes "Forest is unreachable, retry later" from "this id is not triggerable, do not retry", which the single 404 message could not express. The full error stays in the operator log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…RD-49) Adding a tool takes three coordinated edits in server.ts. The ToolName union and allToolNames are type-checked; the registerTool call is not, and nothing asserted it. A rebase dropping that line would leave a tool advertised as available, never registered, and the suite green. Asserts the three names come back from tools/list on the default (no enabledTools) path, with their annotations — those are what lets a client tell the side-effectful tool from the two reads, so they travel over the wire or they do not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(PRD-49) listWorkflows filtered on `!== null` while triggerWorkflow rejects on `== null`. McpWorkflow is an unvalidated cast of the HTTP response, so an absent key would pass the listing and then be rejected at trigger time — reopening the discover/trigger/rejected/discover loop the filter closes. Not reachable today (the route always projects the column), so this is the guard matching its sibling rather than a live fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…RD-49) Three statements in the package's own architecture notes became false with this epic: that tools call the live agent rather than this server, that forestServerClient carries no data, and that the two cross-cutting wrappers are always used together. The audit fail policy — the subtlest invariant in the package, and one that governs every tool at once — was not described at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sees them (PRD-49) The earlier fix sanitized one call site of four. listWorkflows, getWorkflowRun and the trigger call itself had no catch at all, so a raw transport failure reached the model verbatim: ServerUtils interpolates the full Forest server URL into its timeout message, rethrows the raw Node error otherwise, and parseAgentError passes `error.message` through untouched. A blip during listWorkflows sent a private endpoint and an internal host:port into a model's context — and on to the model vendor. A test was pinning that pass-through. The rule is now shared and provable rather than per-tool: anything that is not an HttpError arrived as a raw Node/superagent error, and a 408 is the one HttpError whose message is built client-side. Everything else carries either a fixed string or Forest's own JSON:API detail, which is worth reading and stays. Same catch block, opposite symptom: only NotFoundError was treated as terminal, so a 400 on a non-UUID workflowId — the shape a model produces when it guesses a workflow *name* — was reported as "temporary, retry later" and looped forever. Terminal now means any 4xx that is not a timeout or a rate limit. The 404 keeps its uniform wording so unknown, MCP-disabled and out-of-rendering stay indistinguishable; the others quote Forest's reason, which is safe by construction since the branch is only reachable for an HttpError, and tells the caller not to retry. The trigger's own failure deliberately does not advise a retry: the call is not idempotent and the write may have landed before the transport broke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…se (PRD-49) Three of the four mcp-workflows routes whitelist their response; this one was returning the raw body, and its test pinned the pass-through with toEqual. The package notes claim the projection holds for the route family, so the invariant was stated but not built. It matters less than the listing — this payload does not reach a model — but `name` is written verbatim into a persisted Activity Log label, and McpWorkflowLookup is an unvalidated cast of the HTTP response, so a column added server-side would arrive with nothing to catch it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(PRD-49) The comment justified skipping the call by saying an empty Bearer "would 401 and then be retried on the 404 branch", and the test echoed it. That branch tests `instanceof NotFoundError`; ServerUtils maps a 401 to a plain HttpError, so it would never have been repeated — it would have cost one pointless round trip and an error log naming an auth failure rather than the missing token. The guard is right either way. The reasoning was not, and it is the kind of comment the next reader trusts instead of checking. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One triggerWorkflow writes two Activity Logs entries — this one before the run exists, so the trigger is refused if it cannot be written, and the orchestrator's once the run is committed. An earlier round aligned their labels on the reviewer's request, which made a single trigger read as two identical events: same user, same action, same collection, same record, same sentence, milliseconds apart. The orchestrator's row does carry a discriminator (a `workflow` object with the run id) but the label is the column a human reads, so "how many workflows did assistants start this month?" was answerable only by deduplicating on that object. And the count is not even stable: this row is fail-closed while the orchestrator's is best-effort, so a successful trigger leaves two rows or one. This one now says `requested`, which is what it attests — an intent recorded before the fact, with no run attached. `triggered` stays on the row that proves a run exists, and keeps its parity with the webhook channel's own wording. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary
Integration branch for the PRD-49 epic — expose Forest workflow triggering to MCP clients. Adds a report-only (v1) toolset so an LLM can list, trigger, and observe Forest workflows through the MCP server.
The MCP tools call into
@forestadmin/forestadmin-client(WorkflowsService→ForestHttpApi), which hits the Forest orchestrator (/api/workflow-orchestrator/mcp-workflows/*) under the MCP session identity (forestServerToken,Forest-Application-Source: MCP).Included work
listWorkflowstool (feat(mcp-server): add listWorkflows tool (PRD-736) #1771)triggerWorkflowtool (feat(mcp-server): add triggerWorkflow tool (PRD-738) #1777)getWorkflowRuntool, returns the full hydrated run (feat(mcp-server): add getWorkflowRun tool (PRD-740) #1785)triggerType='mcp'in the run mapper (fix(workflow-executor): accept triggerType='mcp' in run mapper (PRD-832) #1786)triggerWorkflowaudit is now fail-closed: it resolves the workflow via an O(1) by-id lookup and writes the pending activity log (labelledtriggered the workflow "…" via MCP, attached to the collection) before the run starts, likecreate/update/delete.Behavior (v1, report-only)
listWorkflows→ available MCP-enabled workflowstriggerWorkflow→{ runId, runState }. The workflow is resolved by id first (O(1)); an unknown or MCP-disabled workflow is rejected without starting a run, and the audit log is written before the trigger (fail-closed — a run with side effects is never started without an audit trail). The record itself is not validated at trigger time.getWorkflowRun→ the full hydrated run:runStateplus the completeworkflowHistory— every step with its resolved definition (type, title, prompt, task type, outgoing branches) and its per-step context (completion, selected option, error, escalation state, awaiting-input reason).A run parked on a human-gated step is not resumable via MCP in v1 and must be finished from the Forest UI. It is recognised by
runState: startedwith nocontext.erroron the last history entry — which covers two shapes: a step stilldone: falseawaiting an answer, and one alreadydone: truewaiting for someone to confirm before the run advances. Do not key ondone: falsealone. Resuming via MCP is handled in the follow-up PRD-441 (submitWorkflowInput).Tests
New/updated unit tests across
mcp-server,forestadmin-client,workflow-executor, plus cross-package mocks inagent-testingandagent. ThetriggerWorkflowsuite covers the fail-closed ordering (log before trigger), rejection of unknown/MCP-disabled workflows, andfailedmarking on a trigger-time 404/409.Rollout & release notes
triggerType='mcp'at validation. It does pick the run up: the mapper'sDomainValidationErroris classified as a malformed run andreportMalformedRunposts an error outcome, so the run is marked failed with a zod validation message on its first step and routed to the fallback inbox. That reporting path predates the executor's MCP support, so every affected runtime has it. Net effect: every MCP trigger in the environment fails loudly andgetWorkflowRunshows the error — it does not sitpendingforever, as an earlier version of this section claimed. No server-side version gate catches it and the message reaching the assistant is opaque, so the upgrade still has to come first. (oauth2 MCP steps are separately gated on executor ≥ 1.14.0; a different axis from the trigger-type enum.)WorkflowTriggerType.Mcp(and therefore the layout validator the frontend needs), the fourmcp-workflowsroutes, and the by-id lookup this PR's fail-closed audit depends on.listWorkflowskeeps returning the same ids. The tool now logs anErrorserver-side on any lookup failure, and its message tells the caller not to retry an idlistWorkflowsjust returned — without that, the assistant lists, triggers, is told to list again, and loops with nothing in the agent logs.listWorkflows,triggerWorkflow,getWorkflowRun. Integrations that pin a subset viaenabledToolsare unaffected; others gain them on upgrade.triggerWorkflowis side-effectful but inert until an admin enables themcptrigger on a workflow — the server rejects a trigger on a non-opted-in workflow (WorkflowMcpTriggerNotEnabledError). It also declares MCP annotations (readOnlyHint: false,destructiveHint: true,idempotentHint: false,openWorldHint: true) so clients can tell it apart from the read-only tools.triggerWorkflowactivity log records the trigger call (pending→completed = trigger accepted); the run continues asynchronously — its terminal state is read viagetWorkflowRun, not the log. The fail policy is gated by action type and by cause: write actions fail closed (no audit → operation blocked), read actions fail open (the read proceeds with a warning and no status tracking) so an audit-store outage never takes down the read surface, and an authorization refusal (401/403) propagates either way — a refused identity is not an outage. It covers both ways the log can fail to exist: the route rejecting the write (5xx, timeout, or the 400/404 it returns for a missing or unresolvable collection — the likely modes) and the route answering 200 with a null log id (audit store write dropped). The arbitration lives increatePendingActivityLog, next to the null-id guard, so the policy is decided in one place, and it is pinned in both directions on both paths.requested the workflow "X" via MCPbefore the start — fail-closed, and with no runId since the run does not exist yet — and the orchestrator writestriggered the workflow "X" via MCPonce the run is committed, carrying its run id. Only the first is guaranteed; the orchestrator's is best-effort, so a successful trigger leaves two rows or one. An earlier round aligned the two labels, which made a single trigger read as two identical events and left the count answerable only by deduplicating on the run id — hence the split wording.triggeredkeeps its parity with the webhook channel.WorkflowRunTriggerResultslimmed: the never-consumedworkflowName/collectionNamefields are dropped — the contract is exactly{ runId, runState }(the audit label is resolved viagetMcpWorkflowById).@forestadmin/forestadmin-client: theForestAdminClientinterface gains a requiredreadonly workflowsServicemember — a compile-time breaking addition for external implementations of the interface. TheForestAdminClientWithCacheconstructor is a strict append (workflowsServiceis now the last parameter), so positional construction with the previous signature keeps working.Changes from the third adversarial review
listWorkflows,getWorkflowRunand the start call still handed the model the raw error —ServerUtilsinterpolates the full Forest server URL into its timeout message and rethrows the raw Node error otherwise. A test was pinning that pass-through. The rule is now shared: anything that is not anHttpErrorarrived as a raw Node/superagent error, and a408is the oneHttpErrorwhose message is built client-side; everything else carries Forest's own JSON:API detail and still reaches the model, because it says something actionable.NotFoundErrorwas treated as terminal, so a400on a non-UUIDworkflowId— the shape a model produces when it guesses a workflow name — came back as "temporary, retry later" and looped forever. Terminal now means any 4xx that is not a timeout or a rate limit. The404keeps its uniform wording so unknown / MCP-disabled / out-of-rendering stay indistinguishable; the others quote Forest's reason, which is safe by construction since that branch is only reachable for anHttpError. The trigger's own failure deliberately does not advise a retry: the call is not idempotent and the write may have landed before the transport broke.nameis written verbatim into a persisted Activity Log label.instanceof NotFoundError, and a 401 maps to a plainHttpError.Changes from the second adversarial review
401/403from the audit route became a warning and the read proceeded. A refused identity is not an audit-store outage; both now propagate. The rejection's cause is logged too — every fail-open read used to emit the same fixed sentence, leaving an operator unable to tell a validation refusal from a transient outage.listWorkflowsprojects its response, like the other two MCP routes. It was the only one returning the payload as-is, and it is the one whose result is stringified straight into a model's context. Per-stepcontextis projected as well: it is a closed interface client-side but an open bag server-side, so the type promised a fence the code did not build.host:port). It gets a message that distinguishes "Forest is unreachable, retry later" from "this id is not triggerable, do not retry"; the full error stays in the operator log.tools/listis asserted. Registration is the one of the three coordinatedserver.tsedits that nothing type-checks, so a rebase dropping it would have left a tool advertised, never registered, and the suite green. The annotations are asserted over the wire with it.getWorkflowMetadataremoved the second predicate but not the second query —createAndStartRunthen calledgetBpmnAwsS3Identifier, the same method under another name. Passing the identifier already in hand makes "one predicate, one query, one round trip" true and closes the republish window between the gate and the bpmn read.not.toHaveProperty('userProfile' | 'serverToken')). Every contract assertion usedobjectContaining, andWorkflowRunForExecutor extends HydratedWorkflowRun, so swapping the builder compiled and kept the suite green while leaking a live ForestserverToken.triggersarray, so an earlier request landing last could leave the server holding a channel the UI shows as off.requestActionFileUploadis not a read — it mints a pre-authorized upload, and it is on by default), the OAuth2 row is version-scoped rather than "not yet supported", the two audit rows are no longer presented as equally reliable, and the 200-workflow cap is documented.Changes from the adversarial review
createPendingActivityLog.listWorkflowsno longer lists what it cannot trigger — a workflow whose collection was renamed or removed came back with a nullcollectionName, whichtriggerWorkflowrejects up front, so the assistant looped. They are now filtered out, with a warning for the operator.Forest-Application-Source: MCPis stamped on the MCP-only routes rather than taken from the caller's service options. The embeddedmountAiMcpServerpath built its services from the shared client options, which carry no headers, so agent-hosted MCP traffic reached the server unlabelled.getMcpWorkflowRunprojects its response instead of casting it. The tool stringifies the run straight into a model's context, and the orchestrator builds a second shape of the same run carrying auserProfilewith a live ForestserverToken. The MCP route uses a different builder, but the two types are mutually assignable — the whitelist is the guardrail, not the annotation.recordIdis bounded at 255, matching the server column, so an over-long id no longer writes a pending audit row before being rejected.fixes PRD-49
🤖 Generated with Claude Code
Note
Expose workflow tools (
listWorkflows,triggerWorkflow,getWorkflowRun) in the Forest MCP serverlistWorkflows(list MCP-enabled workflows, optionally filtered by collection),triggerWorkflow(start a workflow run on a record with preflight validation and audit logging), andgetWorkflowRun(fetch hydrated run status and history byrunId).WorkflowsServiceinforestadmin-client, which delegates to four newForestHttpApiendpoints under/api/workflow-orchestrator/mcp-workflows.ForestServerClientImplandcreateForestServerClientto accept and exposeWorkflowsService, and propagates the instance throughForestAdminClientWithCacheand theAgentmount path.createPendingActivityLog: write actions (including the newtriggerWorkflow) fail closed if log creation fails or returns no id; read actions fail open with a warning and proceed without an audit trail.TriggerType.Mcpvalue to the workflow executor's validated execution types and server adapter enum.triggerWorkflowis fail-closed on audit log creation — if the activity log service is unavailable, the workflow will not be triggered.Changes since #1792 opened
ForestHttpApi.getMcpWorkflowRunmethod [2be4504]collectionNameinlistWorkflowstool [2be4504]Forest-Application-Source: MCPheader to all MCP-related API methods [2be4504]recordIdparameter intriggerWorkflowtool [2be4504]McpWorkflowLookuptype [2be4504]updateActivityLogStatusfunction to validateforestServerTokenpresence before attempting activity log updates [a3b04ef]ForestAdminClientWithCacheto validate all positional arguments [a3b04ef]workflowIdfield purpose inMcpWorkflowLookupinterface [a3b04ef]markActivityLogAsFailederror handling with invalid auth tokens [23b878b]createPendingActivityLogutility to propagate authorization errors (401/403) even for read operations and log the cause of other read failures via an optional logger parameter while proceeding unaudited [b0aac65]triggerWorkflowtool handler to return generic retry-later message when pre-trigger workflow lookup fails for non-404 reasons instead of exposing internal transport details [b0aac65]ForestHttpApimethods through new projection utilities [b0aac65]listWorkflowshandler to exclude workflows wherecollectionNameis either null or undefined using loose inequality check [b0aac65]CLAUDE.mdto clarify cross-file flow distinctions between record-level and workflow tools, response projection whitelisting, and centralized audit fail policy details [b0aac65]workflow-errormodule [caa542c]declareGetWorkflowRunTool,declareListWorkflowsTool, anddeclareTriggerWorkflowToolhandlers withinmcp-serverpackage [caa542c]ForestHttpApi.getMcpWorkflowByIdmethod inforestadmin-clientpackage to return projected response object [caa542c]updateActivityLogStatusfunction withinactivity-logs-creatormodule [caa542c]Macroscope summarized 790b913.