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 8340ce64cc..20e090bb6f 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 { ForestSchemaCollection, IpWhitelistRulesResponse, McpWorkflow, + WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; import type { ToolConfig } from '@forestadmin/ai-proxy'; @@ -168,4 +169,20 @@ export default class ForestHttpApi implements ForestAdminServerInterface { 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/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 628ae320b3..294f748c73 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -254,7 +254,8 @@ export type ActivityLogAction = | 'update' | 'delete' | 'listRelatedData' - | 'describeCollection'; + | 'describeCollection' + | 'triggerWorkflow'; export type ActivityLogType = 'read' | 'write'; @@ -299,11 +300,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; } /** @@ -351,6 +373,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..9120410d7a 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; @@ -24,4 +30,19 @@ export default class WorkflowsService { collectionName, ); } + + async triggerMcpWorkflow(params: TriggerMcpWorkflowParams): Promise { + const { forestServerToken, renderingId, workflowId, recordId } = params; + + 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..86eecbc3e4 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -245,4 +245,49 @@ 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); + + 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/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/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..a53d47ff23 100644 --- a/packages/forestadmin-client/test/workflows/index.test.ts +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -76,4 +76,57 @@ 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, + 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', + ); + }); + }); }); 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.d.ts b/packages/mcp-server/src/http-client/types.d.ts index 8b8e088c25..b5e8a40751 100644 --- a/packages/mcp-server/src/http-client/types.d.ts +++ b/packages/mcp-server/src/http-client/types.d.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 d38905aba6..74a05fc68a 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -35,6 +35,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'; @@ -91,6 +92,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 = @@ -104,7 +106,8 @@ export type ToolName = | 'dissociate' | 'getActionForm' | 'executeAction' - | 'listWorkflows'; + | 'listWorkflows' + | 'triggerWorkflow'; /** * Options for configuring the Forest Admin MCP Server @@ -227,6 +230,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)); @@ -265,6 +269,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..a1cc388c4e --- /dev/null +++ b/packages/mcp-server/src/tools/trigger-workflow.ts @@ -0,0 +1,67 @@ +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); + + return withActivityLog({ + forestServerClient, + request: extra, + action: 'triggerWorkflow', + context: { + recordId: args.recordId, + label: `triggered the workflow "${args.workflowId}"`, + }, + 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 1968163ee7..e44b38a481 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3192,6 +3192,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..9352e6dc7e --- /dev/null +++ b/packages/mcp-server/test/tools/trigger-workflow.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 { 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.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 for the triggerWorkflow action', async () => { + await registeredToolHandler({ workflowId: 'wf-1', recordId: '42' }, mockExtra); + + expect(mockWithActivityLog).toHaveBeenCalledWith({ + forestServerClient: mockForestServerClient, + request: mockExtra, + action: 'triggerWorkflow', + context: { + recordId: '42', + label: 'triggered the workflow "wf-1"', + }, + logger: mockLogger, + operation: expect.any(Function), + }); + }); + + 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, + }); + }); + }); +});