From 785174990eba7cdcf31183054d5e1b5070675869 Mon Sep 17 00:00:00 2001 From: Christophe Brun Date: Fri, 24 Jul 2026 14:51:30 +0200 Subject: [PATCH 01/43] feat(mcp-server): add listWorkflows tool (PRD-736) (#1771) 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 --- .../src/forest-admin-client-mock.ts | 4 + packages/agent/src/agent.ts | 1 + .../test/__factories__/forest-admin-client.ts | 3 + .../src/build-application-services.ts | 3 + .../src/forest-admin-client-with-cache.ts | 2 + packages/forestadmin-client/src/index.ts | 6 + .../src/permissions/forest-http-api.ts | 17 ++ packages/forestadmin-client/src/types.ts | 30 +++ .../forestadmin-client/src/workflows/index.ts | 27 +++ .../test/__factories__/forest-admin-client.ts | 2 + .../forest-admin-server-interface.ts | 2 + .../test/__factories__/index.ts | 1 + .../test/__factories__/workflows/index.ts | 11 + .../forest-admin-client-with-cache.test.ts | 10 + .../test/permissions/forest-http-api.test.ts | 39 ++++ .../test/workflows/index.test.ts | 79 +++++++ packages/mcp-server/src/http-client/index.ts | 21 +- .../src/http-client/mcp-http-client.ts | 12 +- packages/mcp-server/src/http-client/types.ts | 11 + packages/mcp-server/src/server.ts | 7 +- .../mcp-server/src/tools/list-workflows.ts | 54 +++++ .../src/utils/activity-logs-creator.ts | 21 +- packages/mcp-server/src/utils/auth-context.ts | 24 +++ .../test/helpers/forest-server-client.ts | 1 + .../test/http-client/mcp-http-client.test.ts | 25 +++ packages/mcp-server/test/server.test.ts | 1 + .../test/tools/execute-action.test.ts | 1 + .../test/tools/get-action-form.test.ts | 1 + .../test/tools/list-workflows.test.ts | 201 ++++++++++++++++++ 29 files changed, 593 insertions(+), 24 deletions(-) create mode 100644 packages/forestadmin-client/src/workflows/index.ts create mode 100644 packages/forestadmin-client/test/__factories__/workflows/index.ts create mode 100644 packages/forestadmin-client/test/workflows/index.test.ts create mode 100644 packages/mcp-server/src/tools/list-workflows.ts create mode 100644 packages/mcp-server/src/utils/auth-context.ts create mode 100644 packages/mcp-server/test/tools/list-workflows.test.ts diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 5bd4cb6342..73bf40dd81 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -62,6 +62,10 @@ export default class ForestAdminClientMock implements ForestAdminClient { updateActivityLogStatus: () => Promise.resolve(), }; + readonly workflowsService: ForestAdminClient['workflowsService'] = { + listMcpEnabledWorkflows: () => Promise.resolve([]), + }; + readonly permissionService: any; readonly authService: any; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 3bbea7c8fe..4b4e5030d9 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -376,6 +376,7 @@ export default class Agent extends FrameworkMounter const forestServerClient = new ForestServerClientImpl( this.options.forestAdminClient.schemaService, this.options.forestAdminClient.activityLogsService, + this.options.forestAdminClient.workflowsService, this.options.forestServerUrl, ); diff --git a/packages/agent/test/__factories__/forest-admin-client.ts b/packages/agent/test/__factories__/forest-admin-client.ts index ab7189ccc8..6c2be90627 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -54,6 +54,9 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }, + workflowsService: { + listMcpEnabledWorkflows: jest.fn(), + }, subscribeToServerEvents: jest.fn(), close: jest.fn(), onRefreshCustomizations: jest.fn(), diff --git a/packages/forestadmin-client/src/build-application-services.ts b/packages/forestadmin-client/src/build-application-services.ts index 78158f8668..dbecac4085 100644 --- a/packages/forestadmin-client/src/build-application-services.ts +++ b/packages/forestadmin-client/src/build-application-services.ts @@ -22,6 +22,7 @@ import UserPermissionService from './permissions/user-permission'; import SchemaService from './schema'; import ContextVariablesInstantiator from './utils/context-variables-instantiator'; import defaultLogger from './utils/default-logger'; +import WorkflowsService from './workflows'; export default function buildApplicationServices( forestAdminServerInterface: ForestAdminServerInterface, @@ -31,6 +32,7 @@ export default function buildApplicationServices( renderingPermission: RenderingPermissionService; schema: SchemaService; activityLogs: ActivityLogsService; + workflows: WorkflowsService; contextVariables: ContextVariablesInstantiator; ipWhitelist: IpWhiteListService; permission: PermissionService; @@ -89,6 +91,7 @@ export default function buildApplicationServices( ipWhitelist: new IpWhiteListService(forestAdminServerInterface, optionsWithDefaults), schema: new SchemaService(forestAdminServerInterface, optionsWithDefaults), activityLogs: new ActivityLogsService(forestAdminServerInterface, optionsWithDefaults), + workflows: new WorkflowsService(forestAdminServerInterface, optionsWithDefaults), auth: forestAdminServerInterface.makeAuthService(optionsWithDefaults), modelCustomizationService: new ModelCustomizationFromApiService( forestAdminServerInterface, diff --git a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts index 198ade6471..70ea70b1e8 100644 --- a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts +++ b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts @@ -19,6 +19,7 @@ import type { PermissionService, } from './types'; import type ContextVariablesInstantiator from './utils/context-variables-instantiator'; +import type WorkflowsService from './workflows'; import verifyAndExtractApproval from './permissions/verify-approval'; @@ -32,6 +33,7 @@ export default class ForestAdminClientWithCache implements ForestAdminClient { protected readonly ipWhitelistService: IpWhiteListService, public readonly schemaService: SchemaService, public readonly activityLogsService: ActivityLogsService, + public readonly workflowsService: WorkflowsService, public readonly authService: ForestAdminAuthServiceInterface, public readonly modelCustomizationService: ModelCustomizationService, public readonly mcpServerConfigService: McpServerConfigService, diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 257f30330b..99307a2e5e 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -28,8 +28,11 @@ export { ActivityLogType, CreateActivityLogParams, UpdateActivityLogStatusParams, + McpWorkflow, + ListMcpWorkflowsParams, // Service interfaces for MCP ActivityLogsServiceInterface, + WorkflowsServiceInterface, SchemaServiceInterface, } from './types'; export { IpWhitelistConfiguration } from './ip-whitelist/types'; @@ -55,6 +58,7 @@ export default function createForestAdminClient( ipWhitelist, schema, activityLogs, + workflows, auth, modelCustomizationService, mcpServerConfigService, @@ -71,6 +75,7 @@ export default function createForestAdminClient( ipWhitelist, schema, activityLogs, + workflows, auth, modelCustomizationService, mcpServerConfigService, @@ -94,6 +99,7 @@ export { default as ServerUtils } from './utils/server'; // export is necessary for the agent-generator package export { default as SchemaService, SchemaServiceOptions } from './schema'; export { default as ActivityLogsService, ActivityLogsOptions } from './activity-logs'; +export { default as WorkflowsService, WorkflowsServiceOptions } from './workflows'; export * from './auth/errors'; export * from './utils/errors'; diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 4c3da9a211..8cbebf275c 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -9,6 +9,7 @@ import type { ForestAdminServerInterface, ForestSchemaCollection, IpWhitelistRulesResponse, + McpWorkflow, } from '../types'; import type { HttpOptions } from '../utils/http-options'; @@ -151,4 +152,20 @@ export default class ForestHttpApi implements ForestAdminServerInterface { headers: options.headers, }); } + + async listMcpEnabledWorkflows( + options: ActivityLogHttpOptions, + renderingId: string, + collectionName?: string, + ): Promise { + const query = collectionName ? `?collectionName=${encodeURIComponent(collectionName)}` : ''; + + return ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: `/api/workflow-orchestrator/workflows${query}`, + bearerToken: options.bearerToken, + headers: { 'forest-rendering-id': renderingId, ...options.headers }, + }); + } } diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 5a2d83b664..a32be916e3 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -53,6 +53,7 @@ export interface ForestAdminClient { readonly authService: ForestAdminAuthServiceInterface; readonly schemaService: SchemaServiceInterface; readonly activityLogsService: ActivityLogsServiceInterface; + readonly workflowsService: WorkflowsServiceInterface; verifySignedActionParameters(signedParameters: string): TSignedParameters; @@ -282,6 +283,28 @@ export interface ActivityLogsServiceInterface { updateActivityLogStatus: (params: UpdateActivityLogStatusParams) => Promise; } +/** + * An MCP-enabled workflow, as returned by the Forest server's workflow listing endpoint. + */ +export interface McpWorkflow { + workflowId: string; + name: string; + collectionName: string | null; +} + +export interface ListMcpWorkflowsParams { + forestServerToken: string; + renderingId: string; + collectionName?: string; +} + +/** + * Service interface for workflow operations (MCP-related). + */ +export interface WorkflowsServiceInterface { + listMcpEnabledWorkflows: (params: ListMcpWorkflowsParams) => Promise; +} + /** * Service interface for schema operations (extended for MCP). */ @@ -320,6 +343,13 @@ export interface ForestAdminServerInterface { id: string, body: object, ) => Promise; + + // Workflow operations + listMcpEnabledWorkflows?: ( + options: ActivityLogHttpOptions, + renderingId: string, + collectionName?: string, + ) => Promise; } export type ActivityLogHttpOptions = { diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts new file mode 100644 index 0000000000..542ac94c07 --- /dev/null +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -0,0 +1,27 @@ +import type { ForestAdminServerInterface, ListMcpWorkflowsParams, McpWorkflow } from '../types'; + +export type WorkflowsServiceOptions = { + forestServerUrl: string; + headers?: Record; +}; + +export default class WorkflowsService { + constructor( + private forestAdminServerInterface: ForestAdminServerInterface, + private options: WorkflowsServiceOptions, + ) {} + + async listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise { + const { forestServerToken, renderingId, collectionName } = params; + + return this.forestAdminServerInterface.listMcpEnabledWorkflows( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + collectionName, + ); + } +} diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-client.ts b/packages/forestadmin-client/test/__factories__/forest-admin-client.ts index 6a5ccf5746..b541ea5abe 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-client.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-client.ts @@ -13,6 +13,7 @@ import permissionServiceFactory from './permissions/permission'; import renderingPermissionsFactory from './permissions/rendering-permission'; import schemaServiceFactory from './schema'; import contextVariablesInstantiatorFactory from './utils/context-variables-instantiator'; +import workflowsServiceFactory from './workflows'; import ForestAdminClient from '../../src/forest-admin-client-with-cache'; export class ForestAdminClientFactory extends Factory { @@ -36,6 +37,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define( ipWhitelistServiceFactory.build(), schemaServiceFactory.build(), activityLogsServiceFactory.build(), + workflowsServiceFactory.build(), authServiceFactory.build(), modelCustomizationServiceFactory.build(), mcpServerConfigServiceFactory.build(), diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index d89fb5817e..ad26af3ee4 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -18,6 +18,8 @@ const forestAdminServerInterface = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + // Workflow operations + listMcpEnabledWorkflows: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/__factories__/index.ts b/packages/forestadmin-client/test/__factories__/index.ts index 52d5fccfb4..68dacdb062 100644 --- a/packages/forestadmin-client/test/__factories__/index.ts +++ b/packages/forestadmin-client/test/__factories__/index.ts @@ -11,6 +11,7 @@ export { default as forestAdminClientOptions } from './forest-admin-client-optio export { default as ipWhiteList } from './ip-whitelist'; export { default as schema } from './schema'; export { default as activityLogs } from './activity-logs'; +export { default as workflows } from './workflows'; export { default as auth } from './auth'; export { default as modelCustomization } from './model-customizations/model-customization-from-api'; export { default as mcpServerConfig } from './mcp-server-config'; diff --git a/packages/forestadmin-client/test/__factories__/workflows/index.ts b/packages/forestadmin-client/test/__factories__/workflows/index.ts new file mode 100644 index 0000000000..7dfca54595 --- /dev/null +++ b/packages/forestadmin-client/test/__factories__/workflows/index.ts @@ -0,0 +1,11 @@ +import { Factory } from 'fishery'; + +import WorkflowsService from '../../../src/workflows'; +import forestAdminClientOptions from '../forest-admin-client-options'; +import forestAdminServerInterface from '../forest-admin-server-interface'; + +const workflowsServiceFactory = Factory.define(() => { + return new WorkflowsService(forestAdminServerInterface.build(), forestAdminClientOptions.build()); +}); + +export default workflowsServiceFactory; diff --git a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts index cd8a3e5d5b..d70602fb9d 100644 --- a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts +++ b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts @@ -25,6 +25,7 @@ describe('ForestAdminClientWithCache', () => { whiteListService, factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -53,6 +54,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), schemaService, factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -87,6 +89,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -116,6 +119,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -141,6 +145,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -167,6 +172,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -203,6 +209,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -228,6 +235,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -253,6 +261,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -280,6 +289,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 75ba76222c..1145de4884 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -206,4 +206,43 @@ describe('ForestHttpApi', () => { }); }); }); + + describe('listMcpEnabledWorkflows', () => { + it('should GET the workflows endpoint with the rendering id header', async () => { + const workflows = [{ workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }]; + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(workflows); + + const result = await new ForestHttpApi().listMcpEnabledWorkflows( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: '/api/workflow-orchestrator/workflows', + bearerToken: 'bearer-token', + headers: { 'forest-rendering-id': '12345' }, + }); + expect(result).toEqual(workflows); + }); + + it('should append the collectionName filter as a url-encoded query param', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue([]); + + await new ForestHttpApi().listMcpEnabledWorkflows( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'sales orders', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + path: '/api/workflow-orchestrator/workflows?collectionName=sales%20orders', + headers: { 'forest-rendering-id': '12345' }, + }), + ); + }); + }); }); diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts new file mode 100644 index 0000000000..3deb05c904 --- /dev/null +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -0,0 +1,79 @@ +import type { ForestAdminServerInterface, McpWorkflow } from '../../src/types'; + +import WorkflowsService from '../../src/workflows'; +import * as factories from '../__factories__'; + +describe('WorkflowsService', () => { + const options = { + forestServerUrl: 'http://forestadmin-server.com', + }; + let mockForestAdminServerInterface: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockForestAdminServerInterface = + factories.forestAdminServerInterface.build() as jest.Mocked; + }); + + describe('listMcpEnabledWorkflows', () => { + const workflows: McpWorkflow[] = [ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + ]; + + it('should forward the bearer token and rendering id to the transport', async () => { + mockForestAdminServerInterface.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + const result = await service.listMcpEnabledWorkflows({ + forestServerToken: 'test-token', + renderingId: '12345', + }); + + expect(result).toEqual(workflows); + expect(mockForestAdminServerInterface.listMcpEnabledWorkflows).toHaveBeenCalledWith( + { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, + '12345', + undefined, + ); + }); + + it('should forward the collectionName filter when provided', async () => { + mockForestAdminServerInterface.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + await service.listMcpEnabledWorkflows({ + forestServerToken: 'test-token', + renderingId: '12345', + collectionName: 'orders', + }); + + expect(mockForestAdminServerInterface.listMcpEnabledWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ bearerToken: 'test-token' }), + '12345', + 'orders', + ); + }); + + it('should pass custom headers when provided', async () => { + mockForestAdminServerInterface.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const service = new WorkflowsService(mockForestAdminServerInterface, { + ...options, + headers: { 'Forest-Application-Source': 'MCP' }, + }); + await service.listMcpEnabledWorkflows({ + forestServerToken: 'test-token', + renderingId: '12345', + }); + + expect(mockForestAdminServerInterface.listMcpEnabledWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ + bearerToken: 'test-token', + headers: { 'Forest-Application-Source': 'MCP' }, + }), + '12345', + undefined, + ); + }); + }); +}); diff --git a/packages/mcp-server/src/http-client/index.ts b/packages/mcp-server/src/http-client/index.ts index 5216369941..95cf439fa4 100644 --- a/packages/mcp-server/src/http-client/index.ts +++ b/packages/mcp-server/src/http-client/index.ts @@ -1,6 +1,11 @@ import type { ForestServerClient } from './types'; -import { ActivityLogsService, ForestHttpApi, SchemaService } from '@forestadmin/forestadmin-client'; +import { + ActivityLogsService, + ForestHttpApi, + SchemaService, + WorkflowsService, +} from '@forestadmin/forestadmin-client'; import ForestServerClientImpl from './mcp-http-client'; @@ -27,8 +32,17 @@ export function createForestServerClient( ...serviceOptions, headers: { 'Forest-Application-Source': 'MCP' }, }); + const workflowsService = new WorkflowsService(forestHttpApi, { + forestServerUrl: options.forestServerUrl, + headers: { 'Forest-Application-Source': 'MCP' }, + }); - return new ForestServerClientImpl(schemaService, activityLogsService, options.forestServerUrl); + return new ForestServerClientImpl( + schemaService, + activityLogsService, + workflowsService, + options.forestServerUrl, + ); } export { ForestServerClientImpl }; @@ -39,9 +53,12 @@ export type { ActivityLogType, CreateActivityLogParams, ForestServerClient, + ListMcpWorkflowsParams, + McpWorkflow, UpdateActivityLogStatusParams, ForestSchemaCollection, ForestSchemaField, ForestSchemaAction, SchemaServiceInterface, + WorkflowsServiceInterface, } from './types'; diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index 2be7e2a1d7..faa02d9807 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -4,18 +4,22 @@ import type { CreateActivityLogParams, ForestSchemaCollection, ForestServerClient, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, UpdateActivityLogStatusParams, + WorkflowsServiceInterface, } from './types'; /** - * Default implementation of ForestServerClient that uses SchemaService and ActivityLogsService. - * This provides a convenient API for MCP server operations. + * Default implementation of ForestServerClient that uses SchemaService, ActivityLogsService + * and WorkflowsService. This provides a convenient API for MCP server operations. */ export default class ForestServerClientImpl implements ForestServerClient { constructor( private readonly schemaService: SchemaServiceInterface, private readonly activityLogsService: ActivityLogsServiceInterface, + private readonly workflowsService: WorkflowsServiceInterface, public readonly forestServerUrl: string, ) {} @@ -34,4 +38,8 @@ export default class ForestServerClientImpl implements ForestServerClient { async updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise { return this.activityLogsService.updateActivityLogStatus(params); } + + async listMcpWorkflows(params: ListMcpWorkflowsParams): Promise { + return this.workflowsService.listMcpEnabledWorkflows(params); + } } diff --git a/packages/mcp-server/src/http-client/types.ts b/packages/mcp-server/src/http-client/types.ts index 8ee4b10c07..8b8e088c25 100644 --- a/packages/mcp-server/src/http-client/types.ts +++ b/packages/mcp-server/src/http-client/types.ts @@ -7,8 +7,11 @@ import type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, UpdateActivityLogStatusParams, + WorkflowsServiceInterface, } from '@forestadmin/forestadmin-client'; // Re-export types from forestadmin-client for convenience @@ -21,8 +24,11 @@ export type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, UpdateActivityLogStatusParams, + WorkflowsServiceInterface, }; /** @@ -54,4 +60,9 @@ export interface ForestServerClient { * Updates an activity log status. */ updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise; + + /** + * Lists the MCP-enabled workflows the caller can access in a rendering. + */ + listMcpWorkflows(params: ListMcpWorkflowsParams): Promise; } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 7510bda4d4..0f7c9c4701 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -35,6 +35,7 @@ import declareExecuteActionTool from './tools/execute-action'; import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; +import declareListWorkflowsTool from './tools/list-workflows'; import declareUpdateTool from './tools/update'; import normalizeAgentUrl from './utils/normalize-agent-url'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; @@ -91,6 +92,7 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { executeAction: ['collectionName', 'actionName', 'recordIds'], associate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordId'], dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], + listWorkflows: ['collectionName'], }; export type ToolName = @@ -103,7 +105,8 @@ export type ToolName = | 'associate' | 'dissociate' | 'getActionForm' - | 'executeAction'; + | 'executeAction' + | 'listWorkflows'; /** * Options for configuring the Forest Admin MCP Server @@ -234,6 +237,7 @@ export default class ForestMCPServer { { name: 'dissociate', register: () => declareDissociateTool(mcpServer, ctx) }, { name: 'getActionForm', register: () => declareGetActionFormTool(mcpServer, ctx) }, { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, + { name: 'listWorkflows', register: () => declareListWorkflowsTool(mcpServer, ctx) }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -271,6 +275,7 @@ export default class ForestMCPServer { 'dissociate', 'getActionForm', 'executeAction', + 'listWorkflows', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts new file mode 100644 index 0000000000..8677b5ccb3 --- /dev/null +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -0,0 +1,54 @@ +import type { ToolContext } from '../tool-context'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import getAuthContext from '../utils/auth-context'; +import registerToolWithLogging from '../utils/tool-with-logging'; + +const COLLECTION_NAME_DESCRIPTION = + 'Optional. Narrow the results to workflows operating on this collection — typically the ' + + 'collection of the record currently in context.'; + +export function createListWorkflowsArgumentShape(collectionNames: string[]) { + const collectionName = + collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(); + + return { + collectionName: collectionName.optional().describe(COLLECTION_NAME_DESCRIPTION), + }; +} + +export type ListWorkflowsArgument = z.infer< + z.ZodObject> +>; + +export default function declareListWorkflowsTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames } = ctx; + + return registerToolWithLogging( + mcpServer, + 'listWorkflows', + { + annotations: { readOnlyHint: true }, + title: 'List MCP-enabled workflows', + description: + 'Discover Forest workflows enabled for MCP triggering that you can access. Returns each ' + + "workflow's id, name and the collection it operates on. Optionally filter by collectionName " + + 'to match the record currently in context, then start one with triggerWorkflow.', + inputSchema: createListWorkflowsArgumentShape(collectionNames), + }, + async (args: ListWorkflowsArgument, extra) => { + const { forestServerToken, renderingId } = getAuthContext(extra); + + const workflows = await forestServerClient.listMcpWorkflows({ + forestServerToken, + renderingId, + collectionName: args.collectionName, + }); + + return { content: [{ type: 'text', text: JSON.stringify(workflows) }] }; + }, + logger, + ); +} diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index e245c5d28d..f6a8aa6448 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -10,6 +10,8 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { NotFoundError } from '@forestadmin/forestadmin-client'; +import getAuthContext from './auth-context'; + export type { ActivityLogAction, ActivityLogResponse }; const ACTION_TO_TYPE: Record = { @@ -24,25 +26,6 @@ const ACTION_TO_TYPE: Record = { describeCollection: 'read', }; -function getAuthContext(request: RequestHandlerExtra): { - forestServerToken: string; - renderingId: string; -} { - const forestServerToken = request.authInfo?.extra?.forestServerToken; - const renderingId = request.authInfo?.extra?.renderingId; - - if (!forestServerToken || typeof forestServerToken !== 'string') { - throw new Error('Invalid or missing forestServerToken in authentication context'); - } - - // renderingId can be number (from JWT) or string - convert to string for API calls - if (renderingId === undefined || renderingId === null) { - throw new Error('Invalid or missing renderingId in authentication context'); - } - - return { forestServerToken, renderingId: String(renderingId) }; -} - export default async function createPendingActivityLog( forestServerClient: ForestServerClient, request: RequestHandlerExtra, diff --git a/packages/mcp-server/src/utils/auth-context.ts b/packages/mcp-server/src/utils/auth-context.ts new file mode 100644 index 0000000000..941856e11b --- /dev/null +++ b/packages/mcp-server/src/utils/auth-context.ts @@ -0,0 +1,24 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; + +/** + * 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( + request: RequestHandlerExtra, +): { forestServerToken: string; renderingId: string } { + const forestServerToken = request.authInfo?.extra?.forestServerToken; + const renderingId = request.authInfo?.extra?.renderingId; + + if (!forestServerToken || typeof forestServerToken !== 'string') { + throw new Error('Invalid or missing forestServerToken in authentication context'); + } + + // renderingId can be number (from JWT) or string - convert to string for API calls + if (renderingId === undefined || renderingId === null) { + throw new Error('Invalid or missing renderingId in authentication context'); + } + + return { forestServerToken, renderingId: String(renderingId) }; +} diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 74bc697dac..f726277d42 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -14,6 +14,7 @@ export default function createMockForestServerClient( attributes: { index: 'mock-index' }, }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), + listMcpWorkflows: jest.fn().mockResolvedValue([]), ...overrides, } as jest.Mocked; } diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index 9b904e1918..e39185108f 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -2,6 +2,7 @@ import type { ActivityLogsServiceInterface, ForestSchemaCollection, SchemaServiceInterface, + WorkflowsServiceInterface, } from '../../src/http-client/types'; import { createForestServerClient } from '../../src/http-client'; @@ -10,6 +11,7 @@ import ForestServerClientImpl from '../../src/http-client/mcp-http-client'; describe('ForestServerClientImpl', () => { let mockSchemaService: jest.Mocked; let mockActivityLogsService: jest.Mocked; + let mockWorkflowsService: jest.Mocked; let client: ForestServerClientImpl; beforeEach(() => { @@ -21,9 +23,13 @@ describe('ForestServerClientImpl', () => { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }; + mockWorkflowsService = { + listMcpEnabledWorkflows: jest.fn(), + }; client = new ForestServerClientImpl( mockSchemaService, mockActivityLogsService, + mockWorkflowsService, 'https://api.forestadmin.com', ); }); @@ -102,6 +108,24 @@ describe('ForestServerClientImpl', () => { expect(mockActivityLogsService.updateActivityLogStatus).toHaveBeenCalledWith(params); }); }); + + describe('listMcpWorkflows', () => { + it('should delegate to workflowsService.listMcpEnabledWorkflows()', async () => { + const workflows = [{ workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }]; + mockWorkflowsService.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + collectionName: 'orders', + }; + + const result = await client.listMcpWorkflows(params); + + expect(mockWorkflowsService.listMcpEnabledWorkflows).toHaveBeenCalledWith(params); + expect(result).toBe(workflows); + }); + }); }); describe('createForestServerClient', () => { @@ -133,5 +157,6 @@ describe('createForestServerClient', () => { expect(client.createActivityLog).toBeDefined(); expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); + expect(client.listMcpWorkflows).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index e21a4d7e8d..5c86e5221a 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3285,6 +3285,7 @@ describe('enabledTools', () => { 'dissociate', 'getActionForm', 'executeAction', + 'listWorkflows', ], }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 99e8cd2258..a1789ec924 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -19,6 +19,7 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + listMcpWorkflows: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index a6557a28b4..2d7ce51d6c 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -17,6 +17,7 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + listMcpWorkflows: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts new file mode 100644 index 0000000000..265b2a0f05 --- /dev/null +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -0,0 +1,201 @@ +import type { ForestServerClient } from '../../src/http-client'; +import type { Logger } from '../../src/server'; +import type { RegisteredToolConfig } from '../helpers/registered-tool-config'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { NotFoundError } from '@forestadmin/forestadmin-client'; + +import declareListWorkflowsTool from '../../src/tools/list-workflows'; +import createMockForestServerClient from '../helpers/forest-server-client'; + +const mockLogger: Logger = jest.fn(); + +describe('declareListWorkflowsTool', () => { + let mcpServer: McpServer; + let mockForestServerClient: jest.Mocked; + let registeredToolHandler: (args: unknown, extra: unknown) => Promise; + let registeredToolConfig: RegisteredToolConfig; + + beforeEach(() => { + jest.clearAllMocks(); + + mockForestServerClient = createMockForestServerClient(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + }); + + describe('tool registration', () => { + it('should register a tool named "listWorkflows"', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'listWorkflows', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.title).toBe('List MCP-enabled workflows'); + expect(registeredToolConfig.description).toContain('MCP triggering'); + }); + + it('should be annotated as read-only', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); + }); + + it('should expose an optional collectionName argument', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(schema.collectionName.parse(undefined)).toBeUndefined(); + }); + + it('should accept any string for collectionName when no collection names provided', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + expect(() => schema.collectionName.parse(undefined)).not.toThrow(); + expect(() => schema.collectionName.parse(123)).toThrow(); + }); + + it('should restrict collectionName to the known collections when provided', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['orders', 'users'], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.collectionName.parse('orders')).not.toThrow(); + expect(() => schema.collectionName.parse(undefined)).not.toThrow(); + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: 123, + environmentApiEndpoint: 'https://api.example.com', + }, + }, + } as unknown as RequestHandlerExtra; + + const workflows = [ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + { workflowId: 'wf-2', name: 'Notify customer', collectionName: 'orders' }, + ]; + + beforeEach(() => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + mockForestServerClient.listMcpWorkflows.mockResolvedValue(workflows); + }); + + it('should call listMcpWorkflows with the identity from the auth context', async () => { + await registeredToolHandler({}, mockExtra); + + expect(mockForestServerClient.listMcpWorkflows).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + collectionName: undefined, + }); + }); + + it('should forward the collectionName filter to listMcpWorkflows', async () => { + await registeredToolHandler({ collectionName: 'orders' }, mockExtra); + + expect(mockForestServerClient.listMcpWorkflows).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + collectionName: 'orders', + }); + }); + + it('should return the workflows as JSON text content', async () => { + const result = await registeredToolHandler({}, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(workflows) }], + }); + }); + + it('should return an error result when the auth context is missing the token', async () => { + const extraWithoutToken = { + authInfo: { extra: { renderingId: 123 } }, + } as unknown as RequestHandlerExtra; + + const result = await registeredToolHandler({}, extraWithoutToken); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], + isError: true, + }); + expect(mockForestServerClient.listMcpWorkflows).not.toHaveBeenCalled(); + }); + + it('should map server errors to an error tool result', async () => { + mockForestServerClient.listMcpWorkflows.mockRejectedValue( + new NotFoundError('No active workflow for the rendering'), + ); + + const result = await registeredToolHandler({}, mockExtra); + + expect(result).toEqual({ + content: [ + { type: 'text', text: expect.stringContaining('No active workflow for the rendering') }, + ], + isError: true, + }); + }); + }); +}); From 2884a0442641f8e99636ad69c18c73e30693166b Mon Sep 17 00:00:00 2001 From: Christophe Brun Date: Mon, 27 Jul 2026 18:23:59 +0200 Subject: [PATCH 02/43] feat(mcp-server): add triggerWorkflow tool (PRD-738) (#1777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) --- .../src/forest-admin-client-mock.ts | 1 + .../test/forest-admin-client-mock.test.ts | 29 +++ .../test/__factories__/forest-admin-client.ts | 1 + packages/forestadmin-client/src/index.ts | 3 + .../src/permissions/forest-http-api.ts | 19 +- packages/forestadmin-client/src/types.ts | 30 ++- .../forestadmin-client/src/workflows/index.ts | 35 ++- .../forest-admin-server-interface.ts | 1 + .../test/permissions/forest-http-api.test.ts | 49 +++- .../test/workflows/index.test.ts | 80 +++++++ packages/mcp-server/src/http-client/index.ts | 2 + .../src/http-client/mcp-http-client.ts | 6 + packages/mcp-server/src/http-client/types.ts | 9 + packages/mcp-server/src/server.ts | 7 +- .../mcp-server/src/tools/trigger-workflow.ts | 89 +++++++ .../src/utils/activity-logs-creator.ts | 1 + .../test/helpers/forest-server-client.ts | 1 + .../test/http-client/mcp-http-client.test.ts | 21 ++ packages/mcp-server/test/server.test.ts | 1 + .../test/tools/execute-action.test.ts | 1 + .../test/tools/get-action-form.test.ts | 1 + .../test/tools/trigger-workflow.test.ts | 218 ++++++++++++++++++ 22 files changed, 599 insertions(+), 6 deletions(-) create mode 100644 packages/agent-testing/test/forest-admin-client-mock.test.ts create mode 100644 packages/mcp-server/src/tools/trigger-workflow.ts create mode 100644 packages/mcp-server/test/tools/trigger-workflow.test.ts diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 73bf40dd81..6c2b59ee81 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -64,6 +64,7 @@ export default class ForestAdminClientMock implements ForestAdminClient { readonly workflowsService: ForestAdminClient['workflowsService'] = { listMcpEnabledWorkflows: () => Promise.resolve([]), + triggerMcpWorkflow: () => Promise.resolve({ runId: 1, runState: 'loading' }), }; readonly permissionService: any; diff --git a/packages/agent-testing/test/forest-admin-client-mock.test.ts b/packages/agent-testing/test/forest-admin-client-mock.test.ts new file mode 100644 index 0000000000..c13e51145f --- /dev/null +++ b/packages/agent-testing/test/forest-admin-client-mock.test.ts @@ -0,0 +1,29 @@ +import ForestAdminClientMock from '../src/forest-admin-client-mock'; + +describe('ForestAdminClientMock', () => { + describe('workflowsService', () => { + it('should resolve an empty list of MCP-enabled workflows', async () => { + const client = new ForestAdminClientMock(); + + await expect( + client.workflowsService.listMcpEnabledWorkflows({ + forestServerToken: 'token', + renderingId: '1', + }), + ).resolves.toEqual([]); + }); + + it('should resolve a loading run when triggering a workflow', async () => { + const client = new ForestAdminClientMock(); + + await expect( + client.workflowsService.triggerMcpWorkflow({ + forestServerToken: 'token', + renderingId: '1', + workflowId: 'wf-1', + recordId: '42', + }), + ).resolves.toEqual({ runId: 1, runState: 'loading' }); + }); + }); +}); diff --git a/packages/agent/test/__factories__/forest-admin-client.ts b/packages/agent/test/__factories__/forest-admin-client.ts index 6c2be90627..9091df8eb4 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -56,6 +56,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ }, workflowsService: { listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), }, subscribeToServerEvents: jest.fn(), close: jest.fn(), diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 99307a2e5e..e0def6434e 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -30,6 +30,9 @@ export { UpdateActivityLogStatusParams, McpWorkflow, ListMcpWorkflowsParams, + TriggerMcpWorkflowParams, + WorkflowRunState, + WorkflowRunTriggerResult, // Service interfaces for MCP ActivityLogsServiceInterface, WorkflowsServiceInterface, diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 8cbebf275c..ace2717e13 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -10,6 +10,7 @@ import type { ForestSchemaCollection, IpWhitelistRulesResponse, McpWorkflow, + WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; @@ -163,9 +164,25 @@ export default class ForestHttpApi implements ForestAdminServerInterface { return ServerUtils.queryWithBearerToken({ forestServerUrl: options.forestServerUrl, method: 'get', - path: `/api/workflow-orchestrator/workflows${query}`, + path: `/api/workflow-orchestrator/mcp-workflows${query}`, bearerToken: options.bearerToken, headers: { 'forest-rendering-id': renderingId, ...options.headers }, }); } + + async triggerMcpWorkflow( + options: ActivityLogHttpOptions, + renderingId: string, + workflowId: string, + recordId: string, + ): Promise { + return ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + method: 'post', + path: `/api/workflow-orchestrator/mcp-workflows/${encodeURIComponent(workflowId)}/start`, + bearerToken: options.bearerToken, + body: { recordId }, + headers: { 'forest-rendering-id': renderingId, ...options.headers }, + }); + } } diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index a32be916e3..4fd8019d7f 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -253,7 +253,8 @@ export type ActivityLogAction = | 'update' | 'delete' | 'listRelatedData' - | 'describeCollection'; + | 'describeCollection' + | 'triggerWorkflow'; export type ActivityLogType = 'read' | 'write'; @@ -298,11 +299,32 @@ export interface ListMcpWorkflowsParams { collectionName?: string; } +/** + * The lifecycle state of a workflow run, as persisted by the orchestrator. + */ +export type WorkflowRunState = 'started' | 'pending' | 'loading' | 'aborted' | 'finished'; + +/** + * The outcome of starting a workflow run: the run continues asynchronously server-side. + */ +export interface WorkflowRunTriggerResult { + runId: number; + runState: WorkflowRunState; +} + +export interface TriggerMcpWorkflowParams { + forestServerToken: string; + renderingId: string; + workflowId: string; + recordId: string; +} + /** * Service interface for workflow operations (MCP-related). */ export interface WorkflowsServiceInterface { listMcpEnabledWorkflows: (params: ListMcpWorkflowsParams) => Promise; + triggerMcpWorkflow: (params: TriggerMcpWorkflowParams) => Promise; } /** @@ -350,6 +372,12 @@ export interface ForestAdminServerInterface { renderingId: string, collectionName?: string, ) => Promise; + triggerMcpWorkflow?: ( + options: ActivityLogHttpOptions, + renderingId: string, + workflowId: string, + recordId: string, + ) => Promise; } export type ActivityLogHttpOptions = { diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts index 542ac94c07..347f4074a3 100644 --- a/packages/forestadmin-client/src/workflows/index.ts +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -1,4 +1,10 @@ -import type { ForestAdminServerInterface, ListMcpWorkflowsParams, McpWorkflow } from '../types'; +import type { + ForestAdminServerInterface, + ListMcpWorkflowsParams, + McpWorkflow, + TriggerMcpWorkflowParams, + WorkflowRunTriggerResult, +} from '../types'; export type WorkflowsServiceOptions = { forestServerUrl: string; @@ -14,6 +20,12 @@ export default class WorkflowsService { async listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise { const { forestServerToken, renderingId, collectionName } = params; + if (!this.forestAdminServerInterface.listMcpEnabledWorkflows) { + throw new Error( + 'The configured Forest server transport does not support listMcpEnabledWorkflows.', + ); + } + return this.forestAdminServerInterface.listMcpEnabledWorkflows( { forestServerUrl: this.options.forestServerUrl, @@ -24,4 +36,25 @@ export default class WorkflowsService { collectionName, ); } + + async triggerMcpWorkflow(params: TriggerMcpWorkflowParams): Promise { + const { forestServerToken, renderingId, workflowId, recordId } = params; + + if (!this.forestAdminServerInterface.triggerMcpWorkflow) { + throw new Error( + 'The configured Forest server transport does not support triggerMcpWorkflow.', + ); + } + + return this.forestAdminServerInterface.triggerMcpWorkflow( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + workflowId, + recordId, + ); + } } diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index ad26af3ee4..6b98f88d89 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -20,6 +20,7 @@ const forestAdminServerInterface = { updateActivityLogStatus: jest.fn(), // Workflow operations listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 1145de4884..92cb65b0f5 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -220,7 +220,7 @@ describe('ForestHttpApi', () => { expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ forestServerUrl: options.forestServerUrl, method: 'get', - path: '/api/workflow-orchestrator/workflows', + path: '/api/workflow-orchestrator/mcp-workflows', bearerToken: 'bearer-token', headers: { 'forest-rendering-id': '12345' }, }); @@ -239,10 +239,55 @@ describe('ForestHttpApi', () => { expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith( expect.objectContaining({ method: 'get', - path: '/api/workflow-orchestrator/workflows?collectionName=sales%20orders', + path: '/api/workflow-orchestrator/mcp-workflows?collectionName=sales%20orders', headers: { 'forest-rendering-id': '12345' }, }), ); }); }); + + describe('triggerMcpWorkflow', () => { + it('should POST the record id to the workflow start endpoint with the rendering id header', async () => { + const run = { runId: 7, runState: 'loading' }; + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(run); + + const result = await new ForestHttpApi().triggerMcpWorkflow( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'wf-1', + '42', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ + forestServerUrl: options.forestServerUrl, + method: 'post', + path: '/api/workflow-orchestrator/mcp-workflows/wf-1/start', + bearerToken: 'bearer-token', + body: { recordId: '42' }, + headers: { 'forest-rendering-id': '12345' }, + }); + expect(result).toEqual(run); + }); + + it('should url-encode the workflow id in the path', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runId: 1, + runState: 'loading', + }); + + await new ForestHttpApi().triggerMcpWorkflow( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'wf/with space', + '42', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'post', + path: '/api/workflow-orchestrator/mcp-workflows/wf%2Fwith%20space/start', + }), + ); + }); + }); }); diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts index 3deb05c904..da94880817 100644 --- a/packages/forestadmin-client/test/workflows/index.test.ts +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -75,5 +75,85 @@ describe('WorkflowsService', () => { undefined, ); }); + + it('should throw when the transport does not implement listMcpEnabledWorkflows', async () => { + delete (mockForestAdminServerInterface as Partial) + .listMcpEnabledWorkflows; + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + + await expect( + service.listMcpEnabledWorkflows({ forestServerToken: 'test-token', renderingId: '12345' }), + ).rejects.toThrow('does not support listMcpEnabledWorkflows'); + }); + }); + + describe('triggerMcpWorkflow', () => { + it('should forward the identity, workflowId and recordId to the transport and return the run', async () => { + mockForestAdminServerInterface.triggerMcpWorkflow.mockResolvedValue({ + runId: 7, + runState: 'loading', + }); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + const result = await service.triggerMcpWorkflow({ + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + recordId: '42', + }); + + expect(result).toEqual({ runId: 7, runState: 'loading' }); + expect(mockForestAdminServerInterface.triggerMcpWorkflow).toHaveBeenCalledWith( + { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, + '12345', + 'wf-1', + '42', + ); + }); + + it('should pass custom headers when provided', async () => { + mockForestAdminServerInterface.triggerMcpWorkflow.mockResolvedValue({ + runId: 7, + runState: 'loading', + }); + + const service = new WorkflowsService(mockForestAdminServerInterface, { + ...options, + headers: { 'Forest-Application-Source': 'MCP' }, + }); + await service.triggerMcpWorkflow({ + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + recordId: '42', + }); + + expect(mockForestAdminServerInterface.triggerMcpWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + bearerToken: 'test-token', + headers: { 'Forest-Application-Source': 'MCP' }, + }), + '12345', + 'wf-1', + '42', + ); + }); + + it('should throw when the transport does not implement triggerMcpWorkflow', async () => { + delete (mockForestAdminServerInterface as Partial) + .triggerMcpWorkflow; + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + + await expect( + service.triggerMcpWorkflow({ + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + recordId: '42', + }), + ).rejects.toThrow('does not support triggerMcpWorkflow'); + }); }); }); diff --git a/packages/mcp-server/src/http-client/index.ts b/packages/mcp-server/src/http-client/index.ts index 95cf439fa4..678a023e95 100644 --- a/packages/mcp-server/src/http-client/index.ts +++ b/packages/mcp-server/src/http-client/index.ts @@ -55,6 +55,8 @@ export type { ForestServerClient, ListMcpWorkflowsParams, McpWorkflow, + TriggerMcpWorkflowParams, + WorkflowRunTriggerResult, UpdateActivityLogStatusParams, ForestSchemaCollection, ForestSchemaField, diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index faa02d9807..adc39cde8d 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -7,7 +7,9 @@ import type { ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, + TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunTriggerResult, WorkflowsServiceInterface, } from './types'; @@ -42,4 +44,8 @@ export default class ForestServerClientImpl implements ForestServerClient { async listMcpWorkflows(params: ListMcpWorkflowsParams): Promise { return this.workflowsService.listMcpEnabledWorkflows(params); } + + async triggerWorkflow(params: TriggerMcpWorkflowParams): Promise { + return this.workflowsService.triggerMcpWorkflow(params); + } } diff --git a/packages/mcp-server/src/http-client/types.ts b/packages/mcp-server/src/http-client/types.ts index 8b8e088c25..b5e8a40751 100644 --- a/packages/mcp-server/src/http-client/types.ts +++ b/packages/mcp-server/src/http-client/types.ts @@ -10,7 +10,9 @@ import type { ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, + TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunTriggerResult, WorkflowsServiceInterface, } from '@forestadmin/forestadmin-client'; @@ -27,7 +29,9 @@ export type { ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, + TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunTriggerResult, WorkflowsServiceInterface, }; @@ -65,4 +69,9 @@ export interface ForestServerClient { * Lists the MCP-enabled workflows the caller can access in a rendering. */ listMcpWorkflows(params: ListMcpWorkflowsParams): Promise; + + /** + * Starts a run of an MCP-enabled workflow on a record and returns its runId (async). + */ + triggerWorkflow(params: TriggerMcpWorkflowParams): Promise; } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 0f7c9c4701..a510b939fa 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -36,6 +36,7 @@ import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; import declareListWorkflowsTool from './tools/list-workflows'; +import declareTriggerWorkflowTool from './tools/trigger-workflow'; import declareUpdateTool from './tools/update'; import normalizeAgentUrl from './utils/normalize-agent-url'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; @@ -93,6 +94,7 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { associate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordId'], dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], listWorkflows: ['collectionName'], + triggerWorkflow: ['workflowId', 'recordId'], }; export type ToolName = @@ -106,7 +108,8 @@ export type ToolName = | 'dissociate' | 'getActionForm' | 'executeAction' - | 'listWorkflows'; + | 'listWorkflows' + | 'triggerWorkflow'; /** * Options for configuring the Forest Admin MCP Server @@ -238,6 +241,7 @@ export default class ForestMCPServer { { name: 'getActionForm', register: () => declareGetActionFormTool(mcpServer, ctx) }, { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, { name: 'listWorkflows', register: () => declareListWorkflowsTool(mcpServer, ctx) }, + { name: 'triggerWorkflow', register: () => declareTriggerWorkflowTool(mcpServer, ctx) }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -276,6 +280,7 @@ export default class ForestMCPServer { 'getActionForm', 'executeAction', 'listWorkflows', + 'triggerWorkflow', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts new file mode 100644 index 0000000000..ded9736ff6 --- /dev/null +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -0,0 +1,89 @@ +import type { ToolContext } from '../tool-context'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import getAuthContext from '../utils/auth-context'; +import registerToolWithLogging from '../utils/tool-with-logging'; +import withActivityLog from '../utils/with-activity-log'; + +const WORKFLOW_ID_DESCRIPTION = + 'The id of the workflow to start, as returned by listWorkflows. The workflow must have the MCP ' + + 'trigger enabled.'; + +const RECORD_ID_DESCRIPTION = + 'The id of the record to run the workflow on. For collections with a composite primary key, ' + + 'use the packed id form (values joined by "|").'; + +interface TriggerWorkflowArgument { + workflowId: string; + recordId: string; +} + +export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger } = ctx; + + return registerToolWithLogging( + mcpServer, + 'triggerWorkflow', + { + title: 'Trigger a workflow', + description: + 'Start an MCP-enabled Forest workflow on a specific record. Returns a runId immediately; ' + + 'the run continues asynchronously — poll getWorkflowRun to observe its status. The record ' + + '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), + recordId: z.string().describe(RECORD_ID_DESCRIPTION), + }, + }, + async (args: TriggerWorkflowArgument, extra) => { + const { forestServerToken, renderingId } = getAuthContext(extra); + + // We list workflows first to resolve the name/collection needed for the activity-log label + // (the trigger endpoint returns neither). The server also validates access at trigger time, + // so this lookup is primarily for enrichment; targeting the workflow by id directly would + // save a round-trip — tracked in PRD-831. + const workflows = await forestServerClient.listMcpWorkflows({ + forestServerToken, + renderingId, + }); + const workflow = workflows.find(candidate => candidate.workflowId === args.workflowId); + + // Rejected before withActivityLog: with no resolved workflow there is no collection to + // attach, and the server drops MCP activity logs that carry no resource (see PRD-49), so a + // pre-trigger rejection cannot be audited. Only real triggers are logged (incl. server-side + // 403/409, which fail inside withActivityLog below). + if (!workflow) { + throw new Error( + `Workflow "${args.workflowId}" is not an MCP-enabled workflow you can access. ` + + 'Use listWorkflows to discover triggerable workflows.', + ); + } + + return withActivityLog({ + forestServerClient, + request: extra, + action: 'triggerWorkflow', + context: { + collectionName: workflow.collectionName ?? undefined, + recordId: args.recordId, + label: `triggered the workflow "${workflow.name}"`, + }, + logger, + operation: async () => { + const result = await forestServerClient.triggerWorkflow({ + forestServerToken, + renderingId, + workflowId: args.workflowId, + recordId: args.recordId, + }); + + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + }, + }); + }, + logger, + ); +} diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index f6a8aa6448..7ac1f5be6e 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -24,6 +24,7 @@ const ACTION_TO_TYPE: Record = { delete: 'write', listRelatedData: 'read', describeCollection: 'read', + triggerWorkflow: 'write', }; export default async function createPendingActivityLog( diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index f726277d42..0b720116ce 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -15,6 +15,7 @@ export default function createMockForestServerClient( }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), listMcpWorkflows: jest.fn().mockResolvedValue([]), + triggerWorkflow: jest.fn().mockResolvedValue({ runId: 1, runState: 'loading' }), ...overrides, } as jest.Mocked; } diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index e39185108f..787d22053b 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -25,6 +25,7 @@ describe('ForestServerClientImpl', () => { }; mockWorkflowsService = { listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), }; client = new ForestServerClientImpl( mockSchemaService, @@ -126,6 +127,25 @@ describe('ForestServerClientImpl', () => { expect(result).toBe(workflows); }); }); + + describe('triggerWorkflow', () => { + it('should delegate to workflowsService.triggerMcpWorkflow()', async () => { + const run = { runId: 7, runState: 'loading' as const }; + mockWorkflowsService.triggerMcpWorkflow.mockResolvedValue(run); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + recordId: '42', + }; + + const result = await client.triggerWorkflow(params); + + expect(mockWorkflowsService.triggerMcpWorkflow).toHaveBeenCalledWith(params); + expect(result).toBe(run); + }); + }); }); describe('createForestServerClient', () => { @@ -158,5 +178,6 @@ describe('createForestServerClient', () => { expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); expect(client.listMcpWorkflows).toBeDefined(); + expect(client.triggerWorkflow).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 5c86e5221a..0a6fc98629 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3286,6 +3286,7 @@ describe('enabledTools', () => { 'getActionForm', 'executeAction', 'listWorkflows', + 'triggerWorkflow', ], }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index a1789ec924..59d6b012dd 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -20,6 +20,7 @@ const mockForestServerClient: ForestServerClient = { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), listMcpWorkflows: jest.fn(), + triggerWorkflow: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index 2d7ce51d6c..d49ab024ed 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -18,6 +18,7 @@ const mockForestServerClient: ForestServerClient = { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), listMcpWorkflows: jest.fn(), + triggerWorkflow: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts new file mode 100644 index 0000000000..5efb32662b --- /dev/null +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -0,0 +1,218 @@ +import type { ForestServerClient } from '../../src/http-client'; +import type { Logger } from '../../src/server'; +import type { RegisteredToolConfig } from '../helpers/registered-tool-config'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { NotFoundError } from '@forestadmin/forestadmin-client'; + +import declareTriggerWorkflowTool from '../../src/tools/trigger-workflow'; +import withActivityLog from '../../src/utils/with-activity-log'; +import createMockForestServerClient from '../helpers/forest-server-client'; + +jest.mock('../../src/utils/with-activity-log'); + +const mockLogger: Logger = jest.fn(); +const mockWithActivityLog = withActivityLog as jest.MockedFunction; + +describe('declareTriggerWorkflowTool', () => { + let mcpServer: McpServer; + let mockForestServerClient: jest.Mocked; + let registeredToolHandler: (args: unknown, extra: unknown) => Promise; + let registeredToolConfig: RegisteredToolConfig; + + beforeEach(() => { + jest.clearAllMocks(); + + mockForestServerClient = createMockForestServerClient(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + // By default, withActivityLog executes the operation and returns its result + mockWithActivityLog.mockImplementation(async options => options.operation()); + }); + + describe('tool registration', () => { + it('should register a tool named "triggerWorkflow"', () => { + declareTriggerWorkflowTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'triggerWorkflow', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareTriggerWorkflowTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.title).toBe('Trigger a workflow'); + expect(registeredToolConfig.description).toContain('getWorkflowRun'); + }); + + it('should not be annotated as read-only', () => { + declareTriggerWorkflowTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); + }); + + it('should require string workflowId and recordId arguments', () => { + declareTriggerWorkflowTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + + expect(() => schema.workflowId.parse('wf-1')).not.toThrow(); + expect(() => schema.workflowId.parse(undefined)).toThrow(); + expect(() => schema.recordId.parse('42')).not.toThrow(); + expect(() => schema.recordId.parse(123)).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: 123, + environmentApiEndpoint: 'https://api.example.com', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareTriggerWorkflowTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + mockForestServerClient.listMcpWorkflows.mockResolvedValue([ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + ]); + mockForestServerClient.triggerWorkflow.mockResolvedValue({ runId: 7, runState: 'loading' }); + }); + + it('should call triggerWorkflow with the identity from the auth context and the args', async () => { + await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(mockForestServerClient.triggerWorkflow).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + workflowId: 'wf-1', + recordId: '42', + }); + }); + + it('should return the runId and runState as JSON text content', async () => { + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ runId: 7, runState: 'loading' }) }], + }); + }); + + it('should wrap the trigger in an activity log carrying the resolved collection and record', async () => { + await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(mockWithActivityLog).toHaveBeenCalledWith({ + forestServerClient: mockForestServerClient, + request: mockExtra, + action: 'triggerWorkflow', + context: { + collectionName: 'orders', + recordId: '42', + label: 'triggered the workflow "Refund order"', + }, + logger: mockLogger, + operation: expect.any(Function), + }); + }); + + it('should error without triggering when the workflow is not among accessible workflows', async () => { + mockForestServerClient.listMcpWorkflows.mockResolvedValue([ + { workflowId: 'other-wf', name: 'Other', collectionName: 'orders' }, + ]); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { type: 'text', text: expect.stringContaining('is not an MCP-enabled workflow') }, + ], + isError: true, + }); + expect(mockForestServerClient.triggerWorkflow).not.toHaveBeenCalled(); + expect(mockWithActivityLog).not.toHaveBeenCalled(); + }); + + it('should return an error result when the auth context is missing the token', async () => { + const extraWithoutToken = { + authInfo: { extra: { renderingId: 123 } }, + } as unknown as RequestHandlerExtra; + + const result = await registeredToolHandler( + { workflowId: 'wf-1', recordId: '42' }, + extraWithoutToken, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], + isError: true, + }); + expect(mockForestServerClient.triggerWorkflow).not.toHaveBeenCalled(); + }); + + it('should map a 409 already-ongoing run to an error tool result', async () => { + mockForestServerClient.triggerWorkflow.mockRejectedValue( + new Error('A run is already ongoing on this record'), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { type: 'text', text: expect.stringContaining('already ongoing on this record') }, + ], + isError: true, + }); + }); + + it('should map a 404 non-mcp-enabled workflow to an error tool result', async () => { + mockForestServerClient.triggerWorkflow.mockRejectedValue( + new NotFoundError('Workflow MCP trigger not found or disabled'), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('not found or disabled') }], + isError: true, + }); + }); + }); +}); From 104b71919828084bab9cf67d0a65e32ab5a244ec Mon Sep 17 00:00:00 2001 From: Christophe Brun Date: Tue, 28 Jul 2026 11:33:59 +0200 Subject: [PATCH 03/43] fix(workflow-executor): accept triggerType='mcp' in run mapper (PRD-832) (#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) --- packages/workflow-executor/src/adapters/server-types.ts | 1 + .../workflow-executor/src/types/validated/execution.ts | 1 + .../test/adapters/run-to-available-step-mapper.test.ts | 8 ++++++++ 3 files changed, 10 insertions(+) diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 7295b73ca3..101fbd67c3 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -191,6 +191,7 @@ export type ServerWorkflowRunState = 'started' | 'pending' | 'loading' | 'aborte export enum ServerWorkflowTriggerType { manual = 'manual', webhook = 'webhook', + mcp = 'mcp', } export interface ServerHydratedWorkflowRun { diff --git a/packages/workflow-executor/src/types/validated/execution.ts b/packages/workflow-executor/src/types/validated/execution.ts index fa4ca7c265..1b80e70963 100644 --- a/packages/workflow-executor/src/types/validated/execution.ts +++ b/packages/workflow-executor/src/types/validated/execution.ts @@ -34,6 +34,7 @@ export type Step = z.infer; export enum TriggerType { Manual = 'manual', Webhook = 'webhook', + Mcp = 'mcp', } export const TriggerTypeSchema = z.nativeEnum(TriggerType); diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 4397c00e91..327f0af681 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -135,6 +135,14 @@ describe('toAvailableStepExecution', () => { expect(result?.triggerType).toBe(TriggerType.Webhook); }); + it('should map an mcp-triggered run without failing validation', () => { + const run = makeRun({ triggerType: ServerWorkflowTriggerType.mcp }); + + const result = toAvailableStepExecution(run); + + expect(result?.triggerType).toBe(TriggerType.Mcp); + }); + it('should default triggerType to manual when the orchestrator omits it', () => { const run = makeRun(); delete run.triggerType; From 71eea4a8d49a4ec6dbe5ec92e80875c743cb462e Mon Sep 17 00:00:00 2001 From: Christophe Brun Date: Wed, 29 Jul 2026 17:24:32 +0200 Subject: [PATCH 04/43] feat(mcp-server): add getWorkflowRun tool (PRD-740) (#1785) * 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) --- .../src/forest-admin-client-mock.ts | 2 + .../test/forest-admin-client-mock.test.ts | 16 ++ .../test/__factories__/forest-admin-client.ts | 1 + packages/forestadmin-client/src/index.ts | 3 + .../src/permissions/forest-http-api.ts | 15 ++ packages/forestadmin-client/src/types.ts | 32 +++ .../forestadmin-client/src/workflows/index.ts | 20 ++ .../forest-admin-server-interface.ts | 1 + .../test/permissions/forest-http-api.test.ts | 48 +++++ .../test/workflows/index.test.ts | 65 ++++++ packages/mcp-server/src/http-client/index.ts | 2 + .../src/http-client/mcp-http-client.ts | 6 + packages/mcp-server/src/http-client/types.ts | 9 + packages/mcp-server/src/server.ts | 7 +- .../mcp-server/src/tools/get-workflow-run.ts | 46 ++++ .../test/helpers/forest-server-client.ts | 5 + .../test/http-client/mcp-http-client.test.ts | 25 +++ packages/mcp-server/test/server.test.ts | 1 + .../test/tools/execute-action.test.ts | 1 + .../test/tools/get-action-form.test.ts | 1 + .../test/tools/get-workflow-run.test.ts | 197 ++++++++++++++++++ 21 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-server/src/tools/get-workflow-run.ts create mode 100644 packages/mcp-server/test/tools/get-workflow-run.test.ts diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 6c2b59ee81..e3a21d1530 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -65,6 +65,8 @@ export default class ForestAdminClientMock implements ForestAdminClient { readonly workflowsService: ForestAdminClient['workflowsService'] = { listMcpEnabledWorkflows: () => Promise.resolve([]), triggerMcpWorkflow: () => Promise.resolve({ runId: 1, runState: 'loading' }), + getMcpWorkflowRun: () => + Promise.resolve({ runState: 'loading', currentStep: null, waitingForHumanInput: false }), }; readonly permissionService: any; diff --git a/packages/agent-testing/test/forest-admin-client-mock.test.ts b/packages/agent-testing/test/forest-admin-client-mock.test.ts index c13e51145f..67512d3522 100644 --- a/packages/agent-testing/test/forest-admin-client-mock.test.ts +++ b/packages/agent-testing/test/forest-admin-client-mock.test.ts @@ -25,5 +25,21 @@ describe('ForestAdminClientMock', () => { }), ).resolves.toEqual({ runId: 1, runState: 'loading' }); }); + + it('should resolve a loading run status when fetching a workflow run', async () => { + const client = new ForestAdminClientMock(); + + await expect( + client.workflowsService.getMcpWorkflowRun({ + forestServerToken: 'token', + renderingId: '1', + runId: '1', + }), + ).resolves.toEqual({ + runState: 'loading', + currentStep: null, + waitingForHumanInput: false, + }); + }); }); }); diff --git a/packages/agent/test/__factories__/forest-admin-client.ts b/packages/agent/test/__factories__/forest-admin-client.ts index 9091df8eb4..da3034f40b 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -57,6 +57,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ workflowsService: { listMcpEnabledWorkflows: jest.fn(), triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }, subscribeToServerEvents: jest.fn(), close: jest.fn(), diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index e0def6434e..bf0ed92d82 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -31,7 +31,10 @@ export { McpWorkflow, ListMcpWorkflowsParams, TriggerMcpWorkflowParams, + GetMcpWorkflowRunParams, WorkflowRunState, + WorkflowRunStep, + WorkflowRunStatus, WorkflowRunTriggerResult, // Service interfaces for MCP ActivityLogsServiceInterface, diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index ace2717e13..0780449ba8 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -10,6 +10,7 @@ import type { ForestSchemaCollection, IpWhitelistRulesResponse, McpWorkflow, + WorkflowRunStatus, WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; @@ -185,4 +186,18 @@ export default class ForestHttpApi implements ForestAdminServerInterface { headers: { 'forest-rendering-id': renderingId, ...options.headers }, }); } + + async getMcpWorkflowRun( + options: ActivityLogHttpOptions, + renderingId: string, + runId: string, + ): Promise { + return ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: `/api/workflow-orchestrator/mcp-workflows/runs/${encodeURIComponent(runId)}`, + bearerToken: options.bearerToken, + headers: { 'forest-rendering-id': renderingId, ...options.headers }, + }); + } } diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 4fd8019d7f..658ca09c2f 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -319,12 +319,39 @@ export interface TriggerMcpWorkflowParams { recordId: string; } +/** + * The step a run is currently on, as derived server-side from the workflow history. + */ +export interface WorkflowRunStep { + name: string; + type: string; +} + +/** + * 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 { + runState: WorkflowRunState; + currentStep: WorkflowRunStep | null; + waitingForHumanInput: boolean; + result?: unknown; + error?: unknown; +} + +export interface GetMcpWorkflowRunParams { + forestServerToken: string; + renderingId: string; + runId: string; +} + /** * Service interface for workflow operations (MCP-related). */ export interface WorkflowsServiceInterface { listMcpEnabledWorkflows: (params: ListMcpWorkflowsParams) => Promise; triggerMcpWorkflow: (params: TriggerMcpWorkflowParams) => Promise; + getMcpWorkflowRun: (params: GetMcpWorkflowRunParams) => Promise; } /** @@ -378,6 +405,11 @@ export interface ForestAdminServerInterface { workflowId: string, recordId: string, ) => Promise; + getMcpWorkflowRun?: ( + options: ActivityLogHttpOptions, + renderingId: string, + runId: string, + ) => Promise; } export type ActivityLogHttpOptions = { diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts index 347f4074a3..332079c6c4 100644 --- a/packages/forestadmin-client/src/workflows/index.ts +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -1,8 +1,10 @@ import type { ForestAdminServerInterface, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, TriggerMcpWorkflowParams, + WorkflowRunStatus, WorkflowRunTriggerResult, } from '../types'; @@ -57,4 +59,22 @@ export default class WorkflowsService { recordId, ); } + + async getMcpWorkflowRun(params: GetMcpWorkflowRunParams): Promise { + const { forestServerToken, renderingId, runId } = params; + + if (!this.forestAdminServerInterface.getMcpWorkflowRun) { + throw new Error('The configured Forest server transport does not support getMcpWorkflowRun.'); + } + + return this.forestAdminServerInterface.getMcpWorkflowRun( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + runId, + ); + } } diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index 6b98f88d89..be174cef86 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -21,6 +21,7 @@ const forestAdminServerInterface = { // Workflow operations listMcpEnabledWorkflows: jest.fn(), triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 92cb65b0f5..8719a24fa2 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -290,4 +290,52 @@ describe('ForestHttpApi', () => { ); }); }); + + describe('getMcpWorkflowRun', () => { + it('should GET the workflow run endpoint with the rendering id header', async () => { + const runStatus = { + runState: 'finished', + currentStep: null, + waitingForHumanInput: false, + result: { ok: true }, + }; + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(runStatus); + + const result = await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + '7', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: '/api/workflow-orchestrator/mcp-workflows/runs/7', + bearerToken: 'bearer-token', + headers: { 'forest-rendering-id': '12345' }, + }); + expect(result).toEqual(runStatus); + }); + + it('should url-encode the run id in the path', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runState: 'started', + currentStep: null, + waitingForHumanInput: false, + }); + + await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'run/with space', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + path: '/api/workflow-orchestrator/mcp-workflows/runs/run%2Fwith%20space', + }), + ); + }); + }); }); diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts index da94880817..f6d6f6f034 100644 --- a/packages/forestadmin-client/test/workflows/index.test.ts +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -156,4 +156,69 @@ describe('WorkflowsService', () => { ).rejects.toThrow('does not support triggerMcpWorkflow'); }); }); + + describe('getMcpWorkflowRun', () => { + const runStatus = { + runState: 'finished' as const, + currentStep: null, + waitingForHumanInput: false, + result: { ok: true }, + }; + + it('should forward the identity and runId to the transport and return the status', async () => { + mockForestAdminServerInterface.getMcpWorkflowRun.mockResolvedValue(runStatus); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + const result = await service.getMcpWorkflowRun({ + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }); + + expect(result).toEqual(runStatus); + expect(mockForestAdminServerInterface.getMcpWorkflowRun).toHaveBeenCalledWith( + { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, + '12345', + '7', + ); + }); + + it('should pass custom headers when provided', async () => { + mockForestAdminServerInterface.getMcpWorkflowRun.mockResolvedValue(runStatus); + + const service = new WorkflowsService(mockForestAdminServerInterface, { + ...options, + headers: { 'Forest-Application-Source': 'MCP' }, + }); + await service.getMcpWorkflowRun({ + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }); + + expect(mockForestAdminServerInterface.getMcpWorkflowRun).toHaveBeenCalledWith( + expect.objectContaining({ + bearerToken: 'test-token', + headers: { 'Forest-Application-Source': 'MCP' }, + }), + '12345', + '7', + ); + }); + + it('should throw when the transport does not implement getMcpWorkflowRun', async () => { + delete (mockForestAdminServerInterface as Partial) + .getMcpWorkflowRun; + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + + await expect( + service.getMcpWorkflowRun({ + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }), + ).rejects.toThrow('does not support getMcpWorkflowRun'); + }); + }); }); diff --git a/packages/mcp-server/src/http-client/index.ts b/packages/mcp-server/src/http-client/index.ts index 678a023e95..af80361070 100644 --- a/packages/mcp-server/src/http-client/index.ts +++ b/packages/mcp-server/src/http-client/index.ts @@ -53,9 +53,11 @@ export type { ActivityLogType, CreateActivityLogParams, ForestServerClient, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, TriggerMcpWorkflowParams, + WorkflowRunStatus, WorkflowRunTriggerResult, UpdateActivityLogStatusParams, ForestSchemaCollection, diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index adc39cde8d..200dfcec8d 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -4,11 +4,13 @@ import type { CreateActivityLogParams, ForestSchemaCollection, ForestServerClient, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, } from './types'; @@ -48,4 +50,8 @@ export default class ForestServerClientImpl implements ForestServerClient { async triggerWorkflow(params: TriggerMcpWorkflowParams): Promise { return this.workflowsService.triggerMcpWorkflow(params); } + + async getWorkflowRun(params: GetMcpWorkflowRunParams): Promise { + return this.workflowsService.getMcpWorkflowRun(params); + } } diff --git a/packages/mcp-server/src/http-client/types.ts b/packages/mcp-server/src/http-client/types.ts index b5e8a40751..8bbaccaa66 100644 --- a/packages/mcp-server/src/http-client/types.ts +++ b/packages/mcp-server/src/http-client/types.ts @@ -7,11 +7,13 @@ import type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, } from '@forestadmin/forestadmin-client'; @@ -26,11 +28,13 @@ export type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, }; @@ -74,4 +78,9 @@ export interface ForestServerClient { * Starts a run of an MCP-enabled workflow on a record and returns its runId (async). */ triggerWorkflow(params: TriggerMcpWorkflowParams): Promise; + + /** + * Reads the normalized status of a workflow run, scoped to the caller. + */ + getWorkflowRun(params: GetMcpWorkflowRunParams): Promise; } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index a510b939fa..bb68a4e78d 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -33,6 +33,7 @@ import declareDescribeCollectionTool from './tools/describe-collection'; import declareDissociateTool from './tools/dissociate'; import declareExecuteActionTool from './tools/execute-action'; import declareGetActionFormTool from './tools/get-action-form'; +import declareGetWorkflowRunTool from './tools/get-workflow-run'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; import declareListWorkflowsTool from './tools/list-workflows'; @@ -95,6 +96,7 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], listWorkflows: ['collectionName'], triggerWorkflow: ['workflowId', 'recordId'], + getWorkflowRun: ['runId'], }; export type ToolName = @@ -109,7 +111,8 @@ export type ToolName = | 'getActionForm' | 'executeAction' | 'listWorkflows' - | 'triggerWorkflow'; + | 'triggerWorkflow' + | 'getWorkflowRun'; /** * Options for configuring the Forest Admin MCP Server @@ -242,6 +245,7 @@ export default class ForestMCPServer { { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, { name: 'listWorkflows', register: () => declareListWorkflowsTool(mcpServer, ctx) }, { name: 'triggerWorkflow', register: () => declareTriggerWorkflowTool(mcpServer, ctx) }, + { name: 'getWorkflowRun', register: () => declareGetWorkflowRunTool(mcpServer, ctx) }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -281,6 +285,7 @@ export default class ForestMCPServer { 'executeAction', 'listWorkflows', 'triggerWorkflow', + 'getWorkflowRun', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); diff --git a/packages/mcp-server/src/tools/get-workflow-run.ts b/packages/mcp-server/src/tools/get-workflow-run.ts new file mode 100644 index 0000000000..fb41fbaaac --- /dev/null +++ b/packages/mcp-server/src/tools/get-workflow-run.ts @@ -0,0 +1,46 @@ +import type { ToolContext } from '../tool-context'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import getAuthContext from '../utils/auth-context'; +import registerToolWithLogging from '../utils/tool-with-logging'; + +const RUN_ID_DESCRIPTION = 'The id of the workflow run to observe, as returned by triggerWorkflow.'; + +interface GetWorkflowRunArgument { + runId: string; +} + +export default function declareGetWorkflowRunTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger } = ctx; + + return registerToolWithLogging( + mcpServer, + 'getWorkflowRun', + { + annotations: { readOnlyHint: true }, + title: 'Get a workflow run status', + description: + 'Poll the status of a workflow run started with triggerWorkflow. Returns runState, the ' + + 'currentStep, waitingForHumanInput, and — once finished — the terminal result or error. ' + + 'A run parked on a human-gated step reports waitingForHumanInput: true; it cannot be ' + + 'resumed via MCP and must be finished from the Forest UI.', + inputSchema: { + runId: z.string().describe(RUN_ID_DESCRIPTION), + }, + }, + async (args: GetWorkflowRunArgument, extra) => { + const { forestServerToken, renderingId } = getAuthContext(extra); + + const runStatus = await forestServerClient.getWorkflowRun({ + forestServerToken, + renderingId, + runId: args.runId, + }); + + return { content: [{ type: 'text', text: JSON.stringify(runStatus) }] }; + }, + logger, + ); +} diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 0b720116ce..472fda506c 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -16,6 +16,11 @@ export default function createMockForestServerClient( updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), listMcpWorkflows: jest.fn().mockResolvedValue([]), triggerWorkflow: jest.fn().mockResolvedValue({ runId: 1, runState: 'loading' }), + getWorkflowRun: jest.fn().mockResolvedValue({ + runState: 'started', + currentStep: null, + waitingForHumanInput: false, + }), ...overrides, } as jest.Mocked; } diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index 787d22053b..f2243ef749 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -26,6 +26,7 @@ describe('ForestServerClientImpl', () => { mockWorkflowsService = { listMcpEnabledWorkflows: jest.fn(), triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }; client = new ForestServerClientImpl( mockSchemaService, @@ -146,6 +147,29 @@ describe('ForestServerClientImpl', () => { expect(result).toBe(run); }); }); + + describe('getWorkflowRun', () => { + it('should delegate to workflowsService.getMcpWorkflowRun()', async () => { + const runStatus = { + runState: 'finished' as const, + currentStep: null, + waitingForHumanInput: false, + result: { ok: true }, + }; + mockWorkflowsService.getMcpWorkflowRun.mockResolvedValue(runStatus); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }; + + const result = await client.getWorkflowRun(params); + + expect(mockWorkflowsService.getMcpWorkflowRun).toHaveBeenCalledWith(params); + expect(result).toBe(runStatus); + }); + }); }); describe('createForestServerClient', () => { @@ -179,5 +203,6 @@ describe('createForestServerClient', () => { expect(client.updateActivityLogStatus).toBeDefined(); expect(client.listMcpWorkflows).toBeDefined(); expect(client.triggerWorkflow).toBeDefined(); + expect(client.getWorkflowRun).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 0a6fc98629..ef5abe00a1 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3287,6 +3287,7 @@ describe('enabledTools', () => { 'executeAction', 'listWorkflows', 'triggerWorkflow', + 'getWorkflowRun', ], }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 59d6b012dd..d73e5abfc9 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -21,6 +21,7 @@ const mockForestServerClient: ForestServerClient = { updateActivityLogStatus: jest.fn(), listMcpWorkflows: jest.fn(), triggerWorkflow: jest.fn(), + getWorkflowRun: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index d49ab024ed..0d85c9cf65 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -19,6 +19,7 @@ const mockForestServerClient: ForestServerClient = { updateActivityLogStatus: jest.fn(), listMcpWorkflows: jest.fn(), triggerWorkflow: jest.fn(), + getWorkflowRun: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-workflow-run.test.ts b/packages/mcp-server/test/tools/get-workflow-run.test.ts new file mode 100644 index 0000000000..10a95302ce --- /dev/null +++ b/packages/mcp-server/test/tools/get-workflow-run.test.ts @@ -0,0 +1,197 @@ +import type { ForestServerClient } from '../../src/http-client'; +import type { Logger } from '../../src/server'; +import type { RegisteredToolConfig } from '../helpers/registered-tool-config'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { ForbiddenError, NotFoundError } from '@forestadmin/forestadmin-client'; + +import declareGetWorkflowRunTool from '../../src/tools/get-workflow-run'; +import createMockForestServerClient from '../helpers/forest-server-client'; + +const mockLogger: Logger = jest.fn(); + +describe('declareGetWorkflowRunTool', () => { + let mcpServer: McpServer; + let mockForestServerClient: jest.Mocked; + let registeredToolHandler: (args: unknown, extra: unknown) => Promise; + let registeredToolConfig: RegisteredToolConfig; + + beforeEach(() => { + jest.clearAllMocks(); + + mockForestServerClient = createMockForestServerClient(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + }); + + describe('tool registration', () => { + it('should register a tool named "getWorkflowRun"', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'getWorkflowRun', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.title).toBe('Get a workflow run status'); + expect(registeredToolConfig.description).toContain('waitingForHumanInput'); + }); + + it('should be annotated as read-only', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); + }); + + it('should require a string runId argument', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + + expect(() => schema.runId.parse('7')).not.toThrow(); + expect(() => schema.runId.parse(undefined)).toThrow(); + expect(() => schema.runId.parse(7)).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: 123, + environmentApiEndpoint: 'https://api.example.com', + }, + }, + } as unknown as RequestHandlerExtra; + + const runStatus = { + runState: 'finished' as const, + currentStep: null, + waitingForHumanInput: false, + result: { refunded: true }, + }; + + beforeEach(() => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + mockForestServerClient.getWorkflowRun.mockResolvedValue(runStatus); + }); + + it('should call getWorkflowRun with the identity from the auth context and the runId', async () => { + await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(mockForestServerClient.getWorkflowRun).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + runId: '7', + }); + }); + + it('should return the run status as JSON text content', async () => { + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(runStatus) }], + }); + }); + + it('should report a human-gated run as waitingForHumanInput', async () => { + mockForestServerClient.getWorkflowRun.mockResolvedValue({ + runState: 'started', + currentStep: { name: 'Manager approval', type: 'human' }, + waitingForHumanInput: true, + }); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify({ + runState: 'started', + currentStep: { name: 'Manager approval', type: 'human' }, + waitingForHumanInput: true, + }), + }, + ], + }); + }); + + it('should return an error result when the auth context is missing the token', async () => { + const extraWithoutToken = { + authInfo: { extra: { renderingId: 123 } }, + } as unknown as RequestHandlerExtra; + + const result = await registeredToolHandler({ runId: '7' }, extraWithoutToken); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], + isError: true, + }); + expect(mockForestServerClient.getWorkflowRun).not.toHaveBeenCalled(); + }); + + it('should map an unknown runId 404 to an error tool result', async () => { + mockForestServerClient.getWorkflowRun.mockRejectedValue( + new NotFoundError('Workflow run not found'), + ); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('not found') }], + isError: true, + }); + }); + + it('should map a forbidden runId 403 to an error tool result', async () => { + mockForestServerClient.getWorkflowRun.mockRejectedValue( + new ForbiddenError('You are not allowed to access this workflow run'), + ); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('not allowed') }], + isError: true, + }); + }); + }); +}); From 1f4b2c3140a0cbb44e65ac489f843ef46397b6a0 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 5 Aug 2026 16:46:48 +0200 Subject: [PATCH 05/43] fix(forestadmin-client): normalize workflow run id to a string 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 --- .../src/forest-admin-client-mock.ts | 2 +- .../test/forest-admin-client-mock.test.ts | 2 +- .../src/permissions/forest-http-api.ts | 9 ++++++- packages/forestadmin-client/src/types.ts | 3 ++- .../test/permissions/forest-http-api.test.ts | 24 ++++++++++++++++--- .../test/workflows/index.test.ts | 6 ++--- .../test/helpers/forest-server-client.ts | 2 +- .../test/http-client/mcp-http-client.test.ts | 2 +- .../test/tools/trigger-workflow.test.ts | 4 ++-- 9 files changed, 40 insertions(+), 14 deletions(-) diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index e3a21d1530..7d542930df 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -64,7 +64,7 @@ export default class ForestAdminClientMock implements ForestAdminClient { readonly workflowsService: ForestAdminClient['workflowsService'] = { listMcpEnabledWorkflows: () => Promise.resolve([]), - triggerMcpWorkflow: () => Promise.resolve({ runId: 1, runState: 'loading' }), + triggerMcpWorkflow: () => Promise.resolve({ runId: '1', runState: 'loading' }), getMcpWorkflowRun: () => Promise.resolve({ runState: 'loading', currentStep: null, waitingForHumanInput: false }), }; diff --git a/packages/agent-testing/test/forest-admin-client-mock.test.ts b/packages/agent-testing/test/forest-admin-client-mock.test.ts index 67512d3522..13f8cd0163 100644 --- a/packages/agent-testing/test/forest-admin-client-mock.test.ts +++ b/packages/agent-testing/test/forest-admin-client-mock.test.ts @@ -23,7 +23,7 @@ describe('ForestAdminClientMock', () => { workflowId: 'wf-1', recordId: '42', }), - ).resolves.toEqual({ runId: 1, runState: 'loading' }); + ).resolves.toEqual({ runId: '1', runState: 'loading' }); }); it('should resolve a loading run status when fetching a workflow run', async () => { diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 0780449ba8..c97cc9f131 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -177,7 +177,12 @@ export default class ForestHttpApi implements ForestAdminServerInterface { workflowId: string, recordId: string, ): Promise { - return ServerUtils.queryWithBearerToken({ + // The orchestrator returns a numeric runId; normalize it to the string form + // expected by getMcpWorkflowRun. + const result = await ServerUtils.queryWithBearerToken<{ + runId: number | string; + runState: WorkflowRunTriggerResult['runState']; + }>({ forestServerUrl: options.forestServerUrl, method: 'post', path: `/api/workflow-orchestrator/mcp-workflows/${encodeURIComponent(workflowId)}/start`, @@ -185,6 +190,8 @@ export default class ForestHttpApi implements ForestAdminServerInterface { body: { recordId }, headers: { 'forest-rendering-id': renderingId, ...options.headers }, }); + + return { runId: String(result.runId), runState: result.runState }; } async getMcpWorkflowRun( diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 658ca09c2f..560c27b892 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -306,9 +306,10 @@ export type WorkflowRunState = 'started' | 'pending' | 'loading' | 'aborted' | ' /** * 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. */ export interface WorkflowRunTriggerResult { - runId: number; + runId: string; runState: WorkflowRunState; } diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 8719a24fa2..ad4b41fdba 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -248,8 +248,10 @@ describe('ForestHttpApi', () => { describe('triggerMcpWorkflow', () => { it('should POST the record id to the workflow start endpoint with the rendering id header', async () => { - const run = { runId: 7, runState: 'loading' }; - (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(run); + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runId: 7, + runState: 'loading', + }); const result = await new ForestHttpApi().triggerMcpWorkflow( { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, @@ -266,7 +268,23 @@ describe('ForestHttpApi', () => { body: { recordId: '42' }, headers: { 'forest-rendering-id': '12345' }, }); - expect(result).toEqual(run); + expect(result).toEqual({ runId: '7', runState: 'loading' }); + }); + + it('should normalize a numeric runId returned by the server to a string', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runId: 7, + runState: 'loading', + }); + + const result = await new ForestHttpApi().triggerMcpWorkflow( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'wf-1', + '42', + ); + + expect(result.runId).toBe('7'); }); it('should url-encode the workflow id in the path', async () => { diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts index f6d6f6f034..64bb9a115e 100644 --- a/packages/forestadmin-client/test/workflows/index.test.ts +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -91,7 +91,7 @@ describe('WorkflowsService', () => { describe('triggerMcpWorkflow', () => { it('should forward the identity, workflowId and recordId to the transport and return the run', async () => { mockForestAdminServerInterface.triggerMcpWorkflow.mockResolvedValue({ - runId: 7, + runId: '7', runState: 'loading', }); @@ -103,7 +103,7 @@ describe('WorkflowsService', () => { recordId: '42', }); - expect(result).toEqual({ runId: 7, runState: 'loading' }); + expect(result).toEqual({ runId: '7', runState: 'loading' }); expect(mockForestAdminServerInterface.triggerMcpWorkflow).toHaveBeenCalledWith( { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, '12345', @@ -114,7 +114,7 @@ describe('WorkflowsService', () => { it('should pass custom headers when provided', async () => { mockForestAdminServerInterface.triggerMcpWorkflow.mockResolvedValue({ - runId: 7, + runId: '7', runState: 'loading', }); diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 472fda506c..3eec70bf95 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -15,7 +15,7 @@ export default function createMockForestServerClient( }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), listMcpWorkflows: jest.fn().mockResolvedValue([]), - triggerWorkflow: jest.fn().mockResolvedValue({ runId: 1, runState: 'loading' }), + triggerWorkflow: jest.fn().mockResolvedValue({ runId: '1', runState: 'loading' }), getWorkflowRun: jest.fn().mockResolvedValue({ runState: 'started', currentStep: null, diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index f2243ef749..d5680e0215 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -131,7 +131,7 @@ describe('ForestServerClientImpl', () => { describe('triggerWorkflow', () => { it('should delegate to workflowsService.triggerMcpWorkflow()', async () => { - const run = { runId: 7, runState: 'loading' as const }; + const run = { runId: '7', runState: 'loading' as const }; mockWorkflowsService.triggerMcpWorkflow.mockResolvedValue(run); const params = { diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 5efb32662b..e254eadb75 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -114,7 +114,7 @@ describe('declareTriggerWorkflowTool', () => { mockForestServerClient.listMcpWorkflows.mockResolvedValue([ { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, ]); - mockForestServerClient.triggerWorkflow.mockResolvedValue({ runId: 7, runState: 'loading' }); + mockForestServerClient.triggerWorkflow.mockResolvedValue({ runId: '7', runState: 'loading' }); }); it('should call triggerWorkflow with the identity from the auth context and the args', async () => { @@ -132,7 +132,7 @@ describe('declareTriggerWorkflowTool', () => { const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(result).toEqual({ - content: [{ type: 'text', text: JSON.stringify({ runId: 7, runState: 'loading' }) }], + content: [{ type: 'text', text: JSON.stringify({ runId: '7', runState: 'loading' }) }], }); }); From a4d39492348895f19a2ffd3b4b10aae144566557 Mon Sep 17 00:00:00 2001 From: Christophe Brun Date: Thu, 6 Aug 2026 10:54:12 +0200 Subject: [PATCH 06/43] perf(mcp-server): trigger workflow by id instead of listing all workflows (PRD-831) (#1805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/permissions/forest-http-api.ts | 14 +-- packages/forestadmin-client/src/types.ts | 4 + .../mcp-server/src/tools/trigger-workflow.ts | 94 +++++++++++-------- .../test/tools/trigger-workflow.test.ts | 89 ++++++++++-------- 4 files changed, 118 insertions(+), 83 deletions(-) diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index c97cc9f131..379df4aac4 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -177,12 +177,12 @@ export default class ForestHttpApi implements ForestAdminServerInterface { workflowId: string, recordId: string, ): Promise { - // The orchestrator returns a numeric runId; normalize it to the string form - // expected by getMcpWorkflowRun. - const result = await ServerUtils.queryWithBearerToken<{ - runId: number | string; - runState: WorkflowRunTriggerResult['runState']; - }>({ + // The orchestrator returns a numeric runId; normalize it to the string form expected by + // getMcpWorkflowRun. workflowName/collectionName are passed through when present so the + // caller can label the audit log without listing every workflow first. + const result = await ServerUtils.queryWithBearerToken< + Omit & { runId: number | string } + >({ forestServerUrl: options.forestServerUrl, method: 'post', path: `/api/workflow-orchestrator/mcp-workflows/${encodeURIComponent(workflowId)}/start`, @@ -191,7 +191,7 @@ export default class ForestHttpApi implements ForestAdminServerInterface { headers: { 'forest-rendering-id': renderingId, ...options.headers }, }); - return { runId: String(result.runId), runState: result.runState }; + return { ...result, runId: String(result.runId) }; } async getMcpWorkflowRun( diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 560c27b892..53965363f2 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -307,10 +307,14 @@ export type WorkflowRunState = 'started' | 'pending' | 'loading' | 'aborted' | ' /** * 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 echoed by the start endpoint to build the audit label + * without a second round-trip; they are optional so older servers degrade gracefully. */ export interface WorkflowRunTriggerResult { runId: string; runState: WorkflowRunState; + workflowName?: string; + collectionName?: string | null; } export interface TriggerMcpWorkflowParams { diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index ded9736ff6..b3ba206637 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -1,11 +1,15 @@ +import type { WorkflowRunTriggerResult } from '../http-client'; import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { NotFoundError } from '@forestadmin/forestadmin-client'; import { z } from 'zod'; +import createPendingActivityLog, { + markActivityLogAsSucceeded, +} from '../utils/activity-logs-creator'; import getAuthContext from '../utils/auth-context'; import registerToolWithLogging from '../utils/tool-with-logging'; -import withActivityLog from '../utils/with-activity-log'; const WORKFLOW_ID_DESCRIPTION = 'The id of the workflow to start, as returned by listWorkflows. The workflow must have the MCP ' + @@ -41,48 +45,62 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To async (args: TriggerWorkflowArgument, extra) => { const { forestServerToken, renderingId } = getAuthContext(extra); - // We list workflows first to resolve the name/collection needed for the activity-log label - // (the trigger endpoint returns neither). The server also validates access at trigger time, - // so this lookup is primarily for enrichment; targeting the workflow by id directly would - // save a round-trip — tracked in PRD-831. - const workflows = await forestServerClient.listMcpWorkflows({ - forestServerToken, - renderingId, - }); - const workflow = workflows.find(candidate => candidate.workflowId === args.workflowId); + let result: WorkflowRunTriggerResult; - // Rejected before withActivityLog: with no resolved workflow there is no collection to - // attach, and the server drops MCP activity logs that carry no resource (see PRD-49), so a - // pre-trigger rejection cannot be audited. Only real triggers are logged (incl. server-side - // 403/409, which fail inside withActivityLog below). - if (!workflow) { - throw new Error( - `Workflow "${args.workflowId}" is not an MCP-enabled workflow you can access. ` + - 'Use listWorkflows to discover triggerable workflows.', - ); + try { + result = await forestServerClient.triggerWorkflow({ + forestServerToken, + renderingId, + workflowId: args.workflowId, + recordId: args.recordId, + }); + } catch (error) { + // The server answers 404 both for an unknown workflow and for one whose MCP trigger is + // disabled (indistinguishable on purpose). Surface the same guidance the tool gave when + // it validated the id client-side, so the LLM-facing contract stays identical. + if (error instanceof NotFoundError) { + throw new Error( + `Workflow "${args.workflowId}" is not an MCP-enabled workflow you can access. ` + + 'Use listWorkflows to discover triggerable workflows.', + ); + } + + throw error; } - return withActivityLog({ - forestServerClient, - request: extra, - action: 'triggerWorkflow', - context: { - collectionName: workflow.collectionName ?? undefined, - recordId: args.recordId, - label: `triggered the workflow "${workflow.name}"`, - }, - logger, - operation: async () => { - const result = await forestServerClient.triggerWorkflow({ - forestServerToken, - renderingId, - workflowId: args.workflowId, + // Audit the successful trigger. The start endpoint echoes the workflow name/collection so we + // can label the log without a prior listing; fall back to the id when an older server omits + // them. The run is already started, so a logging hiccup must not fail the tool. + try { + const activityLog = await createPendingActivityLog( + forestServerClient, + extra, + 'triggerWorkflow', + { + collectionName: result.collectionName ?? undefined, recordId: args.recordId, - }); + label: `triggered the workflow "${result.workflowName ?? args.workflowId}"`, + }, + ); + + markActivityLogAsSucceeded({ forestServerClient, request: extra, activityLog, logger }); + } catch (error) { + logger( + 'Warn', + `Failed to record triggerWorkflow activity log: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } - return { content: [{ type: 'text', text: JSON.stringify(result) }] }; - }, - }); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ runId: result.runId, runState: result.runState }), + }, + ], + }; }, logger, ); diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index e254eadb75..2accd00dec 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -8,13 +8,9 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { NotFoundError } from '@forestadmin/forestadmin-client'; import declareTriggerWorkflowTool from '../../src/tools/trigger-workflow'; -import withActivityLog from '../../src/utils/with-activity-log'; import createMockForestServerClient from '../helpers/forest-server-client'; -jest.mock('../../src/utils/with-activity-log'); - const mockLogger: Logger = jest.fn(); -const mockWithActivityLog = withActivityLog as jest.MockedFunction; describe('declareTriggerWorkflowTool', () => { let mcpServer: McpServer; @@ -33,9 +29,6 @@ describe('declareTriggerWorkflowTool', () => { registeredToolHandler = handler; }), } as unknown as McpServer; - - // By default, withActivityLog executes the operation and returns its result - mockWithActivityLog.mockImplementation(async options => options.operation()); }); describe('tool registration', () => { @@ -111,13 +104,15 @@ describe('declareTriggerWorkflowTool', () => { logger: mockLogger, collectionNames: [], }); - mockForestServerClient.listMcpWorkflows.mockResolvedValue([ - { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, - ]); - mockForestServerClient.triggerWorkflow.mockResolvedValue({ runId: '7', runState: 'loading' }); + mockForestServerClient.triggerWorkflow.mockResolvedValue({ + runId: '7', + runState: 'loading', + workflowName: 'Refund order', + collectionName: 'orders', + }); }); - it('should call triggerWorkflow with the identity from the auth context and the args', async () => { + it('should start the workflow directly with the identity from the auth context and the args', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(mockForestServerClient.triggerWorkflow).toHaveBeenCalledWith({ @@ -128,7 +123,13 @@ describe('declareTriggerWorkflowTool', () => { }); }); - it('should return the runId and runState as JSON text content', async () => { + it('should not list workflows before triggering', async () => { + await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(mockForestServerClient.listMcpWorkflows).not.toHaveBeenCalled(); + }); + + it('should return only the runId and runState as JSON text content', async () => { const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(result).toEqual({ @@ -136,38 +137,46 @@ describe('declareTriggerWorkflowTool', () => { }); }); - it('should wrap the trigger in an activity log carrying the resolved collection and record', async () => { + it('should record an activity log labelled from the response name and collection', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); - expect(mockWithActivityLog).toHaveBeenCalledWith({ - forestServerClient: mockForestServerClient, - request: mockExtra, - action: 'triggerWorkflow', - context: { + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'triggerWorkflow', + type: 'write', collectionName: 'orders', recordId: '42', label: 'triggered the workflow "Refund order"', - }, - logger: mockLogger, - operation: expect.any(Function), + }), + ); + }); + + it('should fall back to the workflowId in the label when the response omits name/collection', async () => { + mockForestServerClient.triggerWorkflow.mockResolvedValue({ + runId: '7', + runState: 'loading', }); + + await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ + collectionName: undefined, + recordId: '42', + label: 'triggered the workflow "wf-1"', + }), + ); }); - it('should error without triggering when the workflow is not among accessible workflows', async () => { - mockForestServerClient.listMcpWorkflows.mockResolvedValue([ - { workflowId: 'other-wf', name: 'Other', collectionName: 'orders' }, - ]); + it('should still return the run when recording the activity log fails', async () => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue(new Error('audit down')); const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(result).toEqual({ - content: [ - { type: 'text', text: expect.stringContaining('is not an MCP-enabled workflow') }, - ], - isError: true, + content: [{ type: 'text', text: JSON.stringify({ runId: '7', runState: 'loading' }) }], }); - expect(mockForestServerClient.triggerWorkflow).not.toHaveBeenCalled(); - expect(mockWithActivityLog).not.toHaveBeenCalled(); + expect(mockLogger).toHaveBeenCalledWith('Warn', expect.stringContaining('audit down')); }); it('should return an error result when the auth context is missing the token', async () => { @@ -187,32 +196,36 @@ describe('declareTriggerWorkflowTool', () => { expect(mockForestServerClient.triggerWorkflow).not.toHaveBeenCalled(); }); - it('should map a 409 already-ongoing run to an error tool result', async () => { + it('should map a server 404 to the "is not an MCP-enabled workflow" tool error', async () => { mockForestServerClient.triggerWorkflow.mockRejectedValue( - new Error('A run is already ongoing on this record'), + new NotFoundError('Workflow MCP trigger not found or disabled'), ); const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(result).toEqual({ content: [ - { type: 'text', text: expect.stringContaining('already ongoing on this record') }, + { type: 'text', text: expect.stringContaining('is not an MCP-enabled workflow') }, ], isError: true, }); + expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); }); - it('should map a 404 non-mcp-enabled workflow to an error tool result', async () => { + it('should pass a 409 already-ongoing run through as an error tool result', async () => { mockForestServerClient.triggerWorkflow.mockRejectedValue( - new NotFoundError('Workflow MCP trigger not found or disabled'), + new Error('A run is already ongoing on this record'), ); const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(result).toEqual({ - content: [{ type: 'text', text: expect.stringContaining('not found or disabled') }], + content: [ + { type: 'text', text: expect.stringContaining('already ongoing on this record') }, + ], isError: true, }); + expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); }); }); }); From de2872b61eb6763faffbb419a1abac7fda5fe2c7 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 10 Aug 2026 18:13:55 +0200 Subject: [PATCH 07/43] feat(mcp-server): expose the full hydrated workflow run in getWorkflowRun (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) --- .../src/forest-admin-client-mock.ts | 17 ++- .../test/forest-admin-client-mock.test.ts | 16 +-- packages/forestadmin-client/src/index.ts | 11 +- .../src/permissions/forest-http-api.ts | 6 +- packages/forestadmin-client/src/types.ts | 100 +++++++++++++++--- .../forestadmin-client/src/workflows/index.ts | 4 +- .../test/permissions/forest-http-api.test.ts | 19 +++- .../test/workflows/index.test.ts | 16 ++- packages/mcp-server/src/http-client/index.ts | 2 +- .../src/http-client/mcp-http-client.ts | 8 +- packages/mcp-server/src/http-client/types.ts | 12 +-- .../mcp-server/src/tools/get-workflow-run.ts | 13 ++- .../mcp-server/src/tools/list-workflows.ts | 2 +- .../mcp-server/src/tools/trigger-workflow.ts | 2 +- .../test/helpers/forest-server-client.ts | 6 +- .../test/http-client/mcp-http-client.test.ts | 40 ++++--- .../test/tools/execute-action.test.ts | 6 +- .../test/tools/get-action-form.test.ts | 6 +- .../test/tools/get-workflow-run.test.ts | 90 +++++++++++----- .../test/tools/list-workflows.test.ts | 14 +-- .../test/tools/trigger-workflow.test.ts | 14 +-- 21 files changed, 285 insertions(+), 119 deletions(-) diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 7d542930df..025e8d2080 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -66,7 +66,22 @@ export default class ForestAdminClientMock implements ForestAdminClient { listMcpEnabledWorkflows: () => Promise.resolve([]), triggerMcpWorkflow: () => Promise.resolve({ runId: '1', runState: 'loading' }), getMcpWorkflowRun: () => - Promise.resolve({ runState: 'loading', currentStep: null, waitingForHumanInput: false }), + Promise.resolve({ + id: 1, + userId: 1, + renderingId: 1, + collectionId: 'orders', + workflowId: 'wf-1', + bpmnVersion: '1', + selectedRecordId: '1', + runState: 'loading', + engine: 'orchestrator', + triggerType: 'mcp', + lockedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + workflowHistory: [], + }), }; readonly permissionService: any; diff --git a/packages/agent-testing/test/forest-admin-client-mock.test.ts b/packages/agent-testing/test/forest-admin-client-mock.test.ts index 13f8cd0163..41c6f51170 100644 --- a/packages/agent-testing/test/forest-admin-client-mock.test.ts +++ b/packages/agent-testing/test/forest-admin-client-mock.test.ts @@ -26,7 +26,7 @@ describe('ForestAdminClientMock', () => { ).resolves.toEqual({ runId: '1', runState: 'loading' }); }); - it('should resolve a loading run status when fetching a workflow run', async () => { + it('should resolve a loading hydrated run when fetching a workflow run', async () => { const client = new ForestAdminClientMock(); await expect( @@ -35,11 +35,15 @@ describe('ForestAdminClientMock', () => { renderingId: '1', runId: '1', }), - ).resolves.toEqual({ - runState: 'loading', - currentStep: null, - waitingForHumanInput: false, - }); + ).resolves.toEqual( + expect.objectContaining({ + id: 1, + runState: 'loading', + engine: 'orchestrator', + triggerType: 'mcp', + workflowHistory: [], + }), + ); }); }); }); diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index bf0ed92d82..039471ef0e 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -33,8 +33,15 @@ export { TriggerMcpWorkflowParams, GetMcpWorkflowRunParams, WorkflowRunState, - WorkflowRunStep, - WorkflowRunStatus, + WorkflowRunEngine, + WorkflowRunTriggerType, + WorkflowStepType, + WorkflowTaskType, + WorkflowStepOutgoing, + WorkflowStepDefinition, + WorkflowHistoryStepContext, + WorkflowHistoryStep, + HydratedWorkflowRun, WorkflowRunTriggerResult, // Service interfaces for MCP ActivityLogsServiceInterface, diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 379df4aac4..d8d5817c31 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -8,9 +8,9 @@ import type { ForestAdminClientOptions, ForestAdminServerInterface, ForestSchemaCollection, + HydratedWorkflowRun, IpWhitelistRulesResponse, McpWorkflow, - WorkflowRunStatus, WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; @@ -198,8 +198,8 @@ export default class ForestHttpApi implements ForestAdminServerInterface { options: ActivityLogHttpOptions, renderingId: string, runId: string, - ): Promise { - return ServerUtils.queryWithBearerToken({ + ): Promise { + return ServerUtils.queryWithBearerToken({ forestServerUrl: options.forestServerUrl, method: 'get', path: `/api/workflow-orchestrator/mcp-workflows/runs/${encodeURIComponent(runId)}`, diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 53965363f2..da6d2acc2f 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -324,24 +324,98 @@ export interface TriggerMcpWorkflowParams { recordId: string; } +export type WorkflowRunEngine = 'orchestrator' | 'browser'; + +export type WorkflowRunTriggerType = 'manual' | 'webhook' | 'mcp'; + +export type WorkflowStepType = + | 'task' + | 'condition' + | 'end' + | 'escalation' + | 'start-sub-workflow' + | 'close-sub-workflow'; + +export type WorkflowTaskType = + | 'guideline' + | 'trigger-action' + | 'get-data' + | 'update-data' + | 'load-related-record' + | 'mcp-server'; + +export interface WorkflowStepOutgoing { + stepId: string; + buttonText: string | null; + buttonColor?: string | null; + answer?: string; +} + /** - * The step a run is currently on, as derived server-side from the workflow history. + * The resolved BPMN definition of a step. Common fields are typed; task-type-specific + * fields (completionType, inputType, preRecordedArgs, mcpServerId, ...) vary by `taskType` + * and are left open via the index signature. */ -export interface WorkflowRunStep { - name: string; - type: string; +export interface WorkflowStepDefinition { + type: WorkflowStepType; + title: string; + prompt?: string; + executionType: 'manual' | 'automated-with-confirmation' | 'fully-automated'; + automaticCompletion: boolean; + outgoing: WorkflowStepOutgoing[]; + taskType?: WorkflowTaskType; + [key: string]: unknown; +} + +export interface WorkflowHistoryStepContext { + manuallyCompleted?: true; + completedBy?: number; + subWorkflowVersion?: string; + selectedOption?: string; + error?: string; + childrenWorkflowId?: string; + escalationState?: 'escalated' | 'error'; + awaitingInputReason?: 'needs-oauth-reauth'; } /** - * 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. + * One entry in a run's history: the step and its resolved definition plus per-step context. */ -export interface WorkflowRunStatus { +export interface WorkflowHistoryStep { + stepName: string; + stepIndex: number; + originalStepIndex?: number; + done: boolean; + revised?: boolean; + cancelled?: boolean; + childrenWorkflowId?: string; + isCardStep: boolean; + context?: WorkflowHistoryStepContext; + stepDefinition: WorkflowStepDefinition; +} + +/** + * The full hydrated workflow run as returned by the orchestrator for MCP consumption. + * 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. + */ +export interface HydratedWorkflowRun { + id: number; + userId: number; + renderingId: number; + collectionId: string; + workflowId: string; + bpmnVersion: string; + selectedRecordId: string; runState: WorkflowRunState; - currentStep: WorkflowRunStep | null; - waitingForHumanInput: boolean; - result?: unknown; - error?: unknown; + engine: WorkflowRunEngine; + triggerType: WorkflowRunTriggerType; + lockedAt: string | null; + createdAt: string; + updatedAt: string; + workflowHistory: WorkflowHistoryStep[]; } export interface GetMcpWorkflowRunParams { @@ -356,7 +430,7 @@ export interface GetMcpWorkflowRunParams { export interface WorkflowsServiceInterface { listMcpEnabledWorkflows: (params: ListMcpWorkflowsParams) => Promise; triggerMcpWorkflow: (params: TriggerMcpWorkflowParams) => Promise; - getMcpWorkflowRun: (params: GetMcpWorkflowRunParams) => Promise; + getMcpWorkflowRun: (params: GetMcpWorkflowRunParams) => Promise; } /** @@ -414,7 +488,7 @@ export interface ForestAdminServerInterface { options: ActivityLogHttpOptions, renderingId: string, runId: string, - ) => Promise; + ) => Promise; } export type ActivityLogHttpOptions = { diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts index 332079c6c4..718a041f07 100644 --- a/packages/forestadmin-client/src/workflows/index.ts +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -1,10 +1,10 @@ import type { ForestAdminServerInterface, GetMcpWorkflowRunParams, + HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, TriggerMcpWorkflowParams, - WorkflowRunStatus, WorkflowRunTriggerResult, } from '../types'; @@ -60,7 +60,7 @@ export default class WorkflowsService { ); } - async getMcpWorkflowRun(params: GetMcpWorkflowRunParams): Promise { + async getMcpWorkflowRun(params: GetMcpWorkflowRunParams): Promise { const { forestServerToken, renderingId, runId } = params; if (!this.forestAdminServerInterface.getMcpWorkflowRun) { diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index ad4b41fdba..4144a8eb67 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -312,10 +312,20 @@ describe('ForestHttpApi', () => { describe('getMcpWorkflowRun', () => { it('should GET the workflow run endpoint with the rendering id header', async () => { const runStatus = { + id: 7, + userId: 42, + renderingId: 12345, + collectionId: 'orders', + workflowId: 'wf-1', + bpmnVersion: '3', + selectedRecordId: '99', runState: 'finished', - currentStep: null, - waitingForHumanInput: false, - result: { ok: true }, + engine: 'orchestrator', + triggerType: 'mcp', + lockedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:05:00.000Z', + workflowHistory: [], }; (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(runStatus); @@ -338,8 +348,7 @@ describe('ForestHttpApi', () => { it('should url-encode the run id in the path', async () => { (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ runState: 'started', - currentStep: null, - waitingForHumanInput: false, + workflowHistory: [], }); await new ForestHttpApi().getMcpWorkflowRun( diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts index 64bb9a115e..360bd34e84 100644 --- a/packages/forestadmin-client/test/workflows/index.test.ts +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -159,10 +159,20 @@ describe('WorkflowsService', () => { describe('getMcpWorkflowRun', () => { const runStatus = { + id: 7, + userId: 42, + renderingId: 12345, + collectionId: 'orders', + workflowId: 'wf-1', + bpmnVersion: '3', + selectedRecordId: '99', runState: 'finished' as const, - currentStep: null, - waitingForHumanInput: false, - result: { ok: true }, + engine: 'orchestrator' as const, + triggerType: 'mcp' as const, + lockedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:05:00.000Z', + workflowHistory: [], }; it('should forward the identity and runId to the transport and return the status', async () => { diff --git a/packages/mcp-server/src/http-client/index.ts b/packages/mcp-server/src/http-client/index.ts index af80361070..da9ac51941 100644 --- a/packages/mcp-server/src/http-client/index.ts +++ b/packages/mcp-server/src/http-client/index.ts @@ -57,7 +57,7 @@ export type { ListMcpWorkflowsParams, McpWorkflow, TriggerMcpWorkflowParams, - WorkflowRunStatus, + HydratedWorkflowRun, WorkflowRunTriggerResult, UpdateActivityLogStatusParams, ForestSchemaCollection, diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index 200dfcec8d..8757fb8f07 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -5,12 +5,12 @@ import type { ForestSchemaCollection, ForestServerClient, GetMcpWorkflowRunParams, + HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, - WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, } from './types'; @@ -43,15 +43,15 @@ export default class ForestServerClientImpl implements ForestServerClient { return this.activityLogsService.updateActivityLogStatus(params); } - async listMcpWorkflows(params: ListMcpWorkflowsParams): Promise { + async listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise { return this.workflowsService.listMcpEnabledWorkflows(params); } - async triggerWorkflow(params: TriggerMcpWorkflowParams): Promise { + async triggerMcpWorkflow(params: TriggerMcpWorkflowParams): Promise { return this.workflowsService.triggerMcpWorkflow(params); } - async getWorkflowRun(params: GetMcpWorkflowRunParams): Promise { + async getMcpWorkflowRun(params: GetMcpWorkflowRunParams): Promise { return this.workflowsService.getMcpWorkflowRun(params); } } diff --git a/packages/mcp-server/src/http-client/types.ts b/packages/mcp-server/src/http-client/types.ts index 8bbaccaa66..861cf2c490 100644 --- a/packages/mcp-server/src/http-client/types.ts +++ b/packages/mcp-server/src/http-client/types.ts @@ -8,12 +8,12 @@ import type { ForestSchemaCollection, ForestSchemaField, GetMcpWorkflowRunParams, + HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, - WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, } from '@forestadmin/forestadmin-client'; @@ -29,12 +29,12 @@ export type { ForestSchemaCollection, ForestSchemaField, GetMcpWorkflowRunParams, + HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, - WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, }; @@ -72,15 +72,15 @@ export interface ForestServerClient { /** * Lists the MCP-enabled workflows the caller can access in a rendering. */ - listMcpWorkflows(params: ListMcpWorkflowsParams): Promise; + listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise; /** * Starts a run of an MCP-enabled workflow on a record and returns its runId (async). */ - triggerWorkflow(params: TriggerMcpWorkflowParams): Promise; + triggerMcpWorkflow(params: TriggerMcpWorkflowParams): Promise; /** - * Reads the normalized status of a workflow run, scoped to the caller. + * Reads the full hydrated workflow run, scoped to the caller. */ - getWorkflowRun(params: GetMcpWorkflowRunParams): Promise; + getMcpWorkflowRun(params: GetMcpWorkflowRunParams): Promise; } diff --git a/packages/mcp-server/src/tools/get-workflow-run.ts b/packages/mcp-server/src/tools/get-workflow-run.ts index fb41fbaaac..b416cb8296 100644 --- a/packages/mcp-server/src/tools/get-workflow-run.ts +++ b/packages/mcp-server/src/tools/get-workflow-run.ts @@ -22,10 +22,13 @@ export default function declareGetWorkflowRunTool(mcpServer: McpServer, ctx: Too annotations: { readOnlyHint: true }, title: 'Get a workflow run status', description: - 'Poll the status of a workflow run started with triggerWorkflow. Returns runState, the ' + - 'currentStep, waitingForHumanInput, and — once finished — the terminal result or error. ' + - 'A run parked on a human-gated step reports waitingForHumanInput: true; it cannot be ' + - 'resumed via MCP and must be finished from the Forest UI.', + 'Poll a workflow run started with triggerWorkflow. Returns the full hydrated run: its ' + + 'runState (started, pending, loading, aborted or finished) 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). Use it to see exactly where the run is and ' + + 'what each step does. A run parked on a human-gated step cannot be resumed via MCP in ' + + 'v1 and must be finished from the Forest UI.', inputSchema: { runId: z.string().describe(RUN_ID_DESCRIPTION), }, @@ -33,7 +36,7 @@ export default function declareGetWorkflowRunTool(mcpServer: McpServer, ctx: Too async (args: GetWorkflowRunArgument, extra) => { const { forestServerToken, renderingId } = getAuthContext(extra); - const runStatus = await forestServerClient.getWorkflowRun({ + const runStatus = await forestServerClient.getMcpWorkflowRun({ forestServerToken, renderingId, runId: args.runId, diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts index 8677b5ccb3..a57a8309cf 100644 --- a/packages/mcp-server/src/tools/list-workflows.ts +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -41,7 +41,7 @@ export default function declareListWorkflowsTool(mcpServer: McpServer, ctx: Tool async (args: ListWorkflowsArgument, extra) => { const { forestServerToken, renderingId } = getAuthContext(extra); - const workflows = await forestServerClient.listMcpWorkflows({ + const workflows = await forestServerClient.listMcpEnabledWorkflows({ forestServerToken, renderingId, collectionName: args.collectionName, diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index b3ba206637..e7d6994315 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -48,7 +48,7 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To let result: WorkflowRunTriggerResult; try { - result = await forestServerClient.triggerWorkflow({ + result = await forestServerClient.triggerMcpWorkflow({ forestServerToken, renderingId, workflowId: args.workflowId, diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 3eec70bf95..ec96c26898 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -14,9 +14,9 @@ export default function createMockForestServerClient( attributes: { index: 'mock-index' }, }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), - listMcpWorkflows: jest.fn().mockResolvedValue([]), - triggerWorkflow: jest.fn().mockResolvedValue({ runId: '1', runState: 'loading' }), - getWorkflowRun: jest.fn().mockResolvedValue({ + listMcpEnabledWorkflows: jest.fn().mockResolvedValue([]), + triggerMcpWorkflow: jest.fn().mockResolvedValue({ runId: '1', runState: 'loading' }), + getMcpWorkflowRun: jest.fn().mockResolvedValue({ runState: 'started', currentStep: null, waitingForHumanInput: false, diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index d5680e0215..d74176fa5f 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -111,7 +111,7 @@ describe('ForestServerClientImpl', () => { }); }); - describe('listMcpWorkflows', () => { + describe('listMcpEnabledWorkflows', () => { it('should delegate to workflowsService.listMcpEnabledWorkflows()', async () => { const workflows = [{ workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }]; mockWorkflowsService.listMcpEnabledWorkflows.mockResolvedValue(workflows); @@ -122,14 +122,14 @@ describe('ForestServerClientImpl', () => { collectionName: 'orders', }; - const result = await client.listMcpWorkflows(params); + const result = await client.listMcpEnabledWorkflows(params); expect(mockWorkflowsService.listMcpEnabledWorkflows).toHaveBeenCalledWith(params); expect(result).toBe(workflows); }); }); - describe('triggerWorkflow', () => { + describe('triggerMcpWorkflow', () => { it('should delegate to workflowsService.triggerMcpWorkflow()', async () => { const run = { runId: '7', runState: 'loading' as const }; mockWorkflowsService.triggerMcpWorkflow.mockResolvedValue(run); @@ -141,22 +141,32 @@ describe('ForestServerClientImpl', () => { recordId: '42', }; - const result = await client.triggerWorkflow(params); + const result = await client.triggerMcpWorkflow(params); expect(mockWorkflowsService.triggerMcpWorkflow).toHaveBeenCalledWith(params); expect(result).toBe(run); }); }); - describe('getWorkflowRun', () => { + describe('getMcpWorkflowRun', () => { it('should delegate to workflowsService.getMcpWorkflowRun()', async () => { - const runStatus = { + const hydratedRun = { + id: 7, + userId: 42, + renderingId: 12345, + collectionId: 'orders', + workflowId: 'wf-1', + bpmnVersion: '3', + selectedRecordId: '99', runState: 'finished' as const, - currentStep: null, - waitingForHumanInput: false, - result: { ok: true }, + engine: 'orchestrator' as const, + triggerType: 'mcp' as const, + lockedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:05:00.000Z', + workflowHistory: [], }; - mockWorkflowsService.getMcpWorkflowRun.mockResolvedValue(runStatus); + mockWorkflowsService.getMcpWorkflowRun.mockResolvedValue(hydratedRun); const params = { forestServerToken: 'test-token', @@ -164,10 +174,10 @@ describe('ForestServerClientImpl', () => { runId: '7', }; - const result = await client.getWorkflowRun(params); + const result = await client.getMcpWorkflowRun(params); expect(mockWorkflowsService.getMcpWorkflowRun).toHaveBeenCalledWith(params); - expect(result).toBe(runStatus); + expect(result).toBe(hydratedRun); }); }); }); @@ -201,8 +211,8 @@ describe('createForestServerClient', () => { expect(client.createActivityLog).toBeDefined(); expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); - expect(client.listMcpWorkflows).toBeDefined(); - expect(client.triggerWorkflow).toBeDefined(); - expect(client.getWorkflowRun).toBeDefined(); + expect(client.listMcpEnabledWorkflows).toBeDefined(); + expect(client.triggerMcpWorkflow).toBeDefined(); + expect(client.getMcpWorkflowRun).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index d73e5abfc9..5e5df52630 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -19,9 +19,9 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), - listMcpWorkflows: jest.fn(), - triggerWorkflow: jest.fn(), - getWorkflowRun: jest.fn(), + listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index 0d85c9cf65..d6713337c2 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -17,9 +17,9 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), - listMcpWorkflows: jest.fn(), - triggerWorkflow: jest.fn(), - getWorkflowRun: jest.fn(), + listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-workflow-run.test.ts b/packages/mcp-server/test/tools/get-workflow-run.test.ts index 10a95302ce..55226b2388 100644 --- a/packages/mcp-server/test/tools/get-workflow-run.test.ts +++ b/packages/mcp-server/test/tools/get-workflow-run.test.ts @@ -54,7 +54,8 @@ describe('declareGetWorkflowRunTool', () => { }); expect(registeredToolConfig.title).toBe('Get a workflow run status'); - expect(registeredToolConfig.description).toContain('waitingForHumanInput'); + expect(registeredToolConfig.description).toContain('workflowHistory'); + expect(registeredToolConfig.description).toContain('cannot be resumed via MCP'); }); it('should be annotated as read-only', () => { @@ -97,11 +98,37 @@ describe('declareGetWorkflowRunTool', () => { }, } as unknown as RequestHandlerExtra; - const runStatus = { + const hydratedRun = { + id: 7, + userId: 42, + renderingId: 123, + collectionId: 'orders', + workflowId: 'wf-1', + bpmnVersion: '3', + selectedRecordId: '99', runState: 'finished' as const, - currentStep: null, - waitingForHumanInput: false, - result: { refunded: true }, + engine: 'orchestrator' as const, + triggerType: 'mcp' as const, + lockedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:05:00.000Z', + workflowHistory: [ + { + stepName: 'Refund order', + stepIndex: 0, + done: true, + isCardStep: false, + context: { manuallyCompleted: true as const, completedBy: 42 }, + stepDefinition: { + type: 'task' as const, + title: 'Refund order', + executionType: 'fully-automated' as const, + automaticCompletion: true, + outgoing: [], + taskType: 'trigger-action' as const, + }, + }, + ], }; beforeEach(() => { @@ -110,47 +137,54 @@ describe('declareGetWorkflowRunTool', () => { logger: mockLogger, collectionNames: [], }); - mockForestServerClient.getWorkflowRun.mockResolvedValue(runStatus); + mockForestServerClient.getMcpWorkflowRun.mockResolvedValue(hydratedRun); }); it('should call getWorkflowRun with the identity from the auth context and the runId', async () => { await registeredToolHandler({ runId: '7' }, mockExtra); - expect(mockForestServerClient.getWorkflowRun).toHaveBeenCalledWith({ + expect(mockForestServerClient.getMcpWorkflowRun).toHaveBeenCalledWith({ forestServerToken: 'forest-token', renderingId: '123', runId: '7', }); }); - it('should return the run status as JSON text content', async () => { + it('should return the full hydrated run as JSON text content, unchanged', async () => { const result = await registeredToolHandler({ runId: '7' }, mockExtra); expect(result).toEqual({ - content: [{ type: 'text', text: JSON.stringify(runStatus) }], + content: [{ type: 'text', text: JSON.stringify(hydratedRun) }], }); }); - it('should report a human-gated run as waitingForHumanInput', async () => { - mockForestServerClient.getWorkflowRun.mockResolvedValue({ - runState: 'started', - currentStep: { name: 'Manager approval', type: 'human' }, - waitingForHumanInput: true, - }); + it('should surface a human-gated step and its context verbatim', async () => { + const gatedRun = { + ...hydratedRun, + runState: 'started' as const, + workflowHistory: [ + { + stepName: 'Manager approval', + stepIndex: 0, + done: false, + isCardStep: true, + context: { escalationState: 'escalated' as const }, + stepDefinition: { + type: 'escalation' as const, + title: 'Manager approval', + executionType: 'manual' as const, + automaticCompletion: false, + outgoing: [{ stepId: 'end', buttonText: 'Approve' }], + }, + }, + ], + }; + mockForestServerClient.getMcpWorkflowRun.mockResolvedValue(gatedRun); const result = await registeredToolHandler({ runId: '7' }, mockExtra); expect(result).toEqual({ - content: [ - { - type: 'text', - text: JSON.stringify({ - runState: 'started', - currentStep: { name: 'Manager approval', type: 'human' }, - waitingForHumanInput: true, - }), - }, - ], + content: [{ type: 'text', text: JSON.stringify(gatedRun) }], }); }); @@ -165,11 +199,11 @@ describe('declareGetWorkflowRunTool', () => { content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], isError: true, }); - expect(mockForestServerClient.getWorkflowRun).not.toHaveBeenCalled(); + expect(mockForestServerClient.getMcpWorkflowRun).not.toHaveBeenCalled(); }); it('should map an unknown runId 404 to an error tool result', async () => { - mockForestServerClient.getWorkflowRun.mockRejectedValue( + mockForestServerClient.getMcpWorkflowRun.mockRejectedValue( new NotFoundError('Workflow run not found'), ); @@ -182,7 +216,7 @@ describe('declareGetWorkflowRunTool', () => { }); it('should map a forbidden runId 403 to an error tool result', async () => { - mockForestServerClient.getWorkflowRun.mockRejectedValue( + mockForestServerClient.getMcpWorkflowRun.mockRejectedValue( new ForbiddenError('You are not allowed to access this workflow run'), ); diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts index 265b2a0f05..7567ec8547 100644 --- a/packages/mcp-server/test/tools/list-workflows.test.ts +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -138,23 +138,23 @@ describe('declareListWorkflowsTool', () => { logger: mockLogger, collectionNames: [], }); - mockForestServerClient.listMcpWorkflows.mockResolvedValue(workflows); + mockForestServerClient.listMcpEnabledWorkflows.mockResolvedValue(workflows); }); - it('should call listMcpWorkflows with the identity from the auth context', async () => { + it('should call listMcpEnabledWorkflows with the identity from the auth context', async () => { await registeredToolHandler({}, mockExtra); - expect(mockForestServerClient.listMcpWorkflows).toHaveBeenCalledWith({ + expect(mockForestServerClient.listMcpEnabledWorkflows).toHaveBeenCalledWith({ forestServerToken: 'forest-token', renderingId: '123', collectionName: undefined, }); }); - it('should forward the collectionName filter to listMcpWorkflows', async () => { + it('should forward the collectionName filter to listMcpEnabledWorkflows', async () => { await registeredToolHandler({ collectionName: 'orders' }, mockExtra); - expect(mockForestServerClient.listMcpWorkflows).toHaveBeenCalledWith({ + expect(mockForestServerClient.listMcpEnabledWorkflows).toHaveBeenCalledWith({ forestServerToken: 'forest-token', renderingId: '123', collectionName: 'orders', @@ -180,11 +180,11 @@ describe('declareListWorkflowsTool', () => { content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], isError: true, }); - expect(mockForestServerClient.listMcpWorkflows).not.toHaveBeenCalled(); + expect(mockForestServerClient.listMcpEnabledWorkflows).not.toHaveBeenCalled(); }); it('should map server errors to an error tool result', async () => { - mockForestServerClient.listMcpWorkflows.mockRejectedValue( + mockForestServerClient.listMcpEnabledWorkflows.mockRejectedValue( new NotFoundError('No active workflow for the rendering'), ); diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 2accd00dec..2814333afc 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -104,7 +104,7 @@ describe('declareTriggerWorkflowTool', () => { logger: mockLogger, collectionNames: [], }); - mockForestServerClient.triggerWorkflow.mockResolvedValue({ + mockForestServerClient.triggerMcpWorkflow.mockResolvedValue({ runId: '7', runState: 'loading', workflowName: 'Refund order', @@ -115,7 +115,7 @@ describe('declareTriggerWorkflowTool', () => { it('should start the workflow directly with the identity from the auth context and the args', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); - expect(mockForestServerClient.triggerWorkflow).toHaveBeenCalledWith({ + expect(mockForestServerClient.triggerMcpWorkflow).toHaveBeenCalledWith({ forestServerToken: 'forest-token', renderingId: '123', workflowId: 'wf-1', @@ -126,7 +126,7 @@ describe('declareTriggerWorkflowTool', () => { it('should not list workflows before triggering', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); - expect(mockForestServerClient.listMcpWorkflows).not.toHaveBeenCalled(); + expect(mockForestServerClient.listMcpEnabledWorkflows).not.toHaveBeenCalled(); }); it('should return only the runId and runState as JSON text content', async () => { @@ -152,7 +152,7 @@ describe('declareTriggerWorkflowTool', () => { }); it('should fall back to the workflowId in the label when the response omits name/collection', async () => { - mockForestServerClient.triggerWorkflow.mockResolvedValue({ + mockForestServerClient.triggerMcpWorkflow.mockResolvedValue({ runId: '7', runState: 'loading', }); @@ -193,11 +193,11 @@ describe('declareTriggerWorkflowTool', () => { content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], isError: true, }); - expect(mockForestServerClient.triggerWorkflow).not.toHaveBeenCalled(); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); }); it('should map a server 404 to the "is not an MCP-enabled workflow" tool error', async () => { - mockForestServerClient.triggerWorkflow.mockRejectedValue( + mockForestServerClient.triggerMcpWorkflow.mockRejectedValue( new NotFoundError('Workflow MCP trigger not found or disabled'), ); @@ -213,7 +213,7 @@ describe('declareTriggerWorkflowTool', () => { }); it('should pass a 409 already-ongoing run through as an error tool result', async () => { - mockForestServerClient.triggerWorkflow.mockRejectedValue( + mockForestServerClient.triggerMcpWorkflow.mockRejectedValue( new Error('A run is already ongoing on this record'), ); From 2255f13d22a4a2656da086fa97b2fb5167d509ae Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 10 Aug 2026 18:20:56 +0200 Subject: [PATCH 08/43] test(mcp-server): align mock ForestServerClient default run with HydratedWorkflowRun (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) --- .../test/helpers/forest-server-client.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index ec96c26898..dbeed29109 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -17,9 +17,20 @@ export default function createMockForestServerClient( listMcpEnabledWorkflows: jest.fn().mockResolvedValue([]), triggerMcpWorkflow: jest.fn().mockResolvedValue({ runId: '1', runState: 'loading' }), getMcpWorkflowRun: jest.fn().mockResolvedValue({ + id: 1, + userId: 1, + renderingId: 1, + collectionId: 'orders', + workflowId: 'wf-1', + bpmnVersion: '1', + selectedRecordId: '1', runState: 'started', - currentStep: null, - waitingForHumanInput: false, + engine: 'orchestrator', + triggerType: 'mcp', + lockedAt: null, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + workflowHistory: [], }), ...overrides, } as jest.Mocked; From 0ecd716af964c7195a448acf5ae9a889795c36b2 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 11 Aug 2026 13:49:29 +0200 Subject: [PATCH 09/43] feat(mcp-server): make triggerWorkflow audit fail-closed via O(1) workflow lookup (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/forest-admin-client-mock.ts | 7 ++ .../test/forest-admin-client-mock.test.ts | 17 +++ .../test/__factories__/forest-admin-client.ts | 1 + packages/forestadmin-client/src/index.ts | 2 + .../src/permissions/forest-http-api.ts | 15 +++ packages/forestadmin-client/src/types.ts | 29 ++++- .../forestadmin-client/src/workflows/index.ts | 22 ++++ .../forest-admin-server-interface.ts | 1 + .../test/permissions/forest-http-api.test.ts | 49 +++++++++ .../test/workflows/index.test.ts | 65 ++++++++++++ packages/mcp-server/src/http-client/index.ts | 2 + .../src/http-client/mcp-http-client.ts | 6 ++ packages/mcp-server/src/http-client/types.ts | 9 ++ .../mcp-server/src/tools/trigger-workflow.ts | 81 +++++++------- .../test/helpers/forest-server-client.ts | 6 ++ .../test/http-client/mcp-http-client.test.ts | 25 +++++ .../test/tools/execute-action.test.ts | 1 + .../test/tools/get-action-form.test.ts | 1 + .../test/tools/trigger-workflow.test.ts | 100 ++++++++++++++---- 19 files changed, 377 insertions(+), 62 deletions(-) diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 025e8d2080..508bc4e4c6 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -64,6 +64,13 @@ export default class ForestAdminClientMock implements ForestAdminClient { readonly workflowsService: ForestAdminClient['workflowsService'] = { listMcpEnabledWorkflows: () => Promise.resolve([]), + getMcpWorkflowById: () => + Promise.resolve({ + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: true, + }), triggerMcpWorkflow: () => Promise.resolve({ runId: '1', runState: 'loading' }), getMcpWorkflowRun: () => Promise.resolve({ diff --git a/packages/agent-testing/test/forest-admin-client-mock.test.ts b/packages/agent-testing/test/forest-admin-client-mock.test.ts index 41c6f51170..d165d99ff8 100644 --- a/packages/agent-testing/test/forest-admin-client-mock.test.ts +++ b/packages/agent-testing/test/forest-admin-client-mock.test.ts @@ -13,6 +13,23 @@ describe('ForestAdminClientMock', () => { ).resolves.toEqual([]); }); + it('should resolve an mcp-enabled workflow when fetching one by id', async () => { + const client = new ForestAdminClientMock(); + + await expect( + client.workflowsService.getMcpWorkflowById({ + forestServerToken: 'token', + renderingId: '1', + workflowId: 'wf-1', + }), + ).resolves.toEqual( + expect.objectContaining({ + workflowId: 'wf-1', + mcpEnabled: true, + }), + ); + }); + it('should resolve a loading run when triggering a workflow', async () => { const client = new ForestAdminClientMock(); diff --git a/packages/agent/test/__factories__/forest-admin-client.ts b/packages/agent/test/__factories__/forest-admin-client.ts index da3034f40b..c1097fb551 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -56,6 +56,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ }, workflowsService: { listMcpEnabledWorkflows: jest.fn(), + getMcpWorkflowById: jest.fn(), triggerMcpWorkflow: jest.fn(), getMcpWorkflowRun: jest.fn(), }, diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 039471ef0e..fe300340c7 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -29,7 +29,9 @@ export { CreateActivityLogParams, UpdateActivityLogStatusParams, McpWorkflow, + McpWorkflowLookup, ListMcpWorkflowsParams, + GetMcpWorkflowByIdParams, TriggerMcpWorkflowParams, GetMcpWorkflowRunParams, WorkflowRunState, diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index d8d5817c31..c9a2d937d5 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -11,6 +11,7 @@ import type { HydratedWorkflowRun, IpWhitelistRulesResponse, McpWorkflow, + McpWorkflowLookup, WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; @@ -171,6 +172,20 @@ export default class ForestHttpApi implements ForestAdminServerInterface { }); } + async getMcpWorkflowById( + options: ActivityLogHttpOptions, + renderingId: string, + workflowId: string, + ): Promise { + return ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: `/api/workflow-orchestrator/mcp-workflows/${encodeURIComponent(workflowId)}`, + bearerToken: options.bearerToken, + headers: { 'forest-rendering-id': renderingId, ...options.headers }, + }); + } + async triggerMcpWorkflow( options: ActivityLogHttpOptions, renderingId: string, diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index da6d2acc2f..a87a942e64 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -299,6 +299,25 @@ export interface ListMcpWorkflowsParams { collectionName?: string; } +/** + * A single workflow resolved by id (O(1) lookup). Unlike the listing, this also carries + * `mcpEnabled`: an existing-but-MCP-disabled workflow is returned with `mcpEnabled: false` rather + * than hidden, so the caller can label a fail-closed audit log before triggering while the + * start endpoint stays the guard that refuses a disabled trigger. + */ +export interface McpWorkflowLookup { + workflowId: string; + name: string; + collectionName: string | null; + mcpEnabled: boolean; +} + +export interface GetMcpWorkflowByIdParams { + forestServerToken: string; + renderingId: string; + workflowId: string; +} + /** * The lifecycle state of a workflow run, as persisted by the orchestrator. */ @@ -307,8 +326,8 @@ export type WorkflowRunState = 'started' | 'pending' | 'loading' | 'aborted' | ' /** * 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 echoed by the start endpoint to build the audit label - * without a second round-trip; they are optional so older servers degrade gracefully. + * `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. */ export interface WorkflowRunTriggerResult { runId: string; @@ -429,6 +448,7 @@ export interface GetMcpWorkflowRunParams { */ export interface WorkflowsServiceInterface { listMcpEnabledWorkflows: (params: ListMcpWorkflowsParams) => Promise; + getMcpWorkflowById: (params: GetMcpWorkflowByIdParams) => Promise; triggerMcpWorkflow: (params: TriggerMcpWorkflowParams) => Promise; getMcpWorkflowRun: (params: GetMcpWorkflowRunParams) => Promise; } @@ -478,6 +498,11 @@ export interface ForestAdminServerInterface { renderingId: string, collectionName?: string, ) => Promise; + getMcpWorkflowById?: ( + options: ActivityLogHttpOptions, + renderingId: string, + workflowId: string, + ) => Promise; triggerMcpWorkflow?: ( options: ActivityLogHttpOptions, renderingId: string, diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts index 718a041f07..2a99939219 100644 --- a/packages/forestadmin-client/src/workflows/index.ts +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -1,9 +1,11 @@ import type { ForestAdminServerInterface, + GetMcpWorkflowByIdParams, GetMcpWorkflowRunParams, HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, + McpWorkflowLookup, TriggerMcpWorkflowParams, WorkflowRunTriggerResult, } from '../types'; @@ -39,6 +41,26 @@ export default class WorkflowsService { ); } + async getMcpWorkflowById(params: GetMcpWorkflowByIdParams): Promise { + const { forestServerToken, renderingId, workflowId } = params; + + if (!this.forestAdminServerInterface.getMcpWorkflowById) { + throw new Error( + 'The configured Forest server transport does not support getMcpWorkflowById.', + ); + } + + return this.forestAdminServerInterface.getMcpWorkflowById( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + workflowId, + ); + } + async triggerMcpWorkflow(params: TriggerMcpWorkflowParams): Promise { const { forestServerToken, renderingId, workflowId, recordId } = params; diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index be174cef86..1924091734 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -20,6 +20,7 @@ const forestAdminServerInterface = { updateActivityLogStatus: jest.fn(), // Workflow operations listMcpEnabledWorkflows: jest.fn(), + getMcpWorkflowById: jest.fn(), triggerMcpWorkflow: jest.fn(), getMcpWorkflowRun: jest.fn(), }), diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 4144a8eb67..65520ffa2d 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -246,6 +246,55 @@ describe('ForestHttpApi', () => { }); }); + describe('getMcpWorkflowById', () => { + it('should GET the by-id workflow endpoint with the rendering id header', async () => { + const workflow = { + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: true, + }; + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(workflow); + + const result = await new ForestHttpApi().getMcpWorkflowById( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'wf-1', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: '/api/workflow-orchestrator/mcp-workflows/wf-1', + bearerToken: 'bearer-token', + headers: { 'forest-rendering-id': '12345' }, + }); + expect(result).toEqual(workflow); + }); + + it('should url-encode the workflow id in the path', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + workflowId: 'wf/with space', + name: 'Refund order', + collectionName: null, + mcpEnabled: false, + }); + + await new ForestHttpApi().getMcpWorkflowById( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'wf/with space', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + path: '/api/workflow-orchestrator/mcp-workflows/wf%2Fwith%20space', + }), + ); + }); + }); + describe('triggerMcpWorkflow', () => { it('should POST the record id to the workflow start endpoint with the rendering id header', async () => { (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts index 360bd34e84..b4b1bfcd76 100644 --- a/packages/forestadmin-client/test/workflows/index.test.ts +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -88,6 +88,71 @@ describe('WorkflowsService', () => { }); }); + describe('getMcpWorkflowById', () => { + const workflow = { + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: true, + }; + + it('should forward the identity and workflowId to the transport and return the workflow', async () => { + mockForestAdminServerInterface.getMcpWorkflowById.mockResolvedValue(workflow); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + const result = await service.getMcpWorkflowById({ + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + }); + + expect(result).toEqual(workflow); + expect(mockForestAdminServerInterface.getMcpWorkflowById).toHaveBeenCalledWith( + { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, + '12345', + 'wf-1', + ); + }); + + it('should pass custom headers when provided', async () => { + mockForestAdminServerInterface.getMcpWorkflowById.mockResolvedValue(workflow); + + const service = new WorkflowsService(mockForestAdminServerInterface, { + ...options, + headers: { 'Forest-Application-Source': 'MCP' }, + }); + await service.getMcpWorkflowById({ + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + }); + + expect(mockForestAdminServerInterface.getMcpWorkflowById).toHaveBeenCalledWith( + expect.objectContaining({ + bearerToken: 'test-token', + headers: { 'Forest-Application-Source': 'MCP' }, + }), + '12345', + 'wf-1', + ); + }); + + it('should throw when the transport does not implement getMcpWorkflowById', async () => { + delete (mockForestAdminServerInterface as Partial) + .getMcpWorkflowById; + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + + await expect( + service.getMcpWorkflowById({ + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + }), + ).rejects.toThrow('does not support getMcpWorkflowById'); + }); + }); + describe('triggerMcpWorkflow', () => { it('should forward the identity, workflowId and recordId to the transport and return the run', async () => { mockForestAdminServerInterface.triggerMcpWorkflow.mockResolvedValue({ diff --git a/packages/mcp-server/src/http-client/index.ts b/packages/mcp-server/src/http-client/index.ts index da9ac51941..9aa10a3b22 100644 --- a/packages/mcp-server/src/http-client/index.ts +++ b/packages/mcp-server/src/http-client/index.ts @@ -53,9 +53,11 @@ export type { ActivityLogType, CreateActivityLogParams, ForestServerClient, + GetMcpWorkflowByIdParams, GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, + McpWorkflowLookup, TriggerMcpWorkflowParams, HydratedWorkflowRun, WorkflowRunTriggerResult, diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index 8757fb8f07..674c41146e 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -4,10 +4,12 @@ import type { CreateActivityLogParams, ForestSchemaCollection, ForestServerClient, + GetMcpWorkflowByIdParams, GetMcpWorkflowRunParams, HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, + McpWorkflowLookup, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, @@ -47,6 +49,10 @@ export default class ForestServerClientImpl implements ForestServerClient { return this.workflowsService.listMcpEnabledWorkflows(params); } + async getMcpWorkflowById(params: GetMcpWorkflowByIdParams): Promise { + return this.workflowsService.getMcpWorkflowById(params); + } + async triggerMcpWorkflow(params: TriggerMcpWorkflowParams): Promise { return this.workflowsService.triggerMcpWorkflow(params); } diff --git a/packages/mcp-server/src/http-client/types.ts b/packages/mcp-server/src/http-client/types.ts index 861cf2c490..d3a43b2ebb 100644 --- a/packages/mcp-server/src/http-client/types.ts +++ b/packages/mcp-server/src/http-client/types.ts @@ -7,10 +7,12 @@ import type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowByIdParams, GetMcpWorkflowRunParams, HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, + McpWorkflowLookup, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, @@ -28,10 +30,12 @@ export type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowByIdParams, GetMcpWorkflowRunParams, HydratedWorkflowRun, ListMcpWorkflowsParams, McpWorkflow, + McpWorkflowLookup, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, @@ -74,6 +78,11 @@ export interface ForestServerClient { */ listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise; + /** + * Resolves a single workflow by id (name, collection, mcpEnabled), scoped to the caller. + */ + getMcpWorkflowById(params: GetMcpWorkflowByIdParams): Promise; + /** * Starts a run of an MCP-enabled workflow on a record and returns its runId (async). */ diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index e7d6994315..62982fbde3 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -1,15 +1,13 @@ -import type { WorkflowRunTriggerResult } from '../http-client'; +import type { McpWorkflowLookup } from '../http-client'; import type { ToolContext } from '../tool-context'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { NotFoundError } from '@forestadmin/forestadmin-client'; import { z } from 'zod'; -import createPendingActivityLog, { - markActivityLogAsSucceeded, -} from '../utils/activity-logs-creator'; import getAuthContext from '../utils/auth-context'; import registerToolWithLogging from '../utils/tool-with-logging'; +import withActivityLog from '../utils/with-activity-log'; const WORKFLOW_ID_DESCRIPTION = 'The id of the workflow to start, as returned by listWorkflows. The workflow must have the MCP ' + @@ -24,6 +22,16 @@ interface TriggerWorkflowArgument { recordId: string; } +// The server answers 404 both for an unknown workflow and for one whose MCP trigger is disabled +// (and getMcpWorkflowById returns mcpEnabled: false for the latter). Surface the same guidance in +// every case so the LLM-facing contract stays identical. +function notMcpEnabledMessage(workflowId: string): string { + return ( + `Workflow "${workflowId}" is not an MCP-enabled workflow you can access. ` + + 'Use listWorkflows to discover triggerable workflows.' + ); +} + export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: ToolContext): string { const { forestServerClient, logger } = ctx; @@ -45,54 +53,53 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To async (args: TriggerWorkflowArgument, extra) => { const { forestServerToken, renderingId } = getAuthContext(extra); - let result: WorkflowRunTriggerResult; + // Resolve the workflow O(1) up front so the audit log can be labelled with its name/collection + // BEFORE the trigger (fail-closed, like create/update/delete). Reject unknown or MCP-disabled + // workflows here — no run is started and no log is written for a non-triggerable workflow. + let workflow: McpWorkflowLookup; try { - result = await forestServerClient.triggerMcpWorkflow({ + workflow = await forestServerClient.getMcpWorkflowById({ forestServerToken, renderingId, workflowId: args.workflowId, - recordId: args.recordId, }); } catch (error) { - // The server answers 404 both for an unknown workflow and for one whose MCP trigger is - // disabled (indistinguishable on purpose). Surface the same guidance the tool gave when - // it validated the id client-side, so the LLM-facing contract stays identical. if (error instanceof NotFoundError) { - throw new Error( - `Workflow "${args.workflowId}" is not an MCP-enabled workflow you can access. ` + - 'Use listWorkflows to discover triggerable workflows.', - ); + throw new Error(notMcpEnabledMessage(args.workflowId)); } throw error; } - // Audit the successful trigger. The start endpoint echoes the workflow name/collection so we - // can label the log without a prior listing; fall back to the id when an older server omits - // them. The run is already started, so a logging hiccup must not fail the tool. - try { - const activityLog = await createPendingActivityLog( - forestServerClient, - extra, - 'triggerWorkflow', - { - collectionName: result.collectionName ?? undefined, - recordId: args.recordId, - label: `triggered the workflow "${result.workflowName ?? args.workflowId}"`, - }, - ); - - markActivityLogAsSucceeded({ forestServerClient, request: extra, activityLog, logger }); - } catch (error) { - logger( - 'Warn', - `Failed to record triggerWorkflow activity log: ${ - error instanceof Error ? error.message : String(error) - }`, - ); + if (!workflow.mcpEnabled) { + throw new Error(notMcpEnabledMessage(args.workflowId)); } + const result = await withActivityLog({ + forestServerClient, + request: extra, + action: 'triggerWorkflow', + context: { + collectionName: workflow.collectionName ?? undefined, + recordId: args.recordId, + label: `triggered the workflow "${workflow.name}"`, + }, + logger, + operation: () => + forestServerClient.triggerMcpWorkflow({ + forestServerToken, + renderingId, + workflowId: args.workflowId, + recordId: args.recordId, + }), + // Guard the race where the workflow is disabled/deleted between the lookup and the trigger. + errorEnhancer: async (parsedMessage, originalError) => + originalError instanceof NotFoundError + ? notMcpEnabledMessage(args.workflowId) + : parsedMessage, + }); + return { content: [ { diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index dbeed29109..3809292984 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -15,6 +15,12 @@ export default function createMockForestServerClient( }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), listMcpEnabledWorkflows: jest.fn().mockResolvedValue([]), + getMcpWorkflowById: jest.fn().mockResolvedValue({ + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: true, + }), triggerMcpWorkflow: jest.fn().mockResolvedValue({ runId: '1', runState: 'loading' }), getMcpWorkflowRun: jest.fn().mockResolvedValue({ id: 1, diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index d74176fa5f..5ef51e983f 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -25,6 +25,7 @@ describe('ForestServerClientImpl', () => { }; mockWorkflowsService = { listMcpEnabledWorkflows: jest.fn(), + getMcpWorkflowById: jest.fn(), triggerMcpWorkflow: jest.fn(), getMcpWorkflowRun: jest.fn(), }; @@ -129,6 +130,29 @@ describe('ForestServerClientImpl', () => { }); }); + describe('getMcpWorkflowById', () => { + it('should delegate to workflowsService.getMcpWorkflowById()', async () => { + const workflow = { + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: true, + }; + mockWorkflowsService.getMcpWorkflowById.mockResolvedValue(workflow); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + workflowId: 'wf-1', + }; + + const result = await client.getMcpWorkflowById(params); + + expect(mockWorkflowsService.getMcpWorkflowById).toHaveBeenCalledWith(params); + expect(result).toBe(workflow); + }); + }); + describe('triggerMcpWorkflow', () => { it('should delegate to workflowsService.triggerMcpWorkflow()', async () => { const run = { runId: '7', runState: 'loading' as const }; @@ -212,6 +236,7 @@ describe('createForestServerClient', () => { expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); expect(client.listMcpEnabledWorkflows).toBeDefined(); + expect(client.getMcpWorkflowById).toBeDefined(); expect(client.triggerMcpWorkflow).toBeDefined(); expect(client.getMcpWorkflowRun).toBeDefined(); }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 5e5df52630..4ad218d85c 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -20,6 +20,7 @@ const mockForestServerClient: ForestServerClient = { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), listMcpEnabledWorkflows: jest.fn(), + getMcpWorkflowById: jest.fn(), triggerMcpWorkflow: jest.fn(), getMcpWorkflowRun: jest.fn(), }; diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index d6713337c2..b928bf1595 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -18,6 +18,7 @@ const mockForestServerClient: ForestServerClient = { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), listMcpEnabledWorkflows: jest.fn(), + getMcpWorkflowById: jest.fn(), triggerMcpWorkflow: jest.fn(), getMcpWorkflowRun: jest.fn(), }; diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 2814333afc..9a48007ef3 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -104,15 +104,29 @@ describe('declareTriggerWorkflowTool', () => { logger: mockLogger, collectionNames: [], }); + mockForestServerClient.getMcpWorkflowById.mockResolvedValue({ + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: true, + }); mockForestServerClient.triggerMcpWorkflow.mockResolvedValue({ runId: '7', runState: 'loading', - workflowName: 'Refund order', - collectionName: 'orders', }); }); - it('should start the workflow directly with the identity from the auth context and the args', async () => { + it('should resolve the workflow by id with the identity from the auth context', async () => { + await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(mockForestServerClient.getMcpWorkflowById).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + workflowId: 'wf-1', + }); + }); + + it('should trigger the workflow with the identity from the auth context and the args', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(mockForestServerClient.triggerMcpWorkflow).toHaveBeenCalledWith({ @@ -137,7 +151,7 @@ describe('declareTriggerWorkflowTool', () => { }); }); - it('should record an activity log labelled from the response name and collection', async () => { + it('should record the activity log before triggering, labelled from the resolved workflow', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( @@ -149,34 +163,30 @@ describe('declareTriggerWorkflowTool', () => { label: 'triggered the workflow "Refund order"', }), ); - }); - it('should fall back to the workflowId in the label when the response omits name/collection', async () => { - mockForestServerClient.triggerMcpWorkflow.mockResolvedValue({ - runId: '7', - runState: 'loading', - }); + const logOrder = mockForestServerClient.createMcpActivityLog.mock.invocationCallOrder[0]; + const triggerOrder = mockForestServerClient.triggerMcpWorkflow.mock.invocationCallOrder[0]; + expect(logOrder).toBeLessThan(triggerOrder); + }); + it('should mark the activity log as completed after a successful trigger', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); - expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( - expect.objectContaining({ - collectionName: undefined, - recordId: '42', - label: 'triggered the workflow "wf-1"', - }), + expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }), ); }); - it('should still return the run when recording the activity log fails', async () => { + it('should not start the run when the pending activity log cannot be created (fail-closed)', async () => { mockForestServerClient.createMcpActivityLog.mockRejectedValue(new Error('audit down')); const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(result).toEqual({ - content: [{ type: 'text', text: JSON.stringify({ runId: '7', runState: 'loading' }) }], + content: [{ type: 'text', text: expect.stringContaining('audit down') }], + isError: true, }); - expect(mockLogger).toHaveBeenCalledWith('Warn', expect.stringContaining('audit down')); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); }); it('should return an error result when the auth context is missing the token', async () => { @@ -193,10 +203,48 @@ describe('declareTriggerWorkflowTool', () => { content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], isError: true, }); + expect(mockForestServerClient.getMcpWorkflowById).not.toHaveBeenCalled(); expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); }); - it('should map a server 404 to the "is not an MCP-enabled workflow" tool error', async () => { + it('should reject an unknown workflow (lookup 404) without triggering or auditing', async () => { + mockForestServerClient.getMcpWorkflowById.mockRejectedValue( + new NotFoundError('Workflow not found'), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { type: 'text', text: expect.stringContaining('is not an MCP-enabled workflow') }, + ], + isError: true, + }); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); + expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); + }); + + it('should reject an MCP-disabled workflow without triggering or auditing', async () => { + mockForestServerClient.getMcpWorkflowById.mockResolvedValue({ + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: false, + }); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { type: 'text', text: expect.stringContaining('is not an MCP-enabled workflow') }, + ], + isError: true, + }); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); + expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); + }); + + it('should map a trigger-time 404 race to the friendly error and mark the log failed', async () => { mockForestServerClient.triggerMcpWorkflow.mockRejectedValue( new NotFoundError('Workflow MCP trigger not found or disabled'), ); @@ -209,10 +257,13 @@ describe('declareTriggerWorkflowTool', () => { ], isError: true, }); - expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalled(); + expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }), + ); }); - it('should pass a 409 already-ongoing run through as an error tool result', async () => { + it('should pass a 409 already-ongoing run through as an error and mark the log failed', async () => { mockForestServerClient.triggerMcpWorkflow.mockRejectedValue( new Error('A run is already ongoing on this record'), ); @@ -225,7 +276,10 @@ describe('declareTriggerWorkflowTool', () => { ], isError: true, }); - expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalled(); + expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }), + ); }); }); }); From 1e988e280e63220d56b9d84d75a83f095b8583d8 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 11 Aug 2026 14:30:48 +0200 Subject: [PATCH 10/43] fix(mcp-server): reject empty workflow args and add polling guidance (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) --- packages/mcp-server/src/tools/get-workflow-run.ts | 6 ++++-- packages/mcp-server/src/tools/trigger-workflow.ts | 4 ++-- packages/mcp-server/test/tools/get-workflow-run.test.ts | 2 ++ packages/mcp-server/test/tools/trigger-workflow.test.ts | 2 ++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server/src/tools/get-workflow-run.ts b/packages/mcp-server/src/tools/get-workflow-run.ts index b416cb8296..62f5620bf2 100644 --- a/packages/mcp-server/src/tools/get-workflow-run.ts +++ b/packages/mcp-server/src/tools/get-workflow-run.ts @@ -28,9 +28,11 @@ export default function declareGetWorkflowRunTool(mcpServer: McpServer, ctx: Too 'type, outgoing branches) and its per-step context (completion, selected option, error, ' + 'escalation state, awaiting-input reason). Use it to see exactly where the run is and ' + 'what each step does. A run parked on a human-gated step cannot be resumed via MCP in ' + - 'v1 and must be finished from the Forest UI.', + 'v1 and must be finished from the Forest UI. Poll at a reasonable interval: wait at ' + + 'least a few seconds between calls, and do not busy-loop on a long-running or ' + + 'human-gated run (which never resolves via MCP in v1).', inputSchema: { - runId: z.string().describe(RUN_ID_DESCRIPTION), + runId: z.string().min(1).describe(RUN_ID_DESCRIPTION), }, }, async (args: GetWorkflowRunArgument, extra) => { diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index 62982fbde3..1657fcecfe 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -46,8 +46,8 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To '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), - recordId: z.string().describe(RECORD_ID_DESCRIPTION), + workflowId: z.string().min(1).describe(WORKFLOW_ID_DESCRIPTION), + recordId: z.string().min(1).describe(RECORD_ID_DESCRIPTION), }, }, async (args: TriggerWorkflowArgument, extra) => { diff --git a/packages/mcp-server/test/tools/get-workflow-run.test.ts b/packages/mcp-server/test/tools/get-workflow-run.test.ts index 55226b2388..d8562390f6 100644 --- a/packages/mcp-server/test/tools/get-workflow-run.test.ts +++ b/packages/mcp-server/test/tools/get-workflow-run.test.ts @@ -56,6 +56,7 @@ describe('declareGetWorkflowRunTool', () => { expect(registeredToolConfig.title).toBe('Get a workflow run status'); expect(registeredToolConfig.description).toContain('workflowHistory'); expect(registeredToolConfig.description).toContain('cannot be resumed via MCP'); + expect(registeredToolConfig.description).toContain('wait at least a few seconds'); }); it('should be annotated as read-only', () => { @@ -83,6 +84,7 @@ describe('declareGetWorkflowRunTool', () => { expect(() => schema.runId.parse('7')).not.toThrow(); expect(() => schema.runId.parse(undefined)).toThrow(); expect(() => schema.runId.parse(7)).toThrow(); + expect(() => schema.runId.parse('')).toThrow(); }); }); diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 9a48007ef3..4585dad129 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -81,8 +81,10 @@ describe('declareTriggerWorkflowTool', () => { expect(() => schema.workflowId.parse('wf-1')).not.toThrow(); expect(() => schema.workflowId.parse(undefined)).toThrow(); + expect(() => schema.workflowId.parse('')).toThrow(); expect(() => schema.recordId.parse('42')).not.toThrow(); expect(() => schema.recordId.parse(123)).toThrow(); + expect(() => schema.recordId.parse('')).toThrow(); }); }); From 29a4cf1a20ec24b12e4d723ed2a16dadf2b22025 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 11 Aug 2026 19:40:22 +0200 Subject: [PATCH 11/43] fix(mcp-server): fail closed on null activity-log id and annotate triggerWorkflow (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 --- .../mcp-server/src/tools/trigger-workflow.ts | 11 ++++++-- .../src/utils/activity-logs-creator.ts | 14 +++++++++- .../test/helpers/registered-tool-config.ts | 7 ++++- .../test/tools/trigger-workflow.test.ts | 27 ++++++++++++++++-- .../test/utils/activity-logs-creator.test.ts | 28 +++++++++++++++++++ 5 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index 1657fcecfe..e693659539 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -22,9 +22,8 @@ interface TriggerWorkflowArgument { recordId: string; } -// The server answers 404 both for an unknown workflow and for one whose MCP trigger is disabled -// (and getMcpWorkflowById returns mcpEnabled: false for the latter). Surface the same guidance in -// every case so the LLM-facing contract stays identical. +// One message for the unknown, MCP-disabled and trigger-time-404 cases, so the LLM-facing +// contract stays uniform and never leaks whether a given workflow exists. function notMcpEnabledMessage(workflowId: string): string { return ( `Workflow "${workflowId}" is not an MCP-enabled workflow you can access. ` + @@ -39,6 +38,12 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To mcpServer, 'triggerWorkflow', { + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, title: 'Trigger a workflow', description: 'Start an MCP-enabled Forest workflow on a specific record. Returns a runId immediately; ' + diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 7ac1f5be6e..b876b24f54 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -41,7 +41,7 @@ export default async function createPendingActivityLog( const type = ACTION_TO_TYPE[action]; const { forestServerToken, renderingId } = getAuthContext(request); - return forestServerClient.createMcpActivityLog({ + const activityLog = await forestServerClient.createMcpActivityLog({ forestServerToken, renderingId, action, @@ -51,6 +51,18 @@ export default async function createPendingActivityLog( recordIds: extra?.recordIds, label: extra?.label, }); + + // Fail-closed: the server answers HTTP 200 with a null id when the audit write is dropped + // (audit store down, or a collection that no longer exists). Reject so callers relying on + // withActivityLog block the operation instead of proceeding with no audit trail. + if (activityLog?.id === null || activityLog?.id === undefined) { + throw new Error( + 'Failed to create activity log: the server returned no activity log id. ' + + 'Blocking the operation to preserve the audit trail.', + ); + } + + return activityLog; } interface UpdateActivityLogOptions { diff --git a/packages/mcp-server/test/helpers/registered-tool-config.ts b/packages/mcp-server/test/helpers/registered-tool-config.ts index a1f6b25061..39f10cd1f8 100644 --- a/packages/mcp-server/test/helpers/registered-tool-config.ts +++ b/packages/mcp-server/test/helpers/registered-tool-config.ts @@ -2,5 +2,10 @@ export type RegisteredToolConfig = { title: string; description: string; inputSchema: unknown; - annotations?: { readOnlyHint?: boolean }; + annotations?: { + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; }; diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 4585dad129..5425861a52 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -57,14 +57,19 @@ describe('declareTriggerWorkflowTool', () => { expect(registeredToolConfig.description).toContain('getWorkflowRun'); }); - it('should not be annotated as read-only', () => { + it('should annotate the tool as a non-read-only, non-destructive, non-idempotent write', () => { declareTriggerWorkflowTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, collectionNames: [], }); - expect(registeredToolConfig.annotations?.readOnlyHint).toBeUndefined(); + expect(registeredToolConfig.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }); }); it('should require string workflowId and recordId arguments', () => { @@ -191,6 +196,24 @@ describe('declareTriggerWorkflowTool', () => { expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); }); + it('should not start the run when the audit store returns a 200 with a null id (fail-closed)', async () => { + mockForestServerClient.createMcpActivityLog.mockResolvedValue({ + id: null, + attributes: {}, + } as never); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { type: 'text', text: expect.stringContaining('the server returned no activity log id') }, + ], + isError: true, + }); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); + expect(mockForestServerClient.updateActivityLogStatus).not.toHaveBeenCalled(); + }); + it('should return an error result when the auth context is missing the token', async () => { const extraWithoutToken = { authInfo: { extra: { renderingId: 123 } }, diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index bbb79e8b70..d1724ad24f 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -255,6 +255,34 @@ describe('createPendingActivityLog', () => { createPendingActivityLog(mockForestServerClient, request, 'index'), ).resolves.not.toThrow(); }); + + it('should reject when the server returns a 200 with a null id (fail-closed)', async () => { + mockForestServerClient.createMcpActivityLog.mockResolvedValue({ + id: null, + attributes: {}, + } as never); + + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, 'triggerWorkflow'), + ).rejects.toThrow( + 'Failed to create activity log: the server returned no activity log id. ' + + 'Blocking the operation to preserve the audit trail.', + ); + }); + + it('should reject when the server returns a response with an undefined id (fail-closed)', async () => { + mockForestServerClient.createMcpActivityLog.mockResolvedValue({ + attributes: {}, + } as never); + + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, 'triggerWorkflow'), + ).rejects.toThrow('the server returned no activity log id'); + }); }); }); From 7f60f45850703464e14b133edf1e891ad5985b64 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 11 Aug 2026 19:41:33 +0200 Subject: [PATCH 12/43] fix(forestadmin-client): append workflowsService at the end of the client 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 --- .../src/forest-admin-client-with-cache.ts | 2 +- packages/forestadmin-client/src/index.ts | 2 +- .../test/__factories__/forest-admin-client.ts | 2 +- .../forest-admin-client-with-cache.test.ts | 61 ++++++++++++++++--- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts index 70ea70b1e8..a2a0c65526 100644 --- a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts +++ b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts @@ -33,12 +33,12 @@ export default class ForestAdminClientWithCache implements ForestAdminClient { protected readonly ipWhitelistService: IpWhiteListService, public readonly schemaService: SchemaService, public readonly activityLogsService: ActivityLogsService, - public readonly workflowsService: WorkflowsService, public readonly authService: ForestAdminAuthServiceInterface, public readonly modelCustomizationService: ModelCustomizationService, public readonly mcpServerConfigService: McpServerConfigService, protected readonly eventsSubscription: BaseEventsSubscriptionService, protected readonly eventsHandler: RefreshEventsHandlerService, + public readonly workflowsService: WorkflowsService, ) {} verifySignedActionParameters(signedParameters: string): TSignedParameters { diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index fe300340c7..46628b7ba9 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -90,12 +90,12 @@ export default function createForestAdminClient( ipWhitelist, schema, activityLogs, - workflows, auth, modelCustomizationService, mcpServerConfigService, eventsSubscription, eventsHandler, + workflows, ); } diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-client.ts b/packages/forestadmin-client/test/__factories__/forest-admin-client.ts index b541ea5abe..3c2b0040dd 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-client.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-client.ts @@ -37,12 +37,12 @@ const forestAdminClientFactory = ForestAdminClientFactory.define( ipWhitelistServiceFactory.build(), schemaServiceFactory.build(), activityLogsServiceFactory.build(), - workflowsServiceFactory.build(), authServiceFactory.build(), modelCustomizationServiceFactory.build(), mcpServerConfigServiceFactory.build(), eventsSubscriptionServiceFactory.build(), nativeRefreshEventsHandlerServiceFactory.build(), + workflowsServiceFactory.build(), ), ); diff --git a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts index d70602fb9d..3fbc3ae207 100644 --- a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts +++ b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts @@ -10,6 +10,47 @@ jest.mock('../src/permissions/verify-approval', () => ({ const verifyAndExtractApprovalMock = verifyAndExtractApproval as jest.Mock; describe('ForestAdminClientWithCache', () => { + describe('constructor wiring', () => { + it('should assign each positional service to its matching property', () => { + const permissionService = factories.permission.build(); + const contextVariablesInstantiator = factories.contextVariablesInstantiator.build(); + const chartHandler = factories.chartHandler.build(); + const schemaService = factories.schema.build(); + const activityLogsService = factories.activityLogs.build(); + const authService = factories.auth.build(); + const modelCustomizationService = factories.modelCustomization.build(); + const mcpServerConfigService = factories.mcpServerConfig.build(); + const workflowsService = factories.workflows.build(); + + const forestAdminClient = new ForestAdminClient( + factories.forestAdminClientOptions.build(), + permissionService, + factories.renderingPermission.build(), + contextVariablesInstantiator, + chartHandler, + factories.ipWhiteList.build(), + schemaService, + activityLogsService, + authService, + modelCustomizationService, + mcpServerConfigService, + factories.eventsSubscription.build(), + factories.eventsHandler.build(), + workflowsService, + ); + + expect(forestAdminClient.permissionService).toBe(permissionService); + expect(forestAdminClient.contextVariablesInstantiator).toBe(contextVariablesInstantiator); + expect(forestAdminClient.chartHandler).toBe(chartHandler); + expect(forestAdminClient.schemaService).toBe(schemaService); + expect(forestAdminClient.activityLogsService).toBe(activityLogsService); + expect(forestAdminClient.authService).toBe(authService); + expect(forestAdminClient.modelCustomizationService).toBe(modelCustomizationService); + expect(forestAdminClient.mcpServerConfigService).toBe(mcpServerConfigService); + expect(forestAdminClient.workflowsService).toBe(workflowsService); + }); + }); + describe('getIpWhitelistConfiguration', () => { it('should delegate to the given service', async () => { const whiteListService = factories.ipWhiteList.build({ @@ -25,12 +66,12 @@ describe('ForestAdminClientWithCache', () => { whiteListService, factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), factories.eventsHandler.build(), + factories.workflows.build(), ); const config = await forestAdminClient.getIpWhitelistConfiguration(); @@ -54,12 +95,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), schemaService, factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), factories.eventsHandler.build(), + factories.workflows.build(), ); const result = await forestAdminClient.postSchema({ @@ -89,12 +130,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), factories.eventsHandler.build(), + factories.workflows.build(), ); verifyAndExtractApprovalMock.mockReturnValue(signedParameters); @@ -119,12 +160,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), factories.eventsHandler.build(), + factories.workflows.build(), ); await forestAdminClient.markScopesAsUpdated(42); @@ -145,12 +186,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), factories.eventsHandler.build(), + factories.workflows.build(), ); await forestAdminClient.markScopesAsUpdated(42); @@ -172,12 +213,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), factories.eventsHandler.build(), + factories.workflows.build(), ); (renderingPermissionService.getScope as jest.Mock).mockResolvedValue('scope'); @@ -209,12 +250,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), eventsSubscriptionService, factories.eventsHandler.build(), + factories.workflows.build(), ); await forestAdminClient.subscribeToServerEvents(); @@ -235,12 +276,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), eventsSubscriptionService, factories.eventsHandler.build(), + factories.workflows.build(), ); forestAdminClient.close(); @@ -261,12 +302,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), eventsHandlerService, + factories.workflows.build(), ); const handler = jest.fn(); @@ -289,12 +330,12 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), - factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), factories.eventsSubscription.build(), eventsHandlerService, + factories.workflows.build(), ); const handler = jest.fn(); From 1c30c768c3e8afb8a37f527344223b88319fd765 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 12 Aug 2026 11:33:51 +0200 Subject: [PATCH 13/43] fix(mcp-server): reject workflows without a collection at trigger time (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 --- .../mcp-server/src/tools/trigger-workflow.ts | 20 +++++++++++--- .../test/tools/trigger-workflow.test.ts | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index e693659539..048fdefac5 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -31,6 +31,13 @@ function notMcpEnabledMessage(workflowId: string): string { ); } +function unavailableCollectionMessage(workflowId: string): string { + return ( + `Workflow "${workflowId}" cannot be triggered via MCP because its collection is unavailable. ` + + "Check the workflow's configuration in Forest." + ); +} + export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: ToolContext): string { const { forestServerClient, logger } = ctx; @@ -58,9 +65,8 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To async (args: TriggerWorkflowArgument, extra) => { const { forestServerToken, renderingId } = getAuthContext(extra); - // Resolve the workflow O(1) up front so the audit log can be labelled with its name/collection - // BEFORE the trigger (fail-closed, like create/update/delete). Reject unknown or MCP-disabled - // workflows here — no run is started and no log is written for a non-triggerable workflow. + // Resolve the workflow by id up front so the audit log can be labelled BEFORE the trigger + // (fail-closed). Reject unknown or MCP-disabled workflows here — no run, no log. let workflow: McpWorkflowLookup; try { @@ -81,12 +87,18 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To throw new Error(notMcpEnabledMessage(args.workflowId)); } + // A renamed/deleted collection leaves collectionName null. The activity log would be dropped + // server-side and fail-closed would block with a misleading message, so reject up front. + if (workflow.collectionName == null) { + throw new Error(unavailableCollectionMessage(args.workflowId)); + } + const result = await withActivityLog({ forestServerClient, request: extra, action: 'triggerWorkflow', context: { - collectionName: workflow.collectionName ?? undefined, + collectionName: workflow.collectionName, recordId: args.recordId, label: `triggered the workflow "${workflow.name}"`, }, diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 5425861a52..3fd908dc3c 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -269,6 +269,32 @@ describe('declareTriggerWorkflowTool', () => { expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); }); + it('should reject a workflow whose collection is unavailable without auditing or triggering', async () => { + mockForestServerClient.getMcpWorkflowById.mockResolvedValue({ + workflowId: 'wf-1', + name: 'Refund order', + collectionName: null, + mcpEnabled: true, + }); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: + 'Workflow "wf-1" cannot be triggered via MCP because its collection is unavailable. ' + + "Check the workflow's configuration in Forest.", + }, + ], + isError: true, + }); + expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); + expect(mockForestServerClient.updateActivityLogStatus).not.toHaveBeenCalled(); + }); + it('should map a trigger-time 404 race to the friendly error and mark the log failed', async () => { mockForestServerClient.triggerMcpWorkflow.mockRejectedValue( new NotFoundError('Workflow MCP trigger not found or disabled'), From bbc329e3cd1edf69c29d9ab9d0d78a4e03c3f711 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 12 Aug 2026 11:34:09 +0200 Subject: [PATCH 14/43] docs(forestadmin-client): drop the O(1) claim from the mcp workflow lookup 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 --- packages/forestadmin-client/src/types.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index a87a942e64..37b3593dc7 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -300,7 +300,8 @@ export interface ListMcpWorkflowsParams { } /** - * A single workflow resolved by id (O(1) lookup). Unlike the listing, this also carries + * A single workflow resolved by id: the match is resolved inside Postgres, so the client never + * receives or deserializes the full workflow list. Unlike the listing, this also carries * `mcpEnabled`: an existing-but-MCP-disabled workflow is returned with `mcpEnabled: false` rather * than hidden, so the caller can label a fail-closed audit log before triggering while the * start endpoint stays the guard that refuses a disabled trigger. From 5027916c54c2e0f8b62cdf441b1c30b7dd415867 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 12 Aug 2026 11:41:59 +0200 Subject: [PATCH 15/43] test(forestadmin-client): surface 409 conflict detail through ServerUtils (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../test/utils/server.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/forestadmin-client/test/utils/server.test.ts b/packages/forestadmin-client/test/utils/server.test.ts index 7a5f48164f..fa3f782c4a 100644 --- a/packages/forestadmin-client/test/utils/server.test.ts +++ b/packages/forestadmin-client/test/utils/server.test.ts @@ -322,5 +322,24 @@ describe('ServerUtils', () => { }), ).rejects.toThrow('The requested resource was not found on Forest Admin server.'); }); + + it('should surface the server detail on a 409 conflict (already-running workflow run)', async () => { + nock(options.forestServerUrl) + .post('/api/workflow-orchestrator/mcp-workflows/wf-1/start') + .reply(409, { errors: [{ detail: 'A run is already ongoing on this record' }] }); + + await expect( + ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + method: 'post', + path: '/api/workflow-orchestrator/mcp-workflows/wf-1/start', + bearerToken: 'valid-token', + body: { recordId: '42' }, + }), + ).rejects.toMatchObject({ + message: 'A run is already ongoing on this record', + status: 409, + }); + }); }); }); From 4aa57c6d55b143401b432ce737b31b1f227d190c Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 12 Aug 2026 15:09:49 +0200 Subject: [PATCH 16/43] fix(mcp-server): mark triggerWorkflow as destructive in tool annotations (PRD-49) Co-Authored-By: Claude Fable 5 --- packages/mcp-server/src/tools/trigger-workflow.ts | 2 +- packages/mcp-server/test/tools/trigger-workflow.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index 048fdefac5..bb309a769c 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -47,7 +47,7 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To { annotations: { readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }, diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 3fd908dc3c..492995f59f 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -57,7 +57,7 @@ describe('declareTriggerWorkflowTool', () => { expect(registeredToolConfig.description).toContain('getWorkflowRun'); }); - it('should annotate the tool as a non-read-only, non-destructive, non-idempotent write', () => { + it('should annotate the tool as a non-read-only, destructive, non-idempotent write', () => { declareTriggerWorkflowTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, @@ -66,7 +66,7 @@ describe('declareTriggerWorkflowTool', () => { expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, idempotentHint: false, openWorldHint: true, }); From bdc82084ede7bf929905a0bbea676bfe0b777c11 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 17 Aug 2026 09:06:16 +0200 Subject: [PATCH 17/43] fix(mcp-server): scope the fail-closed audit guard to write actions (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 --- .../src/utils/activity-logs-creator.ts | 21 ++++-- .../mcp-server/src/utils/with-activity-log.ts | 38 ++++++---- .../test/utils/activity-logs-creator.test.ts | 57 ++++++++++----- .../test/utils/with-activity-log.test.ts | 69 +++++++++++++++++++ 4 files changed, 150 insertions(+), 35 deletions(-) diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index b876b24f54..5f8cdd62f2 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -52,14 +52,21 @@ export default async function createPendingActivityLog( label: extra?.label, }); - // Fail-closed: the server answers HTTP 200 with a null id when the audit write is dropped - // (audit store down, or a collection that no longer exists). Reject so callers relying on - // withActivityLog block the operation instead of proceeding with no audit trail. + // The server answers HTTP 200 with a null id when the audit write is dropped + // (audit store down, or a collection that no longer exists). Write actions are + // fail-closed: reject so withActivityLog blocks the operation instead of proceeding + // with no audit trail. Read actions are fail-open: return null so the operation + // proceeds (skipping status tracking) — an audit-store outage must not take down + // the whole read surface. if (activityLog?.id === null || activityLog?.id === undefined) { - throw new Error( - 'Failed to create activity log: the server returned no activity log id. ' + - 'Blocking the operation to preserve the audit trail.', - ); + if (type === 'write') { + throw new Error( + 'Failed to create activity log: the server returned no activity log id. ' + + 'Blocking the operation to preserve the audit trail.', + ); + } + + return null; } return activityLog; diff --git a/packages/mcp-server/src/utils/with-activity-log.ts b/packages/mcp-server/src/utils/with-activity-log.ts index 20eb05beb3..5b37d125e4 100644 --- a/packages/mcp-server/src/utils/with-activity-log.ts +++ b/packages/mcp-server/src/utils/with-activity-log.ts @@ -45,15 +45,27 @@ export default async function withActivityLog(options: WithActivityLogOptions const activityLog = await createPendingActivityLog(forestServerClient, request, action, context); + // Null means the server dropped the audit write for a read action (fail-open): + // proceed without status tracking rather than blocking the read surface. + if (!activityLog) { + logger( + 'Warn', + `Activity log for '${action}' was not persisted by the server; ` + + 'proceeding without audit trail for this read operation.', + ); + } + try { const result = await operation(); - markActivityLogAsSucceeded({ - forestServerClient, - request, - activityLog, - logger, - }); + if (activityLog) { + markActivityLogAsSucceeded({ + forestServerClient, + request, + activityLog, + logger, + }); + } return result; } catch (error) { @@ -74,12 +86,14 @@ export default async function withActivityLog(options: WithActivityLogOptions } } - markActivityLogAsFailed({ - forestServerClient, - request, - activityLog, - logger, - }); + if (activityLog) { + markActivityLogAsFailed({ + forestServerClient, + request, + activityLog, + logger, + }); + } throw new Error(errorMessage); } diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index d1724ad24f..22c6df5696 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -256,23 +256,26 @@ describe('createPendingActivityLog', () => { ).resolves.not.toThrow(); }); - it('should reject when the server returns a 200 with a null id (fail-closed)', async () => { - mockForestServerClient.createMcpActivityLog.mockResolvedValue({ - id: null, - attributes: {}, - } as never); - - const request = createMockRequest(); - - await expect( - createPendingActivityLog(mockForestServerClient, request, 'triggerWorkflow'), - ).rejects.toThrow( - 'Failed to create activity log: the server returned no activity log id. ' + - 'Blocking the operation to preserve the audit trail.', - ); - }); + it.each(['action', 'create', 'update', 'delete', 'triggerWorkflow'])( + 'should reject when the server returns a 200 with a null id for write action "%s" (fail-closed)', + async action => { + mockForestServerClient.createMcpActivityLog.mockResolvedValue({ + id: null, + attributes: {}, + } as never); + + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, action), + ).rejects.toThrow( + 'Failed to create activity log: the server returned no activity log id. ' + + 'Blocking the operation to preserve the audit trail.', + ); + }, + ); - it('should reject when the server returns a response with an undefined id (fail-closed)', async () => { + it('should reject when the server returns a response with an undefined id for a write action (fail-closed)', async () => { mockForestServerClient.createMcpActivityLog.mockResolvedValue({ attributes: {}, } as never); @@ -283,6 +286,28 @@ describe('createPendingActivityLog', () => { createPendingActivityLog(mockForestServerClient, request, 'triggerWorkflow'), ).rejects.toThrow('the server returned no activity log id'); }); + + it.each([ + 'index', + 'search', + 'filter', + 'listRelatedData', + 'describeCollection', + ])( + 'should resolve to null when the server returns a 200 with a null id for read action "%s" (fail-open)', + async action => { + mockForestServerClient.createMcpActivityLog.mockResolvedValue({ + id: null, + attributes: {}, + } as never); + + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, action), + ).resolves.toBeNull(); + }, + ); }); }); diff --git a/packages/mcp-server/test/utils/with-activity-log.test.ts b/packages/mcp-server/test/utils/with-activity-log.test.ts index 338325ce12..01a141fabb 100644 --- a/packages/mcp-server/test/utils/with-activity-log.test.ts +++ b/packages/mcp-server/test/utils/with-activity-log.test.ts @@ -232,6 +232,75 @@ describe('withActivityLog', () => { ); }); + describe('when the activity log was not persisted (read action, fail-open)', () => { + beforeEach(() => { + mockCreatePendingActivityLog.mockResolvedValue(null); + }); + + it('should run the operation and return its result', async () => { + const expectedResult = { content: [{ type: 'text', text: 'data' }] }; + const operation = jest.fn().mockResolvedValue(expectedResult); + + const result = await withActivityLog({ + forestServerClient: mockForestServerClient, + request: mockRequest, + action: 'index', + logger: mockLogger, + operation, + }); + + expect(result).toEqual(expectedResult); + }); + + it('should log a warning about the missing audit trail', async () => { + const operation = jest.fn().mockResolvedValue({ result: 'success' }); + + await withActivityLog({ + forestServerClient: mockForestServerClient, + request: mockRequest, + action: 'index', + logger: mockLogger, + operation, + }); + + expect(mockLogger).toHaveBeenCalledWith( + 'Warn', + "Activity log for 'index' was not persisted by the server; " + + 'proceeding without audit trail for this read operation.', + ); + }); + + it('should not track the activity log status on success', async () => { + const operation = jest.fn().mockResolvedValue({ result: 'success' }); + + await withActivityLog({ + forestServerClient: mockForestServerClient, + request: mockRequest, + action: 'index', + logger: mockLogger, + operation, + }); + + expect(mockMarkActivityLogAsSucceeded).not.toHaveBeenCalled(); + }); + + it('should not track the activity log status on failure, and still throw', async () => { + const operation = jest.fn().mockRejectedValue(new Error('Operation failed')); + + await expect( + withActivityLog({ + forestServerClient: mockForestServerClient, + request: mockRequest, + action: 'index', + logger: mockLogger, + operation, + }), + ).rejects.toThrow('Operation failed'); + + expect(mockMarkActivityLogAsFailed).not.toHaveBeenCalled(); + }); + }); + describe('errorEnhancer', () => { it('should apply errorEnhancer to error message when provided', async () => { const operation = jest.fn().mockRejectedValue(new Error('Original error')); From ecb3125f02f09f1653d087bcbfaf98f1c05be58c Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 17 Aug 2026 09:12:40 +0200 Subject: [PATCH 18/43] fix(mcp-server): label the MCP-side trigger audit row via MCP (PRD-49) 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 --- packages/mcp-server/src/tools/trigger-workflow.ts | 5 ++++- packages/mcp-server/test/tools/trigger-workflow.test.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index bb309a769c..bed3b1f8af 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -100,7 +100,10 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To context: { collectionName: workflow.collectionName, recordId: args.recordId, - label: `triggered the workflow "${workflow.name}"`, + // Aligned with the orchestrator's own audit row ("… via MCP"): one trigger writes two + // complementary entries — this one (fail-closed, no runId yet) and the orchestrator's + // (attached to the run) — so both must read as MCP-originated. + label: `triggered the workflow "${workflow.name}" via MCP`, }, logger, operation: () => diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 492995f59f..aa6d8bd4b6 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -167,7 +167,7 @@ describe('declareTriggerWorkflowTool', () => { type: 'write', collectionName: 'orders', recordId: '42', - label: 'triggered the workflow "Refund order"', + label: 'triggered the workflow "Refund order" via MCP', }), ); From 0cdbcce4fe91a19b0ad9aaf71cd8178516539b07 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 17 Aug 2026 09:13:13 +0200 Subject: [PATCH 19/43] refactor(forestadmin-client): drop workflowName/collectionName from the 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 --- .../forestadmin-client/src/permissions/forest-http-api.ts | 5 ++--- packages/forestadmin-client/src/types.ts | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index c9a2d937d5..070e0e4eb6 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -193,8 +193,7 @@ export default class ForestHttpApi implements ForestAdminServerInterface { recordId: string, ): Promise { // The orchestrator returns a numeric runId; normalize it to the string form expected by - // getMcpWorkflowRun. workflowName/collectionName are passed through when present so the - // caller can label the audit log without listing every workflow first. + // getMcpWorkflowRun. const result = await ServerUtils.queryWithBearerToken< Omit & { runId: number | string } >({ @@ -206,7 +205,7 @@ export default class ForestHttpApi implements ForestAdminServerInterface { headers: { 'forest-rendering-id': renderingId, ...options.headers }, }); - return { ...result, runId: String(result.runId) }; + return { runId: String(result.runId), runState: result.runState }; } async getMcpWorkflowRun( diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 37b3593dc7..e48a80944e 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -327,14 +327,12 @@ export type WorkflowRunState = 'started' | 'pending' | 'loading' | 'aborted' | ' /** * 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. + * Workflow name and collection are not part of this contract — the audit label is resolved + * up front via `getMcpWorkflowById`. */ export interface WorkflowRunTriggerResult { runId: string; runState: WorkflowRunState; - workflowName?: string; - collectionName?: string | null; } export interface TriggerMcpWorkflowParams { From 1ce50dce43ef99b33ee7b11ecea3181de6c1d472 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 17 Aug 2026 09:15:15 +0200 Subject: [PATCH 20/43] docs(forestadmin-client): hedge the hydrated-run customer-data claim (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 --- packages/forestadmin-client/src/types.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index e48a80944e..a01151041a 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -416,8 +416,9 @@ export interface WorkflowHistoryStep { * The full hydrated workflow run as returned by the orchestrator for MCP consumption. * 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. + * and what each step does. The orchestrator carries no record payload (records live in the + * executor), but identifiers (`selectedRecordId`) and executor-reported error strings + * (`context.error`) may still contain customer data. */ export interface HydratedWorkflowRun { id: number; From ade72f8d3c3ffbeca54750c52e1b88665a05f647 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 17 Aug 2026 09:15:16 +0200 Subject: [PATCH 21/43] refactor(forestadmin-client): factor the workflow transport guards (PRD-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 --- .../forestadmin-client/src/workflows/index.ts | 84 +++++++++---------- 1 file changed, 38 insertions(+), 46 deletions(-) diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts index 2a99939219..e001b8e1ca 100644 --- a/packages/forestadmin-client/src/workflows/index.ts +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -15,27 +15,47 @@ export type WorkflowsServiceOptions = { headers?: Record; }; +type WorkflowTransportMethod = + | 'listMcpEnabledWorkflows' + | 'getMcpWorkflowById' + | 'triggerMcpWorkflow' + | 'getMcpWorkflowRun'; + export default class WorkflowsService { constructor( private forestAdminServerInterface: ForestAdminServerInterface, private options: WorkflowsServiceOptions, ) {} - async listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise { - const { forestServerToken, renderingId, collectionName } = params; + // The transport interface methods are optional so external implementations keep compiling; + // resolve the bound method or fail with a uniform message. + private resolveTransportMethod( + name: K, + ): NonNullable { + const method = this.forestAdminServerInterface[name]; - if (!this.forestAdminServerInterface.listMcpEnabledWorkflows) { - throw new Error( - 'The configured Forest server transport does not support listMcpEnabledWorkflows.', - ); + if (!method) { + throw new Error(`The configured Forest server transport does not support ${name}.`); } - return this.forestAdminServerInterface.listMcpEnabledWorkflows( - { - forestServerUrl: this.options.forestServerUrl, - bearerToken: forestServerToken, - headers: this.options.headers, - }, + return method.bind(this.forestAdminServerInterface) as NonNullable< + ForestAdminServerInterface[K] + >; + } + + private httpOptions(forestServerToken: string) { + return { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }; + } + + async listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise { + const { forestServerToken, renderingId, collectionName } = params; + + return this.resolveTransportMethod('listMcpEnabledWorkflows')( + this.httpOptions(forestServerToken), renderingId, collectionName, ); @@ -44,18 +64,8 @@ export default class WorkflowsService { async getMcpWorkflowById(params: GetMcpWorkflowByIdParams): Promise { const { forestServerToken, renderingId, workflowId } = params; - if (!this.forestAdminServerInterface.getMcpWorkflowById) { - throw new Error( - 'The configured Forest server transport does not support getMcpWorkflowById.', - ); - } - - return this.forestAdminServerInterface.getMcpWorkflowById( - { - forestServerUrl: this.options.forestServerUrl, - bearerToken: forestServerToken, - headers: this.options.headers, - }, + return this.resolveTransportMethod('getMcpWorkflowById')( + this.httpOptions(forestServerToken), renderingId, workflowId, ); @@ -64,18 +74,8 @@ export default class WorkflowsService { async triggerMcpWorkflow(params: TriggerMcpWorkflowParams): Promise { const { forestServerToken, renderingId, workflowId, recordId } = params; - if (!this.forestAdminServerInterface.triggerMcpWorkflow) { - throw new Error( - 'The configured Forest server transport does not support triggerMcpWorkflow.', - ); - } - - return this.forestAdminServerInterface.triggerMcpWorkflow( - { - forestServerUrl: this.options.forestServerUrl, - bearerToken: forestServerToken, - headers: this.options.headers, - }, + return this.resolveTransportMethod('triggerMcpWorkflow')( + this.httpOptions(forestServerToken), renderingId, workflowId, recordId, @@ -85,16 +85,8 @@ export default class WorkflowsService { async getMcpWorkflowRun(params: GetMcpWorkflowRunParams): Promise { const { forestServerToken, renderingId, runId } = params; - if (!this.forestAdminServerInterface.getMcpWorkflowRun) { - throw new Error('The configured Forest server transport does not support getMcpWorkflowRun.'); - } - - return this.forestAdminServerInterface.getMcpWorkflowRun( - { - forestServerUrl: this.options.forestServerUrl, - bearerToken: forestServerToken, - headers: this.options.headers, - }, + return this.resolveTransportMethod('getMcpWorkflowRun')( + this.httpOptions(forestServerToken), renderingId, runId, ); From d8ceca491e9b736c88b05488e4b0400f717f74c4 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 17 Aug 2026 09:15:17 +0200 Subject: [PATCH 22/43] test(mcp-server): pin auth-context extraction directly (PRD-49) 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 --- .../test/utils/auth-context.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 packages/mcp-server/test/utils/auth-context.test.ts diff --git a/packages/mcp-server/test/utils/auth-context.test.ts b/packages/mcp-server/test/utils/auth-context.test.ts new file mode 100644 index 0000000000..b1ca6f1331 --- /dev/null +++ b/packages/mcp-server/test/utils/auth-context.test.ts @@ -0,0 +1,69 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import getAuthContext from '../../src/utils/auth-context'; + +describe('getAuthContext', () => { + const createRequest = (extra: Record | undefined) => + ({ + authInfo: extra === undefined ? undefined : { extra }, + } as unknown as RequestHandlerExtra); + + it('should return the forestServerToken and renderingId from authInfo.extra', () => { + const request = createRequest({ forestServerToken: 'forest-token', renderingId: '123' }); + + expect(getAuthContext(request)).toEqual({ + forestServerToken: 'forest-token', + renderingId: '123', + }); + }); + + it('should convert a numeric renderingId (from the JWT) to a string', () => { + const request = createRequest({ forestServerToken: 'forest-token', renderingId: 456 }); + + expect(getAuthContext(request)).toEqual({ + forestServerToken: 'forest-token', + renderingId: '456', + }); + }); + + it('should throw when forestServerToken is missing', () => { + const request = createRequest({ renderingId: '123' }); + + expect(() => getAuthContext(request)).toThrow( + 'Invalid or missing forestServerToken in authentication context', + ); + }); + + it('should throw when forestServerToken is not a string', () => { + const request = createRequest({ forestServerToken: 42, renderingId: '123' }); + + expect(() => getAuthContext(request)).toThrow( + 'Invalid or missing forestServerToken in authentication context', + ); + }); + + it('should throw when renderingId is missing', () => { + const request = createRequest({ forestServerToken: 'forest-token' }); + + expect(() => getAuthContext(request)).toThrow( + 'Invalid or missing renderingId in authentication context', + ); + }); + + it('should throw when renderingId is null', () => { + const request = createRequest({ forestServerToken: 'forest-token', renderingId: null }); + + expect(() => getAuthContext(request)).toThrow( + 'Invalid or missing renderingId in authentication context', + ); + }); + + it('should throw when authInfo is absent entirely', () => { + const request = createRequest(undefined); + + expect(() => getAuthContext(request)).toThrow( + 'Invalid or missing forestServerToken in authentication context', + ); + }); +}); From 0a9f2e270b99c5be8295ee73fb64893224a35b13 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Mon, 17 Aug 2026 10:21:39 +0200 Subject: [PATCH 23/43] docs(mcp-server): tighten review-fix comments (PRD-49) Co-Authored-By: Claude Fable 5 --- packages/mcp-server/src/tools/trigger-workflow.ts | 4 +--- packages/mcp-server/src/utils/activity-logs-creator.ts | 8 ++------ packages/mcp-server/src/utils/with-activity-log.ts | 3 +-- 3 files changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index bed3b1f8af..4ddcc7504f 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -100,9 +100,7 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To context: { collectionName: workflow.collectionName, recordId: args.recordId, - // Aligned with the orchestrator's own audit row ("… via MCP"): one trigger writes two - // complementary entries — this one (fail-closed, no runId yet) and the orchestrator's - // (attached to the run) — so both must read as MCP-originated. + // Matches the orchestrator's own "via MCP" row — one trigger writes two entries. label: `triggered the workflow "${workflow.name}" via MCP`, }, logger, diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 5f8cdd62f2..e1e4fe8713 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -52,12 +52,8 @@ export default async function createPendingActivityLog( label: extra?.label, }); - // The server answers HTTP 200 with a null id when the audit write is dropped - // (audit store down, or a collection that no longer exists). Write actions are - // fail-closed: reject so withActivityLog blocks the operation instead of proceeding - // with no audit trail. Read actions are fail-open: return null so the operation - // proceeds (skipping status tracking) — an audit-store outage must not take down - // the whole read surface. + // 200-with-null-id = audit write dropped (store down, or stale collection). + // Writes fail closed; reads fail open so an audit outage never blocks the read surface. if (activityLog?.id === null || activityLog?.id === undefined) { if (type === 'write') { throw new Error( diff --git a/packages/mcp-server/src/utils/with-activity-log.ts b/packages/mcp-server/src/utils/with-activity-log.ts index 5b37d125e4..a96f96a785 100644 --- a/packages/mcp-server/src/utils/with-activity-log.ts +++ b/packages/mcp-server/src/utils/with-activity-log.ts @@ -45,8 +45,7 @@ export default async function withActivityLog(options: WithActivityLogOptions const activityLog = await createPendingActivityLog(forestServerClient, request, action, context); - // Null means the server dropped the audit write for a read action (fail-open): - // proceed without status tracking rather than blocking the read surface. + // Read whose audit write was dropped (fail-open): proceed without status tracking. if (!activityLog) { logger( 'Warn', From e97b4984506d4ac6a8274fd4774ddccf992f828e Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 18 Aug 2026 16:00:00 +0200 Subject: [PATCH 24/43] fix(mcp-server): arbitrate a rejected audit log by action type (PRD-49) 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) --- .../src/utils/activity-logs-creator.ts | 41 +++++++++---- .../mcp-server/src/utils/with-activity-log.ts | 8 +-- .../test/utils/activity-logs-creator.test.ts | 59 +++++++++++++++++-- .../test/utils/with-activity-log.test.ts | 21 +++++++ 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index e1e4fe8713..ab44d6b496 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -14,6 +14,12 @@ import getAuthContext from './auth-context'; export type { ActivityLogAction, ActivityLogResponse }; +/** + * Fail policy for the audit trail, keyed by action type: a write whose activity log cannot be + * created is blocked (no unaudited side effect), while a read proceeds with a warning (an audit + * store outage must not take down the read surface). Both the refusal/outage path and the + * 200-with-no-id path in `createPendingActivityLog` are arbitrated by this map. + */ const ACTION_TO_TYPE: Record = { index: 'read', search: 'read', @@ -39,21 +45,32 @@ export default async function createPendingActivityLog( }, ) { const type = ACTION_TO_TYPE[action]; + // Outside the fail policy below: a missing auth context is a caller bug, not an audit outage. const { forestServerToken, renderingId } = getAuthContext(request); - const activityLog = await forestServerClient.createMcpActivityLog({ - forestServerToken, - renderingId, - action, - type, - collectionName: extra?.collectionName, - recordId: extra?.recordId, - recordIds: extra?.recordIds, - label: extra?.label, - }); + let activityLog: ActivityLogResponse | undefined; + + try { + activityLog = await forestServerClient.createMcpActivityLog({ + forestServerToken, + renderingId, + action, + type, + collectionName: extra?.collectionName, + recordId: extra?.recordId, + recordIds: extra?.recordIds, + label: extra?.label, + }); + } catch (error) { + // The audit log was refused (400/404 — e.g. an unresolvable collection) or the store is + // unreachable (5xx, timeout, connection error). Both land here, and both are far more likely + // than the 200-with-null-id case below. + if (type === 'write') throw error; + + return null; + } - // 200-with-null-id = audit write dropped (store down, or stale collection). - // Writes fail closed; reads fail open so an audit outage never blocks the read surface. + // 200 with no id: the route answered but the audit store dropped the write. if (activityLog?.id === null || activityLog?.id === undefined) { if (type === 'write') { throw new Error( diff --git a/packages/mcp-server/src/utils/with-activity-log.ts b/packages/mcp-server/src/utils/with-activity-log.ts index a96f96a785..d657dc892b 100644 --- a/packages/mcp-server/src/utils/with-activity-log.ts +++ b/packages/mcp-server/src/utils/with-activity-log.ts @@ -40,12 +40,12 @@ export default async function withActivityLog(options: WithActivityLogOptions const { forestServerClient, request, action, context, logger, operation, errorEnhancer } = options; - // We want to create the activity log before executing the operation - // If activity log creation fails, we must prevent the execution of the operation - + // The activity log is created before the operation runs, so intent is captured even when the + // operation itself fails. `createPendingActivityLog` owns the fail policy: it throws for a write + // whose log could not be created (blocking the operation), and resolves to null for a read. const activityLog = await createPendingActivityLog(forestServerClient, request, action, context); - // Read whose audit write was dropped (fail-open): proceed without status tracking. + // Read whose audit log could not be created (fail-open): proceed without status tracking. if (!activityLog) { logger( 'Warn', diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index 22c6df5696..94905f500e 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -236,16 +236,65 @@ describe('createPendingActivityLog', () => { }); describe('error handling', () => { - it('should propagate error when createMcpActivityLog fails', async () => { - mockForestServerClient.createMcpActivityLog.mockRejectedValue( - new Error('Failed to create activity log: Server error message'), - ); + it.each(['action', 'create', 'update', 'delete', 'triggerWorkflow'])( + 'should propagate the error when createMcpActivityLog rejects for write action "%s" (fail-closed)', + async action => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue( + new Error('Failed to create activity log: Server error message'), + ); + + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, action), + ).rejects.toThrow('Failed to create activity log: Server error message'); + }, + ); + + it.each([ + 'index', + 'search', + 'filter', + 'listRelatedData', + 'describeCollection', + ])( + 'should resolve to null when createMcpActivityLog rejects for read action "%s" (fail-open)', + async action => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue( + new Error('Failed to create activity log: Server error message'), + ); + + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, action), + ).resolves.toBeNull(); + }, + ); + + it.each([ + ['a 400 rejection (unresolvable collection)', new Error('Validation failed')], + ['a 5xx rejection (audit store unreachable)', new Error('Internal Server Error')], + ['a transport failure', new Error('connect ECONNREFUSED')], + ])('should let a read through on %s', async (_label, error) => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue(error); const request = createMockRequest(); await expect( createPendingActivityLog(mockForestServerClient, request, 'index'), - ).rejects.toThrow('Failed to create activity log: Server error message'); + ).resolves.toBeNull(); + }); + + it('should still throw an invalid-auth-context error for a read action', async () => { + const request = { + authInfo: { extra: { renderingId: '12345' } }, + } as unknown as RequestHandlerExtra; + + await expect( + createPendingActivityLog(mockForestServerClient, request, 'index'), + ).rejects.toThrow('Invalid or missing forestServerToken in authentication context'); + expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); }); it('should not throw when createMcpActivityLog succeeds', async () => { diff --git a/packages/mcp-server/test/utils/with-activity-log.test.ts b/packages/mcp-server/test/utils/with-activity-log.test.ts index 01a141fabb..f5453cc3c6 100644 --- a/packages/mcp-server/test/utils/with-activity-log.test.ts +++ b/packages/mcp-server/test/utils/with-activity-log.test.ts @@ -301,6 +301,27 @@ describe('withActivityLog', () => { }); }); + describe('when the activity log could not be created (write action, fail-closed)', () => { + it('should not run the operation and should propagate the error', async () => { + mockCreatePendingActivityLog.mockRejectedValue(new Error('Internal Server Error')); + const operation = jest.fn().mockResolvedValue({ result: 'success' }); + + await expect( + withActivityLog({ + forestServerClient: mockForestServerClient, + request: mockRequest, + action: 'triggerWorkflow', + logger: mockLogger, + operation, + }), + ).rejects.toThrow('Internal Server Error'); + + expect(operation).not.toHaveBeenCalled(); + expect(mockMarkActivityLogAsSucceeded).not.toHaveBeenCalled(); + expect(mockMarkActivityLogAsFailed).not.toHaveBeenCalled(); + }); + }); + describe('errorEnhancer', () => { it('should apply errorEnhancer to error message when provided', async () => { const operation = jest.fn().mockRejectedValue(new Error('Original error')); From 7052c548d206f0992d17983932e3f1be543b08b7 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 18 Aug 2026 16:00:01 +0200 Subject: [PATCH 25/43] fix(mcp-server): stop a missing lookup route from looping the assistant (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) --- .../mcp-server/src/tools/trigger-workflow.ts | 21 ++++++++--- .../test/tools/trigger-workflow.test.ts | 35 +++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index 4ddcc7504f..4dc510b707 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -23,11 +23,14 @@ interface TriggerWorkflowArgument { } // One message for the unknown, MCP-disabled and trigger-time-404 cases, so the LLM-facing -// contract stays uniform and never leaks whether a given workflow exists. +// contract stays uniform and never leaks whether a given workflow exists. The last sentence exists +// so a model cannot loop: an orchestrator that predates (or was rolled back before) the by-id +// endpoint 404s every lookup while listWorkflows keeps returning the same ids. function notMcpEnabledMessage(workflowId: string): string { return ( `Workflow "${workflowId}" is not an MCP-enabled workflow you can access. ` + - 'Use listWorkflows to discover triggerable workflows.' + 'Use listWorkflows to discover triggerable workflows. If listWorkflows just returned this id, ' + + 'do not retry — report it to your Forest administrator instead.' ); } @@ -76,6 +79,15 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To workflowId: args.workflowId, }); } catch (error) { + // Always log: a 404 here is indistinguishable from a missing route, so without this an + // orchestrator too old to serve the lookup is visible only to the model. + logger( + 'Error', + `Failed to resolve workflow "${args.workflowId}" via getMcpWorkflowById: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + if (error instanceof NotFoundError) { throw new Error(notMcpEnabledMessage(args.workflowId)); } @@ -87,8 +99,9 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To throw new Error(notMcpEnabledMessage(args.workflowId)); } - // A renamed/deleted collection leaves collectionName null. The activity log would be dropped - // server-side and fail-closed would block with a misleading message, so reject up front. + // A renamed/deleted collection leaves collectionName null, which the audit route rejects + // (collectionModelName is required). Fail-closed would then block with a misleading message, + // so reject up front with one that names the actual problem. if (workflow.collectionName == null) { throw new Error(unavailableCollectionMessage(args.workflowId)); } diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index aa6d8bd4b6..0ea148547d 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -249,6 +249,41 @@ describe('declareTriggerWorkflowTool', () => { expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); }); + it('should tell the caller not to retry when the lookup 404s, so a missing route cannot loop', async () => { + mockForestServerClient.getMcpWorkflowById.mockRejectedValue( + new NotFoundError('Workflow not found'), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: + 'Workflow "wf-1" is not an MCP-enabled workflow you can access. Use listWorkflows to ' + + 'discover triggerable workflows. If listWorkflows just returned this id, do not ' + + 'retry — report it to your Forest administrator instead.', + }, + ], + isError: true, + }); + }); + + it('should log the underlying lookup failure so an outage is visible to an operator', async () => { + mockForestServerClient.getMcpWorkflowById.mockRejectedValue( + new NotFoundError('Cannot GET /api/workflow-orchestrator/mcp-workflows/wf-1'), + ); + + await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Failed to resolve workflow "wf-1" via getMcpWorkflowById: ' + + 'Cannot GET /api/workflow-orchestrator/mcp-workflows/wf-1', + ); + }); + it('should reject an MCP-disabled workflow without triggering or auditing', async () => { mockForestServerClient.getMcpWorkflowById.mockResolvedValue({ workflowId: 'wf-1', From 9ec10b78b515cf1bd2e515c5e682c9a853c65918 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 18 Aug 2026 16:37:31 +0200 Subject: [PATCH 26/43] fix(mcp-server): hide untriggerable workflows from listWorkflows (PRD-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) --- .../mcp-server/src/tools/list-workflows.ts | 16 +++++- .../test/tools/list-workflows.test.ts | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts index a57a8309cf..4f5ef12ed8 100644 --- a/packages/mcp-server/src/tools/list-workflows.ts +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -47,7 +47,21 @@ export default function declareListWorkflowsTool(mcpServer: McpServer, ctx: Tool collectionName: args.collectionName, }); - return { content: [{ type: 'text', text: JSON.stringify(workflows) }] }; + // A workflow whose collection was renamed or removed cannot be triggered - triggerWorkflow + // rejects a null collectionName up front - so listing it would only send the model round the + // discover, trigger, rejected, discover loop. Surface the drop to the operator instead. + const triggerable = workflows.filter(workflow => workflow.collectionName !== null); + const droppedCount = workflows.length - triggerable.length; + + if (droppedCount > 0) { + logger( + 'Warn', + `listWorkflows hid ${droppedCount} MCP-enabled workflow(s) whose collection is no longer ` + + 'available in this rendering; they cannot be triggered until their configuration is fixed.', + ); + } + + return { content: [{ type: 'text', text: JSON.stringify(triggerable) }] }; }, logger, ); diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts index 7567ec8547..8698712d12 100644 --- a/packages/mcp-server/test/tools/list-workflows.test.ts +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -169,6 +169,55 @@ describe('declareListWorkflowsTool', () => { }); }); + it('should hide workflows whose collection is unavailable, since triggerWorkflow rejects them', async () => { + mockForestServerClient.listMcpEnabledWorkflows.mockResolvedValue([ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + { workflowId: 'wf-2', name: 'Retired process', collectionName: null }, + ]); + + const result = await registeredToolHandler({}, mockExtra); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify([ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + ]), + }, + ], + }); + }); + + it('should warn the operator about each hidden workflow', async () => { + mockForestServerClient.listMcpEnabledWorkflows.mockResolvedValue([ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + { workflowId: 'wf-2', name: 'Retired process', collectionName: null }, + { workflowId: 'wf-3', name: 'Other retired', collectionName: null }, + ]); + + await registeredToolHandler({}, mockExtra); + + expect(mockLogger).toHaveBeenCalledWith( + 'Warn', + 'listWorkflows hid 2 MCP-enabled workflow(s) whose collection is no longer available in ' + + 'this rendering; they cannot be triggered until their configuration is fixed.', + ); + }); + + it('should not warn when every workflow is triggerable', async () => { + mockForestServerClient.listMcpEnabledWorkflows.mockResolvedValue([ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + ]); + + await registeredToolHandler({}, mockExtra); + + expect(mockLogger).not.toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('listWorkflows hid'), + ); + }); + it('should return an error result when the auth context is missing the token', async () => { const extraWithoutToken = { authInfo: { extra: { renderingId: 123 } }, From 80b6b78b8a561a27072fd38348d8b2b843b1f640 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 18 Aug 2026 16:37:33 +0200 Subject: [PATCH 27/43] fix(forestadmin-client): stamp the MCP source header on the MCP-only 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) --- .../src/permissions/forest-http-api.ts | 63 ++++++++++- .../test/permissions/forest-http-api.test.ts | 107 +++++++++++++++++- 2 files changed, 158 insertions(+), 12 deletions(-) diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 070e0e4eb6..46c73b515d 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -21,6 +21,55 @@ import JSONAPISerializer from 'json-api-serializer'; import AuthService from '../auth'; import ServerUtils from '../utils/server'; +/** + * These routes exist only for the MCP transport, so the source header belongs to the call rather + * than to the caller's configuration: the embedded `mountAiMcpServer` path builds its services from + * the shared client options, which carry no headers, and was silently sending MCP traffic + * unlabelled. Callers can still override it. + */ +const MCP_SOURCE_HEADER = { 'Forest-Application-Source': 'MCP' } as const; + +/** + * Projects the run onto the declared contract instead of forwarding the response as-is. The + * getWorkflowRun tool stringifies this straight into an LLM's context, and the orchestrator builds + * a second, executor-facing shape of the same run that carries a `userProfile` with a live Forest + * `serverToken`. The MCP route uses a different builder today, but the two return types are + * assignable to one another, so nothing structural keeps them apart — this whitelist is the + * guardrail rather than the type annotation. + * + * `stepDefinition` is passed through whole: its task-type-specific fields are the organisation's own + * workflow configuration, deliberately surfaced so the model can reason about the step. + */ +function projectHydratedRun(run: HydratedWorkflowRun): HydratedWorkflowRun { + return { + id: run.id, + userId: run.userId, + renderingId: run.renderingId, + collectionId: run.collectionId, + workflowId: run.workflowId, + bpmnVersion: run.bpmnVersion, + selectedRecordId: run.selectedRecordId, + runState: run.runState, + engine: run.engine, + triggerType: run.triggerType, + lockedAt: run.lockedAt, + createdAt: run.createdAt, + updatedAt: run.updatedAt, + workflowHistory: (run.workflowHistory ?? []).map(step => ({ + stepName: step.stepName, + stepIndex: step.stepIndex, + originalStepIndex: step.originalStepIndex, + done: step.done, + revised: step.revised, + cancelled: step.cancelled, + childrenWorkflowId: step.childrenWorkflowId, + isCardStep: step.isCardStep, + context: step.context, + stepDefinition: step.stepDefinition, + })), + }; +} + export default class ForestHttpApi implements ForestAdminServerInterface { async getEnvironmentPermissions(options: HttpOptions): Promise { return ServerUtils.query(options, 'get', '/liana/v4/permissions/environment'); @@ -134,7 +183,7 @@ export default class ForestHttpApi implements ForestAdminServerInterface { path: '/api/activity-logs-requests/mcp', bearerToken: options.bearerToken, body, - headers: options.headers, + headers: { ...MCP_SOURCE_HEADER, ...options.headers }, }); return activityLog; @@ -168,7 +217,7 @@ export default class ForestHttpApi implements ForestAdminServerInterface { method: 'get', path: `/api/workflow-orchestrator/mcp-workflows${query}`, bearerToken: options.bearerToken, - headers: { 'forest-rendering-id': renderingId, ...options.headers }, + headers: { 'forest-rendering-id': renderingId, ...MCP_SOURCE_HEADER, ...options.headers }, }); } @@ -182,7 +231,7 @@ export default class ForestHttpApi implements ForestAdminServerInterface { method: 'get', path: `/api/workflow-orchestrator/mcp-workflows/${encodeURIComponent(workflowId)}`, bearerToken: options.bearerToken, - headers: { 'forest-rendering-id': renderingId, ...options.headers }, + headers: { 'forest-rendering-id': renderingId, ...MCP_SOURCE_HEADER, ...options.headers }, }); } @@ -202,7 +251,7 @@ export default class ForestHttpApi implements ForestAdminServerInterface { path: `/api/workflow-orchestrator/mcp-workflows/${encodeURIComponent(workflowId)}/start`, bearerToken: options.bearerToken, body: { recordId }, - headers: { 'forest-rendering-id': renderingId, ...options.headers }, + headers: { 'forest-rendering-id': renderingId, ...MCP_SOURCE_HEADER, ...options.headers }, }); return { runId: String(result.runId), runState: result.runState }; @@ -213,12 +262,14 @@ export default class ForestHttpApi implements ForestAdminServerInterface { renderingId: string, runId: string, ): Promise { - return ServerUtils.queryWithBearerToken({ + const run = await ServerUtils.queryWithBearerToken({ forestServerUrl: options.forestServerUrl, method: 'get', path: `/api/workflow-orchestrator/mcp-workflows/runs/${encodeURIComponent(runId)}`, bearerToken: options.bearerToken, - headers: { 'forest-rendering-id': renderingId, ...options.headers }, + headers: { 'forest-rendering-id': renderingId, ...MCP_SOURCE_HEADER, ...options.headers }, }); + + return projectHydratedRun(run); } } diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 65520ffa2d..636ef16bf2 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -175,7 +175,7 @@ describe('ForestHttpApi', () => { path: '/api/activity-logs-requests/mcp', bearerToken: 'bearer-token', body, - headers: { 'Custom-Header': 'value' }, + headers: { 'Forest-Application-Source': 'MCP', 'Custom-Header': 'value' }, }); expect(result).toEqual(mockActivityLog); }); @@ -222,7 +222,10 @@ describe('ForestHttpApi', () => { method: 'get', path: '/api/workflow-orchestrator/mcp-workflows', bearerToken: 'bearer-token', - headers: { 'forest-rendering-id': '12345' }, + headers: { + 'forest-rendering-id': '12345', + 'Forest-Application-Source': 'MCP', + }, }); expect(result).toEqual(workflows); }); @@ -240,7 +243,10 @@ describe('ForestHttpApi', () => { expect.objectContaining({ method: 'get', path: '/api/workflow-orchestrator/mcp-workflows?collectionName=sales%20orders', - headers: { 'forest-rendering-id': '12345' }, + headers: { + 'forest-rendering-id': '12345', + 'Forest-Application-Source': 'MCP', + }, }), ); }); @@ -267,7 +273,10 @@ describe('ForestHttpApi', () => { method: 'get', path: '/api/workflow-orchestrator/mcp-workflows/wf-1', bearerToken: 'bearer-token', - headers: { 'forest-rendering-id': '12345' }, + headers: { + 'forest-rendering-id': '12345', + 'Forest-Application-Source': 'MCP', + }, }); expect(result).toEqual(workflow); }); @@ -315,7 +324,10 @@ describe('ForestHttpApi', () => { path: '/api/workflow-orchestrator/mcp-workflows/wf-1/start', bearerToken: 'bearer-token', body: { recordId: '42' }, - headers: { 'forest-rendering-id': '12345' }, + headers: { + 'forest-rendering-id': '12345', + 'Forest-Application-Source': 'MCP', + }, }); expect(result).toEqual({ runId: '7', runState: 'loading' }); }); @@ -389,11 +401,94 @@ describe('ForestHttpApi', () => { method: 'get', path: '/api/workflow-orchestrator/mcp-workflows/runs/7', bearerToken: 'bearer-token', - headers: { 'forest-rendering-id': '12345' }, + headers: { + 'forest-rendering-id': '12345', + 'Forest-Application-Source': 'MCP', + }, }); expect(result).toEqual(runStatus); }); + it('should strip fields the contract does not declare, including a leaked serverToken', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + id: 7, + runState: 'started', + workflowHistory: [ + { stepName: 'Review', stepIndex: 0, done: false, stepDefinition: { type: 'task' } }, + ], + // What the executor-facing build of the same run carries. + collectionName: 'orders', + userProfile: { email: 'ops@example.com', serverToken: 'super-secret-forest-token' }, + }); + + const result = await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + '7', + ); + + expect(result).not.toHaveProperty('userProfile'); + expect(result).not.toHaveProperty('collectionName'); + expect(JSON.stringify(result)).not.toContain('super-secret-forest-token'); + expect(result.id).toBe(7); + expect(result.runState).toBe('started'); + }); + + it('should keep the task-type-specific stepDefinition fields the model reasons about', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runState: 'started', + workflowHistory: [ + { + stepName: 'Refund', + stepIndex: 0, + done: false, + isCardStep: false, + context: { error: 'boom' }, + stepDefinition: { + type: 'task', + taskType: 'update-data', + preRecordedArgs: { fieldName: 'status', value: 'refunded' }, + }, + }, + ], + }); + + const result = await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + '7', + ); + + expect(result.workflowHistory[0]).toEqual({ + stepName: 'Refund', + stepIndex: 0, + originalStepIndex: undefined, + done: false, + revised: undefined, + cancelled: undefined, + childrenWorkflowId: undefined, + isCardStep: false, + context: { error: 'boom' }, + stepDefinition: { + type: 'task', + taskType: 'update-data', + preRecordedArgs: { fieldName: 'status', value: 'refunded' }, + }, + }); + }); + + it('should tolerate a run served without a workflowHistory', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ runState: 'pending' }); + + const result = await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + '7', + ); + + expect(result.workflowHistory).toEqual([]); + }); + it('should url-encode the run id in the path', async () => { (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ runState: 'started', From 2be4504b594d42840ddd5291af10c58cec838c7b Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 18 Aug 2026 16:37:33 +0200 Subject: [PATCH 28/43] fix(mcp-server): bound recordId to the server column width (PRD-49) 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) --- packages/forestadmin-client/src/types.ts | 5 +++-- packages/mcp-server/src/tools/trigger-workflow.ts | 4 +++- packages/mcp-server/test/tools/trigger-workflow.test.ts | 9 +++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index a01151041a..1e939e2e10 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -303,8 +303,9 @@ export interface ListMcpWorkflowsParams { * A single workflow resolved by id: the match is resolved inside Postgres, so the client never * receives or deserializes the full workflow list. Unlike the listing, this also carries * `mcpEnabled`: an existing-but-MCP-disabled workflow is returned with `mcpEnabled: false` rather - * than hidden, so the caller can label a fail-closed audit log before triggering while the - * start endpoint stays the guard that refuses a disabled trigger. + * than hidden, so the caller can tell an unknown workflow from a disabled one and reject the + * latter without starting a run. `name` is what makes the fail-closed audit label possible in the + * enabled case; the start endpoint stays the guard that refuses a disabled trigger. */ export interface McpWorkflowLookup { workflowId: string; diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index 4dc510b707..cc5271cc83 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -62,7 +62,9 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To 'Discover triggerable workflows with listWorkflows first.', inputSchema: { workflowId: z.string().min(1).describe(WORKFLOW_ID_DESCRIPTION), - recordId: z.string().min(1).describe(RECORD_ID_DESCRIPTION), + // 255 matches the server's selectedRecordId column, so an over-long id is rejected here + // rather than after the pending audit row has been written. + recordId: z.string().min(1).max(255).describe(RECORD_ID_DESCRIPTION), }, }, async (args: TriggerWorkflowArgument, extra) => { diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 0ea148547d..079429a13f 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -249,6 +249,15 @@ describe('declareTriggerWorkflowTool', () => { expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); }); + it('should reject a recordId longer than the server column, before any call', async () => { + const schema = registeredToolConfig.inputSchema as { + recordId: { safeParse: (value: unknown) => { success: boolean } }; + }; + + expect(schema.recordId.safeParse('x'.repeat(255)).success).toBe(true); + expect(schema.recordId.safeParse('x'.repeat(256)).success).toBe(false); + }); + it('should tell the caller not to retry when the lookup 404s, so a missing route cannot loop', async () => { mockForestServerClient.getMcpWorkflowById.mockRejectedValue( new NotFoundError('Workflow not found'), From a3b04ef67ea3e324d4890323f46a6bfa2218d631 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 18 Aug 2026 18:58:40 +0200 Subject: [PATCH 29/43] refactor(mcp-server): address the review nits (PRD-49) - 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) --- packages/forestadmin-client/src/types.ts | 1 + .../forest-admin-client-with-cache.test.ts | 44 +++++++++++++------ .../mcp-server/src/tools/get-workflow-run.ts | 10 ++++- .../mcp-server/src/tools/list-workflows.ts | 14 ++++-- .../src/utils/activity-logs-creator.ts | 16 +++++-- .../test/tools/get-workflow-run.test.ts | 11 ++++- .../test/tools/list-workflows.test.ts | 11 ++++- .../test/tools/trigger-workflow.test.ts | 18 +++++++- 8 files changed, 98 insertions(+), 27 deletions(-) diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 1e939e2e10..018c04ec3c 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -308,6 +308,7 @@ export interface ListMcpWorkflowsParams { * enabled case; the start endpoint stays the guard that refuses a disabled trigger. */ export interface McpWorkflowLookup { + /** Echo of the requested id — the server returns it so the payload is self-describing. */ workflowId: string; name: string; collectionName: string | null; diff --git a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts index 3fbc3ae207..fce2a59301 100644 --- a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts +++ b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts @@ -12,42 +12,58 @@ const verifyAndExtractApprovalMock = verifyAndExtractApproval as jest.Mock; describe('ForestAdminClientWithCache', () => { describe('constructor wiring', () => { it('should assign each positional service to its matching property', () => { + const options = factories.forestAdminClientOptions.build(); const permissionService = factories.permission.build(); + const renderingPermissionService = factories.renderingPermission.build(); const contextVariablesInstantiator = factories.contextVariablesInstantiator.build(); const chartHandler = factories.chartHandler.build(); + const ipWhitelistService = factories.ipWhiteList.build(); const schemaService = factories.schema.build(); const activityLogsService = factories.activityLogs.build(); const authService = factories.auth.build(); const modelCustomizationService = factories.modelCustomization.build(); const mcpServerConfigService = factories.mcpServerConfig.build(); + const eventsSubscription = factories.eventsSubscription.build(); + const eventsHandler = factories.eventsHandler.build(); const workflowsService = factories.workflows.build(); const forestAdminClient = new ForestAdminClient( - factories.forestAdminClientOptions.build(), + options, permissionService, - factories.renderingPermission.build(), + renderingPermissionService, contextVariablesInstantiator, chartHandler, - factories.ipWhiteList.build(), + ipWhitelistService, schemaService, activityLogsService, authService, modelCustomizationService, mcpServerConfigService, - factories.eventsSubscription.build(), - factories.eventsHandler.build(), + eventsSubscription, + eventsHandler, workflowsService, ); - expect(forestAdminClient.permissionService).toBe(permissionService); - expect(forestAdminClient.contextVariablesInstantiator).toBe(contextVariablesInstantiator); - expect(forestAdminClient.chartHandler).toBe(chartHandler); - expect(forestAdminClient.schemaService).toBe(schemaService); - expect(forestAdminClient.activityLogsService).toBe(activityLogsService); - expect(forestAdminClient.authService).toBe(authService); - expect(forestAdminClient.modelCustomizationService).toBe(modelCustomizationService); - expect(forestAdminClient.mcpServerConfigService).toBe(mcpServerConfigService); - expect(forestAdminClient.workflowsService).toBe(workflowsService); + // All 14 positions, not just the public ones: this test exists to catch a silent argument + // shift, and two adjacent services of the same shape (eventsSubscription / eventsHandler, + // renderingPermission / ipWhitelist) would swap unnoticed if only the public members were + // asserted. Reading the protected ones needs the cast. + const wired = forestAdminClient as unknown as Record; + + expect(wired.options).toBe(options); + expect(wired.permissionService).toBe(permissionService); + expect(wired.renderingPermissionService).toBe(renderingPermissionService); + expect(wired.contextVariablesInstantiator).toBe(contextVariablesInstantiator); + expect(wired.chartHandler).toBe(chartHandler); + expect(wired.ipWhitelistService).toBe(ipWhitelistService); + expect(wired.schemaService).toBe(schemaService); + expect(wired.activityLogsService).toBe(activityLogsService); + expect(wired.authService).toBe(authService); + expect(wired.modelCustomizationService).toBe(modelCustomizationService); + expect(wired.mcpServerConfigService).toBe(mcpServerConfigService); + expect(wired.eventsSubscription).toBe(eventsSubscription); + expect(wired.eventsHandler).toBe(eventsHandler); + expect(wired.workflowsService).toBe(workflowsService); }); }); diff --git a/packages/mcp-server/src/tools/get-workflow-run.ts b/packages/mcp-server/src/tools/get-workflow-run.ts index 62f5620bf2..2cec4d0f1f 100644 --- a/packages/mcp-server/src/tools/get-workflow-run.ts +++ b/packages/mcp-server/src/tools/get-workflow-run.ts @@ -19,7 +19,15 @@ export default function declareGetWorkflowRunTool(mcpServer: McpServer, ctx: Too mcpServer, 'getWorkflowRun', { - annotations: { readOnlyHint: true }, + // Spelled out rather than relying on defaults: the MCP spec defaults an omitted + // destructiveHint to true, so a client reading that field alone would treat these + // reads as destructive. + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, title: 'Get a workflow run status', description: 'Poll a workflow run started with triggerWorkflow. Returns the full hydrated run: its ' + diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts index 4f5ef12ed8..278582d170 100644 --- a/packages/mcp-server/src/tools/list-workflows.ts +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -10,7 +10,7 @@ const COLLECTION_NAME_DESCRIPTION = 'Optional. Narrow the results to workflows operating on this collection — typically the ' + 'collection of the record currently in context.'; -export function createListWorkflowsArgumentShape(collectionNames: string[]) { +function createListWorkflowsArgumentShape(collectionNames: string[]) { const collectionName = collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(); @@ -19,7 +19,7 @@ export function createListWorkflowsArgumentShape(collectionNames: string[]) { }; } -export type ListWorkflowsArgument = z.infer< +type ListWorkflowsArgument = z.infer< z.ZodObject> >; @@ -30,7 +30,15 @@ export default function declareListWorkflowsTool(mcpServer: McpServer, ctx: Tool mcpServer, 'listWorkflows', { - annotations: { readOnlyHint: true }, + // Spelled out rather than relying on defaults: the MCP spec defaults an omitted + // destructiveHint to true, so a client reading that field alone would treat these + // reads as destructive. + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, title: 'List MCP-enabled workflows', description: 'Discover Forest workflows enabled for MCP triggering that you can access. Returns each ' + diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index ab44d6b496..13f35aec93 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -102,9 +102,19 @@ async function updateActivityLogStatus( ): Promise { const { forestServerClient, request, activityLog, status, logger } = options; - // Use optional chaining with fallback since we're in error handling context - // and don't want to throw a different error if auth context is missing - const forestServerToken = (request.authInfo?.extra?.forestServerToken as string) ?? ''; + // Read directly rather than through getAuthContext: this runs in a fire-and-forget path and must + // not throw a second, unrelated error. A missing token means the request cannot be authenticated, + // so skip the call - sending an empty Bearer would 401 and then be retried on the 404 branch. + const forestServerToken = request.authInfo?.extra?.forestServerToken; + + if (typeof forestServerToken !== 'string' || !forestServerToken) { + logger( + 'Error', + `Cannot update activity log status to '${status}': no forestServerToken in the auth context.`, + ); + + return; + } try { await forestServerClient.updateActivityLogStatus({ diff --git a/packages/mcp-server/test/tools/get-workflow-run.test.ts b/packages/mcp-server/test/tools/get-workflow-run.test.ts index d8562390f6..48e900fce0 100644 --- a/packages/mcp-server/test/tools/get-workflow-run.test.ts +++ b/packages/mcp-server/test/tools/get-workflow-run.test.ts @@ -59,14 +59,21 @@ describe('declareGetWorkflowRunTool', () => { expect(registeredToolConfig.description).toContain('wait at least a few seconds'); }); - it('should be annotated as read-only', () => { + it('should spell out every annotation, not just readOnlyHint', () => { declareGetWorkflowRunTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, collectionNames: [], }); - expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); + // An omitted destructiveHint defaults to true in the MCP spec, so a client reading + // that field alone would treat this read as destructive. + expect(registeredToolConfig.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); }); it('should require a string runId argument', () => { diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts index 8698712d12..2a7a6b06cc 100644 --- a/packages/mcp-server/test/tools/list-workflows.test.ts +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -57,14 +57,21 @@ describe('declareListWorkflowsTool', () => { expect(registeredToolConfig.description).toContain('MCP triggering'); }); - it('should be annotated as read-only', () => { + it('should spell out every annotation, not just readOnlyHint', () => { declareListWorkflowsTool(mcpServer, { forestServerClient: mockForestServerClient, logger: mockLogger, collectionNames: [], }); - expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); + // An omitted destructiveHint defaults to true in the MCP spec, so a client reading + // that field alone would treat this read as destructive. + expect(registeredToolConfig.annotations).toEqual({ + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }); }); it('should expose an optional collectionName argument', () => { diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 079429a13f..7a042162b9 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -352,7 +352,14 @@ describe('declareTriggerWorkflowTool', () => { ], isError: true, }); - expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalled(); + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'triggerWorkflow', + collectionName: 'orders', + recordId: '42', + label: 'triggered the workflow "Refund order" via MCP', + }), + ); expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( expect.objectContaining({ status: 'failed' }), ); @@ -371,7 +378,14 @@ describe('declareTriggerWorkflowTool', () => { ], isError: true, }); - expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalled(); + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'triggerWorkflow', + collectionName: 'orders', + recordId: '42', + label: 'triggered the workflow "Refund order" via MCP', + }), + ); expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( expect.objectContaining({ status: 'failed' }), ); From 23b878b2054df79fb814af69928705ec2e2209f4 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 18 Aug 2026 20:14:24 +0200 Subject: [PATCH 30/43] test(mcp-server): cover the skipped status update on a missing token (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) --- .../test/utils/activity-logs-creator.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index 94905f500e..c7cb8c3523 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -381,6 +381,36 @@ describe('markActivityLogAsFailed', () => { } as unknown as RequestHandlerExtra; } + it.each([ + ['a missing token', {}], + ['a non-string token', { forestServerToken: 42 }], + ['an empty token', { forestServerToken: '' }], + ])('should skip the status update on %s rather than send an empty Bearer', async (_l, extra) => { + const request = { + authInfo: { token: 'mock-token', extra }, + } as unknown as RequestHandlerExtra; + const activityLog = { id: 'log-123', attributes: { index: 'idx-456' } }; + const mockLogger = jest.fn(); + + markActivityLogAsFailed({ + forestServerClient: mockForestServerClient, + request, + activityLog, + logger: mockLogger, + }); + + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + + // An empty Bearer would 401, and the 404 retry branch would then repeat it five times. + expect(mockForestServerClient.updateActivityLogStatus).not.toHaveBeenCalled(); + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + "Cannot update activity log status to 'failed': no forestServerToken in the auth context.", + ); + }); + it('should call updateActivityLogStatus with failed status', async () => { const request = createMockRequest(); const activityLog = { id: 'log-123', attributes: { index: 'idx-456' } }; From 18c954b6d79658aab753df8717de9f97edb92483 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 15:03:24 +0200 Subject: [PATCH 31/43] fix(mcp-server): propagate an authorization refusal on the read path (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) --- .../src/utils/activity-logs-creator.ts | 33 ++++++++- .../mcp-server/src/utils/with-activity-log.ts | 11 ++- .../test/utils/activity-logs-creator.test.ts | 71 ++++++++++++++++++- .../test/utils/with-activity-log.test.ts | 6 +- 4 files changed, 111 insertions(+), 10 deletions(-) diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 13f35aec93..5ba6806317 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -8,7 +8,7 @@ import type { Logger } from '../server'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; -import { NotFoundError } from '@forestadmin/forestadmin-client'; +import { HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; import getAuthContext from './auth-context'; @@ -19,6 +19,9 @@ export type { ActivityLogAction, ActivityLogResponse }; * created is blocked (no unaudited side effect), while a read proceeds with a warning (an audit * store outage must not take down the read surface). Both the refusal/outage path and the * 200-with-no-id path in `createPendingActivityLog` are arbitrated by this map. + * + * One case is arbitrated by the cause instead of the action type: an authorization refusal + * (401/403) propagates for reads too — see `isAuthorizationRefusal`. */ const ACTION_TO_TYPE: Record = { index: 'read', @@ -33,6 +36,15 @@ const ACTION_TO_TYPE: Record = { triggerWorkflow: 'write', }; +/** + * The caller's identity was rejected (401/403), which is not an audit outage: the read the caller + * is about to perform is not authorized either. Fail-open exists so a broken audit store cannot + * take down the read surface — it must not turn an authorization refusal into a silent warning. + */ +function isAuthorizationRefusal(error: unknown): boolean { + return error instanceof HttpError && (error.status === 401 || error.status === 403); +} + export default async function createPendingActivityLog( forestServerClient: ForestServerClient, request: RequestHandlerExtra, @@ -42,6 +54,8 @@ export default async function createPendingActivityLog( recordId?: string | number; recordIds?: string[] | number[]; label?: string; + /** Used only to report why a read proceeded unaudited; never sent to the server. */ + logger?: Logger; }, ) { const type = ACTION_TO_TYPE[action]; @@ -65,7 +79,16 @@ export default async function createPendingActivityLog( // The audit log was refused (400/404 — e.g. an unresolvable collection) or the store is // unreachable (5xx, timeout, connection error). Both land here, and both are far more likely // than the 200-with-null-id case below. - if (type === 'write') throw error; + if (type === 'write' || isAuthorizationRefusal(error)) throw error; + + // Report the cause: without it every fail-open read logs the same sentence, and an operator + // cannot tell a validation refusal (act now) from a transient outage (wait). + extra?.logger?.( + 'Error', + `Activity log for '${action}' could not be created: ${ + error instanceof Error ? error.message : String(error) + }`, + ); return null; } @@ -79,6 +102,12 @@ export default async function createPendingActivityLog( ); } + extra?.logger?.( + 'Error', + `Activity log for '${action}' could not be created: the server answered 200 with no ` + + 'activity log id, so the audit store dropped the write.', + ); + return null; } diff --git a/packages/mcp-server/src/utils/with-activity-log.ts b/packages/mcp-server/src/utils/with-activity-log.ts index d657dc892b..9a1e74bc14 100644 --- a/packages/mcp-server/src/utils/with-activity-log.ts +++ b/packages/mcp-server/src/utils/with-activity-log.ts @@ -42,10 +42,15 @@ export default async function withActivityLog(options: WithActivityLogOptions // The activity log is created before the operation runs, so intent is captured even when the // operation itself fails. `createPendingActivityLog` owns the fail policy: it throws for a write - // whose log could not be created (blocking the operation), and resolves to null for a read. - const activityLog = await createPendingActivityLog(forestServerClient, request, action, context); + // whose log could not be created (blocking the operation) and for an authorization refusal on + // any action, and resolves to null for a read whose audit write was lost. + const activityLog = await createPendingActivityLog(forestServerClient, request, action, { + ...context, + logger, + }); - // Read whose audit log could not be created (fail-open): proceed without status tracking. + // Read whose audit log could not be created (fail-open): proceed without status tracking. The + // cause was already logged by `createPendingActivityLog`. if (!activityLog) { logger( 'Warn', diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index c7cb8c3523..aa1b2712dc 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -3,7 +3,7 @@ import type { ActivityLogAction } from '../../src/utils/activity-logs-creator'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; -import { NotFoundError } from '@forestadmin/forestadmin-client'; +import { ForbiddenError, HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; import createPendingActivityLog, { markActivityLogAsFailed, @@ -272,9 +272,11 @@ describe('createPendingActivityLog', () => { }, ); + // Real HttpErrors rather than bare Errors: the status is what the fail policy now reads, so a + // generic Error would make these labels describe a mode the code never sees. it.each([ - ['a 400 rejection (unresolvable collection)', new Error('Validation failed')], - ['a 5xx rejection (audit store unreachable)', new Error('Internal Server Error')], + ['a 400 rejection (unresolvable collection)', new HttpError('Validation failed', 400)], + ['a 5xx rejection (audit store unreachable)', new HttpError('Internal Server Error', 500)], ['a transport failure', new Error('connect ECONNREFUSED')], ])('should let a read through on %s', async (_label, error) => { mockForestServerClient.createMcpActivityLog.mockRejectedValue(error); @@ -286,6 +288,69 @@ describe('createPendingActivityLog', () => { ).resolves.toBeNull(); }); + // An authorization refusal is not an audit outage: the caller's identity was rejected, so the + // read it is about to perform is not authorized either. Fail-open must not swallow it. + it.each([ + ['a 401 rejection', new HttpError('Unauthorized', 401)], + ['a 403 rejection', new ForbiddenError('Forbidden')], + ])('should propagate %s even for a read action', async (_label, error) => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue(error); + + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, 'index'), + ).rejects.toThrow(error); + }); + + it('should report the cause when a read proceeds unaudited', async () => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue( + new HttpError('collectionModelName is required', 400), + ); + + const logger = jest.fn(); + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, 'index', { logger }), + ).resolves.toBeNull(); + + expect(logger).toHaveBeenCalledWith( + 'Error', + "Activity log for 'index' could not be created: collectionModelName is required", + ); + }); + + it('should report the cause when a read gets a 200 with no activity log id', async () => { + mockForestServerClient.createMcpActivityLog.mockResolvedValue({ id: null } as never); + + const logger = jest.fn(); + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, 'index', { logger }), + ).resolves.toBeNull(); + + expect(logger).toHaveBeenCalledWith( + 'Error', + "Activity log for 'index' could not be created: the server answered 200 with no " + + 'activity log id, so the audit store dropped the write.', + ); + }); + + it('should never forward the logger to the server', async () => { + mockForestServerClient.createMcpActivityLog.mockResolvedValue({ id: 'log-1' } as never); + + const logger = jest.fn(); + const request = createMockRequest(); + + await createPendingActivityLog(mockForestServerClient, request, 'index', { logger }); + + expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( + expect.not.objectContaining({ logger: expect.anything() }), + ); + }); + it('should still throw an invalid-auth-context error for a read action', async () => { const request = { authInfo: { extra: { renderingId: '12345' } }, diff --git a/packages/mcp-server/test/utils/with-activity-log.test.ts b/packages/mcp-server/test/utils/with-activity-log.test.ts index f5453cc3c6..b15af30814 100644 --- a/packages/mcp-server/test/utils/with-activity-log.test.ts +++ b/packages/mcp-server/test/utils/with-activity-log.test.ts @@ -60,7 +60,8 @@ describe('withActivityLog', () => { mockForestServerClient, mockRequest, 'index', - { collectionName: 'users' }, + // The logger travels with the context so the creator can report why a read went unaudited. + { collectionName: 'users', logger: expect.any(Function) }, ); expect(mockCreatePendingActivityLog).toHaveBeenCalledBefore(operation); }); @@ -200,7 +201,7 @@ describe('withActivityLog', () => { mockForestServerClient, mockRequest, 'index', - undefined, + { logger: expect.any(Function) }, ); }); @@ -228,6 +229,7 @@ describe('withActivityLog', () => { collectionName: 'orders', recordIds: [1, 2, 3], label: 'Bulk delete orders', + logger: expect.any(Function), }, ); }); From 4318495c5bd2a557b03539039db8afffabc1f4e1 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 15:03:39 +0200 Subject: [PATCH 32/43] fix(forestadmin-client): project the workflow listing and each step context (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/permissions/forest-http-api.ts | 35 +++++++- .../test/permissions/forest-http-api.test.ts | 86 +++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 46c73b515d..d4c242e218 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -12,6 +12,7 @@ import type { IpWhitelistRulesResponse, McpWorkflow, McpWorkflowLookup, + WorkflowHistoryStepContext, WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; @@ -38,8 +39,27 @@ const MCP_SOURCE_HEADER = { 'Forest-Application-Source': 'MCP' } as const; * guardrail rather than the type annotation. * * `stepDefinition` is passed through whole: its task-type-specific fields are the organisation's own - * workflow configuration, deliberately surfaced so the model can reason about the step. + * workflow configuration, deliberately surfaced so the model can reason about the step. Per-step + * `context` is not — it is a closed interface here but an open bag server-side, so forwarding it + * whole would let a future orchestrator field reach the model with nothing to catch it. */ +function projectStepContext( + context: WorkflowHistoryStepContext | undefined, +): WorkflowHistoryStepContext | undefined { + if (!context) return context; + + return { + manuallyCompleted: context.manuallyCompleted, + completedBy: context.completedBy, + subWorkflowVersion: context.subWorkflowVersion, + selectedOption: context.selectedOption, + error: context.error, + childrenWorkflowId: context.childrenWorkflowId, + escalationState: context.escalationState, + awaitingInputReason: context.awaitingInputReason, + }; +} + function projectHydratedRun(run: HydratedWorkflowRun): HydratedWorkflowRun { return { id: run.id, @@ -64,7 +84,7 @@ function projectHydratedRun(run: HydratedWorkflowRun): HydratedWorkflowRun { cancelled: step.cancelled, childrenWorkflowId: step.childrenWorkflowId, isCardStep: step.isCardStep, - context: step.context, + context: projectStepContext(step.context), stepDefinition: step.stepDefinition, })), }; @@ -212,13 +232,22 @@ export default class ForestHttpApi implements ForestAdminServerInterface { ): Promise { const query = collectionName ? `?collectionName=${encodeURIComponent(collectionName)}` : ''; - return ServerUtils.queryWithBearerToken({ + const workflows = await ServerUtils.queryWithBearerToken({ forestServerUrl: options.forestServerUrl, method: 'get', path: `/api/workflow-orchestrator/mcp-workflows${query}`, bearerToken: options.bearerToken, headers: { 'forest-rendering-id': renderingId, ...MCP_SOURCE_HEADER, ...options.headers }, }); + + // Projected for the same reason as the run: the listWorkflows tool stringifies this array + // straight into an LLM's context, so a column added to the server query must not reach a + // model's prompt without a change here. + return (workflows ?? []).map(workflow => ({ + workflowId: workflow.workflowId, + name: workflow.name, + collectionName: workflow.collectionName, + })); } async getMcpWorkflowById( diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 636ef16bf2..0255421cfb 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -230,6 +230,41 @@ describe('ForestHttpApi', () => { expect(result).toEqual(workflows); }); + it('should strip fields the contract does not declare from each listed workflow', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue([ + { + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + // A column added to the server query must not reach the model's prompt on its own. + createdByEmail: 'ops@example.com', + bpmnAwsS3Identifier: 'internal/path.bpmn', + }, + ]); + + const result = await new ForestHttpApi().listMcpEnabledWorkflows( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + ); + + expect(result).toEqual([ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + ]); + expect(JSON.stringify(result)).not.toContain('ops@example.com'); + expect(JSON.stringify(result)).not.toContain('internal/path.bpmn'); + }); + + it('should return an empty array when the server answers with no body', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(undefined); + + const result = await new ForestHttpApi().listMcpEnabledWorkflows( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + ); + + expect(result).toEqual([]); + }); + it('should append the collectionName filter as a url-encoded query param', async () => { (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue([]); @@ -434,6 +469,57 @@ describe('ForestHttpApi', () => { expect(result.runState).toBe('started'); }); + it('should strip fields the contract does not declare from a per-step context', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runState: 'started', + workflowHistory: [ + { + stepName: 'Approve', + stepIndex: 0, + done: false, + stepDefinition: { type: 'escalation' }, + context: { + escalationState: 'escalated', + error: 'record 42 not found', + // The step context is a closed interface here but an open bag server-side. + completedByUser: { email: 'ops@example.com', serverToken: 'leaked-token' }, + }, + }, + ], + }); + + const result = await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + '7', + ); + + expect(result.workflowHistory[0].context).not.toHaveProperty('completedByUser'); + expect(JSON.stringify(result)).not.toContain('leaked-token'); + // The declared fields the docs tell readers to diagnose with survive. + expect(result.workflowHistory[0].context).toMatchObject({ + escalationState: 'escalated', + error: 'record 42 not found', + }); + }); + + it('should leave a step with no context untouched', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runState: 'started', + workflowHistory: [ + { stepName: 'Review', stepIndex: 0, done: true, stepDefinition: { type: 'task' } }, + ], + }); + + const result = await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + '7', + ); + + expect(result.workflowHistory[0].context).toBeUndefined(); + }); + it('should keep the task-type-specific stepDefinition fields the model reasons about', async () => { (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ runState: 'started', From 1337700ebcf639dedc877d53c76a59860ff78e3a Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 15:03:40 +0200 Subject: [PATCH 33/43] fix(mcp-server): keep transport detail out of the model's context (PRD-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) --- .../mcp-server/src/tools/trigger-workflow.ts | 16 ++++++++- .../test/tools/trigger-workflow.test.ts | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index cc5271cc83..d21af52bd4 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -34,6 +34,17 @@ function notMcpEnabledMessage(workflowId: string): string { ); } +// Distinct from the uniform 404 message: the workflow may well exist and be triggerable, Forest +// just could not be reached. Retrying later is the right advice, and the transport detail stays in +// the operator log rather than in the model's context. +function lookupUnavailableMessage(workflowId: string): string { + return ( + `Workflow "${workflowId}" could not be resolved because Forest could not be reached. ` + + 'This is a temporary server-side failure, not a problem with the workflow id — retry later, ' + + 'and report it to your Forest administrator if it persists.' + ); +} + function unavailableCollectionMessage(workflowId: string): string { return ( `Workflow "${workflowId}" cannot be triggered via MCP because its collection is unavailable. ` + @@ -94,7 +105,10 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To throw new Error(notMcpEnabledMessage(args.workflowId)); } - throw error; + // Anything else (5xx, timeout, ECONNREFUSED) carries transport detail — the Forest server + // URL, an internal host and port — that has no business in a model's context. The full + // error is in the operator log above. + throw new Error(lookupUnavailableMessage(args.workflowId)); } if (!workflow.mcpEnabled) { diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 7a042162b9..ea0329e10e 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -293,6 +293,39 @@ describe('declareTriggerWorkflowTool', () => { ); }); + it('should not leak transport detail to the model when the lookup fails for any other reason', async () => { + mockForestServerClient.getMcpWorkflowById.mockRejectedValue( + new Error( + 'Failed to reach Forest: connect ECONNREFUSED 10.1.2.3:3310 ' + + '(https://api.internal.forestadmin.com)', + ), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: + 'Workflow "wf-1" could not be resolved because Forest could not be reached. This is ' + + 'a temporary server-side failure, not a problem with the workflow id — retry later, ' + + 'and report it to your Forest administrator if it persists.', + }, + ], + isError: true, + }); + // The detail stays operator-side. + expect(JSON.stringify(result)).not.toContain('ECONNREFUSED'); + expect(JSON.stringify(result)).not.toContain('api.internal.forestadmin.com'); + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + expect.stringContaining('connect ECONNREFUSED 10.1.2.3:3310'), + ); + expect(mockForestServerClient.createMcpActivityLog).not.toHaveBeenCalled(); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); + }); + it('should reject an MCP-disabled workflow without triggering or auditing', async () => { mockForestServerClient.getMcpWorkflowById.mockResolvedValue({ workflowId: 'wf-1', From 1f6decffcd54ad58ccf9c20c5b8fb2c80578380f Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 15:03:57 +0200 Subject: [PATCH 34/43] test(mcp-server): pin that the three workflow tools are registered (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/mcp-server/test/server.test.ts | 96 +++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index f4596740ea..d84bc98b79 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3674,6 +3674,102 @@ describe('enabledTools', () => { ); }); + // Registration, the ToolName union and allToolNames are three separate edits in server.ts; the + // union and the list are type-checked, the registration call is not. Without this, dropping the + // registerTool line leaves a tool that is advertised as available, never registered, and green. + it('should register the three workflow tools by default, with their annotations', async () => { + const savedFetch3 = global.fetch; + const savedPort3 = process.env.MCP_SERVER_PORT; + process.env.MCP_SERVER_PORT = (await getAvailablePort()).toString(); + const mockServer3 = new MockServer(); + mockServer3 + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { name: 'users', fields: [{ field: 'id', type: 'Number' }] }, + }, + ], + included: [], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + + global.fetch = mockServer3.fetch; + + // No enabledTools: exercises the default-on path the release notes describe. + const defaultServer = new ForestMCPServer({ + envSecret: 'test-env-secret', + authSecret: 'test-auth-secret', + }); + + const defaultApp = await defaultServer.buildExpressApp(); + const defaultHttpServer = defaultApp.listen(Number(process.env.MCP_SERVER_PORT)) as http.Server; + + await new Promise(resolve => { + defaultHttpServer.on('listening', resolve); + }); + + const validToken = jsonwebtoken.sign( + { id: 123, email: 'user@example.com', renderingId: 456 }, + 'test-auth-secret', + { expiresIn: '1h' }, + ); + + const response = await request(defaultHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${validToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 1 }); + + expect(response.status).toBe(200); + + let responseData: { + result: { tools: Array<{ name: string; annotations?: Record }> }; + }; + + if (response.body && Object.keys(response.body).length > 0) { + responseData = response.body; + } else { + const dataLine = response.text.split('\n').find((line: string) => line.startsWith('data: ')); + if (!dataLine) throw new Error('Expected SSE data line not found in response'); + responseData = JSON.parse(dataLine.replace('data: ', '')); + } + + const { tools } = responseData.result; + const toolNames = tools.map(t => t.name); + + expect(toolNames).toContain('listWorkflows'); + expect(toolNames).toContain('triggerWorkflow'); + expect(toolNames).toContain('getWorkflowRun'); + + // The annotations 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. + expect(tools.find(t => t.name === 'triggerWorkflow')?.annotations).toMatchObject({ + readOnlyHint: false, + destructiveHint: true, + idempotentHint: false, + openWorldHint: true, + }); + expect(tools.find(t => t.name === 'listWorkflows')?.annotations).toMatchObject({ + readOnlyHint: true, + }); + expect(tools.find(t => t.name === 'getWorkflowRun')?.annotations).toMatchObject({ + readOnlyHint: true, + }); + + await new Promise(resolve => { + defaultHttpServer.close(() => resolve()); + }); + global.fetch = savedFetch3; + process.env.MCP_SERVER_PORT = savedPort3; + }); + it('should only expose describeCollection when enabledTools is empty', async () => { const savedFetch2 = global.fetch; const savedPort2 = process.env.MCP_SERVER_PORT; From 8d834c42fa12f9205c80e95f27c5e40a90757b63 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 15:03:57 +0200 Subject: [PATCH 35/43] fix(mcp-server): align the collectionName guard with triggerWorkflow (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/mcp-server/src/tools/list-workflows.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts index 278582d170..c5a59ae0b4 100644 --- a/packages/mcp-server/src/tools/list-workflows.ts +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -58,7 +58,9 @@ export default function declareListWorkflowsTool(mcpServer: McpServer, ctx: Tool // A workflow whose collection was renamed or removed cannot be triggered - triggerWorkflow // rejects a null collectionName up front - so listing it would only send the model round the // discover, trigger, rejected, discover loop. Surface the drop to the operator instead. - const triggerable = workflows.filter(workflow => workflow.collectionName !== null); + // `!= null` on purpose, to match triggerWorkflow's own guard: an absent key and an explicit + // null must be dropped by the same rule, or the discover/trigger loop reopens. + const triggerable = workflows.filter(workflow => workflow.collectionName != null); const droppedCount = workflows.length - triggerable.length; if (droppedCount > 0) { From b0aac6549b7f9e0c84618ee812dca8e8c0df2d27 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 15:03:58 +0200 Subject: [PATCH 36/43] docs(mcp-server): correct the notes the workflow tools invalidated (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/mcp-server/CLAUDE.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index 4358f33e30..b886515e95 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -14,8 +14,10 @@ Key flows that only make sense across files: - **Per-request MCP server.** `handleMcpRequest` builds a *fresh* `McpServer` + `StreamableHTTPServerTransport` per request (`sessionIdGenerator: undefined`, stateless) and closes the transport on response end. Tool registration in `createMcpServer()` therefore re-runs on every call. - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. -- **Tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService`/`ActivityLogsService`) is used only for schema fetch and activity logging, not data. -- **Two cross-cutting wrappers, always used together.** `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). +- **Record-level tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService` / `ActivityLogsService` / `WorkflowsService`) carries schema fetch, activity logging, and — for the workflow tools only — orchestrator data. +- **The workflow tools are the exception.** `listWorkflows` / `triggerWorkflow` / `getWorkflowRun` never reach the agent: workflows live in the orchestrator, so these go through `forestServerClient.workflowsService` over the `/api/workflow-orchestrator/mcp-workflows/*` HTTP contract. Two consequences worth knowing before editing them: responses are **projected onto an explicit whitelist** in `forest-http-api.ts` (their payload is stringified straight into a model's context, so a new server field must not arrive by itself), and `triggerWorkflow` resolves the workflow by id **before** anything is written so its audit label exists ahead of the side effect. +- **Two cross-cutting wrappers.** Every tool uses `registerToolWithLogging`; most, but not all, also use `withActivityLog` — `getActionForm`, `listWorkflows`, `getWorkflowRun` and `requestActionFileUpload` are unaudited (tracked in PRD-967), so do not assume a tool writes an activity log without checking. `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). +- **Audit fail policy lives in one place.** `createPendingActivityLog` (`src/utils/activity-logs-creator.ts`) decides what happens when the audit log cannot be created, for every tool: a **write** is blocked (no unaudited side effect), a **read** proceeds with a warning (an audit-store outage must not take down the read surface), and an **authorization refusal (401/403) propagates either way** — it is not an outage. This covers both a rejection and a `200` with no log id. Changing it changes behaviour for all tools at once; the policy is pinned in both directions in `test/utils/activity-logs-creator.test.ts`. - **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), on by default: `EphemeralStorage` holds the objects in memory unless `fileUploads.storage` provides a backend. `fileUploads: false` is the off switch (it drops `requestActionFileUpload` from the enabled set, so the tool, the upload endpoint and the `executeAction` instructions all follow); leaving the tool out of `enabledTools` does the same, at the cost of freezing the allowlist. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `FieldGetter.getType()` returns the wire value (`['File']`) because agent-bff and workflow-executor put it straight into API responses; `getTypeName()` is the collapsed `'FileList'`, for dispatch and for what this server reports to a model. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context — and withholds them from `tryToSetFields` so a change hook fired by another field never reads `.buffer` off a handle string, while still echoing them back as the field's value and counting them as filling a required field, or `canExecute` could never become true. `download` is **not** consume-on-read: `resolve.ts` fetches every reference before `setFields` and `execute`, so a later failure must leave the objects retryable. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns on the first `createUploadUrl` rather than at startup — uploads being on by default, a boot warning would reach agents that have no file field at all. - **`collectionNames` → `z.enum`.** `fetchCollectionNames()` populates the schema's collection list; tools turn it into a `z.enum` for `collectionName` so the LLM gets autocomplete/validation. If schema fetch fails the server logs a warning and runs "degraded" with `z.string()`. From edf972101a13e96ae0aeeccbfab65ffd309b2e08 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 19:04:21 +0200 Subject: [PATCH 37/43] fix(mcp-server): classify workflow transport failures before a model sees them (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../mcp-server/src/tools/get-workflow-run.ts | 31 +++++-- .../mcp-server/src/tools/list-workflows.ts | 29 ++++-- .../mcp-server/src/tools/trigger-workflow.ts | 77 ++++++++++++++-- .../mcp-server/src/utils/workflow-error.ts | 53 +++++++++++ .../test/tools/get-workflow-run.test.ts | 33 ++++++- .../test/tools/list-workflows.test.ts | 36 +++++++- .../test/tools/trigger-workflow.test.ts | 89 ++++++++++++++++++- 7 files changed, 325 insertions(+), 23 deletions(-) create mode 100644 packages/mcp-server/src/utils/workflow-error.ts diff --git a/packages/mcp-server/src/tools/get-workflow-run.ts b/packages/mcp-server/src/tools/get-workflow-run.ts index 2cec4d0f1f..65a450f7c7 100644 --- a/packages/mcp-server/src/tools/get-workflow-run.ts +++ b/packages/mcp-server/src/tools/get-workflow-run.ts @@ -5,9 +5,18 @@ import { z } from 'zod'; import getAuthContext from '../utils/auth-context'; import registerToolWithLogging from '../utils/tool-with-logging'; +import toModelSafeError from '../utils/workflow-error'; const RUN_ID_DESCRIPTION = 'The id of the workflow run to observe, as returned by triggerWorkflow.'; +function runUnavailableMessage(runId: string): string { + return ( + `Run "${runId}" could not be read because Forest could not be reached. This is a temporary ` + + 'server-side failure, not a problem with the run id — retry later, and report it to your ' + + 'Forest administrator if it persists.' + ); +} + interface GetWorkflowRunArgument { runId: string; } @@ -46,11 +55,23 @@ export default function declareGetWorkflowRunTool(mcpServer: McpServer, ctx: Too async (args: GetWorkflowRunArgument, extra) => { const { forestServerToken, renderingId } = getAuthContext(extra); - const runStatus = await forestServerClient.getMcpWorkflowRun({ - forestServerToken, - renderingId, - runId: args.runId, - }); + let runStatus; + + try { + runStatus = await forestServerClient.getMcpWorkflowRun({ + forestServerToken, + renderingId, + runId: args.runId, + }); + } catch (error) { + // Same rule as the two sibling workflow tools: Forest's own refusal is worth reading, a + // raw transport failure is transport detail the model must never receive. + throw toModelSafeError(error, { + logger, + context: `Failed to read workflow run "${args.runId}"`, + unavailableMessage: runUnavailableMessage(args.runId), + }); + } return { content: [{ type: 'text', text: JSON.stringify(runStatus) }] }; }, diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts index c5a59ae0b4..7389da3ce3 100644 --- a/packages/mcp-server/src/tools/list-workflows.ts +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -5,6 +5,12 @@ import { z } from 'zod'; import getAuthContext from '../utils/auth-context'; import registerToolWithLogging from '../utils/tool-with-logging'; +import toModelSafeError from '../utils/workflow-error'; + +const LISTING_UNAVAILABLE_MESSAGE = + 'The MCP-enabled workflows could not be listed because Forest could not be reached. This is a ' + + 'temporary server-side failure — retry later, and report it to your Forest administrator if it ' + + 'persists.'; const COLLECTION_NAME_DESCRIPTION = 'Optional. Narrow the results to workflows operating on this collection — typically the ' + @@ -49,11 +55,24 @@ export default function declareListWorkflowsTool(mcpServer: McpServer, ctx: Tool async (args: ListWorkflowsArgument, extra) => { const { forestServerToken, renderingId } = getAuthContext(extra); - const workflows = await forestServerClient.listMcpEnabledWorkflows({ - forestServerToken, - renderingId, - collectionName: args.collectionName, - }); + let workflows; + + try { + workflows = await forestServerClient.listMcpEnabledWorkflows({ + forestServerToken, + renderingId, + collectionName: args.collectionName, + }); + } catch (error) { + // Forest's own refusals still reach the model — they say something actionable. What must + // not is a raw transport failure: its message carries the Forest server URL and an internal + // host and port, and this result is stringified straight into a model's context. + throw toModelSafeError(error, { + logger, + context: 'Failed to list MCP-enabled workflows', + unavailableMessage: LISTING_UNAVAILABLE_MESSAGE, + }); + } // A workflow whose collection was renamed or removed cannot be triggered - triggerWorkflow // rejects a null collectionName up front - so listing it would only send the model round the diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index d21af52bd4..87736a5663 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import getAuthContext from '../utils/auth-context'; import registerToolWithLogging from '../utils/tool-with-logging'; import withActivityLog from '../utils/with-activity-log'; +import { carriesTransportDetail, isRetryable } from '../utils/workflow-error'; const WORKFLOW_ID_DESCRIPTION = 'The id of the workflow to start, as returned by listWorkflows. The workflow must have the MCP ' + @@ -36,7 +37,8 @@ function notMcpEnabledMessage(workflowId: string): string { // Distinct from the uniform 404 message: the workflow may well exist and be triggerable, Forest // just could not be reached. Retrying later is the right advice, and the transport detail stays in -// the operator log rather than in the model's context. +// the operator log rather than in the model's context. Reserved for genuinely retryable failures — +// see isRetryable: a 4xx handed this message would make the model retry an id that can never work. function lookupUnavailableMessage(workflowId: string): string { return ( `Workflow "${workflowId}" could not be resolved because Forest could not be reached. ` + @@ -45,6 +47,30 @@ function lookupUnavailableMessage(workflowId: string): string { ); } +// Forest refused the lookup for a reason that will not change on retry, and said why. Reached only +// for an HttpError — isRetryable treats everything else as retryable — so `detail` is either the +// server's own JSON:API message or a fixed string, never transport detail. Keeping it distinguishes +// a malformed id, a refused identity and a browser-engine environment from one another, instead of +// having all three claim the workflow is not MCP-enabled. +function terminalLookupMessage(workflowId: string, detail: string): string { + const reason = detail.replace(/\.$/, ''); + + return ( + `Workflow "${workflowId}" could not be resolved — ${reason}. Retrying will not help: fix the ` + + 'request, or report it to your Forest administrator.' + ); +} + +// The trigger is not idempotent and the write may have landed before the transport failed, so this +// deliberately does not tell the model to retry: a blind retry either duplicates intent or hits the +// active-run conflict, and neither is something a model should decide on its own. +function triggerUnavailableMessage(workflowId: string): string { + return ( + `Workflow "${workflowId}" could not be triggered because Forest could not be reached. The run ` + + 'may or may not have started — do not retry blindly; report it to your Forest administrator.' + ); +} + function unavailableCollectionMessage(workflowId: string): string { return ( `Workflow "${workflowId}" cannot be triggered via MCP because its collection is unavailable. ` + @@ -101,13 +127,28 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To }`, ); + // A 404 keeps the uniform wording on purpose: unknown, MCP-disabled and out-of-rendering + // must stay indistinguishable, or the contract leaks whether a given workflow exists. if (error instanceof NotFoundError) { throw new Error(notMcpEnabledMessage(args.workflowId)); } - // Anything else (5xx, timeout, ECONNREFUSED) carries transport detail — the Forest server - // URL, an internal host and port — that has no business in a model's context. The full - // error is in the operator log above. + // Any other non-retryable refusal (a 400 on a malformed id, a 401/403 on the identity, a + // 409 on a browser-engine environment) fails identically on every attempt. Sending those + // down the "retry later" path is what made a model loop forever on a request that can + // never succeed — typically a workflow *name* it guessed instead of the id. + if (!isRetryable(error)) { + throw new Error( + terminalLookupMessage( + args.workflowId, + error instanceof Error ? error.message : String(error), + ), + ); + } + + // Retryable (5xx, timeout, ECONNREFUSED). Its message carries transport detail — the Forest + // server URL, an internal host and port — that has no business in a model's context, so + // only the fixed sentence travels. The full error is in the operator log above. throw new Error(lookupUnavailableMessage(args.workflowId)); } @@ -140,11 +181,29 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To workflowId: args.workflowId, recordId: args.recordId, }), - // Guard the race where the workflow is disabled/deleted between the lookup and the trigger. - errorEnhancer: async (parsedMessage, originalError) => - originalError instanceof NotFoundError - ? notMcpEnabledMessage(args.workflowId) - : parsedMessage, + errorEnhancer: async (parsedMessage, originalError) => { + // Guard the race where the workflow is disabled/deleted between the lookup and the + // trigger. + if (originalError instanceof NotFoundError) { + return notMcpEnabledMessage(args.workflowId); + } + + // withActivityLog rethrows the parsed message as-is, so without this the raw transport + // failure of the start call itself reaches the model — the same leak the lookup above + // guards, one call later. + if (carriesTransportDetail(originalError)) { + logger( + 'Error', + `Failed to trigger workflow "${args.workflowId}": ${ + originalError instanceof Error ? originalError.message : String(originalError) + }`, + ); + + return triggerUnavailableMessage(args.workflowId); + } + + return parsedMessage; + }, }); return { diff --git a/packages/mcp-server/src/utils/workflow-error.ts b/packages/mcp-server/src/utils/workflow-error.ts new file mode 100644 index 0000000000..4fd2d72d31 --- /dev/null +++ b/packages/mcp-server/src/utils/workflow-error.ts @@ -0,0 +1,53 @@ +import type { Logger } from '../server'; + +import { HttpError } from '@forestadmin/forestadmin-client'; + +// A timeout is the one HttpError whose message is built client-side: ServerUtils interpolates the +// full Forest server URL into it. Every other HttpError message is either a fixed string or the +// server's own JSON:API detail, both of which are meant to be read. +const TIMEOUT_STATUS = 408; +const RATE_LIMITED_STATUS = 429; + +/** + * True when the error's message carries transport detail — the Forest server URL, an internal + * host:port, a TLS chain. Anything that is not an `HttpError` reached us as a raw Node/superagent + * error, so its message is unfit for a model's context. + */ +export function carriesTransportDetail(error: unknown): boolean { + return !(error instanceof HttpError) || error.status === TIMEOUT_STATUS; +} + +/** + * True when retrying the same call could plausibly succeed. A 4xx that is neither a timeout nor a + * rate-limit is the caller's own fault and will fail identically forever — telling a model to + * retry one of those is what turns a bad argument into an endless loop. + */ +export function isRetryable(error: unknown): boolean { + if (!(error instanceof HttpError)) return true; + + return ( + error.status < 400 || + error.status >= 500 || + error.status === TIMEOUT_STATUS || + error.status === RATE_LIMITED_STATUS + ); +} + +/** + * Turns a workflow transport failure into an error the model may see: the full cause always goes + * to the operator log, and a message carrying transport detail is replaced by `unavailableMessage`. + */ +export default function toModelSafeError( + error: unknown, + { + logger, + context, + unavailableMessage, + }: { logger: Logger; context: string; unavailableMessage: string }, +): Error { + const detail = error instanceof Error ? error.message : String(error); + + logger('Error', `${context}: ${detail}`); + + return new Error(carriesTransportDetail(error) ? unavailableMessage : detail); +} diff --git a/packages/mcp-server/test/tools/get-workflow-run.test.ts b/packages/mcp-server/test/tools/get-workflow-run.test.ts index 48e900fce0..d80446ece3 100644 --- a/packages/mcp-server/test/tools/get-workflow-run.test.ts +++ b/packages/mcp-server/test/tools/get-workflow-run.test.ts @@ -5,7 +5,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; -import { ForbiddenError, NotFoundError } from '@forestadmin/forestadmin-client'; +import { ForbiddenError, HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; import declareGetWorkflowRunTool from '../../src/tools/get-workflow-run'; import createMockForestServerClient from '../helpers/forest-server-client'; @@ -236,5 +236,36 @@ describe('declareGetWorkflowRunTool', () => { isError: true, }); }); + + it.each([ + ['a raw connection error', new Error('connect ECONNREFUSED 10.0.4.17:3310')], + [ + 'a timeout naming the server URL', + new HttpError( + 'The request to Forest Admin server has timed out while trying to reach ' + + 'https://api.internal.acme.corp/api/workflow-orchestrator/mcp-workflows/runs/7 at ' + + '2026-08-19T13:00:00.000Z. Message: Timeout of 10000ms exceeded', + 408, + ), + ], + ])('should keep transport detail out of the tool result on %s', async (_, error) => { + mockForestServerClient.getMcpWorkflowRun.mockRejectedValue(error); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(result).toMatchObject({ isError: true }); + expect(text).toBe( + 'Run "7" could not be read because Forest could not be reached. This is a temporary ' + + 'server-side failure, not a problem with the run id — retry later, and report it to ' + + 'your Forest administrator if it persists.', + ); + expect(text).not.toContain('10.0.4.17'); + expect(text).not.toContain('acme.corp'); + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + `Failed to read workflow run "7": ${error.message}`, + ); + }); }); }); diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts index 2a7a6b06cc..1897513bbb 100644 --- a/packages/mcp-server/test/tools/list-workflows.test.ts +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -5,7 +5,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; -import { NotFoundError } from '@forestadmin/forestadmin-client'; +import { HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; import declareListWorkflowsTool from '../../src/tools/list-workflows'; import createMockForestServerClient from '../helpers/forest-server-client'; @@ -253,5 +253,39 @@ describe('declareListWorkflowsTool', () => { isError: true, }); }); + + // This result is stringified straight into a model's context, so it is the one place a raw + // connection error must not reach: the model vendor would receive the private Forest endpoint. + it.each([ + ['a raw connection error', new Error('connect ECONNREFUSED 10.0.4.17:3310')], + [ + 'a timeout naming the server URL', + new HttpError( + 'The request to Forest Admin server has timed out while trying to reach ' + + 'https://api.internal.acme.corp/api/workflow-orchestrator/mcp-workflows at ' + + '2026-08-19T13:00:00.000Z. Message: Timeout of 10000ms exceeded', + 408, + ), + ], + ])('should keep transport detail out of the tool result on %s', async (_, error) => { + mockForestServerClient.listMcpEnabledWorkflows.mockRejectedValue(error); + + const result = await registeredToolHandler({}, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(result).toMatchObject({ isError: true }); + expect(text).toBe( + 'The MCP-enabled workflows could not be listed because Forest could not be reached. This ' + + 'is a temporary server-side failure — retry later, and report it to your Forest ' + + 'administrator if it persists.', + ); + expect(text).not.toContain('10.0.4.17'); + expect(text).not.toContain('acme.corp'); + // The operator keeps the whole cause. + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + `Failed to list MCP-enabled workflows: ${error.message}`, + ); + }); }); }); diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index ea0329e10e..577c777f67 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -5,7 +5,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; -import { NotFoundError } from '@forestadmin/forestadmin-client'; +import { ForbiddenError, HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; import declareTriggerWorkflowTool from '../../src/tools/trigger-workflow'; import createMockForestServerClient from '../helpers/forest-server-client'; @@ -326,6 +326,88 @@ describe('declareTriggerWorkflowTool', () => { expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); }); + // The server validates workflowId as a UUID, the identity, and the workflow engine on every + // lookup, so these fail identically on every attempt. Sending them down the "retry later" path + // is what let a model loop forever on a request — typically a workflow *name* it guessed + // instead of the id — that can never succeed. Each keeps Forest's own reason: unlike the 404, + // none of them leaks whether the workflow exists. + it.each([ + ['a non-UUID workflowId (400)', new HttpError('"workflowId" must be a valid GUID', 400)], + ['an expired token (401)', new HttpError('Unauthorized', 401)], + ['a refused identity (403)', new ForbiddenError('Forbidden')], + [ + 'a browser-engine environment (409)', + new HttpError('This environment still runs workflows in the browser', 409), + ], + ])('should treat %s as terminal rather than retryable', async (_, error) => { + mockForestServerClient.getMcpWorkflowById.mockRejectedValue(error); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(result).toMatchObject({ isError: true }); + expect(text).toBe( + `Workflow "wf-1" could not be resolved — ${error.message.replace(/\.$/, '')}. Retrying ` + + 'will not help: fix the request, or report it to your Forest administrator.', + ); + expect(text).not.toContain('retry later'); + expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); + }); + + // The 404 is the one that must stay uniform: it covers unknown, MCP-disabled and + // out-of-rendering alike, so a caller cannot use it to probe which workflows exist. + it('should keep the uniform wording on a 404 so it cannot be used to probe', async () => { + mockForestServerClient.getMcpWorkflowById.mockRejectedValue( + new NotFoundError('Workflow 3f7c not found in rendering 42'), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(text).toBe( + 'Workflow "wf-1" is not an MCP-enabled workflow you can access. Use listWorkflows to ' + + 'discover triggerable workflows. If listWorkflows just returned this id, do not retry ' + + '— report it to your Forest administrator instead.', + ); + expect(text).not.toContain('rendering 42'); + }); + + it('should still tell the caller to retry when the lookup fails with a 5xx', async () => { + mockForestServerClient.getMcpWorkflowById.mockRejectedValue( + new HttpError('Internal Server Error', 503), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect((result as { content: [{ text: string }] }).content[0].text).toContain('retry later'); + }); + + // The lookup's guard is one call early: the start itself goes through withActivityLog, which + // rethrows the parsed message as-is. + it('should keep transport detail out of the model when the trigger call itself fails', async () => { + mockForestServerClient.triggerMcpWorkflow.mockRejectedValue( + new Error('connect ECONNREFUSED 10.0.4.17:3310'), + ); + + const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(result).toMatchObject({ isError: true }); + expect(text).toBe( + 'Workflow "wf-1" could not be triggered because Forest could not be reached. The run may ' + + 'or may not have started — do not retry blindly; report it to your Forest administrator.', + ); + expect(text).not.toContain('ECONNREFUSED'); + expect(mockLogger).toHaveBeenCalledWith( + 'Error', + 'Failed to trigger workflow "wf-1": connect ECONNREFUSED 10.0.4.17:3310', + ); + // The audit row was written before the trigger and must still be closed out. + expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed' }), + ); + }); + it('should reject an MCP-disabled workflow without triggering or auditing', async () => { mockForestServerClient.getMcpWorkflowById.mockResolvedValue({ workflowId: 'wf-1', @@ -399,8 +481,11 @@ describe('declareTriggerWorkflowTool', () => { }); it('should pass a 409 already-ongoing run through as an error and mark the log failed', async () => { + // An HttpError, not a bare Error: ServerUtils maps a 409 carrying a JSON:API detail to + // `new HttpError(detail, 409)`. The distinction is load-bearing — a bare Error is what a raw + // transport failure looks like, and those are the ones whose message must not reach a model. mockForestServerClient.triggerMcpWorkflow.mockRejectedValue( - new Error('A run is already ongoing on this record'), + new HttpError('A run is already ongoing on this record', 409), ); const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); From 122cf3af8cdc9a5eb89b7e038e349432d41e3c18 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 19:04:40 +0200 Subject: [PATCH 38/43] fix(forestadmin-client): project the mcp workflow by-id lookup response (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/permissions/forest-http-api.ts | 12 ++++++++- .../test/permissions/forest-http-api.test.ts | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index d4c242e218..4193e4a788 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -255,13 +255,23 @@ export default class ForestHttpApi implements ForestAdminServerInterface { renderingId: string, workflowId: string, ): Promise { - return ServerUtils.queryWithBearerToken({ + const workflow = await ServerUtils.queryWithBearerToken({ forestServerUrl: options.forestServerUrl, method: 'get', path: `/api/workflow-orchestrator/mcp-workflows/${encodeURIComponent(workflowId)}`, bearerToken: options.bearerToken, headers: { 'forest-rendering-id': renderingId, ...MCP_SOURCE_HEADER, ...options.headers }, }); + + // Projected like the other three MCP routes. This payload does not reach a model directly, but + // `name` is written verbatim into a persisted Activity Log label, and the type is an unvalidated + // cast of the HTTP response — so a column added server-side would otherwise arrive unannounced. + return { + workflowId: workflow.workflowId, + name: workflow.name, + collectionName: workflow.collectionName, + mcpEnabled: workflow.mcpEnabled, + }; } async triggerMcpWorkflow( diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 0255421cfb..1f01172d4c 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -316,6 +316,31 @@ describe('ForestHttpApi', () => { expect(result).toEqual(workflow); }); + it('should drop any field the contract does not declare', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + workflowId: 'wf-1', + name: 'Refund order', + collectionName: 'orders', + mcpEnabled: true, + // A column the server could add tomorrow. `name` from this payload is written into a + // persisted audit label, so the response is a whitelist like the other three MCP routes. + internalOwnerEmail: 'ops@acme.corp', + }); + + const result = await new ForestHttpApi().getMcpWorkflowById( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'wf-1', + ); + + expect(Object.keys(result).sort()).toEqual([ + 'collectionName', + 'mcpEnabled', + 'name', + 'workflowId', + ]); + }); + it('should url-encode the workflow id in the path', async () => { (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ workflowId: 'wf/with space', From caa542cb577609ea4945d38058ba03906abc9c2a Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 19 Aug 2026 19:04:41 +0200 Subject: [PATCH 39/43] docs(mcp-server): correct the rationale on the skipped status update (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/mcp-server/src/utils/activity-logs-creator.ts | 2 +- packages/mcp-server/test/utils/activity-logs-creator.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 5ba6806317..2b7fbd210a 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -133,7 +133,7 @@ async function updateActivityLogStatus( // Read directly rather than through getAuthContext: this runs in a fire-and-forget path and must // not throw a second, unrelated error. A missing token means the request cannot be authenticated, - // so skip the call - sending an empty Bearer would 401 and then be retried on the 404 branch. + // so skip the call rather than spend a round trip on a request that can only be refused. const forestServerToken = request.authInfo?.extra?.forestServerToken; if (typeof forestServerToken !== 'string' || !forestServerToken) { diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index aa1b2712dc..ece389bc9a 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -468,7 +468,9 @@ describe('markActivityLogAsFailed', () => { setTimeout(resolve, 0); }); - // An empty Bearer would 401, and the 404 retry branch would then repeat it five times. + // An empty Bearer can only be refused, so the round trip is skipped entirely. (The retry + // branch below is 404-only, so a 401 would not have been repeated — it would just have cost + // one pointless call and an error log naming an auth failure rather than the missing token.) expect(mockForestServerClient.updateActivityLogStatus).not.toHaveBeenCalled(); expect(mockLogger).toHaveBeenCalledWith( 'Error', From 31976ed56016618ffd8d019d5c6a6deed985a7c3 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 20 Aug 2026 09:19:54 +0200 Subject: [PATCH 40/43] fix(mcp-server): tell the two MCP trigger audit rows apart (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/mcp-server/src/tools/trigger-workflow.ts | 10 ++++++++-- .../mcp-server/test/tools/trigger-workflow.test.ts | 13 +++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index 87736a5663..9da6af9fcb 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -170,8 +170,14 @@ export default function declareTriggerWorkflowTool(mcpServer: McpServer, ctx: To context: { collectionName: workflow.collectionName, recordId: args.recordId, - // Matches the orchestrator's own "via MCP" row — one trigger writes two entries. - label: `triggered the workflow "${workflow.name}" via MCP`, + // One trigger writes two Activity Logs rows, and this is the first: it records the + // request, before the run exists. The orchestrator writes the second once the run is + // committed, labelled "triggered ... via MCP". The two deliberately do not share a + // wording — this one cannot carry a runId (that is what makes it fail-closed), and the + // second is best-effort, so a successful trigger can leave only this one. Identical + // labels made a single trigger read as two indistinguishable events, and made "how many + // workflows did assistants start?" answerable only by deduplicating on the runId. + label: `requested the workflow "${workflow.name}" via MCP`, }, logger, operation: () => diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 577c777f67..5099a5fe38 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -158,7 +158,12 @@ describe('declareTriggerWorkflowTool', () => { }); }); - it('should record the activity log before triggering, labelled from the resolved workflow', async () => { + // "requested", not "triggered": the orchestrator writes a second row with the latter once the + // run is committed. Aligning the two wordings — which an earlier round did — made one trigger + // read as two identical events in Activity Logs, and since the orchestrator's row is + // best-effort while this one is fail-closed, the number of rows per trigger is not even + // stable. Do not "fix" this back to match its sibling. + it('should record the activity log before triggering, labelled as a request', async () => { await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); expect(mockForestServerClient.createMcpActivityLog).toHaveBeenCalledWith( @@ -167,7 +172,7 @@ describe('declareTriggerWorkflowTool', () => { type: 'write', collectionName: 'orders', recordId: '42', - label: 'triggered the workflow "Refund order" via MCP', + label: 'requested the workflow "Refund order" via MCP', }), ); @@ -472,7 +477,7 @@ describe('declareTriggerWorkflowTool', () => { action: 'triggerWorkflow', collectionName: 'orders', recordId: '42', - label: 'triggered the workflow "Refund order" via MCP', + label: 'requested the workflow "Refund order" via MCP', }), ); expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( @@ -501,7 +506,7 @@ describe('declareTriggerWorkflowTool', () => { action: 'triggerWorkflow', collectionName: 'orders', recordId: '42', - label: 'triggered the workflow "Refund order" via MCP', + label: 'requested the workflow "Refund order" via MCP', }), ); expect(mockForestServerClient.updateActivityLogStatus).toHaveBeenCalledWith( From 701f523a2bacea76a7efe7f6ae9506c726221557 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 20 Aug 2026 14:28:14 +0200 Subject: [PATCH 41/43] fix(mcp-server): tell the polling tools when a refusal will repeat (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isRetryable was written, exported, and imported by one of the three workflow tools. 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. That matters most on getWorkflowRun, whose own description tells the model to poll: the loop it produces is the documented usage rather than a mistake. It is the same defect that was fixed on triggerWorkflow last round, on the two tools where it is likelier to fire. toModelSafeError now applies the arbitration the trigger path already had, so all three tools agree: transport detail is replaced, a 4xx that is neither a timeout nor a rate limit is told to stop, and 429 and 5xx keep travelling as-is because retrying them can work. The closing sentence moves into a shared RETRY_WILL_NOT_HELP constant. terminalLookupMessage had its own copy, and the wording is the only signal the model gets — 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. Also corrects this package's CLAUDE.md, which claimed the workflow responses are projected onto an explicit whitelist "so a new server field must not arrive by itself". True except for stepDefinition, which is forwarded whole on purpose so the model can reason about the step — and a test pins that pass-through. A field added to a step type does reach the model unannounced, so the note now says so. Co-Authored-By: Claude Opus 5 (1M context) --- packages/mcp-server/CLAUDE.md | 2 +- .../mcp-server/src/tools/trigger-workflow.ts | 7 ++-- .../mcp-server/src/utils/workflow-error.ts | 21 +++++++++-- .../test/tools/get-workflow-run.test.ts | 36 +++++++++++++++++++ .../test/tools/list-workflows.test.ts | 29 +++++++++++++++ 5 files changed, 87 insertions(+), 8 deletions(-) diff --git a/packages/mcp-server/CLAUDE.md b/packages/mcp-server/CLAUDE.md index b886515e95..0a4eee3a3f 100644 --- a/packages/mcp-server/CLAUDE.md +++ b/packages/mcp-server/CLAUDE.md @@ -15,7 +15,7 @@ Key flows that only make sense across files: - **Per-request MCP server.** `handleMcpRequest` builds a *fresh* `McpServer` + `StreamableHTTPServerTransport` per request (`sessionIdGenerator: undefined`, stateless) and closes the transport on response end. Tool registration in `createMcpServer()` therefore re-runs on every call. - **OAuth = pass-through to Forest Admin.** `ForestOAuthProvider` (`src/forest-oauth-provider.ts`) does not store tokens. `/oauth/authorize` redirects to the Forest app; `exchange*` calls relay to the Forest server's `/oauth/token`, then `generateAccessToken` re-signs a JWT with `authSecret` carrying the Forest token under the `serverToken` claim. `verifyAccessToken` verifies that JWT and builds `AuthInfo.extra` with `forestServerToken: decoded.serverToken` plus `environmentApiEndpoint` — the latter is **not** in the JWT, it's read from the provider's own `this.environmentApiEndpoint` field (set during env discovery). Both lifetimes mirror the Forest token's own `exp`, optionally shortened by the `tokenTtl` option (`src/utils/token-ttl.ts`) — always a `min`, never a `max`, and capped at the point the TTL is computed so the advertised `expires_in` matches the signed JWT (except on the already-expired-upstream branch, which advertises 3600). The refresh cap is anchored on a `sessionStartedAt` claim carried across refreshes and never re-stamped: Forest grants a full refresh lifetime on every refresh, so a per-refresh cap would slide forever and never force a re-login. `clientsStore.getClient` is the single choke point all three OAuth paths (authorize, code exchange, refresh) resolve clients through — when the `allowedOAuthClients` option is set, it rejects any client whose registered redirect URIs are not all http(s) URIs on an allowed domain or subdomain, by throwing `InvalidClientError` (rendered as a 400 `invalid_client` by the SDK handlers, never a redirect). DCR registration itself happens on the Forest server and is never blocked here. - **Record-level tools call the live agent, not this server.** Each tool in `src/tools/*` is a `declareXxxTool(mcpServer, forestServerClient, logger, collectionNames)` factory. At call time `buildClient(extra)` (`src/utils/agent-caller.ts`) reads `extra.authInfo` (`forestServerToken` + `environmentApiEndpoint` from `AuthInfo.extra`) and builds a `createRemoteAgentClient` from `@forestadmin/agent-client` — i.e. the tool RPCs into the user's actual running agent. `forestServerClient` (`src/http-client`, wrapping `@forestadmin/forestadmin-client`'s `SchemaService` / `ActivityLogsService` / `WorkflowsService`) carries schema fetch, activity logging, and — for the workflow tools only — orchestrator data. -- **The workflow tools are the exception.** `listWorkflows` / `triggerWorkflow` / `getWorkflowRun` never reach the agent: workflows live in the orchestrator, so these go through `forestServerClient.workflowsService` over the `/api/workflow-orchestrator/mcp-workflows/*` HTTP contract. Two consequences worth knowing before editing them: responses are **projected onto an explicit whitelist** in `forest-http-api.ts` (their payload is stringified straight into a model's context, so a new server field must not arrive by itself), and `triggerWorkflow` resolves the workflow by id **before** anything is written so its audit label exists ahead of the side effect. +- **The workflow tools are the exception.** `listWorkflows` / `triggerWorkflow` / `getWorkflowRun` never reach the agent: workflows live in the orchestrator, so these go through `forestServerClient.workflowsService` over the `/api/workflow-orchestrator/mcp-workflows/*` HTTP contract. Two consequences worth knowing before editing them: responses are **projected onto an explicit whitelist** in `forest-http-api.ts` (their payload is stringified straight into a model's context, so a new server field must not arrive by itself) — with one deliberate hole, `stepDefinition`, forwarded whole so the model can reason about the step, which means a field added to a *step* type does reach the model unannounced; and `triggerWorkflow` resolves the workflow by id **before** anything is written so its audit label exists ahead of the side effect. - **Two cross-cutting wrappers.** Every tool uses `registerToolWithLogging`; most, but not all, also use `withActivityLog` — `getActionForm`, `listWorkflows`, `getWorkflowRun` and `requestActionFileUpload` are unaudited (tracked in PRD-967), so do not assume a tool writes an activity log without checking. `registerToolWithLogging` (`src/utils/tool-with-logging.ts`) registers the tool and converts thrown errors into `{ isError: true }` results (per MCP spec) instead of protocol errors. Inside the handler, `withActivityLog` (`src/utils/with-activity-log.ts`) brackets the operation with a pending→succeeded/failed Forest activity log and runs `parseAgentError` + optional `errorEnhancer` (e.g. `list` appends sortable field names on "Invalid sort"). - **Audit fail policy lives in one place.** `createPendingActivityLog` (`src/utils/activity-logs-creator.ts`) decides what happens when the audit log cannot be created, for every tool: a **write** is blocked (no unaudited side effect), a **read** proceeds with a warning (an audit-store outage must not take down the read surface), and an **authorization refusal (401/403) propagates either way** — it is not an outage. This covers both a rejection and a `200` with no log id. Changing it changes behaviour for all tools at once; the policy is pinned in both directions in `test/utils/activity-logs-creator.test.ts`. - **Action file uploads are an opt-in side-channel** (`src/file-uploads/`), on by default: `EphemeralStorage` holds the objects in memory unless `fileUploads.storage` provides a backend. `fileUploads: false` is the off switch (it drops `requestActionFileUpload` from the enabled set, so the tool, the upload endpoint and the `executeAction` instructions all follow); leaving the tool out of `enabledTools` does the same, at the cost of freezing the allowlist. The `requestActionFileUpload` tool (`src/tools/request-action-file-upload.ts`) returns a pre-authorized upload URL plus a user-bound JWT handle signed with `authSecret`. `FieldGetter.getType()` returns the wire value (`['File']`) because agent-bff and workflow-executor put it straight into API responses; `getTypeName()` is the collapsed `'FileList'`, for dispatch and for what this server reports to a model. It is a tool rather than an HTTP route so clients discover it through `tools/list` instead of a sentence in a description. It checks the `mcp:action` scope itself, because `/mcp` only requires `mcp:read` while the route it replaced required `mcp:action`. `executeAction` swaps `"$uploadedFile:"` values for the downloaded `File` object (`resolve.ts`) and lets **agent-client** encode it — this package never builds a data uri itself. `parseFileReference` (`file-reference.ts`) is the single place that recognizes a reference, so SEP-2631 file URIs can be added there without touching the resolution path. `getActionForm` leaves handles unresolved on purpose, because it echoes values back into the model's context — and withholds them from `tryToSetFields` so a change hook fired by another field never reads `.buffer` off a handle string, while still echoing them back as the field's value and counting them as filling a required field, or `canExecute` could never become true. `download` is **not** consume-on-read: `resolve.ts` fetches every reference before `setFields` and `execute`, so a later failure must leave the objects retryable. With no `storage`, `EphemeralStorage` holds the objects in memory and serves a PUT endpoint under `${prefix}/mcp/uploads` — registered **before the body parsers** (they would consume the raw stream) and before `allowedMethods(['POST'])`; `makeIsMcpRoute` already claims everything under `/mcp/`, so no routing change was needed. Per-instance, so it warns on the first `createUploadUrl` rather than at startup — uploads being on by default, a boot warning would reach agents that have no file field at all. diff --git a/packages/mcp-server/src/tools/trigger-workflow.ts b/packages/mcp-server/src/tools/trigger-workflow.ts index 9da6af9fcb..601fea6b24 100644 --- a/packages/mcp-server/src/tools/trigger-workflow.ts +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; import getAuthContext from '../utils/auth-context'; import registerToolWithLogging from '../utils/tool-with-logging'; import withActivityLog from '../utils/with-activity-log'; -import { carriesTransportDetail, isRetryable } from '../utils/workflow-error'; +import { RETRY_WILL_NOT_HELP, carriesTransportDetail, isRetryable } from '../utils/workflow-error'; const WORKFLOW_ID_DESCRIPTION = 'The id of the workflow to start, as returned by listWorkflows. The workflow must have the MCP ' + @@ -55,10 +55,7 @@ function lookupUnavailableMessage(workflowId: string): string { function terminalLookupMessage(workflowId: string, detail: string): string { const reason = detail.replace(/\.$/, ''); - return ( - `Workflow "${workflowId}" could not be resolved — ${reason}. Retrying will not help: fix the ` + - 'request, or report it to your Forest administrator.' - ); + return `Workflow "${workflowId}" could not be resolved — ${reason}. ${RETRY_WILL_NOT_HELP}`; } // The trigger is not idempotent and the write may have landed before the transport failed, so this diff --git a/packages/mcp-server/src/utils/workflow-error.ts b/packages/mcp-server/src/utils/workflow-error.ts index 4fd2d72d31..85d1b2496e 100644 --- a/packages/mcp-server/src/utils/workflow-error.ts +++ b/packages/mcp-server/src/utils/workflow-error.ts @@ -33,9 +33,17 @@ export function isRetryable(error: unknown): boolean { ); } +/** + * Closing advice for a refusal that will repeat. Shared so the three workflow tools tell a model + * the same thing — the wording is the only signal it has that retrying is pointless. + */ +export const RETRY_WILL_NOT_HELP = + 'Retrying will not help: fix the request, or report it to your Forest administrator.'; + /** * Turns a workflow transport failure into an error the model may see: the full cause always goes - * to the operator log, and a message carrying transport detail is replaced by `unavailableMessage`. + * to the operator log, a message carrying transport detail is replaced by `unavailableMessage`, + * and a refusal that will repeat is told to stop. */ export default function toModelSafeError( error: unknown, @@ -49,5 +57,14 @@ export default function toModelSafeError( logger('Error', `${context}: ${detail}`); - return new Error(carriesTransportDetail(error) ? unavailableMessage : detail); + if (carriesTransportDetail(error)) return new Error(unavailableMessage); + + // A 4xx that is neither a timeout nor a rate limit fails identically on every attempt. Handing a + // model the bare reason leaves nothing saying so — and these are the tools it is told to poll, so + // the loop it produces is the documented usage rather than a mistake. + if (!isRetryable(error)) { + return new Error(`${detail.replace(/\.$/, '')}. ${RETRY_WILL_NOT_HELP}`); + } + + return new Error(detail); } diff --git a/packages/mcp-server/test/tools/get-workflow-run.test.ts b/packages/mcp-server/test/tools/get-workflow-run.test.ts index d80446ece3..143633a47b 100644 --- a/packages/mcp-server/test/tools/get-workflow-run.test.ts +++ b/packages/mcp-server/test/tools/get-workflow-run.test.ts @@ -267,5 +267,41 @@ describe('declareGetWorkflowRunTool', () => { `Failed to read workflow run "7": ${error.message}`, ); }); + + // This is the tool the model is told to poll, so a refusal it cannot fix has to say so or the + // documented usage becomes an endless loop. + it.each([ + ['an unknown runId', new NotFoundError('Workflow run not found')], + ['a refused identity', new ForbiddenError('You are not allowed to access this workflow run')], + ['a malformed runId', new HttpError('Validation failed', 400)], + [ + 'a browser-engine environment', + new HttpError('This environment runs workflows in the browser', 409), + ], + ])('should tell the model not to retry %s', async (_, error) => { + mockForestServerClient.getMcpWorkflowRun.mockRejectedValue(error); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(result).toMatchObject({ isError: true }); + expect(text).toBe( + `${error.message.replace(/\.$/, '')}. Retrying will not help: fix the request, or report ` + + 'it to your Forest administrator.', + ); + }); + + it.each([ + ['a rate limit', new HttpError('Too many requests', 429)], + ['a server error', new HttpError('Internal server error', 500)], + ])('should leave %s retryable, with no advice to stop', async (_, error) => { + mockForestServerClient.getMcpWorkflowRun.mockRejectedValue(error); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(text).toBe(error.message); + expect(text).not.toContain('Retrying will not help'); + }); }); }); diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts index 1897513bbb..5f630c6b03 100644 --- a/packages/mcp-server/test/tools/list-workflows.test.ts +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -287,5 +287,34 @@ describe('declareListWorkflowsTool', () => { `Failed to list MCP-enabled workflows: ${error.message}`, ); }); + + // Discovery is the step a model repeats when anything downstream rejects it, so a refusal that + // will not change has to close the loop rather than invite another round. + it.each([ + ['an unknown rendering', new NotFoundError('Rendering not found')], + ['a malformed collectionName', new HttpError('Validation failed', 400)], + ])('should tell the model not to retry %s', async (_, error) => { + mockForestServerClient.listMcpEnabledWorkflows.mockRejectedValue(error); + + const result = await registeredToolHandler({}, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(result).toMatchObject({ isError: true }); + expect(text).toBe( + `${error.message.replace(/\.$/, '')}. Retrying will not help: fix the request, or report ` + + 'it to your Forest administrator.', + ); + }); + + it('should leave a server error retryable, with no advice to stop', async () => { + const error = new HttpError('Internal server error', 500); + mockForestServerClient.listMcpEnabledWorkflows.mockRejectedValue(error); + + const result = await registeredToolHandler({}, mockExtra); + const { text } = (result as { content: [{ text: string }] }).content[0]; + + expect(text).toBe('Internal server error'); + expect(text).not.toContain('Retrying will not help'); + }); }); }); From 1627c04788f6cab7195a12449179f7b4f5da186c Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 20 Aug 2026 15:13:45 +0200 Subject: [PATCH 42/43] docs(mcp-server): list the three workflow tools in the README (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Available Tools table stopped at requestActionFileUpload, so nothing in this package's own README said triggerWorkflow exists. It is enabled by default like every other tool, which means an integration mounting the MCP server without an enabledTools allowlist gains a destructive tool on upgrade and reads about it nowhere — the rollout note lives in the PR description, which nobody upgrading is going to read. The README is also where a standalone integrator reads the legal values for FOREST_MCP_ENABLED_TOOLS and enabledTools, both documented a few sections down, so an allowlist composed from this table silently dropped all three. Adds the three rows plus a sentence on what triggerWorkflow is: annotated destructive so clients confirm each call, on by default, and inert until a workflow opts in through its MCP trigger. Co-Authored-By: Claude Opus 5 (1M context) --- packages/mcp-server/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 5d0a7f8389..c0ca93b959 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -21,6 +21,14 @@ This MCP server provides HTTP REST API access to Forest Admin operations, enabli | `getActionForm` | Get the form fields for a custom action | | `executeAction` | Execute a custom action | | `requestActionFileUpload` | Get a destination to upload a file to, for an action `File` field (only with `fileUploads`) | +| `listWorkflows` | List the MCP-enabled workflows in the caller's rendering, optionally filtered by collection | +| `triggerWorkflow` | Start a workflow run on a record, and return its `runId` | +| `getWorkflowRun` | Read a run started through MCP: its state and step-by-step history | + +`triggerWorkflow` starts a process with side effects, so it is annotated `destructiveHint: true` and +MCP clients are expected to ask the user before each call. Like every other tool it is **enabled by +default**, but it stays inert until someone turns the MCP trigger on for a given workflow in Forest +— the server refuses a trigger on any workflow that has not opted in. ## Usage From 283fcb3e83b691b1d9503f847544555ae384cd74 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 20 Aug 2026 16:33:55 +0200 Subject: [PATCH 43/43] fix(mcp-server): keep the audit failure cause out of the model's context (PRD-49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A write blocked by the fail-closed audit guard reported the cause verbatim to the caller, and for an MCP tool the caller is a model. `createPendingActivityLog` is awaited outside `withActivityLog`'s try, so `errorEnhancer` never sees this error: it travels up through the tool handler and becomes `content[0].text`. What travelled: a timeout on POST /activity-logs-requests/mcp is the one HttpError whose message ServerUtils builds client-side, interpolating the full Forest server URL; an unreachable store raises a raw superagent error naming an internal host and port; the TLS branch appends a certificate chain. This is the leak trigger-workflow.ts already guards for its own /start call and list-workflows.ts for its listing — the third HTTP call was missed. It is also inconsistent within this function: the 200-with-no-id branch a few lines below has always thrown a fixed sentence. Fixed where it originates rather than in trigger-workflow, because the guard is shared: `action`, `create`, `update` and `delete` reach the same throw and leaked the same way. The split reuses `carriesTransportDetail` instead of restating the 408 rule — an HttpError message is the server's own detail or a fixed string, both meant to be read, so a 400 on an unresolvable collection still reaches the model with its reason. Retrying is safe advice here, unlike a failure of the operation itself: this throws before the operation runs. Two tests pinned the old behaviour and were defending the leak. The fail-closed matrix asserted a bare Error's message propagated for all five write actions; it now uses an HttpError, whose detail is meant to survive, with a second matrix covering the transport failure and the timeout. trigger-workflow's fail-closed test asserted `stringContaining('audit down')` and now asserts the cause is absent. Follow-up worth considering: `carriesTransportDetail` is no longer used only by the workflow tools, so `workflow-error.ts` is now a slightly wrong home for it. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/utils/activity-logs-creator.ts | 32 ++++++++++++++++++- .../test/tools/trigger-workflow.test.ts | 13 ++++++-- .../test/utils/activity-logs-creator.test.ts | 32 +++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 2b7fbd210a..3867173802 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -11,6 +11,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; import getAuthContext from './auth-context'; +import { carriesTransportDetail } from './workflow-error'; export type { ActivityLogAction, ActivityLogResponse }; @@ -45,6 +46,16 @@ function isAuthorizationRefusal(error: unknown): boolean { return error instanceof HttpError && (error.status === 401 || error.status === 403); } +/** + * Sent instead of the cause when a write is blocked by an audit failure whose message carries + * transport detail. Retrying is safe advice here, unlike a failure of the operation itself: this + * throws before the operation runs, so nothing has happened yet. + */ +export const AUDIT_UNAVAILABLE_MESSAGE = + 'Forest could not be reached to write the audit trail, so the operation was not performed. ' + + 'This is a temporary server-side failure — retry later, and report it to your Forest ' + + 'administrator if it persists.'; + export default async function createPendingActivityLog( forestServerClient: ForestServerClient, request: RequestHandlerExtra, @@ -79,7 +90,26 @@ export default async function createPendingActivityLog( // The audit log was refused (400/404 — e.g. an unresolvable collection) or the store is // unreachable (5xx, timeout, connection error). Both land here, and both are far more likely // than the 200-with-null-id case below. - if (type === 'write' || isAuthorizationRefusal(error)) throw error; + if (type === 'write' || isAuthorizationRefusal(error)) { + // A blocked write is reported to the caller, and for an MCP tool the caller is a model. An + // HttpError message is the server's own detail or a fixed string, both meant to be read; a + // timeout or a raw Node/superagent error instead carries the Forest server URL, an internal + // host:port or a TLS chain. Only the sanitized sentence may travel — the same split the + // workflow tools apply to their own calls, and what the 200-with-no-id branch below has + // always done. Without this the cause reaches the model verbatim. + if (carriesTransportDetail(error)) { + extra?.logger?.( + 'Error', + `Activity log for '${action}' could not be created: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + + throw new Error(AUDIT_UNAVAILABLE_MESSAGE); + } + + throw error; + } // Report the cause: without it every fail-open read logs the same sentence, and an operator // cannot tell a validation refusal (act now) from a transient outage (wait). diff --git a/packages/mcp-server/test/tools/trigger-workflow.test.ts b/packages/mcp-server/test/tools/trigger-workflow.test.ts index 5099a5fe38..7afa1e1b50 100644 --- a/packages/mcp-server/test/tools/trigger-workflow.test.ts +++ b/packages/mcp-server/test/tools/trigger-workflow.test.ts @@ -190,14 +190,21 @@ describe('declareTriggerWorkflowTool', () => { }); it('should not start the run when the pending activity log cannot be created (fail-closed)', async () => { - mockForestServerClient.createMcpActivityLog.mockRejectedValue(new Error('audit down')); + mockForestServerClient.createMcpActivityLog.mockRejectedValue( + new Error('connect ECONNREFUSED 10.0.0.4:5432'), + ); - const result = await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + const result = (await registeredToolHandler( + { workflowId: 'wf-1', recordId: '42' }, + mockExtra, + )) as { content: [{ type: string; text: string }]; isError: boolean }; expect(result).toEqual({ - content: [{ type: 'text', text: expect.stringContaining('audit down') }], + content: [{ type: 'text', text: expect.stringContaining('was not performed') }], isError: true, }); + // The cause never reaches the model: it names an internal host and port. + expect(result.content[0].text).not.toContain('ECONNREFUSED'); expect(mockForestServerClient.triggerMcpWorkflow).not.toHaveBeenCalled(); }); diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index ece389bc9a..5de9d42720 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -6,6 +6,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { ForbiddenError, HttpError, NotFoundError } from '@forestadmin/forestadmin-client'; import createPendingActivityLog, { + AUDIT_UNAVAILABLE_MESSAGE, markActivityLogAsFailed, markActivityLogAsSucceeded, } from '../../src/utils/activity-logs-creator'; @@ -237,20 +238,45 @@ describe('createPendingActivityLog', () => { describe('error handling', () => { it.each(['action', 'create', 'update', 'delete', 'triggerWorkflow'])( - 'should propagate the error when createMcpActivityLog rejects for write action "%s" (fail-closed)', + 'should block write action "%s" and keep the server reason when createMcpActivityLog rejects (fail-closed)', async action => { mockForestServerClient.createMcpActivityLog.mockRejectedValue( - new Error('Failed to create activity log: Server error message'), + new HttpError('collectionModelName is required', 400), ); const request = createMockRequest(); await expect( createPendingActivityLog(mockForestServerClient, request, action), - ).rejects.toThrow('Failed to create activity log: Server error message'); + ).rejects.toThrow('collectionModelName is required'); }, ); + // The blocked write is reported to a model, so a cause carrying the Forest server URL, an + // internal host:port or a TLS chain must not travel with it - only the fixed sentence does, + // and the cause goes to the operator log instead. + it.each([ + ['a transport failure', new Error('connect ECONNREFUSED 10.0.0.4:5432')], + [ + 'a timeout', + new HttpError('Timeout of 10000ms exceeded on https://api.forestadmin.com/liana', 408), + ], + ])('should replace %s with a sanitized message on a write', async (_label, error) => { + mockForestServerClient.createMcpActivityLog.mockRejectedValue(error); + + const logger = jest.fn(); + const request = createMockRequest(); + + await expect( + createPendingActivityLog(mockForestServerClient, request, 'triggerWorkflow', { logger }), + ).rejects.toThrow(AUDIT_UNAVAILABLE_MESSAGE); + + expect(logger).toHaveBeenCalledWith( + 'Error', + `Activity log for 'triggerWorkflow' could not be created: ${(error as Error).message}`, + ); + }); + it.each([ 'index', 'search',