diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 5bd4cb6342..73bf40dd81 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -62,6 +62,10 @@ export default class ForestAdminClientMock implements ForestAdminClient { updateActivityLogStatus: () => Promise.resolve(), }; + readonly workflowsService: ForestAdminClient['workflowsService'] = { + listMcpEnabledWorkflows: () => Promise.resolve([]), + }; + readonly permissionService: any; readonly authService: any; diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 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..6c2be90627 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -54,6 +54,9 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }, + workflowsService: { + listMcpEnabledWorkflows: jest.fn(), + }, subscribeToServerEvents: jest.fn(), close: jest.fn(), onRefreshCustomizations: jest.fn(), diff --git a/packages/forestadmin-client/src/build-application-services.ts b/packages/forestadmin-client/src/build-application-services.ts index 78158f8668..dbecac4085 100644 --- a/packages/forestadmin-client/src/build-application-services.ts +++ b/packages/forestadmin-client/src/build-application-services.ts @@ -22,6 +22,7 @@ import UserPermissionService from './permissions/user-permission'; import SchemaService from './schema'; import ContextVariablesInstantiator from './utils/context-variables-instantiator'; import defaultLogger from './utils/default-logger'; +import WorkflowsService from './workflows'; export default function buildApplicationServices( forestAdminServerInterface: ForestAdminServerInterface, @@ -31,6 +32,7 @@ export default function buildApplicationServices( renderingPermission: RenderingPermissionService; schema: SchemaService; activityLogs: ActivityLogsService; + workflows: WorkflowsService; contextVariables: ContextVariablesInstantiator; ipWhitelist: IpWhiteListService; permission: PermissionService; @@ -89,6 +91,7 @@ export default function buildApplicationServices( ipWhitelist: new IpWhiteListService(forestAdminServerInterface, optionsWithDefaults), schema: new SchemaService(forestAdminServerInterface, optionsWithDefaults), activityLogs: new ActivityLogsService(forestAdminServerInterface, optionsWithDefaults), + workflows: new WorkflowsService(forestAdminServerInterface, optionsWithDefaults), auth: forestAdminServerInterface.makeAuthService(optionsWithDefaults), modelCustomizationService: new ModelCustomizationFromApiService( forestAdminServerInterface, diff --git a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts index 198ade6471..70ea70b1e8 100644 --- a/packages/forestadmin-client/src/forest-admin-client-with-cache.ts +++ b/packages/forestadmin-client/src/forest-admin-client-with-cache.ts @@ -19,6 +19,7 @@ import type { PermissionService, } from './types'; import type ContextVariablesInstantiator from './utils/context-variables-instantiator'; +import type WorkflowsService from './workflows'; import verifyAndExtractApproval from './permissions/verify-approval'; @@ -32,6 +33,7 @@ export default class ForestAdminClientWithCache implements ForestAdminClient { protected readonly ipWhitelistService: IpWhiteListService, public readonly schemaService: SchemaService, public readonly activityLogsService: ActivityLogsService, + public readonly workflowsService: WorkflowsService, public readonly authService: ForestAdminAuthServiceInterface, public readonly modelCustomizationService: ModelCustomizationService, public readonly mcpServerConfigService: McpServerConfigService, diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index 257f30330b..99307a2e5e 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -28,8 +28,11 @@ export { ActivityLogType, CreateActivityLogParams, UpdateActivityLogStatusParams, + McpWorkflow, + ListMcpWorkflowsParams, // Service interfaces for MCP ActivityLogsServiceInterface, + WorkflowsServiceInterface, SchemaServiceInterface, } from './types'; export { IpWhitelistConfiguration } from './ip-whitelist/types'; @@ -55,6 +58,7 @@ export default function createForestAdminClient( ipWhitelist, schema, activityLogs, + workflows, auth, modelCustomizationService, mcpServerConfigService, @@ -71,6 +75,7 @@ export default function createForestAdminClient( ipWhitelist, schema, activityLogs, + workflows, auth, modelCustomizationService, mcpServerConfigService, @@ -94,6 +99,7 @@ export { default as ServerUtils } from './utils/server'; // export is necessary for the agent-generator package export { default as SchemaService, SchemaServiceOptions } from './schema'; export { default as ActivityLogsService, ActivityLogsOptions } from './activity-logs'; +export { default as WorkflowsService, WorkflowsServiceOptions } from './workflows'; export * from './auth/errors'; export * from './utils/errors'; diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index 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 5cc0b2e7b7..628ae320b3 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; @@ -283,6 +284,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). */ @@ -321,6 +344,13 @@ export interface ForestAdminServerInterface { id: string, body: object, ) => Promise; + + // Workflow operations + listMcpEnabledWorkflows?: ( + options: ActivityLogHttpOptions, + renderingId: string, + collectionName?: string, + ) => Promise; } export type ActivityLogHttpOptions = { diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts new file mode 100644 index 0000000000..542ac94c07 --- /dev/null +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -0,0 +1,27 @@ +import type { ForestAdminServerInterface, ListMcpWorkflowsParams, McpWorkflow } from '../types'; + +export type WorkflowsServiceOptions = { + forestServerUrl: string; + headers?: Record; +}; + +export default class WorkflowsService { + constructor( + private forestAdminServerInterface: ForestAdminServerInterface, + private options: WorkflowsServiceOptions, + ) {} + + async listMcpEnabledWorkflows(params: ListMcpWorkflowsParams): Promise { + const { forestServerToken, renderingId, collectionName } = params; + + return this.forestAdminServerInterface.listMcpEnabledWorkflows( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + collectionName, + ); + } +} diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-client.ts b/packages/forestadmin-client/test/__factories__/forest-admin-client.ts index 6a5ccf5746..b541ea5abe 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-client.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-client.ts @@ -13,6 +13,7 @@ import permissionServiceFactory from './permissions/permission'; import renderingPermissionsFactory from './permissions/rendering-permission'; import schemaServiceFactory from './schema'; import contextVariablesInstantiatorFactory from './utils/context-variables-instantiator'; +import workflowsServiceFactory from './workflows'; import ForestAdminClient from '../../src/forest-admin-client-with-cache'; export class ForestAdminClientFactory extends Factory { @@ -36,6 +37,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define( ipWhitelistServiceFactory.build(), schemaServiceFactory.build(), activityLogsServiceFactory.build(), + workflowsServiceFactory.build(), authServiceFactory.build(), modelCustomizationServiceFactory.build(), mcpServerConfigServiceFactory.build(), diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index d89fb5817e..ad26af3ee4 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -18,6 +18,8 @@ const forestAdminServerInterface = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + // Workflow operations + listMcpEnabledWorkflows: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/__factories__/index.ts b/packages/forestadmin-client/test/__factories__/index.ts index 52d5fccfb4..68dacdb062 100644 --- a/packages/forestadmin-client/test/__factories__/index.ts +++ b/packages/forestadmin-client/test/__factories__/index.ts @@ -11,6 +11,7 @@ export { default as forestAdminClientOptions } from './forest-admin-client-optio export { default as ipWhiteList } from './ip-whitelist'; export { default as schema } from './schema'; export { default as activityLogs } from './activity-logs'; +export { default as workflows } from './workflows'; export { default as auth } from './auth'; export { default as modelCustomization } from './model-customizations/model-customization-from-api'; export { default as mcpServerConfig } from './mcp-server-config'; diff --git a/packages/forestadmin-client/test/__factories__/workflows/index.ts b/packages/forestadmin-client/test/__factories__/workflows/index.ts new file mode 100644 index 0000000000..7dfca54595 --- /dev/null +++ b/packages/forestadmin-client/test/__factories__/workflows/index.ts @@ -0,0 +1,11 @@ +import { Factory } from 'fishery'; + +import WorkflowsService from '../../../src/workflows'; +import forestAdminClientOptions from '../forest-admin-client-options'; +import forestAdminServerInterface from '../forest-admin-server-interface'; + +const workflowsServiceFactory = Factory.define(() => { + return new WorkflowsService(forestAdminServerInterface.build(), forestAdminClientOptions.build()); +}); + +export default workflowsServiceFactory; diff --git a/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts b/packages/forestadmin-client/test/forest-admin-client-with-cache.test.ts index 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..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 449ba3a9c3..d38905aba6 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -34,6 +34,7 @@ import declareExecuteActionTool from './tools/execute-action'; import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; +import declareListWorkflowsTool from './tools/list-workflows'; import declareUpdateTool from './tools/update'; import normalizeAgentUrl from './utils/normalize-agent-url'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; @@ -89,6 +90,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 = @@ -101,7 +103,8 @@ export type ToolName = | 'associate' | 'dissociate' | 'getActionForm' - | 'executeAction'; + | 'executeAction' + | 'listWorkflows'; /** * Options for configuring the Forest Admin MCP Server @@ -223,6 +226,7 @@ export default class ForestMCPServer { { name: 'dissociate', register: () => declareDissociateTool(mcpServer, ctx) }, { name: 'getActionForm', register: () => declareGetActionFormTool(mcpServer, ctx) }, { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, + { name: 'listWorkflows', register: () => declareListWorkflowsTool(mcpServer, ctx) }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -260,6 +264,7 @@ export default class ForestMCPServer { 'dissociate', 'getActionForm', 'executeAction', + 'listWorkflows', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); diff --git a/packages/mcp-server/src/tools/list-workflows.ts b/packages/mcp-server/src/tools/list-workflows.ts new file mode 100644 index 0000000000..8677b5ccb3 --- /dev/null +++ b/packages/mcp-server/src/tools/list-workflows.ts @@ -0,0 +1,54 @@ +import type { ToolContext } from '../tool-context'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import getAuthContext from '../utils/auth-context'; +import registerToolWithLogging from '../utils/tool-with-logging'; + +const COLLECTION_NAME_DESCRIPTION = + 'Optional. Narrow the results to workflows operating on this collection — typically the ' + + 'collection of the record currently in context.'; + +export function createListWorkflowsArgumentShape(collectionNames: string[]) { + const collectionName = + collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(); + + return { + collectionName: collectionName.optional().describe(COLLECTION_NAME_DESCRIPTION), + }; +} + +export type ListWorkflowsArgument = z.infer< + z.ZodObject> +>; + +export default function declareListWorkflowsTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger, collectionNames } = ctx; + + return registerToolWithLogging( + mcpServer, + 'listWorkflows', + { + annotations: { readOnlyHint: true }, + title: 'List MCP-enabled workflows', + description: + 'Discover Forest workflows enabled for MCP triggering that you can access. Returns each ' + + "workflow's id, name and the collection it operates on. Optionally filter by collectionName " + + 'to match the record currently in context, then start one with triggerWorkflow.', + inputSchema: createListWorkflowsArgumentShape(collectionNames), + }, + async (args: ListWorkflowsArgument, extra) => { + const { forestServerToken, renderingId } = getAuthContext(extra); + + const workflows = await forestServerClient.listMcpWorkflows({ + forestServerToken, + renderingId, + collectionName: args.collectionName, + }); + + return { content: [{ type: 'text', text: JSON.stringify(workflows) }] }; + }, + logger, + ); +} diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index e245c5d28d..f6a8aa6448 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -10,6 +10,8 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { NotFoundError } from '@forestadmin/forestadmin-client'; +import getAuthContext from './auth-context'; + export type { ActivityLogAction, ActivityLogResponse }; const ACTION_TO_TYPE: Record = { @@ -24,25 +26,6 @@ const ACTION_TO_TYPE: Record = { describeCollection: 'read', }; -function getAuthContext(request: RequestHandlerExtra): { - forestServerToken: string; - renderingId: string; -} { - const forestServerToken = request.authInfo?.extra?.forestServerToken; - const renderingId = request.authInfo?.extra?.renderingId; - - if (!forestServerToken || typeof forestServerToken !== 'string') { - throw new Error('Invalid or missing forestServerToken in authentication context'); - } - - // renderingId can be number (from JWT) or string - convert to string for API calls - if (renderingId === undefined || renderingId === null) { - throw new Error('Invalid or missing renderingId in authentication context'); - } - - return { forestServerToken, renderingId: String(renderingId) }; -} - export default async function createPendingActivityLog( forestServerClient: ForestServerClient, request: RequestHandlerExtra, diff --git a/packages/mcp-server/src/utils/auth-context.ts b/packages/mcp-server/src/utils/auth-context.ts new file mode 100644 index 0000000000..941856e11b --- /dev/null +++ b/packages/mcp-server/src/utils/auth-context.ts @@ -0,0 +1,24 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Extracts the caller's Forest identity from the MCP request auth context. + * Populated by the OAuth provider's `verifyAccessToken` (see `forest-oauth-provider.ts`). + */ +export default function getAuthContext( + request: RequestHandlerExtra, +): { forestServerToken: string; renderingId: string } { + const forestServerToken = request.authInfo?.extra?.forestServerToken; + const renderingId = request.authInfo?.extra?.renderingId; + + if (!forestServerToken || typeof forestServerToken !== 'string') { + throw new Error('Invalid or missing forestServerToken in authentication context'); + } + + // renderingId can be number (from JWT) or string - convert to string for API calls + if (renderingId === undefined || renderingId === null) { + throw new Error('Invalid or missing renderingId in authentication context'); + } + + return { forestServerToken, renderingId: String(renderingId) }; +} diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 74bc697dac..f726277d42 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -14,6 +14,7 @@ export default function createMockForestServerClient( attributes: { index: 'mock-index' }, }), updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), + listMcpWorkflows: jest.fn().mockResolvedValue([]), ...overrides, } as jest.Mocked; } diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index 9b904e1918..e39185108f 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -2,6 +2,7 @@ import type { ActivityLogsServiceInterface, ForestSchemaCollection, SchemaServiceInterface, + WorkflowsServiceInterface, } from '../../src/http-client/types'; import { createForestServerClient } from '../../src/http-client'; @@ -10,6 +11,7 @@ import ForestServerClientImpl from '../../src/http-client/mcp-http-client'; describe('ForestServerClientImpl', () => { let mockSchemaService: jest.Mocked; let mockActivityLogsService: jest.Mocked; + let mockWorkflowsService: jest.Mocked; let client: ForestServerClientImpl; beforeEach(() => { @@ -21,9 +23,13 @@ describe('ForestServerClientImpl', () => { createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), }; + mockWorkflowsService = { + listMcpEnabledWorkflows: jest.fn(), + }; client = new ForestServerClientImpl( mockSchemaService, mockActivityLogsService, + mockWorkflowsService, 'https://api.forestadmin.com', ); }); @@ -102,6 +108,24 @@ describe('ForestServerClientImpl', () => { expect(mockActivityLogsService.updateActivityLogStatus).toHaveBeenCalledWith(params); }); }); + + describe('listMcpWorkflows', () => { + it('should delegate to workflowsService.listMcpEnabledWorkflows()', async () => { + const workflows = [{ workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }]; + mockWorkflowsService.listMcpEnabledWorkflows.mockResolvedValue(workflows); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + collectionName: 'orders', + }; + + const result = await client.listMcpWorkflows(params); + + expect(mockWorkflowsService.listMcpEnabledWorkflows).toHaveBeenCalledWith(params); + expect(result).toBe(workflows); + }); + }); }); describe('createForestServerClient', () => { @@ -133,5 +157,6 @@ describe('createForestServerClient', () => { expect(client.createActivityLog).toBeDefined(); expect(client.createMcpActivityLog).toBeDefined(); expect(client.updateActivityLogStatus).toBeDefined(); + expect(client.listMcpWorkflows).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 73e96e6798..1968163ee7 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3191,6 +3191,7 @@ describe('enabledTools', () => { 'dissociate', 'getActionForm', 'executeAction', + 'listWorkflows', ], }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 99e8cd2258..a1789ec924 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -19,6 +19,7 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + listMcpWorkflows: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index a6557a28b4..2d7ce51d6c 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -17,6 +17,7 @@ const mockForestServerClient: ForestServerClient = { createActivityLog: jest.fn(), createMcpActivityLog: jest.fn(), updateActivityLogStatus: jest.fn(), + listMcpWorkflows: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/list-workflows.test.ts b/packages/mcp-server/test/tools/list-workflows.test.ts new file mode 100644 index 0000000000..265b2a0f05 --- /dev/null +++ b/packages/mcp-server/test/tools/list-workflows.test.ts @@ -0,0 +1,201 @@ +import type { ForestServerClient } from '../../src/http-client'; +import type { Logger } from '../../src/server'; +import type { RegisteredToolConfig } from '../helpers/registered-tool-config'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { NotFoundError } from '@forestadmin/forestadmin-client'; + +import declareListWorkflowsTool from '../../src/tools/list-workflows'; +import createMockForestServerClient from '../helpers/forest-server-client'; + +const mockLogger: Logger = jest.fn(); + +describe('declareListWorkflowsTool', () => { + let mcpServer: McpServer; + let mockForestServerClient: jest.Mocked; + let registeredToolHandler: (args: unknown, extra: unknown) => Promise; + let registeredToolConfig: RegisteredToolConfig; + + beforeEach(() => { + jest.clearAllMocks(); + + mockForestServerClient = createMockForestServerClient(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + }); + + describe('tool registration', () => { + it('should register a tool named "listWorkflows"', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'listWorkflows', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.title).toBe('List MCP-enabled workflows'); + expect(registeredToolConfig.description).toContain('MCP triggering'); + }); + + it('should be annotated as read-only', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); + }); + + it('should expose an optional collectionName argument', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(schema.collectionName.parse(undefined)).toBeUndefined(); + }); + + it('should accept any string for collectionName when no collection names provided', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + expect(() => schema.collectionName.parse(undefined)).not.toThrow(); + expect(() => schema.collectionName.parse(123)).toThrow(); + }); + + it('should restrict collectionName to the known collections when provided', () => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: ['orders', 'users'], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.collectionName.parse('orders')).not.toThrow(); + expect(() => schema.collectionName.parse(undefined)).not.toThrow(); + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: 123, + environmentApiEndpoint: 'https://api.example.com', + }, + }, + } as unknown as RequestHandlerExtra; + + const workflows = [ + { workflowId: 'wf-1', name: 'Refund order', collectionName: 'orders' }, + { workflowId: 'wf-2', name: 'Notify customer', collectionName: 'orders' }, + ]; + + beforeEach(() => { + declareListWorkflowsTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + mockForestServerClient.listMcpWorkflows.mockResolvedValue(workflows); + }); + + it('should call listMcpWorkflows with the identity from the auth context', async () => { + await registeredToolHandler({}, mockExtra); + + expect(mockForestServerClient.listMcpWorkflows).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + collectionName: undefined, + }); + }); + + it('should forward the collectionName filter to listMcpWorkflows', async () => { + await registeredToolHandler({ collectionName: 'orders' }, mockExtra); + + expect(mockForestServerClient.listMcpWorkflows).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + collectionName: 'orders', + }); + }); + + it('should return the workflows as JSON text content', async () => { + const result = await registeredToolHandler({}, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(workflows) }], + }); + }); + + it('should return an error result when the auth context is missing the token', async () => { + const extraWithoutToken = { + authInfo: { extra: { renderingId: 123 } }, + } as unknown as RequestHandlerExtra; + + const result = await registeredToolHandler({}, extraWithoutToken); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], + isError: true, + }); + expect(mockForestServerClient.listMcpWorkflows).not.toHaveBeenCalled(); + }); + + it('should map server errors to an error tool result', async () => { + mockForestServerClient.listMcpWorkflows.mockRejectedValue( + new NotFoundError('No active workflow for the rendering'), + ); + + const result = await registeredToolHandler({}, mockExtra); + + expect(result).toEqual({ + content: [ + { type: 'text', text: expect.stringContaining('No active workflow for the rendering') }, + ], + isError: true, + }); + }); + }); +});