From 41eb742f3143edf1446ee5d6c13ebad1e09bea1b Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 22 Jul 2026 15:47:15 +0200 Subject: [PATCH] feat(mcp-server): add listWorkflows tool (PRD-736) Expose MCP-enabled workflows to LLM clients via a new listWorkflows tool, calling the Forest server MS3 endpoint (GET /api/workflow-orchestrator/workflows) over the HTTP contract with the caller's forestServerToken + renderingId. - forestadmin-client: WorkflowsService + ForestHttpApi.listMcpEnabledWorkflows - mcp-server: listWorkflows tool, http-client wiring, shared getAuthContext util Co-Authored-By: Claude Opus 4.8 --- packages/forestadmin-client/src/index.ts | 4 + .../src/permissions/forest-http-api.ts | 17 ++ packages/forestadmin-client/src/types.ts | 29 +++ .../forestadmin-client/src/workflows/index.ts | 27 +++ .../forest-admin-server-interface.ts | 2 + .../test/permissions/forest-http-api.test.ts | 39 ++++ .../test/workflows/index.test.ts | 79 ++++++++ packages/mcp-server/src/http-client/index.ts | 21 ++- .../src/http-client/mcp-http-client.ts | 12 +- .../mcp-server/src/http-client/types.d.ts | 11 ++ packages/mcp-server/src/server.ts | 16 +- .../mcp-server/src/tools/list-workflows.ts | 58 ++++++ .../src/utils/activity-logs-creator.ts | 21 +-- packages/mcp-server/src/utils/auth-context.ts | 24 +++ .../test/helpers/forest-server-client.ts | 1 + .../test/http-client/mcp-http-client.test.ts | 25 +++ packages/mcp-server/test/server.test.ts | 1 + .../test/tools/execute-action.test.ts | 1 + .../test/tools/get-action-form.test.ts | 1 + .../test/tools/list-workflows.test.ts | 173 ++++++++++++++++++ 20 files changed, 538 insertions(+), 24 deletions(-) create mode 100644 packages/forestadmin-client/src/workflows/index.ts create mode 100644 packages/forestadmin-client/test/workflows/index.test.ts create mode 100644 packages/mcp-server/src/tools/list-workflows.ts create mode 100644 packages/mcp-server/src/utils/auth-context.ts create mode 100644 packages/mcp-server/test/tools/list-workflows.test.ts diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 257f30330b..2dff192ee5 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -28,8 +28,11 @@ export { ActivityLogType, CreateActivityLogParams, UpdateActivityLogStatusParams, + McpWorkflow, + ListMcpWorkflowsParams, // Service interfaces for MCP ActivityLogsServiceInterface, + WorkflowsServiceInterface, SchemaServiceInterface, } from './types'; export { IpWhitelistConfiguration } from './ip-whitelist/types'; @@ -94,6 +97,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..8340ce64cc 100644 --- a/packages/forestadmin-client/src/permissions/forest-http-api.ts +++ b/packages/forestadmin-client/src/permissions/forest-http-api.ts @@ -8,6 +8,7 @@ import type { ForestAdminServerInterface, ForestSchemaCollection, IpWhitelistRulesResponse, + McpWorkflow, } from '../types'; import type { HttpOptions } from '../utils/http-options'; import type { ToolConfig } from '@forestadmin/ai-proxy'; @@ -151,4 +152,20 @@ export default class ForestHttpApi implements ForestAdminServerInterface { headers: options.headers, }); } + + async listMcpEnabledWorkflows( + options: ActivityLogHttpOptions, + renderingId: string, + collectionName?: string, + ): Promise { + const query = collectionName ? `?collectionName=${encodeURIComponent(collectionName)}` : ''; + + return ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: `/api/workflow-orchestrator/workflows${query}`, + bearerToken: options.bearerToken, + headers: { 'forest-rendering-id': renderingId, ...options.headers }, + }); + } } diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 703a771e69..d925bc29de 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -267,6 +267,28 @@ export interface ActivityLogsServiceInterface { updateActivityLogStatus: (params: UpdateActivityLogStatusParams) => Promise; } +/** + * An MCP-enabled workflow, as returned by the Forest server's workflow listing endpoint. + */ +export interface McpWorkflow { + workflowId: string; + name: string; + collectionName: string | null; +} + +export interface ListMcpWorkflowsParams { + forestServerToken: string; + renderingId: string; + collectionName?: string; +} + +/** + * Service interface for workflow operations (MCP-related). + */ +export interface WorkflowsServiceInterface { + listMcpEnabledWorkflows: (params: ListMcpWorkflowsParams) => Promise; +} + /** * Service interface for schema operations (extended for MCP). */ @@ -305,6 +327,13 @@ export interface ForestAdminServerInterface { id: string, body: object, ) => Promise; + + // Workflow operations + listMcpEnabledWorkflows?: ( + options: ActivityLogHttpOptions, + renderingId: string, + collectionName?: string, + ) => Promise; } export type ActivityLogHttpOptions = { diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts new file mode 100644 index 0000000000..542ac94c07 --- /dev/null +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -0,0 +1,27 @@ +import type { ForestAdminServerInterface, ListMcpWorkflowsParams, McpWorkflow } from '../types'; + +export type WorkflowsServiceOptions = { + forestServerUrl: string; + headers?: Record; +}; + +export default class WorkflowsService { + constructor( + private forestAdminServerInterface: ForestAdminServerInterface, + private options: WorkflowsServiceOptions, + ) {} + + async listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise { + const { forestServerToken, renderingId, collectionName } = params; + + return this.forestAdminServerInterface.listMcpEnabledWorkflows( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + collectionName, + ); + } +} diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index d89fb5817e..ad26af3ee4 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -18,6 +18,8 @@ const forestAdminServerInterface = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + // Workflow operations + listMcpEnabledWorkflows: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 75ba76222c..1145de4884 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -206,4 +206,43 @@ describe('ForestHttpApi', () => { }); }); }); + + describe('listMcpEnabledWorkflows', () => { + it('should GET the workflows endpoint with the rendering id header', async () => { + const workflows = [{ workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }]; + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(workflows); + + const result = await new ForestHttpApi().listMcpEnabledWorkflows( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: '/api/workflow-orchestrator/workflows', + bearerToken: 'bearer-token', + headers: { 'forest-rendering-id': '12345' }, + }); + expect(result).toEqual(workflows); + }); + + it('should append the collectionName filter as a url-encoded query param', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue([]); + + await new ForestHttpApi().listMcpEnabledWorkflows( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'sales orders', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + path: '/api/workflow-orchestrator/workflows?collectionName=sales%20orders', + headers: { 'forest-rendering-id': '12345' }, + }), + ); + }); + }); }); diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts new file mode 100644 index 0000000000..3deb05c904 --- /dev/null +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -0,0 +1,79 @@ +import type { ForestAdminServerInterface, McpWorkflow } from '../../src/types'; + +import WorkflowsService from '../../src/workflows'; +import * as factories from '../__factories__'; + +describe('WorkflowsService', () => { + const options = { + forestServerUrl: 'http://forestadmin-server.com', + }; + let mockForestAdminServerInterface: jest.Mocked; + + beforeEach(() => { + jest.clearAllMocks(); + mockForestAdminServerInterface = + factories.forestAdminServerInterface.build() as jest.Mocked; + }); + + describe('listMcpEnabledWorkflows', () => { + const workflows: McpWorkflow[] = [ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + ]; + + it('should forward the bearer token and rendering id to the transport', async () => { + mockForestAdminServerInterface.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + const result = await service.listMcpEnabledWorkflows({ + forestServerToken: 'test-token', + renderingId: '12345', + }); + + expect(result).toEqual(workflows); + expect(mockForestAdminServerInterface.listMcpEnabledWorkflows).toHaveBeenCalledWith( + { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, + '12345', + undefined, + ); + }); + + it('should forward the collectionName filter when provided', async () => { + mockForestAdminServerInterface.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + await service.listMcpEnabledWorkflows({ + forestServerToken: 'test-token', + renderingId: '12345', + collectionName: 'orders', + }); + + expect(mockForestAdminServerInterface.listMcpEnabledWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ bearerToken: 'test-token' }), + '12345', + 'orders', + ); + }); + + it('should pass custom headers when provided', async () => { + mockForestAdminServerInterface.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const service = new WorkflowsService(mockForestAdminServerInterface, { + ...options, + headers: { 'Forest-Application-Source': 'MCP' }, + }); + await service.listMcpEnabledWorkflows({ + forestServerToken: 'test-token', + renderingId: '12345', + }); + + expect(mockForestAdminServerInterface.listMcpEnabledWorkflows).toHaveBeenCalledWith( + expect.objectContaining({ + bearerToken: 'test-token', + headers: { 'Forest-Application-Source': 'MCP' }, + }), + '12345', + undefined, + ); + }); + }); +}); diff --git a/packages/mcp-server/src/http-client/index.ts b/packages/mcp-server/src/http-client/index.ts index 5216369941..95cf439fa4 100644 --- a/packages/mcp-server/src/http-client/index.ts +++ b/packages/mcp-server/src/http-client/index.ts @@ -1,6 +1,11 @@ import type { ForestServerClient } from './types'; -import { ActivityLogsService, ForestHttpApi, SchemaService } from '@forestadmin/forestadmin-client'; +import { + ActivityLogsService, + ForestHttpApi, + SchemaService, + WorkflowsService, +} from '@forestadmin/forestadmin-client'; import ForestServerClientImpl from './mcp-http-client'; @@ -27,8 +32,17 @@ export function createForestServerClient( ...serviceOptions, headers: { 'Forest-Application-Source': 'MCP' }, }); + const workflowsService = new WorkflowsService(forestHttpApi, { + forestServerUrl: options.forestServerUrl, + headers: { 'Forest-Application-Source': 'MCP' }, + }); - return new ForestServerClientImpl(schemaService, activityLogsService, options.forestServerUrl); + return new ForestServerClientImpl( + schemaService, + activityLogsService, + workflowsService, + options.forestServerUrl, + ); } export { ForestServerClientImpl }; @@ -39,9 +53,12 @@ export type { ActivityLogType, CreateActivityLogParams, ForestServerClient, + ListMcpWorkflowsParams, + McpWorkflow, UpdateActivityLogStatusParams, ForestSchemaCollection, ForestSchemaField, ForestSchemaAction, SchemaServiceInterface, + WorkflowsServiceInterface, } from './types'; diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index 2be7e2a1d7..faa02d9807 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -4,18 +4,22 @@ import type { CreateActivityLogParams, ForestSchemaCollection, ForestServerClient, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, UpdateActivityLogStatusParams, + WorkflowsServiceInterface, } from './types'; /** - * Default implementation of ForestServerClient that uses SchemaService and ActivityLogsService. - * This provides a convenient API for MCP server operations. + * Default implementation of ForestServerClient that uses SchemaService, ActivityLogsService + * and WorkflowsService. This provides a convenient API for MCP server operations. */ export default class ForestServerClientImpl implements ForestServerClient { constructor( private readonly schemaService: SchemaServiceInterface, private readonly activityLogsService: ActivityLogsServiceInterface, + private readonly workflowsService: WorkflowsServiceInterface, public readonly forestServerUrl: string, ) {} @@ -34,4 +38,8 @@ export default class ForestServerClientImpl implements ForestServerClient { async updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise { return this.activityLogsService.updateActivityLogStatus(params); } + + async listMcpWorkflows(params: ListMcpWorkflowsParams): Promise { + return this.workflowsService.listMcpEnabledWorkflows(params); + } } diff --git a/packages/mcp-server/src/http-client/types.d.ts b/packages/mcp-server/src/http-client/types.d.ts index 8ee4b10c07..8b8e088c25 100644 --- a/packages/mcp-server/src/http-client/types.d.ts +++ b/packages/mcp-server/src/http-client/types.d.ts @@ -7,8 +7,11 @@ import type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, UpdateActivityLogStatusParams, + WorkflowsServiceInterface, } from '@forestadmin/forestadmin-client'; // Re-export types from forestadmin-client for convenience @@ -21,8 +24,11 @@ export type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + ListMcpWorkflowsParams, + McpWorkflow, SchemaServiceInterface, UpdateActivityLogStatusParams, + WorkflowsServiceInterface, }; /** @@ -54,4 +60,9 @@ export interface ForestServerClient { * Updates an activity log status. */ updateActivityLogStatus(params: UpdateActivityLogStatusParams): Promise; + + /** + * Lists the MCP-enabled workflows the caller can access in a rendering. + */ + listMcpWorkflows(params: ListMcpWorkflowsParams): Promise; } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 9ed632b95e..486da9ea7f 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -32,6 +32,7 @@ import declareExecuteActionTool from './tools/execute-action'; import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; +import declareListWorkflowsTool from './tools/list-workflows'; import declareUpdateTool from './tools/update'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; @@ -86,6 +87,7 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { executeAction: ['collectionName', 'actionName', 'recordIds'], associate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordId'], dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], + listWorkflows: ['collectionName'], }; export type ToolName = @@ -98,7 +100,8 @@ export type ToolName = | 'associate' | 'dissociate' | 'getActionForm' - | 'executeAction'; + | 'executeAction' + | 'listWorkflows'; /** * Options for configuring the Forest Admin MCP Server @@ -269,6 +272,16 @@ export default class ForestMCPServer { this.collectionNames, ), }, + { + name: 'listWorkflows', + register: () => + declareListWorkflowsTool( + mcpServer, + this.forestServerClient, + this.logger, + this.collectionNames, + ), + }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -306,6 +319,7 @@ export default class ForestMCPServer { 'dissociate', 'getActionForm', 'executeAction', + 'listWorkflows', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts new file mode 100644 index 0000000000..e7b165a8b6 --- /dev/null +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -0,0 +1,58 @@ +import type { ForestServerClient } from '../http-client'; +import type { Logger } from '../server'; +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, + forestServerClient: ForestServerClient, + logger: Logger, + collectionNames: string[] = [], +): string { + return registerToolWithLogging( + mcpServer, + 'listWorkflows', + { + annotations: { readOnlyHint: true }, + title: 'List MCP-enabled workflows', + description: + 'Discover Forest workflows enabled for MCP triggering that you can access. Returns each ' + + "workflow's id, name and the collection it operates on. Optionally filter by collectionName " + + 'to match the record currently in context, then start one with triggerWorkflow.', + inputSchema: createListWorkflowsArgumentShape(collectionNames), + }, + async (args: ListWorkflowsArgument, extra) => { + const { forestServerToken, renderingId } = getAuthContext(extra); + + const workflows = await forestServerClient.listMcpWorkflows({ + forestServerToken, + renderingId, + collectionName: args.collectionName, + }); + + return { content: [{ type: 'text', text: JSON.stringify(workflows) }] }; + }, + logger, + ); +} diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index e245c5d28d..f6a8aa6448 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -10,6 +10,8 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { NotFoundError } from '@forestadmin/forestadmin-client'; +import getAuthContext from './auth-context'; + export type { ActivityLogAction, ActivityLogResponse }; const ACTION_TO_TYPE: Record = { @@ -24,25 +26,6 @@ const ACTION_TO_TYPE: Record = { describeCollection: 'read', }; -function getAuthContext(request: RequestHandlerExtra): { - forestServerToken: string; - renderingId: string; -} { - const forestServerToken = request.authInfo?.extra?.forestServerToken; - const renderingId = request.authInfo?.extra?.renderingId; - - if (!forestServerToken || typeof forestServerToken !== 'string') { - throw new Error('Invalid or missing forestServerToken in authentication context'); - } - - // renderingId can be number (from JWT) or string - convert to string for API calls - if (renderingId === undefined || renderingId === null) { - throw new Error('Invalid or missing renderingId in authentication context'); - } - - return { forestServerToken, renderingId: String(renderingId) }; -} - export default async function createPendingActivityLog( forestServerClient: ForestServerClient, request: RequestHandlerExtra, diff --git a/packages/mcp-server/src/utils/auth-context.ts b/packages/mcp-server/src/utils/auth-context.ts new file mode 100644 index 0000000000..941856e11b --- /dev/null +++ b/packages/mcp-server/src/utils/auth-context.ts @@ -0,0 +1,24 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Extracts the caller's Forest identity from the MCP request auth context. + * Populated by the OAuth provider's `verifyAccessToken` (see `forest-oauth-provider.ts`). + */ +export default function getAuthContext( + request: RequestHandlerExtra, +): { forestServerToken: string; renderingId: string } { + const forestServerToken = request.authInfo?.extra?.forestServerToken; + const renderingId = request.authInfo?.extra?.renderingId; + + if (!forestServerToken || typeof forestServerToken !== 'string') { + throw new Error('Invalid or missing forestServerToken in authentication context'); + } + + // renderingId can be number (from JWT) or string - convert to string for API calls + if (renderingId === undefined || renderingId === null) { + throw new Error('Invalid or missing renderingId in authentication context'); + } + + return { forestServerToken, renderingId: String(renderingId) }; +} diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 74bc697dac..f726277d42 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -14,6 +14,7 @@ export default function createMockForestServerClient( attributes: { index: 'mock-index' }, }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), + listMcpWorkflows: jest.fn().mockResolvedValue([]), ...overrides, } as jest.Mocked; } diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index 9b904e1918..e39185108f 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -2,6 +2,7 @@ import type { ActivityLogsServiceInterface, ForestSchemaCollection, SchemaServiceInterface, + WorkflowsServiceInterface, } from '../../src/http-client/types'; import { createForestServerClient } from '../../src/http-client'; @@ -10,6 +11,7 @@ import ForestServerClientImpl from '../../src/http-client/mcp-http-client'; describe('ForestServerClientImpl', () => { let mockSchemaService: jest.Mocked; let mockActivityLogsService: jest.Mocked; + let mockWorkflowsService: jest.Mocked; let client: ForestServerClientImpl; beforeEach(() => { @@ -21,9 +23,13 @@ describe('ForestServerClientImpl', () => { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }; + mockWorkflowsService = { + listMcpEnabledWorkflows: jest.fn(), + }; client = new ForestServerClientImpl( mockSchemaService, mockActivityLogsService, + mockWorkflowsService, 'https://api.forestadmin.com', ); }); @@ -102,6 +108,24 @@ describe('ForestServerClientImpl', () => { expect(mockActivityLogsService.updateActivityLogStatus).toHaveBeenCalledWith(params); }); }); + + describe('listMcpWorkflows', () => { + it('should delegate to workflowsService.listMcpEnabledWorkflows()', async () => { + const workflows = [{ workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }]; + mockWorkflowsService.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + collectionName: 'orders', + }; + + const result = await client.listMcpWorkflows(params); + + expect(mockWorkflowsService.listMcpEnabledWorkflows).toHaveBeenCalledWith(params); + expect(result).toBe(workflows); + }); + }); }); describe('createForestServerClient', () => { @@ -133,5 +157,6 @@ describe('createForestServerClient', () => { expect(client.createActivityLog).toBeDefined(); expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); + expect(client.listMcpWorkflows).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 254e0de2cd..3a1cafaad2 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3070,6 +3070,7 @@ describe('enabledTools', () => { 'dissociate', 'getActionForm', 'executeAction', + 'listWorkflows', ], }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index ca606ad68d..0b9e0e7d92 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -19,6 +19,7 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + listMcpWorkflows: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index c412f772f0..c220effef0 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -17,6 +17,7 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + listMcpWorkflows: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts new file mode 100644 index 0000000000..0546203915 --- /dev/null +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -0,0 +1,173 @@ +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, mockForestServerClient, mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'listWorkflows', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareListWorkflowsTool(mcpServer, mockForestServerClient, mockLogger); + + expect(registeredToolConfig.title).toBe('List MCP-enabled workflows'); + expect(registeredToolConfig.description).toContain('MCP triggering'); + }); + + it('should be annotated as read-only', () => { + declareListWorkflowsTool(mcpServer, mockForestServerClient, mockLogger); + + expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); + }); + + it('should expose an optional collectionName argument', () => { + declareListWorkflowsTool(mcpServer, mockForestServerClient, mockLogger); + + 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, mockForestServerClient, mockLogger); + + 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, mockForestServerClient, mockLogger, ['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, mockForestServerClient, mockLogger); + 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, + }); + }); + }); +});