feat(mcp-server): expose workflow tools in Forest MCP server (PRD-49) - #1792
Merged
christophebrun-forest merged 44 commits intoAug 24, 2026
Merged
Conversation
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 fourth adversarial review
isRetryablewas written, exported, and imported by one tool out of three.getWorkflowRunandlistWorkflowswent throughtoModelSafeError, which only askscarriesTransportDetail— so a404, a403, or a400on a malformedrunIdcame back as a bare reason with nothing saying the call cannot succeed. It lands hardest ongetWorkflowRun, 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 singleRETRY_WILL_NOT_HELPconstant — the wording is the only signal the model gets, so two copies of it would drift. Pinned in both directions: anit.eachover the terminal statuses asserts the advice is appended, another over429and5xxasserts it is not.CLAUDE.mdclaimed the workflow responses are projected onto an explicit whitelist "so a new server field must not arrive by itself". True for every field butstepDefinition, 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
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. One field sits outside it on purpose:stepDefinitionis 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.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]mcp-serverpackage [1627c04]Macroscope summarized 790b913.