Skip to content

feat(mcp-server): expose workflow tools in Forest MCP server (PRD-49) - #1792

Merged
christophebrun-forest merged 44 commits into
mainfrom
feature/prd-49-expose-workflow-tools-in-forest-mcp-server
Aug 24, 2026
Merged

feat(mcp-server): expose workflow tools in Forest MCP server (PRD-49)#1792
christophebrun-forest merged 44 commits into
mainfrom
feature/prd-49-expose-workflow-tools-in-forest-mcp-server

Conversation

@christophebrun-forest

@christophebrun-forest christophebrun-forest commented Jul 30, 2026

Copy link
Copy Markdown
Member

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 (WorkflowsServiceForestHttpApi), which hits the Forest orchestrator (/api/workflow-orchestrator/mcp-workflows/*) under the MCP session identity (forestServerToken, Forest-Application-Source: MCP).

Included work

Server dependency: the fail-closed audit relies on the by-id endpoint GET /api/workflow-orchestrator/mcp-workflows/:workflowId (forestadmin-server, PRD-49). Deploy the server side first.

Behavior (v1, report-only)

  1. listWorkflows → available MCP-enabled workflows
  2. triggerWorkflow{ 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.
  3. getWorkflowRun → the full hydrated run: runState plus the complete workflowHistory — 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: started with no context.error on the last history entry — which covers two shapes: a step still done: false awaiting an answer, and one already done: true waiting for someone to confirm before the run advances. Do not key on done: false alone. 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 in agent-testing and agent. The triggerWorkflow suite covers the fail-closed ordering (log before trigger), rejection of unknown/MCP-disabled workflows, and failed marking on a trigger-time 404/409.

Rollout & release notes

  • Deploy order — the sequence is constrained, and it spans three repos:
    1. Forest Runtime / workflow executors to the PRD-832 release — an older executor rejects triggerType='mcp' at validation. It does pick the run up: the mapper's DomainValidationError is classified as a malformed run and reportMalformedRun posts 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 and getWorkflowRun shows the error — it does not sit pending forever, 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.)
    2. forestadmin-server (PRD-49) — provides WorkflowTriggerType.Mcp (and therefore the layout validator the frontend needs), the four mcp-workflows routes, and the by-id lookup this PR's fail-closed audit depends on.
    3. This PR and the frontend (ForestAdmin/forestadmin#9870), in either order.
  • Rollback is the case worth rehearsing. Reverting step 2 while step 3 stays deployed makes the by-id lookup 404 on every trigger, while listWorkflows keeps returning the same ids. The tool now logs an Error server-side on any lookup failure, and its message tells the caller not to retry an id listWorkflows just returned — without that, the assistant lists, triggers, is told to list again, and loops with nothing in the agent logs.
  • New default-on MCP tools: listWorkflows, triggerWorkflow, getWorkflowRun. Integrations that pin a subset via enabledTools are unaffected; others gain them on upgrade. triggerWorkflow is side-effectful but inert until an admin enables the mcp trigger 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.
  • Audit semantics: the triggerWorkflow activity log records the trigger call (pending→completed = trigger accepted); the run continues asynchronously — its terminal state is read via getWorkflowRun, 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 in createPendingActivityLog, next to the null-id guard, so the policy is decided in one place, and it is pinned in both directions on both paths.
  • Two audit rows per MCP trigger, worded differently on purpose: the MCP server writes requested the workflow "X" via MCP before the start — fail-closed, and with no runId since the run does not exist yet — and the orchestrator writes triggered the workflow "X" via MCP once 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. triggered keeps its parity with the webhook channel.
  • WorkflowRunTriggerResult slimmed: the never-consumed workflowName/collectionName fields are dropped — the contract is exactly { runId, runState } (the audit label is resolved via getMcpWorkflowById).
  • Public API of @forestadmin/forestadmin-client: the ForestAdminClient interface gains a required readonly workflowsService member — a compile-time breaking addition for external implementations of the interface. The ForestAdminClientWithCache constructor is a strict append (workflowsService is now the last parameter), so positional construction with the previous signature keeps working.

Changes from the fourth adversarial review

  • The two polling tools now say when a refusal will repeat. isRetryable was written, exported, and imported by one tool out of three. getWorkflowRun and listWorkflows went through toModelSafeError, which only asks carriesTransportDetail — so a 404, a 403, or a 400 on a malformed runId came back as a bare reason with nothing saying the call cannot succeed. It lands hardest on getWorkflowRun, whose own description tells the model to poll: the loop that produces is the documented usage rather than a mistake. All three tools now share the arbitration, and the closing sentence moves into a single RETRY_WILL_NOT_HELP constant — the wording is the only signal the model gets, so two copies of it would drift. Pinned in both directions: an it.each over the terminal statuses asserts the advice is appended, another over 429 and 5xx asserts it is not.
  • The projection guarantee stops being absolute. This package's CLAUDE.md claimed the workflow responses are projected onto an explicit whitelist "so a new server field must not arrive by itself". True for every field but stepDefinition, which is forwarded whole by design. The note now names the exception, as does the docs page (docs(workflows): MCP triggering — tools, flow and trigger settings (PRD-742) docs#21) that carried the same sentence.

Changes from the third adversarial review

  • Transport failures are classified before a model sees them, on all four workflow calls. The previous round sanitized the pre-flight lookup and left the other three untouched, so listWorkflows, getWorkflowRun and the start call still handed the model the raw error — ServerUtils interpolates 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 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 Forest's own JSON:API detail and still reaches the model, because it says something actionable.
  • A terminal refusal no longer tells the model to retry. 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 — came back 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 / out-of-rendering stay indistinguishable; the others quote Forest's reason, which is safe by construction since that branch is only reachable for an HttpError. 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.
  • The by-id lookup projects its response, like the other three routes. It was the last one returning the raw body while the package notes claimed the projection held for the whole family. Its payload does not reach a model, but name is written verbatim into a persisted Activity Log label.
  • A comment that described a mechanism that does not exist is corrected: skipping the status update was justified by "an empty Bearer would 401 and then be retried on the 404 branch". That branch tests instanceof NotFoundError, and a 401 maps to a plain HttpError.

Changes from the second adversarial review

  • The read fail-open no longer swallows an authorization refusal. The policy arbitrated on the action type alone, so a 401/403 from 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.
  • listWorkflows projects 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-step context is 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.
  • A non-404 lookup failure no longer hands the model transport detail (the Forest server URL, an internal 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/list is asserted. Registration is the one of the three coordinated server.ts edits 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.
  • The server drops a duplicate query on the MCP start path. Folding the mcp-enabled predicate into getWorkflowMetadata removed the second predicate but not the second query — createAndStartRun then called getBpmnAwsS3Identifier, 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.
  • What the MCP run must not expose is now pinned (not.toHaveProperty('userProfile' | 'serverToken')). Every contract assertion used objectContaining, and WorkflowRunForExecutor extends HydratedWorkflowRun, so swapping the builder compiled and kept the suite green while leaking a live Forest serverToken.
  • The three trigger rows require workflow-manage permission in the UI. Enabling an automated trigger is what makes a workflow startable by an unattended caller, so it takes the same level as managing the workflow — not the broader layout permission the PATCH is classified under server-side. That server half is PRD-981: an Editor can still flip it through the API.
  • Trigger saves are serialized per workflow, across channels. Each row had its own in-flight flag, and the PATCH replaces the whole triggers array, so an earlier request landing last could leave the server holding a channel the UI shows as off.
  • Docs: the error table's promised split landed, the unaudited-tool list went from three to four (requestActionFileUpload is 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

  • Audit fail policy — the read-fail-open path only covered the 200-with-null-id case; a rejection propagated and failed the tool, reads included. A test pinned the contradictory behaviour on a read action. Arbitration moved into createPendingActivityLog.
  • listWorkflows no longer lists what it cannot trigger — a workflow whose collection was renamed or removed came back with a null collectionName, which triggerWorkflow rejects up front, so the assistant looped. They are now filtered out, with a warning for the operator.
  • Forest-Application-Source: MCP is stamped on the MCP-only routes rather than taken from the caller's service options. The embedded mountAiMcpServer path built its services from the shared client options, which carry no headers, so agent-hosted MCP traffic reached the server unlabelled.
  • getMcpWorkflowRun projects 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 a userProfile with a live Forest serverToken. The MCP route uses a different builder, but the two types are mutually assignable — the whitelist is the guardrail, not the annotation. One field sits outside it on purpose: stepDefinition is forwarded whole so the model can reason about the step, and a test pins that pass-through — so a field added to a step type does reach the model unannounced.
  • recordId is 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 server

  • Adds three new MCP tools to server.ts: listWorkflows (list MCP-enabled workflows, optionally filtered by collection), triggerWorkflow (start a workflow run on a record with preflight validation and audit logging), and getWorkflowRun (fetch hydrated run status and history by runId).
  • Wires workflow HTTP calls through a new WorkflowsService in forestadmin-client, which delegates to four new ForestHttpApi endpoints under /api/workflow-orchestrator/mcp-workflows.
  • Extends ForestServerClientImpl and createForestServerClient to accept and expose WorkflowsService, and propagates the instance through ForestAdminClientWithCache and the Agent mount path.
  • Hardens activity log creation in createPendingActivityLog: write actions (including the new triggerWorkflow) fail closed if log creation fails or returns no id; read actions fail open with a warning and proceed without an audit trail.
  • Adds a TriggerType.Mcp value to the workflow executor's validated execution types and server adapter enum.
  • Risk: triggerWorkflow is fail-closed on audit log creation — if the activity log service is unavailable, the workflow will not be triggered.

Changes since #1792 opened

  • Added response field projection to ForestHttpApi.getMcpWorkflowRun method [2be4504]
  • Added workflow filtering by collectionName in listWorkflows tool [2be4504]
  • Added Forest-Application-Source: MCP header to all MCP-related API methods [2be4504]
  • Added maximum length validation for recordId parameter in triggerWorkflow tool [2be4504]
  • Updated documentation comments for McpWorkflowLookup type [2be4504]
  • Fixed updateActivityLogStatus function to validate forestServerToken presence before attempting activity log updates [a3b04ef]
  • Added explicit MCP specification annotations to workflow tool registrations [a3b04ef]
  • Scoped workflow list tool helper types and functions to module-internal visibility [a3b04ef]
  • Enhanced test coverage for workflow tool MCP annotations and activity log tracking [a3b04ef]
  • Hardened constructor wiring test for ForestAdminClientWithCache to validate all positional arguments [a3b04ef]
  • Added documentation clarifying workflowId field purpose in McpWorkflowLookup interface [a3b04ef]
  • Added parameterized test for markActivityLogAsFailed error handling with invalid auth tokens [23b878b]
  • Modified audit creation policy in createPendingActivityLog utility 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]
  • Changed error handling in triggerWorkflow tool handler to return generic retry-later message when pre-trigger workflow lookup fails for non-404 reasons instead of exposing internal transport details [b0aac65]
  • Implemented response field whitelisting for workflow-related data returned by ForestHttpApi methods through new projection utilities [b0aac65]
  • Updated workflow filtering logic in listWorkflows handler to exclude workflows where collectionName is either null or undefined using loose inequality check [b0aac65]
  • Added integration and unit tests covering workflow tool registration, response projection, error handling for lookup failures, and expanded audit policy behavior [b0aac65]
  • Updated documentation in CLAUDE.md to clarify cross-file flow distinctions between record-level and workflow tools, response projection whitelisting, and centralized audit fail policy details [b0aac65]
  • Introduced error classification and sanitization utilities in workflow-error module [caa542c]
  • Implemented sanitized error handling in declareGetWorkflowRunTool, declareListWorkflowsTool, and declareTriggerWorkflowTool handlers within mcp-server package [caa542c]
  • Modified ForestHttpApi.getMcpWorkflowById method in forestadmin-client package to return projected response object [caa542c]
  • Added comprehensive test coverage for sanitized error handling across workflow tools and response projection [caa542c]
  • Updated comments in updateActivityLogStatus function within activity-logs-creator module [caa542c]
  • Changed Activity Log label for pre-trigger workflow execution [31976ed]
  • Added standardized retry advice to non-retryable workflow errors [701f523]
  • Clarified stepDefinition forwarding behavior in workflow tools documentation [701f523]
  • Added documentation for three workflow tools to the mcp-server package [1627c04]
  • Added error sanitization for audit logging failures that expose transport details in write operations [283fcb3]
  • Updated tests to verify transport error sanitization behavior in workflow triggering and activity log creation [283fcb3]

Macroscope summarized 790b913.

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

3 participants