From bc70f2caff86fa3092fa42fcc10de093a0250670 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 28 Jul 2026 09:01:54 +0200 Subject: [PATCH 1/3] feat(mcp-server): add getWorkflowRun tool (PRD-740) Expose the getWorkflowRun polling tool so the LLM can observe a run's status, closing the discover -> trigger -> poll loop. Report-only in v1: human-gated runs report waitingForHumanInput but cannot be resumed via MCP (tracked in PRD-441). Threads a getMcpWorkflowRun call through forestadmin-client (types, HTTP api, workflows service) to the MS7 read endpoint, and registers a read-only getWorkflowRun MCP tool scoped to the caller. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/forestadmin-client/src/index.ts | 3 + .../src/permissions/forest-http-api.ts | 15 ++ packages/forestadmin-client/src/types.ts | 32 +++ .../forestadmin-client/src/workflows/index.ts | 20 ++ .../forest-admin-server-interface.ts | 1 + .../test/permissions/forest-http-api.test.ts | 48 +++++ .../test/workflows/index.test.ts | 65 ++++++ packages/mcp-server/src/http-client/index.ts | 2 + .../src/http-client/mcp-http-client.ts | 6 + .../mcp-server/src/http-client/types.d.ts | 9 + packages/mcp-server/src/server.ts | 7 +- .../mcp-server/src/tools/get-workflow-run.ts | 46 ++++ .../test/helpers/forest-server-client.ts | 5 + .../test/http-client/mcp-http-client.test.ts | 25 +++ packages/mcp-server/test/server.test.ts | 1 + .../test/tools/execute-action.test.ts | 1 + .../test/tools/get-action-form.test.ts | 1 + .../test/tools/get-workflow-run.test.ts | 197 ++++++++++++++++++ 18 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-server/src/tools/get-workflow-run.ts create mode 100644 packages/mcp-server/test/tools/get-workflow-run.test.ts diff --git a/packages/forestadmin-client/src/index.ts b/packages/forestadmin-client/src/index.ts index e0def6434e..bf0ed92d82 100644 --- a/packages/forestadmin-client/src/index.ts +++ b/packages/forestadmin-client/src/index.ts @@ -31,7 +31,10 @@ export { McpWorkflow, ListMcpWorkflowsParams, TriggerMcpWorkflowParams, + GetMcpWorkflowRunParams, WorkflowRunState, + WorkflowRunStep, + WorkflowRunStatus, WorkflowRunTriggerResult, // Service interfaces for MCP ActivityLogsServiceInterface, diff --git a/packages/forestadmin-client/src/permissions/forest-http-api.ts b/packages/forestadmin-client/src/permissions/forest-http-api.ts index cf2217e6d4..1c89e876be 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, + WorkflowRunStatus, WorkflowRunTriggerResult, } from '../types'; import type { HttpOptions } from '../utils/http-options'; @@ -185,4 +186,18 @@ export default class ForestHttpApi implements ForestAdminServerInterface { headers: { 'forest-rendering-id': renderingId, ...options.headers }, }); } + + async getMcpWorkflowRun( + options: ActivityLogHttpOptions, + renderingId: string, + runId: string, + ): Promise { + return ServerUtils.queryWithBearerToken({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: `/api/workflow-orchestrator/mcp-workflows/runs/${encodeURIComponent(runId)}`, + bearerToken: options.bearerToken, + headers: { 'forest-rendering-id': renderingId, ...options.headers }, + }); + } } diff --git a/packages/forestadmin-client/src/types.ts b/packages/forestadmin-client/src/types.ts index 294f748c73..da63500bcd 100644 --- a/packages/forestadmin-client/src/types.ts +++ b/packages/forestadmin-client/src/types.ts @@ -320,12 +320,39 @@ export interface TriggerMcpWorkflowParams { recordId: string; } +/** + * The step a run is currently on, as derived server-side from the workflow history. + */ +export interface WorkflowRunStep { + name: string; + type: string; +} + +/** + * The normalized status of a workflow run, as exposed for external (MCP) consumption. + * `result` is the terminal output when finished; `error` the failure detail otherwise. + */ +export interface WorkflowRunStatus { + runState: WorkflowRunState; + currentStep: WorkflowRunStep | null; + waitingForHumanInput: boolean; + result?: unknown; + error?: unknown; +} + +export interface GetMcpWorkflowRunParams { + forestServerToken: string; + renderingId: string; + runId: string; +} + /** * Service interface for workflow operations (MCP-related). */ export interface WorkflowsServiceInterface { listMcpEnabledWorkflows: (params: ListMcpWorkflowsParams) => Promise; triggerMcpWorkflow: (params: TriggerMcpWorkflowParams) => Promise; + getMcpWorkflowRun: (params: GetMcpWorkflowRunParams) => Promise; } /** @@ -379,6 +406,11 @@ export interface ForestAdminServerInterface { workflowId: string, recordId: string, ) => Promise; + getMcpWorkflowRun?: ( + options: ActivityLogHttpOptions, + renderingId: string, + runId: string, + ) => Promise; } export type ActivityLogHttpOptions = { diff --git a/packages/forestadmin-client/src/workflows/index.ts b/packages/forestadmin-client/src/workflows/index.ts index 347f4074a3..332079c6c4 100644 --- a/packages/forestadmin-client/src/workflows/index.ts +++ b/packages/forestadmin-client/src/workflows/index.ts @@ -1,8 +1,10 @@ import type { ForestAdminServerInterface, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, TriggerMcpWorkflowParams, + WorkflowRunStatus, WorkflowRunTriggerResult, } from '../types'; @@ -57,4 +59,22 @@ export default class WorkflowsService { recordId, ); } + + async getMcpWorkflowRun(params: GetMcpWorkflowRunParams): Promise { + const { forestServerToken, renderingId, runId } = params; + + if (!this.forestAdminServerInterface.getMcpWorkflowRun) { + throw new Error('The configured Forest server transport does not support getMcpWorkflowRun.'); + } + + return this.forestAdminServerInterface.getMcpWorkflowRun( + { + forestServerUrl: this.options.forestServerUrl, + bearerToken: forestServerToken, + headers: this.options.headers, + }, + renderingId, + runId, + ); + } } diff --git a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts index 6b98f88d89..be174cef86 100644 --- a/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts +++ b/packages/forestadmin-client/test/__factories__/forest-admin-server-interface.ts @@ -21,6 +21,7 @@ const forestAdminServerInterface = { // Workflow operations listMcpEnabledWorkflows: jest.fn(), triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }), }; diff --git a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts index 92cb65b0f5..8719a24fa2 100644 --- a/packages/forestadmin-client/test/permissions/forest-http-api.test.ts +++ b/packages/forestadmin-client/test/permissions/forest-http-api.test.ts @@ -290,4 +290,52 @@ describe('ForestHttpApi', () => { ); }); }); + + describe('getMcpWorkflowRun', () => { + it('should GET the workflow run endpoint with the rendering id header', async () => { + const runStatus = { + runState: 'finished', + currentStep: null, + waitingForHumanInput: false, + result: { ok: true }, + }; + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue(runStatus); + + const result = await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + '7', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith({ + forestServerUrl: options.forestServerUrl, + method: 'get', + path: '/api/workflow-orchestrator/mcp-workflows/runs/7', + bearerToken: 'bearer-token', + headers: { 'forest-rendering-id': '12345' }, + }); + expect(result).toEqual(runStatus); + }); + + it('should url-encode the run id in the path', async () => { + (ServerUtils.queryWithBearerToken as jest.Mock).mockResolvedValue({ + runState: 'started', + currentStep: null, + waitingForHumanInput: false, + }); + + await new ForestHttpApi().getMcpWorkflowRun( + { forestServerUrl: options.forestServerUrl, bearerToken: 'bearer-token' }, + '12345', + 'run/with space', + ); + + expect(ServerUtils.queryWithBearerToken).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'get', + path: '/api/workflow-orchestrator/mcp-workflows/runs/run%2Fwith%20space', + }), + ); + }); + }); }); diff --git a/packages/forestadmin-client/test/workflows/index.test.ts b/packages/forestadmin-client/test/workflows/index.test.ts index da94880817..f6d6f6f034 100644 --- a/packages/forestadmin-client/test/workflows/index.test.ts +++ b/packages/forestadmin-client/test/workflows/index.test.ts @@ -156,4 +156,69 @@ describe('WorkflowsService', () => { ).rejects.toThrow('does not support triggerMcpWorkflow'); }); }); + + describe('getMcpWorkflowRun', () => { + const runStatus = { + runState: 'finished' as const, + currentStep: null, + waitingForHumanInput: false, + result: { ok: true }, + }; + + it('should forward the identity and runId to the transport and return the status', async () => { + mockForestAdminServerInterface.getMcpWorkflowRun.mockResolvedValue(runStatus); + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + const result = await service.getMcpWorkflowRun({ + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }); + + expect(result).toEqual(runStatus); + expect(mockForestAdminServerInterface.getMcpWorkflowRun).toHaveBeenCalledWith( + { forestServerUrl: options.forestServerUrl, bearerToken: 'test-token', headers: undefined }, + '12345', + '7', + ); + }); + + it('should pass custom headers when provided', async () => { + mockForestAdminServerInterface.getMcpWorkflowRun.mockResolvedValue(runStatus); + + const service = new WorkflowsService(mockForestAdminServerInterface, { + ...options, + headers: { 'Forest-Application-Source': 'MCP' }, + }); + await service.getMcpWorkflowRun({ + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }); + + expect(mockForestAdminServerInterface.getMcpWorkflowRun).toHaveBeenCalledWith( + expect.objectContaining({ + bearerToken: 'test-token', + headers: { 'Forest-Application-Source': 'MCP' }, + }), + '12345', + '7', + ); + }); + + it('should throw when the transport does not implement getMcpWorkflowRun', async () => { + delete (mockForestAdminServerInterface as Partial) + .getMcpWorkflowRun; + + const service = new WorkflowsService(mockForestAdminServerInterface, options); + + await expect( + service.getMcpWorkflowRun({ + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }), + ).rejects.toThrow('does not support getMcpWorkflowRun'); + }); + }); }); diff --git a/packages/mcp-server/src/http-client/index.ts b/packages/mcp-server/src/http-client/index.ts index 678a023e95..af80361070 100644 --- a/packages/mcp-server/src/http-client/index.ts +++ b/packages/mcp-server/src/http-client/index.ts @@ -53,9 +53,11 @@ export type { ActivityLogType, CreateActivityLogParams, ForestServerClient, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, TriggerMcpWorkflowParams, + WorkflowRunStatus, WorkflowRunTriggerResult, UpdateActivityLogStatusParams, ForestSchemaCollection, diff --git a/packages/mcp-server/src/http-client/mcp-http-client.ts b/packages/mcp-server/src/http-client/mcp-http-client.ts index adc39cde8d..200dfcec8d 100644 --- a/packages/mcp-server/src/http-client/mcp-http-client.ts +++ b/packages/mcp-server/src/http-client/mcp-http-client.ts @@ -4,11 +4,13 @@ import type { CreateActivityLogParams, ForestSchemaCollection, ForestServerClient, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, } from './types'; @@ -48,4 +50,8 @@ export default class ForestServerClientImpl implements ForestServerClient { async triggerWorkflow(params: TriggerMcpWorkflowParams): Promise { return this.workflowsService.triggerMcpWorkflow(params); } + + async getWorkflowRun(params: GetMcpWorkflowRunParams): Promise { + return this.workflowsService.getMcpWorkflowRun(params); + } } diff --git a/packages/mcp-server/src/http-client/types.d.ts b/packages/mcp-server/src/http-client/types.d.ts index b5e8a40751..8bbaccaa66 100644 --- a/packages/mcp-server/src/http-client/types.d.ts +++ b/packages/mcp-server/src/http-client/types.d.ts @@ -7,11 +7,13 @@ import type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, } from '@forestadmin/forestadmin-client'; @@ -26,11 +28,13 @@ export type { ForestSchemaAction, ForestSchemaCollection, ForestSchemaField, + GetMcpWorkflowRunParams, ListMcpWorkflowsParams, McpWorkflow, SchemaServiceInterface, TriggerMcpWorkflowParams, UpdateActivityLogStatusParams, + WorkflowRunStatus, WorkflowRunTriggerResult, WorkflowsServiceInterface, }; @@ -74,4 +78,9 @@ export interface ForestServerClient { * Starts a run of an MCP-enabled workflow on a record and returns its runId (async). */ triggerWorkflow(params: TriggerMcpWorkflowParams): Promise; + + /** + * Reads the normalized status of a workflow run, scoped to the caller. + */ + getWorkflowRun(params: GetMcpWorkflowRunParams): Promise; } diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 74a05fc68a..fccf915fae 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -32,6 +32,7 @@ import declareDescribeCollectionTool from './tools/describe-collection'; import declareDissociateTool from './tools/dissociate'; import declareExecuteActionTool from './tools/execute-action'; import declareGetActionFormTool from './tools/get-action-form'; +import declareGetWorkflowRunTool from './tools/get-workflow-run'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; import declareListWorkflowsTool from './tools/list-workflows'; @@ -93,6 +94,7 @@ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { dissociate: ['collectionName', 'relationName', 'parentRecordId', 'targetRecordIds'], listWorkflows: ['collectionName'], triggerWorkflow: ['workflowId', 'recordId'], + getWorkflowRun: ['runId'], }; export type ToolName = @@ -107,7 +109,8 @@ export type ToolName = | 'getActionForm' | 'executeAction' | 'listWorkflows' - | 'triggerWorkflow'; + | 'triggerWorkflow' + | 'getWorkflowRun'; /** * Options for configuring the Forest Admin MCP Server @@ -231,6 +234,7 @@ export default class ForestMCPServer { { name: 'executeAction', register: () => declareExecuteActionTool(mcpServer, ctx) }, { name: 'listWorkflows', register: () => declareListWorkflowsTool(mcpServer, ctx) }, { name: 'triggerWorkflow', register: () => declareTriggerWorkflowTool(mcpServer, ctx) }, + { name: 'getWorkflowRun', register: () => declareGetWorkflowRunTool(mcpServer, ctx) }, ]; const enabledToolEntries = allTools.filter(tool => this.enabledTools.has(tool.name)); @@ -270,6 +274,7 @@ export default class ForestMCPServer { 'executeAction', 'listWorkflows', 'triggerWorkflow', + 'getWorkflowRun', ]; const enabled = new Set(options?.enabledTools ?? allToolNames); diff --git a/packages/mcp-server/src/tools/get-workflow-run.ts b/packages/mcp-server/src/tools/get-workflow-run.ts new file mode 100644 index 0000000000..fb41fbaaac --- /dev/null +++ b/packages/mcp-server/src/tools/get-workflow-run.ts @@ -0,0 +1,46 @@ +import type { ToolContext } from '../tool-context'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import getAuthContext from '../utils/auth-context'; +import registerToolWithLogging from '../utils/tool-with-logging'; + +const RUN_ID_DESCRIPTION = 'The id of the workflow run to observe, as returned by triggerWorkflow.'; + +interface GetWorkflowRunArgument { + runId: string; +} + +export default function declareGetWorkflowRunTool(mcpServer: McpServer, ctx: ToolContext): string { + const { forestServerClient, logger } = ctx; + + return registerToolWithLogging( + mcpServer, + 'getWorkflowRun', + { + annotations: { readOnlyHint: true }, + title: 'Get a workflow run status', + description: + 'Poll the status of a workflow run started with triggerWorkflow. Returns runState, the ' + + 'currentStep, waitingForHumanInput, and — once finished — the terminal result or error. ' + + 'A run parked on a human-gated step reports waitingForHumanInput: true; it cannot be ' + + 'resumed via MCP and must be finished from the Forest UI.', + inputSchema: { + runId: z.string().describe(RUN_ID_DESCRIPTION), + }, + }, + async (args: GetWorkflowRunArgument, extra) => { + const { forestServerToken, renderingId } = getAuthContext(extra); + + const runStatus = await forestServerClient.getWorkflowRun({ + forestServerToken, + renderingId, + runId: args.runId, + }); + + return { content: [{ type: 'text', text: JSON.stringify(runStatus) }] }; + }, + logger, + ); +} diff --git a/packages/mcp-server/test/helpers/forest-server-client.ts b/packages/mcp-server/test/helpers/forest-server-client.ts index 0b720116ce..472fda506c 100644 --- a/packages/mcp-server/test/helpers/forest-server-client.ts +++ b/packages/mcp-server/test/helpers/forest-server-client.ts @@ -16,6 +16,11 @@ export default function createMockForestServerClient( updateActivityLogStatus: jest.fn().mockResolvedValue(undefined), listMcpWorkflows: jest.fn().mockResolvedValue([]), triggerWorkflow: jest.fn().mockResolvedValue({ runId: 1, runState: 'loading' }), + getWorkflowRun: jest.fn().mockResolvedValue({ + runState: 'started', + currentStep: null, + waitingForHumanInput: false, + }), ...overrides, } as jest.Mocked; } diff --git a/packages/mcp-server/test/http-client/mcp-http-client.test.ts b/packages/mcp-server/test/http-client/mcp-http-client.test.ts index 787d22053b..f2243ef749 100644 --- a/packages/mcp-server/test/http-client/mcp-http-client.test.ts +++ b/packages/mcp-server/test/http-client/mcp-http-client.test.ts @@ -26,6 +26,7 @@ describe('ForestServerClientImpl', () => { mockWorkflowsService = { listMcpEnabledWorkflows: jest.fn(), triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }; client = new ForestServerClientImpl( mockSchemaService, @@ -146,6 +147,29 @@ describe('ForestServerClientImpl', () => { expect(result).toBe(run); }); }); + + describe('getWorkflowRun', () => { + it('should delegate to workflowsService.getMcpWorkflowRun()', async () => { + const runStatus = { + runState: 'finished' as const, + currentStep: null, + waitingForHumanInput: false, + result: { ok: true }, + }; + mockWorkflowsService.getMcpWorkflowRun.mockResolvedValue(runStatus); + + const params = { + forestServerToken: 'test-token', + renderingId: '12345', + runId: '7', + }; + + const result = await client.getWorkflowRun(params); + + expect(mockWorkflowsService.getMcpWorkflowRun).toHaveBeenCalledWith(params); + expect(result).toBe(runStatus); + }); + }); }); describe('createForestServerClient', () => { @@ -179,5 +203,6 @@ describe('createForestServerClient', () => { expect(client.updateActivityLogStatus).toBeDefined(); expect(client.listMcpWorkflows).toBeDefined(); expect(client.triggerWorkflow).toBeDefined(); + expect(client.getWorkflowRun).toBeDefined(); }); }); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index e44b38a481..dc869bc9f9 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -3193,6 +3193,7 @@ describe('enabledTools', () => { 'executeAction', 'listWorkflows', 'triggerWorkflow', + 'getWorkflowRun', ], }); diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 59d6b012dd..d73e5abfc9 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -21,6 +21,7 @@ const mockForestServerClient: ForestServerClient = { updateActivityLogStatus: jest.fn(), listMcpWorkflows: jest.fn(), triggerWorkflow: jest.fn(), + getWorkflowRun: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts index d49ab024ed..0d85c9cf65 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -19,6 +19,7 @@ const mockForestServerClient: ForestServerClient = { updateActivityLogStatus: jest.fn(), listMcpWorkflows: jest.fn(), triggerWorkflow: jest.fn(), + getWorkflowRun: jest.fn(), }; const mockBuildClientWithActions = buildClientWithActions as jest.MockedFunction< diff --git a/packages/mcp-server/test/tools/get-workflow-run.test.ts b/packages/mcp-server/test/tools/get-workflow-run.test.ts new file mode 100644 index 0000000000..10a95302ce --- /dev/null +++ b/packages/mcp-server/test/tools/get-workflow-run.test.ts @@ -0,0 +1,197 @@ +import type { ForestServerClient } from '../../src/http-client'; +import type { Logger } from '../../src/server'; +import type { RegisteredToolConfig } from '../helpers/registered-tool-config'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { ForbiddenError, NotFoundError } from '@forestadmin/forestadmin-client'; + +import declareGetWorkflowRunTool from '../../src/tools/get-workflow-run'; +import createMockForestServerClient from '../helpers/forest-server-client'; + +const mockLogger: Logger = jest.fn(); + +describe('declareGetWorkflowRunTool', () => { + let mcpServer: McpServer; + let mockForestServerClient: jest.Mocked; + let registeredToolHandler: (args: unknown, extra: unknown) => Promise; + let registeredToolConfig: RegisteredToolConfig; + + beforeEach(() => { + jest.clearAllMocks(); + + mockForestServerClient = createMockForestServerClient(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + }); + + describe('tool registration', () => { + it('should register a tool named "getWorkflowRun"', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'getWorkflowRun', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.title).toBe('Get a workflow run status'); + expect(registeredToolConfig.description).toContain('waitingForHumanInput'); + }); + + it('should be annotated as read-only', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + expect(registeredToolConfig.annotations).toEqual({ readOnlyHint: true }); + }); + + it('should require a string runId argument', () => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + + expect(() => schema.runId.parse('7')).not.toThrow(); + expect(() => schema.runId.parse(undefined)).toThrow(); + expect(() => schema.runId.parse(7)).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: 123, + environmentApiEndpoint: 'https://api.example.com', + }, + }, + } as unknown as RequestHandlerExtra; + + const runStatus = { + runState: 'finished' as const, + currentStep: null, + waitingForHumanInput: false, + result: { refunded: true }, + }; + + beforeEach(() => { + declareGetWorkflowRunTool(mcpServer, { + forestServerClient: mockForestServerClient, + logger: mockLogger, + collectionNames: [], + }); + mockForestServerClient.getWorkflowRun.mockResolvedValue(runStatus); + }); + + it('should call getWorkflowRun with the identity from the auth context and the runId', async () => { + await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(mockForestServerClient.getWorkflowRun).toHaveBeenCalledWith({ + forestServerToken: 'forest-token', + renderingId: '123', + runId: '7', + }); + }); + + it('should return the run status as JSON text content', async () => { + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(runStatus) }], + }); + }); + + it('should report a human-gated run as waitingForHumanInput', async () => { + mockForestServerClient.getWorkflowRun.mockResolvedValue({ + runState: 'started', + currentStep: { name: 'Manager approval', type: 'human' }, + waitingForHumanInput: true, + }); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify({ + runState: 'started', + currentStep: { name: 'Manager approval', type: 'human' }, + waitingForHumanInput: true, + }), + }, + ], + }); + }); + + it('should return an error result when the auth context is missing the token', async () => { + const extraWithoutToken = { + authInfo: { extra: { renderingId: 123 } }, + } as unknown as RequestHandlerExtra; + + const result = await registeredToolHandler({ runId: '7' }, extraWithoutToken); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('forestServerToken') }], + isError: true, + }); + expect(mockForestServerClient.getWorkflowRun).not.toHaveBeenCalled(); + }); + + it('should map an unknown runId 404 to an error tool result', async () => { + mockForestServerClient.getWorkflowRun.mockRejectedValue( + new NotFoundError('Workflow run not found'), + ); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('not found') }], + isError: true, + }); + }); + + it('should map a forbidden runId 403 to an error tool result', async () => { + mockForestServerClient.getWorkflowRun.mockRejectedValue( + new ForbiddenError('You are not allowed to access this workflow run'), + ); + + const result = await registeredToolHandler({ runId: '7' }, mockExtra); + + expect(result).toEqual({ + content: [{ type: 'text', text: expect.stringContaining('not allowed') }], + isError: true, + }); + }); + }); +}); From e316e848076f735fc2f36b27dac82173e5462922 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 29 Jul 2026 15:00:50 +0200 Subject: [PATCH 2/3] fix(agent-testing): add getMcpWorkflowRun to client mock (PRD-740) Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/agent-testing/src/forest-admin-client-mock.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/agent-testing/src/forest-admin-client-mock.ts b/packages/agent-testing/src/forest-admin-client-mock.ts index 6c2b59ee81..e3a21d1530 100644 --- a/packages/agent-testing/src/forest-admin-client-mock.ts +++ b/packages/agent-testing/src/forest-admin-client-mock.ts @@ -65,6 +65,8 @@ export default class ForestAdminClientMock implements ForestAdminClient { readonly workflowsService: ForestAdminClient['workflowsService'] = { listMcpEnabledWorkflows: () => Promise.resolve([]), triggerMcpWorkflow: () => Promise.resolve({ runId: 1, runState: 'loading' }), + getMcpWorkflowRun: () => + Promise.resolve({ runState: 'loading', currentStep: null, waitingForHumanInput: false }), }; readonly permissionService: any; From e02cf17c6373fca5d70bf5595a69e1c21c22bfdc Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Wed, 29 Jul 2026 15:26:56 +0200 Subject: [PATCH 3/3] fix(agent): add getMcpWorkflowRun to client test factory (PRD-740) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../test/forest-admin-client-mock.test.ts | 16 ++++++++++++++++ .../test/__factories__/forest-admin-client.ts | 1 + 2 files changed, 17 insertions(+) diff --git a/packages/agent-testing/test/forest-admin-client-mock.test.ts b/packages/agent-testing/test/forest-admin-client-mock.test.ts index c13e51145f..67512d3522 100644 --- a/packages/agent-testing/test/forest-admin-client-mock.test.ts +++ b/packages/agent-testing/test/forest-admin-client-mock.test.ts @@ -25,5 +25,21 @@ describe('ForestAdminClientMock', () => { }), ).resolves.toEqual({ runId: 1, runState: 'loading' }); }); + + it('should resolve a loading run status when fetching a workflow run', async () => { + const client = new ForestAdminClientMock(); + + await expect( + client.workflowsService.getMcpWorkflowRun({ + forestServerToken: 'token', + renderingId: '1', + runId: '1', + }), + ).resolves.toEqual({ + runState: 'loading', + currentStep: null, + waitingForHumanInput: false, + }); + }); }); }); diff --git a/packages/agent/test/__factories__/forest-admin-client.ts b/packages/agent/test/__factories__/forest-admin-client.ts index 9091df8eb4..da3034f40b 100644 --- a/packages/agent/test/__factories__/forest-admin-client.ts +++ b/packages/agent/test/__factories__/forest-admin-client.ts @@ -57,6 +57,7 @@ const forestAdminClientFactory = ForestAdminClientFactory.define(() => ({ workflowsService: { listMcpEnabledWorkflows: jest.fn(), triggerMcpWorkflow: jest.fn(), + getMcpWorkflowRun: jest.fn(), }, subscribeToServerEvents: jest.fn(), close: jest.fn(),