From 785174990eba7cdcf31183054d5e1b5070675869 Mon Sep 17 00:00:00 2001 From: Christophe Brun Date: Fri, 24 Jul 2026 14:51:30 +0200 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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(); }); }); });