diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 5bd4cb6342..e3a21d1530 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -62,6 +62,13 @@ export default class ForestAdminClientMock implements ForestAdminClient { updateActivityLogStatus: () => Promise.resolve(), }; + 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; readonly authService: 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..67512d3522 --- /dev/null +++ b/packages/agent-testing/test/forest-admin-client-mock.test.ts @@ -0,0 +1,45 @@ +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' }); + }); + + 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/src/agent.ts b/packages/agent/src/agent.ts index a8e3b2397d..106e10efce 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -417,6 +417,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..da3034f40b 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -54,6 +54,11 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }, + workflowsService: { + listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: 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..bf0ed92d82 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -28,8 +28,17 @@ export { ActivityLogType, CreateActivityLogParams, UpdateActivityLogStatusParams, + McpWorkflow, + ListMcpWorkflowsParams, + TriggerMcpWorkflowParams, + GetMcpWorkflowRunParams, + WorkflowRunState, + WorkflowRunStep, + WorkflowRunStatus, + WorkflowRunTriggerResult, // Service interfaces for MCP ActivityLogsServiceInterface, + WorkflowsServiceInterface, SchemaServiceInterface, } from './types'; export { IpWhitelistConfiguration } from './ip-whitelist/types'; @@ -55,6 +64,7 @@ export default function createForestAdminClient( ipWhitelist, schema, activityLogs, + workflows, auth, modelCustomizationService, mcpServerConfigService, @@ -71,6 +81,7 @@ export default function createForestAdminClient( ipWhitelist, schema, activityLogs, + workflows, auth, modelCustomizationService, mcpServerConfigService, @@ -94,6 +105,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 af58ce3684..1c89e876be 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -8,6 +8,9 @@ import type { ForestAdminServerInterface, ForestSchemaCollection, IpWhitelistRulesResponse, + McpWorkflow, + WorkflowRunStatus, + WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; import type { ToolConfig } from '@forestadmin/ai-proxy'; @@ -151,4 +154,50 @@ 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/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 }, + }); + } + + 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 5cc0b2e7b7..da63500bcd 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -54,6 +54,7 @@ export interface ForestAdminClient { readonly authService: ForestAdminAuthServiceInterface; readonly schemaService: SchemaServiceInterface; readonly activityLogsService: ActivityLogsServiceInterface; + readonly workflowsService: WorkflowsServiceInterface; verifySignedActionParameters(signedParameters: string): TSignedParameters; @@ -253,7 +254,8 @@ export type ActivityLogAction = | 'update' | 'delete' | 'listRelatedData' - | 'describeCollection'; + | 'describeCollection' + | 'triggerWorkflow'; export type ActivityLogType = 'read' | 'write'; @@ -283,6 +285,76 @@ 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; +} + +/** + * 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; +} + +/** + * 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; +} + /** * Service interface for schema operations (extended for MCP). */ @@ -321,6 +393,24 @@ export interface ForestAdminServerInterface { id: string, body: object, ) => Promise; + + // Workflow operations + listMcpEnabledWorkflows?: ( + options: ActivityLogHttpOptions, + renderingId: string, + collectionName?: string, + ) => Promise; + triggerMcpWorkflow?: ( + options: ActivityLogHttpOptions, + renderingId: string, + 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 new file mode 100644 index 0000000000..332079c6c4 --- /dev/null +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -0,0 +1,80 @@ +import type { + ForestAdminServerInterface, + GetMcpWorkflowRunParams, + ListMcpWorkflowsParams, + McpWorkflow, + TriggerMcpWorkflowParams, + WorkflowRunStatus, + WorkflowRunTriggerResult, +} 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; + + if (!this.forestAdminServerInterface.listMcpEnabledWorkflows) { + throw new Error( + 'The configured Forest server transport does not support listMcpEnabledWorkflows.', + ); + } + + return this.forestAdminServerInterface.listMcpEnabledWorkflows( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + 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, + ); + } + + 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-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..be174cef86 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,10 @@ const forestAdminServerInterface = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + // Workflow operations + listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: 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 993592013a..cf435d6e47 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(), @@ -88,6 +90,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -117,6 +120,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -142,6 +146,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -168,6 +173,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -204,6 +210,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -229,6 +236,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -254,6 +262,7 @@ describe('ForestAdminClientWithCache', () => { factories.ipWhiteList.build(), factories.schema.build(), factories.activityLogs.build(), + factories.workflows.build(), factories.auth.build(), factories.modelCustomization.build(), factories.mcpServerConfig.build(), @@ -281,6 +290,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..8719a24fa2 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,136 @@ 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/mcp-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/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', + }), + ); + }); + }); + + 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 new file mode 100644 index 0000000000..f6d6f6f034 --- /dev/null +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -0,0 +1,224 @@ +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, + ); + }); + + 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'); + }); + }); + + 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 5216369941..af80361070 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,16 @@ export type { ActivityLogType, CreateActivityLogParams, ForestServerClient, + GetMcpWorkflowRunParams, + ListMcpWorkflowsParams, + McpWorkflow, + TriggerMcpWorkflowParams, + WorkflowRunStatus, + WorkflowRunTriggerResult, 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..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,18 +4,26 @@ import type { CreateActivityLogParams, ForestSchemaCollection, ForestServerClient, + GetMcpWorkflowRunParams, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, + TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, + WorkflowRunTriggerResult, + 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 +42,16 @@ 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); + } + + 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.d.ts b/packages/mcp-server/src/http-client/types.d.ts index 8ee4b10c07..8bbaccaa66 100644 --- a/packages/mcp-server/src/http-client/types.d.ts +++ b/packages/mcp-server/src/http-client/types.d.ts @@ -7,8 +7,15 @@ import type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowRunParams, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, + TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, + WorkflowRunTriggerResult, + WorkflowsServiceInterface, } from '@forestadmin/forestadmin-client'; // Re-export types from forestadmin-client for convenience @@ -21,8 +28,15 @@ export type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowRunParams, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, + TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, + WorkflowRunTriggerResult, + WorkflowsServiceInterface, }; /** @@ -54,4 +68,19 @@ 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; + + /** + * 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 449ba3a9c3..fccf915fae 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -32,8 +32,11 @@ 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'; +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'; @@ -89,6 +92,9 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { executeAction: ['collectionName', 'actionName', 'recordIds'], associate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordId'], dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], + listWorkflows: ['collectionName'], + triggerWorkflow: ['workflowId', 'recordId'], + getWorkflowRun: ['runId'], }; export type ToolName = @@ -101,7 +107,10 @@ export type ToolName = | 'associate' | 'dissociate' | 'getActionForm' - | 'executeAction'; + | 'executeAction' + | 'listWorkflows' + | 'triggerWorkflow' + | 'getWorkflowRun'; /** * Options for configuring the Forest Admin MCP Server @@ -223,6 +232,9 @@ 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) }, + { name: 'triggerWorkflow', register: () => declareTriggerWorkflowTool(mcpServer, ctx) }, + { name: 'getWorkflowRun', register: () => declareGetWorkflowRunTool(mcpServer, ctx) }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -260,6 +272,9 @@ export default class ForestMCPServer { 'dissociate', 'getActionForm', '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/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/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 e245c5d28d..7ac1f5be6e 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 = { @@ -22,27 +24,9 @@ const ACTION_TO_TYPE: Record = { delete: 'write', listRelatedData: 'read', describeCollection: 'read', + triggerWorkflow: 'write', }; -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..472fda506c 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -14,6 +14,13 @@ export default function createMockForestServerClient( attributes: { index: 'mock-index' }, }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), + listMcpWorkflows: jest.fn().mockResolvedValue([]), + triggerWorkflow: jest.fn().mockResolvedValue({ runId: 1, runState: 'loading' }), + getWorkflowRun: jest.fn().mockResolvedValue({ + 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 9b904e1918..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 @@ -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,15 @@ describe('ForestServerClientImpl', () => { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }; + mockWorkflowsService = { + listMcpEnabledWorkflows: jest.fn(), + triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), + }; client = new ForestServerClientImpl( mockSchemaService, mockActivityLogsService, + mockWorkflowsService, 'https://api.forestadmin.com', ); }); @@ -102,6 +110,66 @@ 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('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('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', () => { @@ -133,5 +201,8 @@ describe('createForestServerClient', () => { expect(client.createActivityLog).toBeDefined(); expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); + expect(client.listMcpWorkflows).toBeDefined(); + expect(client.triggerWorkflow).toBeDefined(); + expect(client.getWorkflowRun).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 73e96e6798..dc869bc9f9 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3191,6 +3191,9 @@ describe('enabledTools', () => { 'dissociate', 'getActionForm', '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 99e8cd2258..d73e5abfc9 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -19,6 +19,9 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), 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 a6557a28b4..0d85c9cf65 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,9 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), 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, + }); + }); + }); +}); 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, + }); + }); + }); +}); 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, + }); + }); + }); +}); 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;