From 92d46c9241eac6c99711ddfb23623c28428f9a5f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 19 Dec 2025 17:22:22 +0100 Subject: [PATCH 01/18] feat(mcp-server): add create, update and delete tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `create` tool to create records in a collection - Add `update` tool to update existing records - Add `delete` tool to delete one or more records - Add integration tests for all three tools (43 new tests) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/mcp-server/src/server.ts | 9 + packages/mcp-server/src/tools/create.ts | 73 +++++ packages/mcp-server/src/tools/delete.ts | 74 +++++ packages/mcp-server/src/tools/update.ts | 76 +++++ .../test/forest-oauth-provider.test.ts | 2 +- packages/mcp-server/test/server.test.ts | 1 + packages/mcp-server/test/tools/create.test.ts | 264 ++++++++++++++++ packages/mcp-server/test/tools/delete.test.ts | 270 +++++++++++++++++ packages/mcp-server/test/tools/list.test.ts | 6 +- packages/mcp-server/test/tools/update.test.ts | 285 ++++++++++++++++++ 10 files changed, 1056 insertions(+), 4 deletions(-) create mode 100644 packages/mcp-server/src/tools/create.ts create mode 100644 packages/mcp-server/src/tools/delete.ts create mode 100644 packages/mcp-server/src/tools/update.ts create mode 100644 packages/mcp-server/test/tools/create.test.ts create mode 100644 packages/mcp-server/test/tools/delete.test.ts create mode 100644 packages/mcp-server/test/tools/update.test.ts diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index de3e815f5b..41e5ad356b 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -20,7 +20,10 @@ import * as http from 'http'; import ForestOAuthProvider from './forest-oauth-provider'; import { isMcpRoute } from './mcp-paths'; +import declareCreateTool from './tools/create'; +import declareDeleteTool from './tools/delete'; import declareListTool from './tools/list'; +import declareUpdateTool from './tools/update'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; import { NAME, VERSION } from './version'; @@ -48,6 +51,9 @@ const defaultLogger: Logger = (level, message) => { /** Fields that are safe to log for each tool (non-sensitive data) */ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { list: ['collectionName'], + create: ['collectionName'], + update: ['collectionName', 'recordId'], + delete: ['collectionName', 'recordIds'], }; /** @@ -125,6 +131,9 @@ export default class ForestMCPServer { } declareListTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareCreateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareUpdateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareDeleteTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); } private ensureSecretsAreSet(): { envSecret: string; authSecret: string } { diff --git a/packages/mcp-server/src/tools/create.ts b/packages/mcp-server/src/tools/create.ts new file mode 100644 index 0000000000..4a6c9d1d62 --- /dev/null +++ b/packages/mcp-server/src/tools/create.ts @@ -0,0 +1,73 @@ +import type { Logger } from '../server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import createActivityLog from '../utils/activity-logs-creator.js'; +import buildClient from '../utils/agent-caller.js'; +import parseAgentError from '../utils/error-parser.js'; +import registerToolWithLogging from '../utils/tool-with-logging.js'; + +// Preprocess to handle LLM sending attributes as JSON string instead of object +const attributesWithPreprocess = z.preprocess(val => { + if (typeof val !== 'string') return val; + + try { + return JSON.parse(val); + } catch { + return val; + } +}, z.record(z.string(), z.unknown())); + +interface CreateArgument { + collectionName: string; + attributes: Record; +} + +function createArgumentShape(collectionNames: string[]) { + return { + collectionName: + collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(), + attributes: attributesWithPreprocess.describe( + 'The attributes of the record to create. Must be an object with field names as keys.', + ), + }; +} + +export default function declareCreateTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], +): void { + const argumentShape = createArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'create', + { + title: 'Create a record', + description: 'Create a new record in the specified collection.', + inputSchema: argumentShape, + }, + async (options: CreateArgument, extra) => { + const { rpcClient } = await buildClient(extra); + + await createActivityLog(forestServerUrl, extra, 'create', { + collectionName: options.collectionName, + }); + + try { + const record = await rpcClient + .collection(options.collectionName) + .create(options.attributes); + + return { content: [{ type: 'text', text: JSON.stringify({ record }) }] }; + } catch (error) { + const errorDetail = parseAgentError(error); + throw errorDetail ? new Error(errorDetail) : error; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/src/tools/delete.ts b/packages/mcp-server/src/tools/delete.ts new file mode 100644 index 0000000000..a9450dd97f --- /dev/null +++ b/packages/mcp-server/src/tools/delete.ts @@ -0,0 +1,74 @@ +import type { Logger } from '../server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import createActivityLog from '../utils/activity-logs-creator.js'; +import buildClient from '../utils/agent-caller.js'; +import parseAgentError from '../utils/error-parser.js'; +import registerToolWithLogging from '../utils/tool-with-logging.js'; + +interface DeleteArgument { + collectionName: string; + recordIds: (string | number)[]; +} + +function createArgumentShape(collectionNames: string[]) { + return { + collectionName: + collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(), + recordIds: z + .array(z.union([z.string(), z.number()])) + .describe('The IDs of the records to delete.'), + }; +} + +export default function declareDeleteTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], +): void { + const argumentShape = createArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'delete', + { + title: 'Delete records', + description: 'Delete one or more records from the specified collection.', + inputSchema: argumentShape, + }, + async (options: DeleteArgument, extra) => { + const { rpcClient } = await buildClient(extra); + + // Cast to satisfy the type system - the API accepts both string[] and number[] + const recordIds = options.recordIds as string[] | number[]; + + await createActivityLog(forestServerUrl, extra, 'delete', { + collectionName: options.collectionName, + recordIds, + }); + + try { + await rpcClient.collection(options.collectionName).delete(recordIds); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: `Successfully deleted ${options.recordIds.length} record(s) from ${options.collectionName}`, + }), + }, + ], + }; + } catch (error) { + const errorDetail = parseAgentError(error); + throw errorDetail ? new Error(errorDetail) : error; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/src/tools/update.ts b/packages/mcp-server/src/tools/update.ts new file mode 100644 index 0000000000..a6fa0d6e9b --- /dev/null +++ b/packages/mcp-server/src/tools/update.ts @@ -0,0 +1,76 @@ +import type { Logger } from '../server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import createActivityLog from '../utils/activity-logs-creator.js'; +import buildClient from '../utils/agent-caller.js'; +import parseAgentError from '../utils/error-parser.js'; +import registerToolWithLogging from '../utils/tool-with-logging.js'; + +// Preprocess to handle LLM sending attributes as JSON string instead of object +const attributesWithPreprocess = z.preprocess(val => { + if (typeof val !== 'string') return val; + + try { + return JSON.parse(val); + } catch { + return val; + } +}, z.record(z.string(), z.unknown())); + +interface UpdateArgument { + collectionName: string; + recordId: string | number; + attributes: Record; +} + +function createArgumentShape(collectionNames: string[]) { + return { + collectionName: + collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(), + recordId: z.union([z.string(), z.number()]).describe('The ID of the record to update.'), + attributes: attributesWithPreprocess.describe( + 'The attributes to update. Must be an object with field names as keys.', + ), + }; +} + +export default function declareUpdateTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], +): void { + const argumentShape = createArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'update', + { + title: 'Update a record', + description: 'Update an existing record in the specified collection.', + inputSchema: argumentShape, + }, + async (options: UpdateArgument, extra) => { + const { rpcClient } = await buildClient(extra); + + await createActivityLog(forestServerUrl, extra, 'update', { + collectionName: options.collectionName, + recordId: options.recordId, + }); + + try { + const record = await rpcClient + .collection(options.collectionName) + .update(options.recordId, options.attributes); + + return { content: [{ type: 'text', text: JSON.stringify({ record }) }] }; + } catch (error) { + const errorDetail = parseAgentError(error); + throw errorDetail ? new Error(errorDetail) : error; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/test/forest-oauth-provider.test.ts b/packages/mcp-server/test/forest-oauth-provider.test.ts index 15f79e21f8..9f329ea529 100644 --- a/packages/mcp-server/test/forest-oauth-provider.test.ts +++ b/packages/mcp-server/test/forest-oauth-provider.test.ts @@ -4,8 +4,8 @@ import type { Response } from 'express'; import createForestAdminClient from '@forestadmin/forestadmin-client'; import jsonwebtoken from 'jsonwebtoken'; -import ForestOAuthProvider from '../src/forest-oauth-provider'; import MockServer from './test-utils/mock-server'; +import ForestOAuthProvider from '../src/forest-oauth-provider'; jest.mock('jsonwebtoken'); jest.mock('@forestadmin/forestadmin-client'); diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index dc9d2c22ab..9bbf32dd13 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -5,6 +5,7 @@ import request from 'supertest'; import MockServer from './test-utils/mock-server'; import ForestMCPServer from '../src/server'; +import { clearSchemaCache } from '../src/utils/schema-fetcher.js'; function shutDownHttpServer(server: http.Server | undefined): Promise { if (!server) return Promise.resolve(); diff --git a/packages/mcp-server/test/tools/create.test.ts b/packages/mcp-server/test/tools/create.test.ts new file mode 100644 index 0000000000..32e4111a46 --- /dev/null +++ b/packages/mcp-server/test/tools/create.test.ts @@ -0,0 +1,264 @@ +import type { Logger } from '../../src/server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import declareCreateTool from '../../src/tools/create'; +import createActivityLog from '../../src/utils/activity-logs-creator'; +import buildClient from '../../src/utils/agent-caller'; + +jest.mock('../../src/utils/agent-caller'); +jest.mock('../../src/utils/activity-logs-creator'); + +const mockLogger: Logger = jest.fn(); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockCreateActivityLog = createActivityLog as jest.MockedFunction; + +describe('declareCreateTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + mockCreateActivityLog.mockResolvedValue(undefined); + }); + + describe('tool registration', () => { + it('should register a tool named "create"', () => { + declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'create', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('Create a record'); + expect(registeredToolConfig.description).toBe( + 'Create a new record in the specified collection.', + ); + }); + + it('should define correct input schema', () => { + declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('attributes'); + }); + + it('should use string type for collectionName when no collection names provided', () => { + declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toBeUndefined(); + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + 'users', + 'products', + ]); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toEqual(['users', 'products']); + expect(() => schema.collectionName.parse('users')).not.toThrow(); + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareCreateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + }); + + it('should call buildClient with the extra parameter', async () => { + const mockCreate = jest.fn().mockResolvedValue({ id: 1, name: 'New Record' }); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', attributes: { name: 'John' } }, + mockExtra, + ); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + }); + + it('should call rpcClient.collection with the collection name', async () => { + const mockCreate = jest.fn().mockResolvedValue({ id: 1, name: 'New Record' }); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'products', attributes: { name: 'Product' } }, + mockExtra, + ); + + expect(mockCollection).toHaveBeenCalledWith('products'); + }); + + it('should call create with the attributes', async () => { + const mockCreate = jest + .fn() + .mockResolvedValue({ id: 1, name: 'John', email: 'john@test.com' }); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const attributes = { name: 'John', email: 'john@test.com' }; + await registeredToolHandler({ collectionName: 'users', attributes }, mockExtra); + + expect(mockCreate).toHaveBeenCalledWith(attributes); + }); + + it('should return the created record as JSON text content', async () => { + const createdRecord = { id: 1, name: 'John', email: 'john@test.com' }; + const mockCreate = jest.fn().mockResolvedValue(createdRecord); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { collectionName: 'users', attributes: { name: 'John', email: 'john@test.com' } }, + mockExtra, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ record: createdRecord }) }], + }); + }); + + describe('activity logging', () => { + beforeEach(() => { + const mockCreate = jest.fn().mockResolvedValue({ id: 1 }); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should create activity log with "create" action type', async () => { + await registeredToolHandler( + { collectionName: 'users', attributes: { name: 'John' } }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'create', + { collectionName: 'users' }, + ); + }); + }); + + describe('attributes parsing', () => { + it('should parse attributes sent as JSON string (LLM workaround)', () => { + const attributes = { name: 'John', age: 30 }; + const attributesAsString = JSON.stringify(attributes); + + const inputSchema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + const parsedAttributes = inputSchema.attributes.parse(attributesAsString); + + expect(parsedAttributes).toEqual(attributes); + }); + + it('should handle attributes as object when not sent as string', async () => { + const mockCreate = jest.fn().mockResolvedValue({ id: 1 }); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const attributes = { name: 'John', age: 30 }; + await registeredToolHandler({ collectionName: 'users', attributes }, mockExtra); + + expect(mockCreate).toHaveBeenCalledWith(attributes); + }); + }); + + describe('error handling', () => { + it('should parse error with nested error.text structure in message', async () => { + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Name is required' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + const mockCreate = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler({ collectionName: 'users', attributes: {} }, mockExtra), + ).rejects.toThrow('Name is required'); + }); + + it('should rethrow original error when no parsable error found', async () => { + const agentError = { unknownProperty: 'some value' }; + const mockCreate = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ create: mockCreate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler({ collectionName: 'users', attributes: {} }, mockExtra), + ).rejects.toEqual(agentError); + }); + }); + }); +}); diff --git a/packages/mcp-server/test/tools/delete.test.ts b/packages/mcp-server/test/tools/delete.test.ts new file mode 100644 index 0000000000..e17d45beea --- /dev/null +++ b/packages/mcp-server/test/tools/delete.test.ts @@ -0,0 +1,270 @@ +import type { Logger } from '../../src/server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import declareDeleteTool from '../../src/tools/delete'; +import createActivityLog from '../../src/utils/activity-logs-creator'; +import buildClient from '../../src/utils/agent-caller'; + +jest.mock('../../src/utils/agent-caller'); +jest.mock('../../src/utils/activity-logs-creator'); + +const mockLogger: Logger = jest.fn(); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockCreateActivityLog = createActivityLog as jest.MockedFunction; + +describe('declareDeleteTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + mockCreateActivityLog.mockResolvedValue(undefined); + }); + + describe('tool registration', () => { + it('should register a tool named "delete"', () => { + declareDeleteTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'delete', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareDeleteTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('Delete records'); + expect(registeredToolConfig.description).toBe( + 'Delete one or more records from the specified collection.', + ); + }); + + it('should define correct input schema', () => { + declareDeleteTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('recordIds'); + }); + + it('should use string type for collectionName when no collection names provided', () => { + declareDeleteTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toBeUndefined(); + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareDeleteTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + 'users', + 'products', + ]); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toEqual(['users', 'products']); + expect(() => schema.collectionName.parse('users')).not.toThrow(); + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + + it('should accept array of strings or numbers for recordIds', () => { + declareDeleteTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.recordIds.parse(['1', '2', '3'])).not.toThrow(); + expect(() => schema.recordIds.parse([1, 2, 3])).not.toThrow(); + expect(() => schema.recordIds.parse(['1', 2, '3'])).not.toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareDeleteTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + }); + + it('should call buildClient with the extra parameter', async () => { + const mockDelete = jest.fn().mockResolvedValue(undefined); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler({ collectionName: 'users', recordIds: [1, 2] }, mockExtra); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + }); + + it('should call rpcClient.collection with the collection name', async () => { + const mockDelete = jest.fn().mockResolvedValue(undefined); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler({ collectionName: 'products', recordIds: [1] }, mockExtra); + + expect(mockCollection).toHaveBeenCalledWith('products'); + }); + + it('should call delete with the recordIds', async () => { + const mockDelete = jest.fn().mockResolvedValue(undefined); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const recordIds = [1, 2, 3]; + await registeredToolHandler({ collectionName: 'users', recordIds }, mockExtra); + + expect(mockDelete).toHaveBeenCalledWith(recordIds); + }); + + it('should return success message with count', async () => { + const mockDelete = jest.fn().mockResolvedValue(undefined); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { collectionName: 'users', recordIds: [1, 2, 3] }, + mockExtra, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Successfully deleted 3 record(s) from users', + }), + }, + ], + }); + }); + + it('should handle single record deletion', async () => { + const mockDelete = jest.fn().mockResolvedValue(undefined); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { collectionName: 'users', recordIds: [42] }, + mockExtra, + ); + + expect(result).toEqual({ + content: [ + { + type: 'text', + text: JSON.stringify({ + success: true, + message: 'Successfully deleted 1 record(s) from users', + }), + }, + ], + }); + }); + + describe('activity logging', () => { + beforeEach(() => { + const mockDelete = jest.fn().mockResolvedValue(undefined); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should create activity log with "delete" action type and recordIds', async () => { + const recordIds = [1, 2, 3]; + await registeredToolHandler({ collectionName: 'users', recordIds }, mockExtra); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'delete', + { collectionName: 'users', recordIds }, + ); + }); + }); + + describe('error handling', () => { + it('should parse error with nested error.text structure in message', async () => { + const errorPayload = { + error: { + status: 403, + text: JSON.stringify({ + errors: [{ name: 'ForbiddenError', detail: 'Cannot delete protected records' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + const mockDelete = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler({ collectionName: 'users', recordIds: [1] }, mockExtra), + ).rejects.toThrow('Cannot delete protected records'); + }); + + it('should rethrow original error when no parsable error found', async () => { + const agentError = { unknownProperty: 'some value' }; + const mockDelete = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ delete: mockDelete }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler({ collectionName: 'users', recordIds: [1] }, mockExtra), + ).rejects.toEqual(agentError); + }); + }); + }); +}); diff --git a/packages/mcp-server/test/tools/list.test.ts b/packages/mcp-server/test/tools/list.test.ts index 9a6078a873..ee1e52aab5 100644 --- a/packages/mcp-server/test/tools/list.test.ts +++ b/packages/mcp-server/test/tools/list.test.ts @@ -680,9 +680,9 @@ describe('declareListTool', () => { expect(callOrder.indexOf('list-start')).toBeLessThan(callOrder.indexOf('list-end')); expect(callOrder.indexOf('count-start')).toBeLessThan(callOrder.indexOf('count-end')); // Both starts should happen before both ends - expect( - callOrder.indexOf('list-start') < 2 && callOrder.indexOf('count-start') < 2, - ).toBe(true); + expect(callOrder.indexOf('list-start') < 2 && callOrder.indexOf('count-start') < 2).toBe( + true, + ); }); }); diff --git a/packages/mcp-server/test/tools/update.test.ts b/packages/mcp-server/test/tools/update.test.ts new file mode 100644 index 0000000000..27ecc0dd78 --- /dev/null +++ b/packages/mcp-server/test/tools/update.test.ts @@ -0,0 +1,285 @@ +import type { Logger } from '../../src/server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import declareUpdateTool from '../../src/tools/update'; +import createActivityLog from '../../src/utils/activity-logs-creator'; +import buildClient from '../../src/utils/agent-caller'; + +jest.mock('../../src/utils/agent-caller'); +jest.mock('../../src/utils/activity-logs-creator'); + +const mockLogger: Logger = jest.fn(); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockCreateActivityLog = createActivityLog as jest.MockedFunction; + +describe('declareUpdateTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + mockCreateActivityLog.mockResolvedValue(undefined); + }); + + describe('tool registration', () => { + it('should register a tool named "update"', () => { + declareUpdateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'update', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareUpdateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('Update a record'); + expect(registeredToolConfig.description).toBe( + 'Update an existing record in the specified collection.', + ); + }); + + it('should define correct input schema', () => { + declareUpdateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('recordId'); + expect(registeredToolConfig.inputSchema).toHaveProperty('attributes'); + }); + + it('should use string type for collectionName when no collection names provided', () => { + declareUpdateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toBeUndefined(); + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareUpdateTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + 'users', + 'products', + ]); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toEqual(['users', 'products']); + expect(() => schema.collectionName.parse('users')).not.toThrow(); + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + + it('should accept both string and number for recordId', () => { + declareUpdateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.recordId.parse('123')).not.toThrow(); + expect(() => schema.recordId.parse(123)).not.toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareUpdateTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + }); + + it('should call buildClient with the extra parameter', async () => { + const mockUpdate = jest.fn().mockResolvedValue({ id: 1, name: 'Updated' }); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', recordId: 1, attributes: { name: 'Updated' } }, + mockExtra, + ); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + }); + + it('should call rpcClient.collection with the collection name', async () => { + const mockUpdate = jest.fn().mockResolvedValue({ id: 1, name: 'Updated' }); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'products', recordId: 1, attributes: { name: 'Updated' } }, + mockExtra, + ); + + expect(mockCollection).toHaveBeenCalledWith('products'); + }); + + it('should call update with the recordId and attributes', async () => { + const mockUpdate = jest + .fn() + .mockResolvedValue({ id: 1, name: 'Updated', email: 'new@test.com' }); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const attributes = { name: 'Updated', email: 'new@test.com' }; + await registeredToolHandler({ collectionName: 'users', recordId: 42, attributes }, mockExtra); + + expect(mockUpdate).toHaveBeenCalledWith(42, attributes); + }); + + it('should return the updated record as JSON text content', async () => { + const updatedRecord = { id: 1, name: 'Updated', email: 'new@test.com' }; + const mockUpdate = jest.fn().mockResolvedValue(updatedRecord); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { collectionName: 'users', recordId: 1, attributes: { name: 'Updated' } }, + mockExtra, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ record: updatedRecord }) }], + }); + }); + + describe('activity logging', () => { + beforeEach(() => { + const mockUpdate = jest.fn().mockResolvedValue({ id: 1 }); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should create activity log with "update" action type and recordId', async () => { + await registeredToolHandler( + { collectionName: 'users', recordId: 42, attributes: { name: 'Updated' } }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'update', + { collectionName: 'users', recordId: 42 }, + ); + }); + }); + + describe('attributes parsing', () => { + it('should parse attributes sent as JSON string (LLM workaround)', () => { + const attributes = { name: 'Updated', age: 31 }; + const attributesAsString = JSON.stringify(attributes); + + const inputSchema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + const parsedAttributes = inputSchema.attributes.parse(attributesAsString); + + expect(parsedAttributes).toEqual(attributes); + }); + + it('should handle attributes as object when not sent as string', async () => { + const mockUpdate = jest.fn().mockResolvedValue({ id: 1 }); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const attributes = { name: 'Updated', age: 31 }; + await registeredToolHandler( + { collectionName: 'users', recordId: 1, attributes }, + mockExtra, + ); + + expect(mockUpdate).toHaveBeenCalledWith(1, attributes); + }); + }); + + describe('error handling', () => { + it('should parse error with nested error.text structure in message', async () => { + const errorPayload = { + error: { + status: 404, + text: JSON.stringify({ + errors: [{ name: 'NotFoundError', detail: 'Record not found' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + const mockUpdate = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler( + { collectionName: 'users', recordId: 999, attributes: {} }, + mockExtra, + ), + ).rejects.toThrow('Record not found'); + }); + + it('should rethrow original error when no parsable error found', async () => { + const agentError = { unknownProperty: 'some value' }; + const mockUpdate = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ update: mockUpdate }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler( + { collectionName: 'users', recordId: 1, attributes: {} }, + mockExtra, + ), + ).rejects.toEqual(agentError); + }); + }); + }); +}); From 11f1dab63c7f84421edfb7c52bf8f4ad64ecf0ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Molini=C3=A9?= Date: Tue, 9 Dec 2025 17:32:36 +0100 Subject: [PATCH 02/18] feat(forest mcp): add has many tool --- packages/mcp-server/src/server.ts | 2 + packages/mcp-server/src/tools/has-many.ts | 101 +++ packages/mcp-server/src/tools/list.ts | 4 +- .../mcp-server/src/utils/schema-fetcher.ts | 1 + packages/mcp-server/test/server.test.ts | 284 +++++++ .../mcp-server/test/tools/has-many.test.ts | 693 ++++++++++++++++++ 6 files changed, 1083 insertions(+), 2 deletions(-) create mode 100644 packages/mcp-server/src/tools/has-many.ts create mode 100644 packages/mcp-server/test/tools/has-many.test.ts diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 41e5ad356b..cc0303495c 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -22,6 +22,7 @@ import ForestOAuthProvider from './forest-oauth-provider'; import { isMcpRoute } from './mcp-paths'; import declareCreateTool from './tools/create'; import declareDeleteTool from './tools/delete'; +import declareListHasManyTool from './tools/has-many'; import declareListTool from './tools/list'; import declareUpdateTool from './tools/update'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; @@ -134,6 +135,7 @@ export default class ForestMCPServer { declareCreateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareUpdateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareDeleteTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareListHasManyTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); } private ensureSecretsAreSet(): { envSecret: string; authSecret: string } { diff --git a/packages/mcp-server/src/tools/has-many.ts b/packages/mcp-server/src/tools/has-many.ts new file mode 100644 index 0000000000..3196b8f06a --- /dev/null +++ b/packages/mcp-server/src/tools/has-many.ts @@ -0,0 +1,101 @@ +import type { SelectOptions } from '@forestadmin/agent-client'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import type { ListArgument } from './list.js'; +import { createListArgumentShape } from './list.js'; +import { Logger } from '../server.js'; +import createActivityLog from '../utils/activity-logs-creator.js'; +import buildClient from '../utils/agent-caller.js'; +import parseAgentError from '../utils/error-parser.js'; +import { fetchForestSchema, getFieldsOfCollection } from '../utils/schema-fetcher.js'; +import registerToolWithLogging from '../utils/tool-with-logging.js'; + +function createHasManyArgumentShape(collectionNames: string[]) { + return { + ...createListArgumentShape(collectionNames), + relationName: z.string(), + parentRecordId: z.union([z.string(), z.number()]), + }; +} + +type HasManyArgument = ListArgument & { + relationName: string; + parentRecordId: string | number; +}; + +export default function declareListHasManyTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], +): void { + const listArgumentShape = createHasManyArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'getHasMany', + { + title: 'List records from a hasMany relationship', + description: 'Retrieve a list of records from the specified hasMany relationship.', + inputSchema: listArgumentShape, + }, + async (options: HasManyArgument, extra) => { + const { rpcClient } = await buildClient(extra); + + await createActivityLog(forestServerUrl, extra, 'index', { + collectionName: options.collectionName, + recordId: options.parentRecordId, + label: `list hasMany relation "${options.relationName}"`, + }); + + try { + const result = await rpcClient + .collection(options.collectionName) + .relation(options.relationName, options.parentRecordId) + .list(options as SelectOptions); + + return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + } catch (error) { + // Parse error text if it's a JSON string from the agent + const errorDetail = parseAgentError(error); + + const fields = getFieldsOfCollection( + await fetchForestSchema(forestServerUrl), + options.collectionName, + ); + + if ( + error.message?.toLowerCase()?.includes('not found') && + !fields + .filter(field => field.relationship === 'HasMany') + .some(field => field.field === options.relationName) + ) { + throw new Error( + `The relation name provided is invalid for this collection. Available relation for the collection are ${ + options.collectionName + } are: ${fields + .filter(field => field.relationship === 'HasMany') + .map(field => field.field) + .join(', ')}.`, + ); + } + + if (errorDetail?.includes('Invalid sort')) { + throw new Error( + `The sort field provided is invalid for this collection. Available fields for the collection ${ + options.collectionName + } are: ${fields + .filter(field => field.isSortable) + .map(field => field.field) + .join(', ')}.`, + ); + } + + throw errorDetail ? new Error(errorDetail) : error; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/src/tools/list.ts b/packages/mcp-server/src/tools/list.ts index fd29d6fa95..2065bd62fd 100644 --- a/packages/mcp-server/src/tools/list.ts +++ b/packages/mcp-server/src/tools/list.ts @@ -61,9 +61,9 @@ const listArgumentSchema = z.object({ .describe('When true, also returns totalCount of matching records'), }); -type ListArgument = z.infer; +export type ListArgument = z.infer; -function createListArgumentShape(collectionNames: string[]) { +export function createListArgumentShape(collectionNames: string[]) { return { ...listArgumentSchema.shape, collectionName: diff --git a/packages/mcp-server/src/utils/schema-fetcher.ts b/packages/mcp-server/src/utils/schema-fetcher.ts index f386f13818..884741d3b8 100644 --- a/packages/mcp-server/src/utils/schema-fetcher.ts +++ b/packages/mcp-server/src/utils/schema-fetcher.ts @@ -21,6 +21,7 @@ export interface ForestField { validations?: unknown[]; defaultValue?: unknown; isPrimaryKey: boolean; + relationship?: 'HasMany' | 'BelongsTo' | 'HasOne' | null; } export interface ForestCollection { diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 9bbf32dd13..2f958ab2b1 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -4,6 +4,7 @@ import jsonwebtoken from 'jsonwebtoken'; import request from 'supertest'; import MockServer from './test-utils/mock-server'; +import { clearSchemaCache } from './utils/schema-fetcher.js'; import ForestMCPServer from '../src/server'; import { clearSchemaCache } from '../src/utils/schema-fetcher.js'; @@ -2152,4 +2153,287 @@ describe('ForestMCPServer Instance', () => { expect(responseIndex).toBeGreaterThan(toolCallIndex); }); }); + + /** + * Integration tests for the getHasMany tool + * Tests that the getHasMany tool is properly registered and accessible + */ + describe('GetHasMany tool integration', () => { + let hasManyServer: ForestAdminMCPServer; + let hasManyHttpServer: http.Server; + let hasManyMockServer: MockServer; + + beforeAll(async () => { + process.env.FOREST_ENV_SECRET = 'test-env-secret'; + process.env.FOREST_AUTH_SECRET = 'test-auth-secret'; + process.env.FOREST_SERVER_URL = 'https://test.forestadmin.com'; + process.env.AGENT_HOSTNAME = 'http://localhost:3310'; + process.env.MCP_SERVER_PORT = '39340'; + + // Clear the schema cache from previous tests to ensure fresh fetch + clearSchemaCache(); + + hasManyMockServer = new MockServer(); + hasManyMockServer + .get('/liana/environment', { + data: { id: '12345', attributes: { api_endpoint: 'https://api.example.com' } }, + }) + .get('/liana/forest-schema', { + data: [ + { + id: 'users', + type: 'collections', + attributes: { + name: 'users', + fields: [ + { field: 'id', type: 'Number' }, + { + field: 'orders', + type: 'HasMany', + reference: 'orders', + relationship: 'HasMany', + }, + ], + }, + }, + { + id: 'orders', + type: 'collections', + attributes: { name: 'orders', fields: [{ field: 'id', type: 'Number' }] }, + }, + ], + meta: { liana: 'forest-express-sequelize', liana_version: '9.0.0', liana_features: null }, + }) + .get(/\/oauth\/register\/registered-client/, { + client_id: 'registered-client', + redirect_uris: ['https://example.com/callback'], + client_name: 'Test Client', + scope: 'mcp:read mcp:write', + }) + .get(/\/oauth\/register\//, { error: 'Client not found' }, 404); + + global.fetch = hasManyMockServer.fetch; + + hasManyServer = new ForestAdminMCPServer(); + hasManyServer.run(); + + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + hasManyHttpServer = hasManyServer.httpServer as http.Server; + }); + + afterAll(async () => { + await new Promise(resolve => { + if (hasManyServer?.httpServer) { + (hasManyServer.httpServer as http.Server).close(() => resolve()); + } else { + resolve(); + } + }); + }); + + it('should have getHasMany tool registered in the MCP server', async () => { + const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; + const validToken = jsonwebtoken.sign( + { + id: 123, + email: 'user@example.com', + renderingId: 456, + }, + authSecret, + { expiresIn: '1h' }, + ); + + const response = await request(hasManyHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${validToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + method: 'tools/list', + id: 1, + }); + + expect(response.status).toBe(200); + + let responseData: { + jsonrpc: string; + id: number; + result: { + tools: Array<{ + name: string; + description: string; + inputSchema: { properties: Record }; + }>; + }; + }; + + if (response.body && Object.keys(response.body).length > 0) { + responseData = response.body; + } else { + const textResponse = response.text; + const lines = textResponse.split('\n').filter((line: string) => line.trim()); + const dataLine = lines.find((line: string) => line.startsWith('data: ')); + + if (dataLine) { + responseData = JSON.parse(dataLine.replace('data: ', '')); + } else { + responseData = JSON.parse(lines[lines.length - 1]); + } + } + + expect(responseData.jsonrpc).toBe('2.0'); + expect(responseData.id).toBe(1); + expect(responseData.result).toBeDefined(); + expect(responseData.result.tools).toBeDefined(); + expect(Array.isArray(responseData.result.tools)).toBe(true); + + // Verify the 'getHasMany' tool is registered + const getHasManyTool = responseData.result.tools.find( + (tool: { name: string }) => tool.name === 'getHasMany', + ); + expect(getHasManyTool).toBeDefined(); + expect(getHasManyTool.description).toBe( + 'Retrieve a list of records from the specified hasMany relationship.', + ); + expect(getHasManyTool.inputSchema).toBeDefined(); + expect(getHasManyTool.inputSchema.properties).toHaveProperty('collectionName'); + expect(getHasManyTool.inputSchema.properties).toHaveProperty('relationName'); + expect(getHasManyTool.inputSchema.properties).toHaveProperty('parentRecordId'); + expect(getHasManyTool.inputSchema.properties).toHaveProperty('search'); + expect(getHasManyTool.inputSchema.properties).toHaveProperty('filters'); + expect(getHasManyTool.inputSchema.properties).toHaveProperty('sort'); + + // Verify collectionName has enum with the collection names from the mocked schema + const collectionNameSchema = getHasManyTool.inputSchema.properties.collectionName as { + type: string; + enum?: string[]; + }; + expect(collectionNameSchema.type).toBe('string'); + expect(collectionNameSchema.enum).toBeDefined(); + expect(collectionNameSchema.enum).toEqual(['users', 'orders']); + }); + + it('should create activity log with forestServerToken and relation label when calling getHasMany tool', async () => { + const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; + const forestServerToken = 'original-forest-server-token-for-has-many'; + + // Create MCP JWT with embedded serverToken (as done during OAuth token exchange) + const mcpToken = jsonwebtoken.sign( + { + id: 123, + email: 'user@example.com', + renderingId: 456, + serverToken: forestServerToken, + }, + authSecret, + { expiresIn: '1h' }, + ); + + // Setup mock to capture the activity log API call and mock agent response + hasManyMockServer.clear(); + hasManyMockServer + .post('/api/activity-logs-requests', { success: true }) + .post('/forest/rpc', { result: [{ id: 1, product: 'Widget', total: 100 }] }); + + const response = await request(hasManyHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${mcpToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'getHasMany', + arguments: { + collectionName: 'users', + relationName: 'orders', + parentRecordId: 42, + }, + }, + id: 2, + }); + + // The tool call should succeed (or fail on agent call, but activity log should be created first) + expect(response.status).toBe(200); + + // Verify activity log API was called with the correct forestServerToken + const activityLogCall = hasManyMockServer.fetch.mock.calls.find( + (call: [string, RequestInit]) => + call[0] === 'https://test.forestadmin.com/api/activity-logs-requests', + ) as [string, RequestInit] | undefined; + + expect(activityLogCall).toBeDefined(); + expect(activityLogCall![1].headers).toMatchObject({ + Authorization: `Bearer ${forestServerToken}`, + 'Content-Type': 'application/json', + 'Forest-Application-Source': 'MCP', + }); + + // Verify the body contains the correct data with relation label + const body = JSON.parse(activityLogCall![1].body as string); + expect(body.data.attributes.action).toBe('index'); + expect(body.data.attributes.label).toBe('list hasMany relation "orders"'); + expect(body.data.relationships.collection.data).toEqual({ + id: 'users', + type: 'collections', + }); + }); + + it('should accept string parentRecordId when calling getHasMany tool', async () => { + const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; + const forestServerToken = 'forest-token-for-string-id-test'; + + const mcpToken = jsonwebtoken.sign( + { + id: 123, + email: 'user@example.com', + renderingId: 456, + serverToken: forestServerToken, + }, + authSecret, + { expiresIn: '1h' }, + ); + + hasManyMockServer.clear(); + hasManyMockServer + .post('/api/activity-logs-requests', { success: true }) + .post('/forest/rpc', { result: [{ id: 1, product: 'Widget' }] }); + + const response = await request(hasManyHttpServer) + .post('/mcp') + .set('Authorization', `Bearer ${mcpToken}`) + .set('Content-Type', 'application/json') + .set('Accept', 'application/json, text/event-stream') + .send({ + jsonrpc: '2.0', + method: 'tools/call', + params: { + name: 'getHasMany', + arguments: { + collectionName: 'users', + relationName: 'orders', + parentRecordId: 'uuid-abc-123', + }, + }, + id: 3, + }); + + expect(response.status).toBe(200); + + // Verify activity log was created with string recordId + const activityLogCall = hasManyMockServer.fetch.mock.calls.find( + (call: [string, RequestInit]) => + call[0] === 'https://test.forestadmin.com/api/activity-logs-requests', + ) as [string, RequestInit] | undefined; + + expect(activityLogCall).toBeDefined(); + const body = JSON.parse(activityLogCall![1].body as string); + expect(body.data.attributes.label).toBe('list hasMany relation "orders"'); + }); + }); }); diff --git a/packages/mcp-server/test/tools/has-many.test.ts b/packages/mcp-server/test/tools/has-many.test.ts new file mode 100644 index 0000000000..49e9b40313 --- /dev/null +++ b/packages/mcp-server/test/tools/has-many.test.ts @@ -0,0 +1,693 @@ +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import declareListHasManyTool from '../../src/tools/has-many.js'; +import { Logger } from '../../src/server.js'; +import createActivityLog from '../../src/utils/activity-logs-creator.js'; +import buildClient from '../../src/utils/agent-caller.js'; +import * as schemaFetcher from '../../src/utils/schema-fetcher.js'; + +jest.mock('../../src/utils/agent-caller.js'); +jest.mock('../../src/utils/activity-logs-creator.js'); +jest.mock('../../src/utils/schema-fetcher.js'); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockCreateActivityLog = createActivityLog as jest.MockedFunction; +const mockFetchForestSchema = schemaFetcher.fetchForestSchema as jest.MockedFunction< + typeof schemaFetcher.fetchForestSchema +>; +const mockGetFieldsOfCollection = schemaFetcher.getFieldsOfCollection as jest.MockedFunction< + typeof schemaFetcher.getFieldsOfCollection +>; + +describe('declareListHasManyTool', () => { + let mcpServer: McpServer; + let mockLogger: Logger; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a mock logger + mockLogger = jest.fn(); + + // Create a mock MCP server that captures the registered tool + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + mockCreateActivityLog.mockResolvedValue(undefined); + }); + + describe('tool registration', () => { + it('should register a tool named "getHasMany"', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'getHasMany', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('List records from a hasMany relationship'); + expect(registeredToolConfig.description).toBe( + 'Retrieve a list of records from the specified hasMany relationship.', + ); + }); + + it('should define correct input schema', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('parentRecordId'); + expect(registeredToolConfig.inputSchema).toHaveProperty('search'); + expect(registeredToolConfig.inputSchema).toHaveProperty('filters'); + expect(registeredToolConfig.inputSchema).toHaveProperty('sort'); + }); + + it('should use string type for collectionName when no collection names provided', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + // String type should not have options property (enum has options) + expect(schema.collectionName.options).toBeUndefined(); + // Should accept any string + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use string type for collectionName when empty array provided', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger, []); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + // String type should not have options property + expect(schema.collectionName.options).toBeUndefined(); + // Should accept any string + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + 'users', + 'products', + 'orders', + ]); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + // Enum type should have options property with the collection names + expect(schema.collectionName.options).toEqual(['users', 'products', 'orders']); + // Should accept valid collection names + expect(() => schema.collectionName.parse('users')).not.toThrow(); + expect(() => schema.collectionName.parse('products')).not.toThrow(); + // Should reject invalid collection names + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + + it('should accept string parentRecordId', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.parentRecordId.parse('abc-123')).not.toThrow(); + }); + + it('should accept number parentRecordId', () => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + expect(() => schema.parentRecordId.parse(123)).not.toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + }); + + it('should call buildClient with the extra parameter', async () => { + const mockList = jest.fn().mockResolvedValue([{ id: 1, name: 'Item 1' }]); + const mockRelation = jest.fn().mockReturnValue({ list: mockList }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + }); + + it('should call rpcClient.collection with the collection name', async () => { + const mockList = jest.fn().mockResolvedValue([]); + const mockRelation = jest.fn().mockReturnValue({ list: mockList }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ); + + expect(mockCollection).toHaveBeenCalledWith('users'); + }); + + it('should call relation with the relation name and parent record id', async () => { + const mockList = jest.fn().mockResolvedValue([]); + const mockRelation = jest.fn().mockReturnValue({ list: mockList }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 42 }, + mockExtra, + ); + + expect(mockRelation).toHaveBeenCalledWith('orders', 42); + }); + + it('should call relation with string parentRecordId', async () => { + const mockList = jest.fn().mockResolvedValue([]); + const mockRelation = jest.fn().mockReturnValue({ list: mockList }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 'uuid-123' }, + mockExtra, + ); + + expect(mockRelation).toHaveBeenCalledWith('orders', 'uuid-123'); + }); + + it('should return results as JSON text content', async () => { + const mockData = [ + { id: 1, name: 'Order 1' }, + { id: 2, name: 'Order 2' }, + ]; + const mockList = jest.fn().mockResolvedValue(mockData); + const mockRelation = jest.fn().mockReturnValue({ list: mockList }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify(mockData) }], + }); + }); + + describe('activity logging', () => { + beforeEach(() => { + const mockList = jest.fn().mockResolvedValue([]); + const mockRelation = jest.fn().mockReturnValue({ list: mockList }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should create activity log with "index" action and relation label', async () => { + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 42 }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'index', + { + collectionName: 'users', + recordId: 42, + label: 'list hasMany relation "orders"', + }, + ); + }); + + it('should include parentRecordId in activity log', async () => { + await registeredToolHandler( + { collectionName: 'products', relationName: 'reviews', parentRecordId: 'prod-123' }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'index', + { + collectionName: 'products', + recordId: 'prod-123', + label: 'list hasMany relation "reviews"', + }, + ); + }); + }); + + describe('list parameters', () => { + let mockList: jest.Mock; + let mockRelation: jest.Mock; + let mockCollection: jest.Mock; + + beforeEach(() => { + mockList = jest.fn().mockResolvedValue([]); + mockRelation = jest.fn().mockReturnValue({ list: mockList }); + mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should call list with basic parameters', async () => { + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + }); + }); + + it('should pass search parameter to list', async () => { + await registeredToolHandler( + { + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + search: 'test query', + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + search: 'test query', + }); + }); + + it('should pass filters to list', async () => { + const filters = { field: 'status', operator: 'Equal', value: 'completed' }; + + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1, filters }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + filters, + }); + }); + + it('should pass sort parameter when both field and ascending are provided', async () => { + await registeredToolHandler( + { + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + sort: { field: 'createdAt', ascending: true }, + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + sort: { field: 'createdAt', ascending: true }, + }); + }); + + it('should pass sort parameter when ascending is false', async () => { + await registeredToolHandler( + { + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + sort: { field: 'createdAt', ascending: false }, + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + sort: { field: 'createdAt', ascending: false }, + }); + }); + + it('should pass all parameters together', async () => { + const filters = { + aggregator: 'And', + conditions: [{ field: 'status', operator: 'Equal', value: 'pending' }], + }; + + await registeredToolHandler( + { + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + search: 'test', + filters, + sort: { field: 'total', ascending: false }, + }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalledWith({ + collectionName: 'users', + relationName: 'orders', + parentRecordId: 1, + search: 'test', + filters, + sort: { field: 'total', ascending: false }, + }); + }); + }); + + describe('error handling', () => { + let mockList: jest.Mock; + let mockRelation: jest.Mock; + let mockCollection: jest.Mock; + + beforeEach(() => { + mockList = jest.fn(); + mockRelation = jest.fn().mockReturnValue({ list: mockList }); + mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should parse error with nested error.text structure in message', async () => { + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Invalid filters provided' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + mockList.mockRejectedValue(agentError); + + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toThrow('Invalid filters provided'); + }); + + it('should parse error with direct text property in message', async () => { + const errorPayload = { + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Direct text error' }], + }), + }; + const agentError = new Error(JSON.stringify(errorPayload)); + mockList.mockRejectedValue(agentError); + + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toThrow('Direct text error'); + }); + + it('should provide helpful error message for Invalid sort errors', async () => { + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Invalid sort field: invalidField' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + mockList.mockRejectedValue(agentError); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'id', + type: 'Number', + isSortable: true, + enum: null, + reference: null, + isReadOnly: false, + isRequired: true, + isPrimaryKey: true, + }, + { + field: 'total', + type: 'Number', + isSortable: true, + enum: null, + reference: null, + isReadOnly: false, + isRequired: false, + isPrimaryKey: false, + }, + { + field: 'computed', + type: 'String', + isSortable: false, + enum: null, + reference: null, + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + }, + ]; + const mockSchema: schemaFetcher.ForestSchema = { + collections: [{ name: 'users', fields: mockFields }], + }; + mockFetchForestSchema.mockResolvedValue(mockSchema); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toThrow( + 'The sort field provided is invalid for this collection. Available fields for the collection users are: id, total.', + ); + + expect(mockFetchForestSchema).toHaveBeenCalledWith('https://api.forestadmin.com'); + expect(mockGetFieldsOfCollection).toHaveBeenCalledWith(mockSchema, 'users'); + }); + + it('should provide helpful error message when relation is not found', async () => { + const agentError = new Error('Relation not found'); + mockList.mockRejectedValue(agentError); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'id', + type: 'Number', + isSortable: true, + enum: null, + reference: null, + isReadOnly: false, + isRequired: true, + isPrimaryKey: true, + relationship: null, + }, + { + field: 'orders', + type: 'HasMany', + isSortable: false, + enum: null, + reference: 'orders', + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + relationship: 'HasMany', + }, + { + field: 'reviews', + type: 'HasMany', + isSortable: false, + enum: null, + reference: 'reviews', + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + relationship: 'HasMany', + }, + ]; + const mockSchema: schemaFetcher.ForestSchema = { + collections: [{ name: 'users', fields: mockFields }], + }; + mockFetchForestSchema.mockResolvedValue(mockSchema); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'invalidRelation', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toThrow( + 'The relation name provided is invalid for this collection. Available relation for the collection are users are: orders, reviews.', + ); + }); + + it('should not show relation error when relation exists but error is different', async () => { + const agentError = new Error('Some other error'); + mockList.mockRejectedValue(agentError); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'orders', + type: 'HasMany', + isSortable: false, + enum: null, + reference: 'orders', + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + relationship: 'HasMany', + }, + ]; + const mockSchema: schemaFetcher.ForestSchema = { + collections: [{ name: 'users', fields: mockFields }], + }; + mockFetchForestSchema.mockResolvedValue(mockSchema); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + // Should throw the original error message since 'orders' relation exists + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toThrow('Some other error'); + }); + + it('should fall back to error.message when message is not valid JSON', async () => { + const agentError = new Error('Plain error message'); + mockList.mockRejectedValue(agentError); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'orders', + type: 'HasMany', + isSortable: false, + enum: null, + reference: 'orders', + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + relationship: 'HasMany', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ + collections: [{ name: 'users', fields: mockFields }], + }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toThrow('Plain error message'); + }); + + it('should rethrow original error when no parsable error found', async () => { + const agentError = { unknownProperty: 'some value' }; + mockList.mockRejectedValue(agentError); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'orders', + type: 'HasMany', + isSortable: false, + enum: null, + reference: 'orders', + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + relationship: 'HasMany', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ + collections: [{ name: 'users', fields: mockFields }], + }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toEqual(agentError); + }); + }); + }); +}); From 48e6759a6a0a03e4cc83fdb32ecf2f1122ec1a71 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 18 Dec 2025 17:28:32 +0100 Subject: [PATCH 03/18] refactor(mcp-server): rename listRelated tool and add enableCount support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename tool from getHasMany to listRelated for clarity - Rename has-many.ts to list-related.ts - Add enableCount option with totalCount support (parallel execution) - Change response format to always return { records: [...] } - Add count() method to Relation class in agent-client - Fix typo in error message for invalid relations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/agent-client/src/domains/relation.ts | 12 +++ .../test/domains/relation.test.ts | 51 +++++++++++++ packages/mcp-server/src/server.ts | 4 +- .../tools/{has-many.ts => list-related.ts} | 30 +++++--- packages/mcp-server/test/server.test.ts | 50 ++++++------ ...{has-many.test.ts => list-related.test.ts} | 76 ++++++++++++++----- 6 files changed, 168 insertions(+), 55 deletions(-) rename packages/mcp-server/src/tools/{has-many.ts => list-related.ts} (83%) rename packages/mcp-server/test/tools/{has-many.test.ts => list-related.test.ts} (88%) diff --git a/packages/agent-client/src/domains/relation.ts b/packages/agent-client/src/domains/relation.ts index 9ebcb19485..5f6d1071f3 100644 --- a/packages/agent-client/src/domains/relation.ts +++ b/packages/agent-client/src/domains/relation.ts @@ -28,4 +28,16 @@ export default class Relation { query: QuerySerializer.serialize(options, this.collectionName), }); } + + async count(options?: SelectOptions): Promise { + return Number( + ( + await this.httpRequester.query<{ count: number }>({ + method: 'get', + path: `/forest/${this.collectionName}/${this.parentId}/relationships/${this.name}/count`, + query: QuerySerializer.serialize(options, this.collectionName), + }) + ).count, + ); + } } diff --git a/packages/agent-client/test/domains/relation.test.ts b/packages/agent-client/test/domains/relation.test.ts index d35810535b..36446a4b1e 100644 --- a/packages/agent-client/test/domains/relation.test.ts +++ b/packages/agent-client/test/domains/relation.test.ts @@ -70,4 +70,55 @@ describe('Relation', () => { expect(result).toEqual(expectedData); }); }); + + describe('count', () => { + it('should call httpRequester.query with correct count path', async () => { + const relation = new Relation('posts', 'users', 1, httpRequester); + httpRequester.query.mockResolvedValue({ count: 42 }); + + const result = await relation.count(); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'get', + path: '/forest/users/1/relationships/posts/count', + query: expect.any(Object), + }); + expect(result).toBe(42); + }); + + it('should handle string parent id', async () => { + const relation = new Relation('posts', 'users', 'abc-123', httpRequester); + httpRequester.query.mockResolvedValue({ count: 10 }); + + await relation.count(); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'get', + path: '/forest/users/abc-123/relationships/posts/count', + query: expect.any(Object), + }); + }); + + it('should pass options to query serializer', async () => { + const relation = new Relation('posts', 'users', 1, httpRequester); + httpRequester.query.mockResolvedValue({ count: 5 }); + + await relation.count({ search: 'title' }); + + expect(httpRequester.query).toHaveBeenCalledWith({ + method: 'get', + path: '/forest/users/1/relationships/posts/count', + query: expect.objectContaining({ search: 'title' }), + }); + }); + + it('should return number from count response', async () => { + const relation = new Relation('posts', 'users', 1, httpRequester); + httpRequester.query.mockResolvedValue({ count: '100' }); + + const result = await relation.count(); + + expect(result).toBe(100); + }); + }); }); diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index cc0303495c..25cdb89930 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -22,8 +22,8 @@ import ForestOAuthProvider from './forest-oauth-provider'; import { isMcpRoute } from './mcp-paths'; import declareCreateTool from './tools/create'; import declareDeleteTool from './tools/delete'; -import declareListHasManyTool from './tools/has-many'; import declareListTool from './tools/list'; +import declareListRelatedTool from './tools/list-related'; import declareUpdateTool from './tools/update'; import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; @@ -132,10 +132,10 @@ export default class ForestMCPServer { } declareListTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareListRelatedTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareCreateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareUpdateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareDeleteTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); - declareListHasManyTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); } private ensureSecretsAreSet(): { envSecret: string; authSecret: string } { diff --git a/packages/mcp-server/src/tools/has-many.ts b/packages/mcp-server/src/tools/list-related.ts similarity index 83% rename from packages/mcp-server/src/tools/has-many.ts rename to packages/mcp-server/src/tools/list-related.ts index 3196b8f06a..8e220b86d3 100644 --- a/packages/mcp-server/src/tools/has-many.ts +++ b/packages/mcp-server/src/tools/list-related.ts @@ -1,9 +1,9 @@ +import type { ListArgument } from './list.js'; import type { SelectOptions } from '@forestadmin/agent-client'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import type { ListArgument } from './list.js'; import { createListArgumentShape } from './list.js'; import { Logger } from '../server.js'; import createActivityLog from '../utils/activity-logs-creator.js'; @@ -25,7 +25,7 @@ type HasManyArgument = ListArgument & { parentRecordId: string | number; }; -export default function declareListHasManyTool( +export default function declareListRelatedTool( mcpServer: McpServer, forestServerUrl: string, logger: Logger, @@ -35,10 +35,10 @@ export default function declareListHasManyTool( registerToolWithLogging( mcpServer, - 'getHasMany', + 'listRelated', { - title: 'List records from a hasMany relationship', - description: 'Retrieve a list of records from the specified hasMany relationship.', + title: 'List records from a relation', + description: 'Retrieve a list of records from the specified relation (hasMany).', inputSchema: listArgumentShape, }, async (options: HasManyArgument, extra) => { @@ -51,12 +51,22 @@ export default function declareListHasManyTool( }); try { - const result = await rpcClient + const relation = rpcClient .collection(options.collectionName) - .relation(options.relationName, options.parentRecordId) - .list(options as SelectOptions); + .relation(options.relationName, options.parentRecordId); + + if (options.enableCount) { + const [records, totalCount] = await Promise.all([ + relation.list(options as SelectOptions), + relation.count(options as SelectOptions), + ]); + + return { content: [{ type: 'text', text: JSON.stringify({ records, totalCount }) }] }; + } + + const records = await relation.list(options as SelectOptions); - return { content: [{ type: 'text', text: JSON.stringify(result) }] }; + return { content: [{ type: 'text', text: JSON.stringify({ records }) }] }; } catch (error) { // Parse error text if it's a JSON string from the agent const errorDetail = parseAgentError(error); @@ -73,7 +83,7 @@ export default function declareListHasManyTool( .some(field => field.field === options.relationName) ) { throw new Error( - `The relation name provided is invalid for this collection. Available relation for the collection are ${ + `The relation name provided is invalid for this collection. Available relations for collection ${ options.collectionName } are: ${fields .filter(field => field.relationship === 'HasMany') diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 2f958ab2b1..7c18ac4e2d 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -4,7 +4,7 @@ import jsonwebtoken from 'jsonwebtoken'; import request from 'supertest'; import MockServer from './test-utils/mock-server'; -import { clearSchemaCache } from './utils/schema-fetcher.js'; +import { clearSchemaCache } from '../src/utils/schema-fetcher.js'; import ForestMCPServer from '../src/server'; import { clearSchemaCache } from '../src/utils/schema-fetcher.js'; @@ -2155,11 +2155,11 @@ describe('ForestMCPServer Instance', () => { }); /** - * Integration tests for the getHasMany tool - * Tests that the getHasMany tool is properly registered and accessible + * Integration tests for the listRelated tool + * Tests that the listRelated tool is properly registered and accessible */ - describe('GetHasMany tool integration', () => { - let hasManyServer: ForestAdminMCPServer; + describe('listRelated tool integration', () => { + let hasManyServer: ForestMCPServer; let hasManyHttpServer: http.Server; let hasManyMockServer: MockServer; @@ -2214,7 +2214,7 @@ describe('ForestMCPServer Instance', () => { global.fetch = hasManyMockServer.fetch; - hasManyServer = new ForestAdminMCPServer(); + hasManyServer = new ForestMCPServer(); hasManyServer.run(); await new Promise(resolve => { @@ -2234,7 +2234,7 @@ describe('ForestMCPServer Instance', () => { }); }); - it('should have getHasMany tool registered in the MCP server', async () => { + it('should have listRelated tool registered in the MCP server', async () => { const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; const validToken = jsonwebtoken.sign( { @@ -2291,24 +2291,24 @@ describe('ForestMCPServer Instance', () => { expect(responseData.result.tools).toBeDefined(); expect(Array.isArray(responseData.result.tools)).toBe(true); - // Verify the 'getHasMany' tool is registered - const getHasManyTool = responseData.result.tools.find( - (tool: { name: string }) => tool.name === 'getHasMany', + // Verify the 'listRelated' tool is registered + const listRelatedTool = responseData.result.tools.find( + (tool: { name: string }) => tool.name === 'listRelated', ); - expect(getHasManyTool).toBeDefined(); - expect(getHasManyTool.description).toBe( - 'Retrieve a list of records from the specified hasMany relationship.', + expect(listRelatedTool).toBeDefined(); + expect(listRelatedTool.description).toBe( + 'Retrieve a list of records from the specified relation (hasMany).', ); - expect(getHasManyTool.inputSchema).toBeDefined(); - expect(getHasManyTool.inputSchema.properties).toHaveProperty('collectionName'); - expect(getHasManyTool.inputSchema.properties).toHaveProperty('relationName'); - expect(getHasManyTool.inputSchema.properties).toHaveProperty('parentRecordId'); - expect(getHasManyTool.inputSchema.properties).toHaveProperty('search'); - expect(getHasManyTool.inputSchema.properties).toHaveProperty('filters'); - expect(getHasManyTool.inputSchema.properties).toHaveProperty('sort'); + expect(listRelatedTool.inputSchema).toBeDefined(); + expect(listRelatedTool.inputSchema.properties).toHaveProperty('collectionName'); + expect(listRelatedTool.inputSchema.properties).toHaveProperty('relationName'); + expect(listRelatedTool.inputSchema.properties).toHaveProperty('parentRecordId'); + expect(listRelatedTool.inputSchema.properties).toHaveProperty('search'); + expect(listRelatedTool.inputSchema.properties).toHaveProperty('filters'); + expect(listRelatedTool.inputSchema.properties).toHaveProperty('sort'); // Verify collectionName has enum with the collection names from the mocked schema - const collectionNameSchema = getHasManyTool.inputSchema.properties.collectionName as { + const collectionNameSchema = listRelatedTool.inputSchema.properties.collectionName as { type: string; enum?: string[]; }; @@ -2317,7 +2317,7 @@ describe('ForestMCPServer Instance', () => { expect(collectionNameSchema.enum).toEqual(['users', 'orders']); }); - it('should create activity log with forestServerToken and relation label when calling getHasMany tool', async () => { + it('should create activity log with forestServerToken and relation label when calling listRelated tool', async () => { const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; const forestServerToken = 'original-forest-server-token-for-has-many'; @@ -2348,7 +2348,7 @@ describe('ForestMCPServer Instance', () => { jsonrpc: '2.0', method: 'tools/call', params: { - name: 'getHasMany', + name: 'listRelated', arguments: { collectionName: 'users', relationName: 'orders', @@ -2384,7 +2384,7 @@ describe('ForestMCPServer Instance', () => { }); }); - it('should accept string parentRecordId when calling getHasMany tool', async () => { + it('should accept string parentRecordId when calling listRelated tool', async () => { const authSecret = process.env.FOREST_AUTH_SECRET || 'test-auth-secret'; const forestServerToken = 'forest-token-for-string-id-test'; @@ -2413,7 +2413,7 @@ describe('ForestMCPServer Instance', () => { jsonrpc: '2.0', method: 'tools/call', params: { - name: 'getHasMany', + name: 'listRelated', arguments: { collectionName: 'users', relationName: 'orders', diff --git a/packages/mcp-server/test/tools/has-many.test.ts b/packages/mcp-server/test/tools/list-related.test.ts similarity index 88% rename from packages/mcp-server/test/tools/has-many.test.ts rename to packages/mcp-server/test/tools/list-related.test.ts index 49e9b40313..ac959b8767 100644 --- a/packages/mcp-server/test/tools/has-many.test.ts +++ b/packages/mcp-server/test/tools/list-related.test.ts @@ -3,8 +3,8 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import declareListHasManyTool from '../../src/tools/has-many.js'; import { Logger } from '../../src/server.js'; +import declareListRelatedTool from '../../src/tools/list-related.js'; import createActivityLog from '../../src/utils/activity-logs-creator.js'; import buildClient from '../../src/utils/agent-caller.js'; import * as schemaFetcher from '../../src/utils/schema-fetcher.js'; @@ -22,7 +22,7 @@ const mockGetFieldsOfCollection = schemaFetcher.getFieldsOfCollection as jest.Mo typeof schemaFetcher.getFieldsOfCollection >; -describe('declareListHasManyTool', () => { +describe('declareListRelatedTool', () => { let mcpServer: McpServer; let mockLogger: Logger; let registeredToolHandler: (options: unknown, extra: unknown) => Promise; @@ -46,27 +46,27 @@ describe('declareListHasManyTool', () => { }); describe('tool registration', () => { - it('should register a tool named "getHasMany"', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + it('should register a tool named "listRelated"', () => { + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); expect(mcpServer.registerTool).toHaveBeenCalledWith( - 'getHasMany', + 'listRelated', expect.any(Object), expect.any(Function), ); }); it('should register tool with correct title and description', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); - expect(registeredToolConfig.title).toBe('List records from a hasMany relationship'); + expect(registeredToolConfig.title).toBe('List records from a relation'); expect(registeredToolConfig.description).toBe( - 'Retrieve a list of records from the specified hasMany relationship.', + 'Retrieve a list of records from the specified relation (hasMany).', ); }); it('should define correct input schema', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('relationName'); @@ -77,7 +77,7 @@ describe('declareListHasManyTool', () => { }); it('should use string type for collectionName when no collection names provided', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); const schema = registeredToolConfig.inputSchema as Record< string, @@ -90,7 +90,7 @@ describe('declareListHasManyTool', () => { }); it('should use string type for collectionName when empty array provided', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger, []); + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger, []); const schema = registeredToolConfig.inputSchema as Record< string, @@ -103,7 +103,7 @@ describe('declareListHasManyTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ 'users', 'products', 'orders', @@ -123,7 +123,7 @@ describe('declareListHasManyTool', () => { }); it('should accept string parentRecordId', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); const schema = registeredToolConfig.inputSchema as Record< string, @@ -133,7 +133,7 @@ describe('declareListHasManyTool', () => { }); it('should accept number parentRecordId', () => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); const schema = registeredToolConfig.inputSchema as Record< string, @@ -155,7 +155,7 @@ describe('declareListHasManyTool', () => { } as unknown as RequestHandlerExtra; beforeEach(() => { - declareListHasManyTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); }); it('should call buildClient with the extra parameter', async () => { @@ -226,7 +226,7 @@ describe('declareListHasManyTool', () => { expect(mockRelation).toHaveBeenCalledWith('orders', 'uuid-123'); }); - it('should return results as JSON text content', async () => { + it('should return results as JSON text content with records wrapper', async () => { const mockData = [ { id: 1, name: 'Order 1' }, { id: 2, name: 'Order 2' }, @@ -245,10 +245,50 @@ describe('declareListHasManyTool', () => { ); expect(result).toEqual({ - content: [{ type: 'text', text: JSON.stringify(mockData) }], + content: [{ type: 'text', text: JSON.stringify({ records: mockData }) }], }); }); + it('should return records with totalCount when enableCount is true', async () => { + const mockData = [{ id: 1, name: 'Order 1' }]; + const mockList = jest.fn().mockResolvedValue(mockData); + const mockCount = jest.fn().mockResolvedValue(42); + const mockRelation = jest.fn().mockReturnValue({ list: mockList, count: mockCount }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1, enableCount: true }, + mockExtra, + ); + + expect(result).toEqual({ + content: [{ type: 'text', text: JSON.stringify({ records: mockData, totalCount: 42 }) }], + }); + }); + + it('should call list and count in parallel when enableCount is true', async () => { + const mockList = jest.fn().mockResolvedValue([]); + const mockCount = jest.fn().mockResolvedValue(0); + const mockRelation = jest.fn().mockReturnValue({ list: mockList, count: mockCount }); + const mockCollection = jest.fn().mockReturnValue({ relation: mockRelation }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', relationName: 'orders', parentRecordId: 1, enableCount: true }, + mockExtra, + ); + + expect(mockList).toHaveBeenCalled(); + expect(mockCount).toHaveBeenCalled(); + }); + describe('activity logging', () => { beforeEach(() => { const mockList = jest.fn().mockResolvedValue([]); @@ -593,7 +633,7 @@ describe('declareListHasManyTool', () => { mockExtra, ), ).rejects.toThrow( - 'The relation name provided is invalid for this collection. Available relation for the collection are users are: orders, reviews.', + 'The relation name provided is invalid for this collection. Available relations for collection users are: orders, reviews.', ); }); From 91fa3a12cc5676cf01e16f0bcc3ffab44ac09f24 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 18 Dec 2025 17:52:27 +0100 Subject: [PATCH 04/18] feat(mcp-server): add BelongsToMany (ManyToMany) support to listRelated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update ForestField relationship type to include 'BelongsToMany' - Include BelongsToMany relations in error messages alongside HasMany - Add test for BelongsToMany relations in available relations error - Fix existing error tests with missing schema mocks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/mcp-server/src/tools/list-related.ts | 13 +++-- .../mcp-server/src/utils/schema-fetcher.ts | 2 +- .../test/tools/list-related.test.ts | 50 +++++++++++++++++++ 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/mcp-server/src/tools/list-related.ts b/packages/mcp-server/src/tools/list-related.ts index 8e220b86d3..39373eb406 100644 --- a/packages/mcp-server/src/tools/list-related.ts +++ b/packages/mcp-server/src/tools/list-related.ts @@ -76,19 +76,18 @@ export default function declareListRelatedTool( options.collectionName, ); + const toManyRelations = fields.filter( + field => field.relationship === 'HasMany' || field.relationship === 'BelongsToMany', + ); + if ( error.message?.toLowerCase()?.includes('not found') && - !fields - .filter(field => field.relationship === 'HasMany') - .some(field => field.field === options.relationName) + !toManyRelations.some(field => field.field === options.relationName) ) { throw new Error( `The relation name provided is invalid for this collection. Available relations for collection ${ options.collectionName - } are: ${fields - .filter(field => field.relationship === 'HasMany') - .map(field => field.field) - .join(', ')}.`, + } are: ${toManyRelations.map(field => field.field).join(', ')}.`, ); } diff --git a/packages/mcp-server/src/utils/schema-fetcher.ts b/packages/mcp-server/src/utils/schema-fetcher.ts index 884741d3b8..2e21537ea2 100644 --- a/packages/mcp-server/src/utils/schema-fetcher.ts +++ b/packages/mcp-server/src/utils/schema-fetcher.ts @@ -21,7 +21,7 @@ export interface ForestField { validations?: unknown[]; defaultValue?: unknown; isPrimaryKey: boolean; - relationship?: 'HasMany' | 'BelongsTo' | 'HasOne' | null; + relationship?: 'HasMany' | 'BelongsToMany' | 'BelongsTo' | 'HasOne' | null; } export interface ForestCollection { diff --git a/packages/mcp-server/test/tools/list-related.test.ts b/packages/mcp-server/test/tools/list-related.test.ts index ac959b8767..9f9571452f 100644 --- a/packages/mcp-server/test/tools/list-related.test.ts +++ b/packages/mcp-server/test/tools/list-related.test.ts @@ -494,6 +494,9 @@ describe('declareListRelatedTool', () => { const agentError = new Error(JSON.stringify(errorPayload)); mockList.mockRejectedValue(agentError); + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + await expect( registeredToolHandler( { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, @@ -511,6 +514,9 @@ describe('declareListRelatedTool', () => { const agentError = new Error(JSON.stringify(errorPayload)); mockList.mockRejectedValue(agentError); + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + await expect( registeredToolHandler( { collectionName: 'users', relationName: 'orders', parentRecordId: 1 }, @@ -637,6 +643,50 @@ describe('declareListRelatedTool', () => { ); }); + it('should include BelongsToMany relations in available relations error message', async () => { + const agentError = new Error('Relation not found'); + mockList.mockRejectedValue(agentError); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'orders', + type: 'HasMany', + isSortable: false, + enum: null, + reference: 'orders', + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + relationship: 'HasMany', + }, + { + field: 'tags', + type: 'BelongsToMany', + isSortable: false, + enum: null, + reference: 'tags', + isReadOnly: true, + isRequired: false, + isPrimaryKey: false, + relationship: 'BelongsToMany', + }, + ]; + const mockSchema: schemaFetcher.ForestSchema = { + collections: [{ name: 'users', fields: mockFields }], + }; + mockFetchForestSchema.mockResolvedValue(mockSchema); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + await expect( + registeredToolHandler( + { collectionName: 'users', relationName: 'invalidRelation', parentRecordId: 1 }, + mockExtra, + ), + ).rejects.toThrow( + 'The relation name provided is invalid for this collection. Available relations for collection users are: orders, tags.', + ); + }); + it('should not show relation error when relation exists but error is different', async () => { const agentError = new Error('Some other error'); mockList.mockRejectedValue(agentError); From 9d333c736eb0876d56ef57438a3ef525142a927c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Thu, 18 Dec 2025 21:58:48 +0100 Subject: [PATCH 05/18] fix(mcp-server): improve listRelated tool description for AI readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change description from "(hasMany)" to "one-to-many or many-to-many" - Simplify activity log label from "list hasMany relation" to "list relation" - Update related tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/mcp-server/src/tools/list-related.ts | 4 ++-- packages/mcp-server/test/server.test.ts | 4 ++-- packages/mcp-server/test/tools/list-related.test.ts | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/mcp-server/src/tools/list-related.ts b/packages/mcp-server/src/tools/list-related.ts index 39373eb406..6b669a72e8 100644 --- a/packages/mcp-server/src/tools/list-related.ts +++ b/packages/mcp-server/src/tools/list-related.ts @@ -38,7 +38,7 @@ export default function declareListRelatedTool( 'listRelated', { title: 'List records from a relation', - description: 'Retrieve a list of records from the specified relation (hasMany).', + description: 'Retrieve a list of records from a one-to-many or many-to-many relation.', inputSchema: listArgumentShape, }, async (options: HasManyArgument, extra) => { @@ -47,7 +47,7 @@ export default function declareListRelatedTool( await createActivityLog(forestServerUrl, extra, 'index', { collectionName: options.collectionName, recordId: options.parentRecordId, - label: `list hasMany relation "${options.relationName}"`, + label: `list relation "${options.relationName}"`, }); try { diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 7c18ac4e2d..3d85328b0a 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2377,7 +2377,7 @@ describe('ForestMCPServer Instance', () => { // Verify the body contains the correct data with relation label const body = JSON.parse(activityLogCall![1].body as string); expect(body.data.attributes.action).toBe('index'); - expect(body.data.attributes.label).toBe('list hasMany relation "orders"'); + expect(body.data.attributes.label).toBe('list relation "orders"'); expect(body.data.relationships.collection.data).toEqual({ id: 'users', type: 'collections', @@ -2433,7 +2433,7 @@ describe('ForestMCPServer Instance', () => { expect(activityLogCall).toBeDefined(); const body = JSON.parse(activityLogCall![1].body as string); - expect(body.data.attributes.label).toBe('list hasMany relation "orders"'); + expect(body.data.attributes.label).toBe('list relation "orders"'); }); }); }); diff --git a/packages/mcp-server/test/tools/list-related.test.ts b/packages/mcp-server/test/tools/list-related.test.ts index 9f9571452f..55ce653506 100644 --- a/packages/mcp-server/test/tools/list-related.test.ts +++ b/packages/mcp-server/test/tools/list-related.test.ts @@ -61,7 +61,7 @@ describe('declareListRelatedTool', () => { expect(registeredToolConfig.title).toBe('List records from a relation'); expect(registeredToolConfig.description).toBe( - 'Retrieve a list of records from the specified relation (hasMany).', + 'Retrieve a list of records from a one-to-many or many-to-many relation.', ); }); @@ -313,7 +313,7 @@ describe('declareListRelatedTool', () => { { collectionName: 'users', recordId: 42, - label: 'list hasMany relation "orders"', + label: 'list relation "orders"', }, ); }); @@ -331,7 +331,7 @@ describe('declareListRelatedTool', () => { { collectionName: 'products', recordId: 'prod-123', - label: 'list hasMany relation "reviews"', + label: 'list relation "reviews"', }, ); }); From a350d3e2e18aa53066d80e35436474ac23b4506c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 19 Dec 2025 08:12:50 +0100 Subject: [PATCH 06/18] feat(mcp-server): add describeCollection tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add describeCollection tool to expose collection schema to AI - Add capabilities() method to Collection class in agent-client - Returns fields with types, operators, and relations - Falls back to forest schema if capabilities route unavailable - Fix integration test description mismatch 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../agent-client/src/domains/collection.ts | 16 +++ packages/mcp-server/src/server.ts | 7 + .../src/tools/describe-collection.ts | 131 ++++++++++++++++++ packages/mcp-server/test/server.test.ts | 2 +- 4 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/mcp-server/src/tools/describe-collection.ts diff --git a/packages/agent-client/src/domains/collection.ts b/packages/agent-client/src/domains/collection.ts index b27b56fade..ba104795fa 100644 --- a/packages/agent-client/src/domains/collection.ts +++ b/packages/agent-client/src/domains/collection.ts @@ -102,6 +102,22 @@ export default class Collection extends CollectionChart { ); } + async capabilities(): Promise<{ + fields: { name: string; type: string; operators: string[] }[]; + }> { + const result = await this.httpRequester.query<{ + collections: { name: string; fields: { name: string; type: string; operators: string[] }[] }[]; + }>({ + method: 'post', + path: '/forest/_internal/capabilities', + body: { collectionNames: [this.name] }, + }); + + const collection = result.collections.find(c => c.name === this.name); + + return { fields: collection?.fields || [] }; + } + async delete(ids: string[] | number[]): Promise { const serializedIds = ids.map((id: string | number) => id.toString()); const requestBody = { diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 25cdb89930..320cf6c24d 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -22,6 +22,7 @@ import ForestOAuthProvider from './forest-oauth-provider'; import { isMcpRoute } from './mcp-paths'; import declareCreateTool from './tools/create'; import declareDeleteTool from './tools/delete'; +import declareDescribeCollectionTool from './tools/describe-collection'; import declareListTool from './tools/list'; import declareListRelatedTool from './tools/list-related'; import declareUpdateTool from './tools/update'; @@ -131,6 +132,12 @@ export default class ForestMCPServer { ); } + declareDescribeCollectionTool( + this.mcpServer, + this.forestServerUrl, + this.logger, + collectionNames, + ); declareListTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareListRelatedTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareCreateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); diff --git a/packages/mcp-server/src/tools/describe-collection.ts b/packages/mcp-server/src/tools/describe-collection.ts new file mode 100644 index 0000000000..cdfa46dd63 --- /dev/null +++ b/packages/mcp-server/src/tools/describe-collection.ts @@ -0,0 +1,131 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import { Logger } from '../server.js'; +import buildClient from '../utils/agent-caller.js'; +import { fetchForestSchema, getFieldsOfCollection } from '../utils/schema-fetcher.js'; +import registerToolWithLogging from '../utils/tool-with-logging.js'; + +interface DescribeCollectionArgument { + collectionName: string; +} + +function createDescribeCollectionArgumentShape(collectionNames: string[]) { + return { + collectionName: + collectionNames.length > 0 ? z.enum(collectionNames as [string, ...string[]]) : z.string(), + }; +} + +export default function declareDescribeCollectionTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], +): void { + const argumentShape = createDescribeCollectionArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'describeCollection', + { + title: 'Describe a collection', + description: + 'Get detailed information about a collection including its fields, data types, available filter operators, and relations to other collections.', + inputSchema: argumentShape, + }, + async (options: DescribeCollectionArgument, extra) => { + const { rpcClient } = await buildClient(extra); + + // Get schema from forest server (relations, isFilterable, isSortable, etc.) + const schema = await fetchForestSchema(forestServerUrl); + const schemaFields = getFieldsOfCollection(schema, options.collectionName); + + // Try to get capabilities from agent (fields with types and operators) + // This may fail on older agent versions + let collectionCapabilities: + | { fields: { name: string; type: string; operators: string[] }[] } + | undefined; + + try { + collectionCapabilities = await rpcClient.collection(options.collectionName).capabilities(); + } catch (error) { + // Capabilities route not available, we'll use schema info only + logger( + 'Warn', + `Failed to fetch capabilities for collection ${options.collectionName}: ${error}`, + ); + } + + // Build fields array - use capabilities if available, otherwise fall back to schema + const fields = collectionCapabilities?.fields + ? collectionCapabilities.fields.map(capField => { + const schemaField = schemaFields.find(f => f.field === capField.name); + + return { + name: capField.name, + type: capField.type, + operators: capField.operators, + isPrimaryKey: schemaField?.isPrimaryKey || false, + isReadOnly: schemaField?.isReadOnly || false, + isRequired: schemaField?.isRequired || false, + isSortable: schemaField?.isSortable || false, + }; + }) + : schemaFields + .filter(f => !f.relationship) // Only non-relation fields + .map(schemaField => ({ + name: schemaField.field, + type: schemaField.type, + operators: [], // Not available without capabilities route + isPrimaryKey: schemaField.isPrimaryKey, + isReadOnly: schemaField.isReadOnly, + isRequired: schemaField.isRequired, + isSortable: schemaField.isSortable || false, + })); + + // Extract relations from schema + const relations = schemaFields + .filter(f => f.relationship) + .map(f => { + // reference format is "collectionName.fieldName" + const targetCollection = f.reference?.split('.')[0] || null; + + let relationType: string; + + switch (f.relationship) { + case 'HasMany': + relationType = 'one-to-many'; + break; + case 'BelongsToMany': + relationType = 'many-to-many'; + break; + case 'BelongsTo': + relationType = 'many-to-one'; + break; + case 'HasOne': + relationType = 'one-to-one'; + break; + default: + relationType = f.relationship || 'unknown'; + } + + return { + name: f.field, + type: relationType, + targetCollection, + }; + }); + + const result = { + collection: options.collectionName, + fields, + relations, + }; + + return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; + }, + logger, + ); +} diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 3d85328b0a..43b3515551 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -2297,7 +2297,7 @@ describe('ForestMCPServer Instance', () => { ); expect(listRelatedTool).toBeDefined(); expect(listRelatedTool.description).toBe( - 'Retrieve a list of records from the specified relation (hasMany).', + 'Retrieve a list of records from a one-to-many or many-to-many relation.', ); expect(listRelatedTool.inputSchema).toBeDefined(); expect(listRelatedTool.inputSchema.properties).toHaveProperty('collectionName'); From 74c48055820455654561de0341b754bce5061525 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 19 Dec 2025 08:19:35 +0100 Subject: [PATCH 07/18] fix(mcp-server): improve describeCollection description for AI guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add instruction to use this tool first before querying data 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/mcp-server/src/tools/describe-collection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp-server/src/tools/describe-collection.ts b/packages/mcp-server/src/tools/describe-collection.ts index cdfa46dd63..317e6e0d22 100644 --- a/packages/mcp-server/src/tools/describe-collection.ts +++ b/packages/mcp-server/src/tools/describe-collection.ts @@ -32,7 +32,7 @@ export default function declareDescribeCollectionTool( { title: 'Describe a collection', description: - 'Get detailed information about a collection including its fields, data types, available filter operators, and relations to other collections.', + 'Get detailed information about a collection including its fields, data types, available filter operators, and relations to other collections. Use this tool first before querying data to understand the collection structure and build accurate filters.', inputSchema: argumentShape, }, async (options: DescribeCollectionArgument, extra) => { From e0d61d574ce8690c467c9fa2cfc7d14285e2a705 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 19 Dec 2025 10:26:34 +0100 Subject: [PATCH 08/18] test(mcp-server): add integration tests for describeCollection tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../test/tools/describe-collection.test.ts | 637 ++++++++++++++++++ 1 file changed, 637 insertions(+) create mode 100644 packages/mcp-server/test/tools/describe-collection.test.ts diff --git a/packages/mcp-server/test/tools/describe-collection.test.ts b/packages/mcp-server/test/tools/describe-collection.test.ts new file mode 100644 index 0000000000..336e2a72d2 --- /dev/null +++ b/packages/mcp-server/test/tools/describe-collection.test.ts @@ -0,0 +1,637 @@ +import type { Logger } from '../../src/server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import declareDescribeCollectionTool from '../../src/tools/describe-collection'; +import buildClient from '../../src/utils/agent-caller'; +import * as schemaFetcher from '../../src/utils/schema-fetcher'; + +jest.mock('../../src/utils/agent-caller'); +jest.mock('../../src/utils/schema-fetcher'); + +const mockLogger: Logger = jest.fn(); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockFetchForestSchema = schemaFetcher.fetchForestSchema as jest.MockedFunction< + typeof schemaFetcher.fetchForestSchema +>; +const mockGetFieldsOfCollection = schemaFetcher.getFieldsOfCollection as jest.MockedFunction< + typeof schemaFetcher.getFieldsOfCollection +>; + +describe('declareDescribeCollectionTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + // Create a mock MCP server that captures the registered tool + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + }); + + describe('tool registration', () => { + it('should register a tool named "describeCollection"', () => { + declareDescribeCollectionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'describeCollection', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareDescribeCollectionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('Describe a collection'); + expect(registeredToolConfig.description).toBe( + 'Get detailed information about a collection including its fields, data types, available filter operators, and relations to other collections. Use this tool first before querying data to understand the collection structure and build accurate filters.', + ); + }); + + it('should define correct input schema', () => { + declareDescribeCollectionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + }); + + it('should use string type for collectionName when no collection names provided', () => { + declareDescribeCollectionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + // String type should not have options property (enum has options) + expect(schema.collectionName.options).toBeUndefined(); + // Should accept any string + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareDescribeCollectionTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + 'users', + 'products', + 'orders', + ]); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + // Enum type should have options property with the collection names + expect(schema.collectionName.options).toEqual(['users', 'products', 'orders']); + // Should accept valid collection names + expect(() => schema.collectionName.parse('users')).not.toThrow(); + expect(() => schema.collectionName.parse('products')).not.toThrow(); + // Should reject invalid collection names + expect(() => schema.collectionName.parse('invalid-collection')).toThrow(); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareDescribeCollectionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + }); + + it('should call buildClient with the extra parameter', async () => { + const mockCapabilities = jest.fn().mockResolvedValue({ fields: [] }); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + + await registeredToolHandler({ collectionName: 'users' }, mockExtra); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra); + }); + + it('should fetch forest schema', async () => { + const mockCapabilities = jest.fn().mockResolvedValue({ fields: [] }); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + + await registeredToolHandler({ collectionName: 'users' }, mockExtra); + + expect(mockFetchForestSchema).toHaveBeenCalledWith('https://api.forestadmin.com'); + }); + + it('should call capabilities on the collection', async () => { + const mockCapabilities = jest.fn().mockResolvedValue({ fields: [] }); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + + await registeredToolHandler({ collectionName: 'users' }, mockExtra); + + expect(mockCollection).toHaveBeenCalledWith('users'); + expect(mockCapabilities).toHaveBeenCalled(); + }); + + describe('when capabilities are available', () => { + it('should return fields from capabilities with schema metadata', async () => { + const mockCapabilities = jest.fn().mockResolvedValue({ + fields: [ + { name: 'id', type: 'Number', operators: ['Equal', 'NotEqual'] }, + { name: 'name', type: 'String', operators: ['Equal', 'Contains'] }, + ], + }); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const mockSchema: schemaFetcher.ForestSchema = { + collections: [ + { + name: 'users', + fields: [ + { + field: 'id', + type: 'Number', + isSortable: true, + isPrimaryKey: true, + isReadOnly: false, + isRequired: true, + enum: null, + reference: null, + }, + { + field: 'name', + type: 'String', + isSortable: true, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: null, + }, + ], + }, + ], + }; + mockFetchForestSchema.mockResolvedValue(mockSchema); + mockGetFieldsOfCollection.mockReturnValue(mockSchema.collections[0].fields); + + const result = (await registeredToolHandler( + { collectionName: 'users' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.collection).toBe('users'); + expect(parsed.fields).toEqual([ + { + name: 'id', + type: 'Number', + operators: ['Equal', 'NotEqual'], + isPrimaryKey: true, + isReadOnly: false, + isRequired: true, + isSortable: true, + }, + { + name: 'name', + type: 'String', + operators: ['Equal', 'Contains'], + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + isSortable: true, + }, + ]); + }); + }); + + describe('when capabilities are not available (older agent)', () => { + it('should fall back to schema fields with empty operators', async () => { + const mockCapabilities = jest.fn().mockRejectedValue(new Error('Not found')); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const mockSchema: schemaFetcher.ForestSchema = { + collections: [ + { + name: 'users', + fields: [ + { + field: 'id', + type: 'Number', + isSortable: true, + isPrimaryKey: true, + isReadOnly: false, + isRequired: true, + enum: null, + reference: null, + }, + { + field: 'email', + type: 'String', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: null, + }, + ], + }, + ], + }; + mockFetchForestSchema.mockResolvedValue(mockSchema); + mockGetFieldsOfCollection.mockReturnValue(mockSchema.collections[0].fields); + + const result = (await registeredToolHandler( + { collectionName: 'users' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.fields).toEqual([ + { + name: 'id', + type: 'Number', + operators: [], + isPrimaryKey: true, + isReadOnly: false, + isRequired: true, + isSortable: true, + }, + { + name: 'email', + type: 'String', + operators: [], + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + isSortable: false, + }, + ]); + }); + + it('should log a warning when capabilities fail', async () => { + const mockCapabilities = jest.fn().mockRejectedValue(new Error('Not found')); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + + await registeredToolHandler({ collectionName: 'users' }, mockExtra); + + expect(mockLogger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining('Failed to fetch capabilities for collection users'), + ); + }); + + it('should exclude relation fields from schema fallback', async () => { + const mockCapabilities = jest.fn().mockRejectedValue(new Error('Not found')); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'id', + type: 'Number', + isSortable: true, + isPrimaryKey: true, + isReadOnly: false, + isRequired: true, + enum: null, + reference: null, + }, + { + field: 'orders', + type: '[Number]', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'orders.id', + relationship: 'HasMany', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'users', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'users' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + // Should only include non-relation fields + expect(parsed.fields).toHaveLength(1); + expect(parsed.fields[0].name).toBe('id'); + }); + }); + + describe('relations extraction', () => { + beforeEach(() => { + const mockCapabilities = jest.fn().mockResolvedValue({ fields: [] }); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should extract HasMany relations as one-to-many', async () => { + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'orders', + type: '[Number]', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'orders.id', + relationship: 'HasMany', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'users', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'users' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.relations).toContainEqual({ + name: 'orders', + type: 'one-to-many', + targetCollection: 'orders', + }); + }); + + it('should extract BelongsToMany relations as many-to-many', async () => { + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'tags', + type: '[Number]', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'tags.id', + relationship: 'BelongsToMany', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'posts', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'posts' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.relations).toContainEqual({ + name: 'tags', + type: 'many-to-many', + targetCollection: 'tags', + }); + }); + + it('should extract BelongsTo relations as many-to-one', async () => { + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'user', + type: 'Number', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'users.id', + relationship: 'BelongsTo', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'orders', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'orders' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.relations).toContainEqual({ + name: 'user', + type: 'many-to-one', + targetCollection: 'users', + }); + }); + + it('should extract HasOne relations as one-to-one', async () => { + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'profile', + type: 'Number', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'profiles.id', + relationship: 'HasOne', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'users', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'users' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.relations).toContainEqual({ + name: 'profile', + type: 'one-to-one', + targetCollection: 'profiles', + }); + }); + + it('should handle unknown relationship types', async () => { + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'custom', + type: 'Number', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'custom.id', + relationship: 'CustomRelation' as schemaFetcher.ForestField['relationship'], + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'test', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'test' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.relations).toContainEqual({ + name: 'custom', + type: 'CustomRelation', + targetCollection: 'custom', + }); + }); + + it('should handle missing reference gracefully', async () => { + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'orphan', + type: 'Number', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: null, + relationship: 'HasMany', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'test', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'test' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.relations).toContainEqual({ + name: 'orphan', + type: 'one-to-many', + targetCollection: null, + }); + }); + }); + + describe('response format', () => { + it('should return JSON formatted with indentation', async () => { + const mockCapabilities = jest.fn().mockResolvedValue({ fields: [] }); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + + const result = (await registeredToolHandler( + { collectionName: 'users' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + // Check that JSON is formatted (has newlines) + expect(result.content[0].text).toContain('\n'); + expect(result.content[0].type).toBe('text'); + }); + + it('should return complete structure with collection, fields, and relations', async () => { + const mockCapabilities = jest.fn().mockResolvedValue({ + fields: [{ name: 'id', type: 'Number', operators: ['Equal'] }], + }); + const mockCollection = jest.fn().mockReturnValue({ capabilities: mockCapabilities }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const mockFields: schemaFetcher.ForestField[] = [ + { + field: 'id', + type: 'Number', + isSortable: true, + isPrimaryKey: true, + isReadOnly: false, + isRequired: true, + enum: null, + reference: null, + }, + { + field: 'posts', + type: '[Number]', + isSortable: false, + isPrimaryKey: false, + isReadOnly: false, + isRequired: false, + enum: null, + reference: 'posts.id', + relationship: 'HasMany', + }, + ]; + mockFetchForestSchema.mockResolvedValue({ collections: [{ name: 'users', fields: mockFields }] }); + mockGetFieldsOfCollection.mockReturnValue(mockFields); + + const result = (await registeredToolHandler( + { collectionName: 'users' }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsed = JSON.parse(result.content[0].text); + expect(parsed).toHaveProperty('collection', 'users'); + expect(parsed).toHaveProperty('fields'); + expect(parsed).toHaveProperty('relations'); + expect(parsed.fields).toBeInstanceOf(Array); + expect(parsed.relations).toBeInstanceOf(Array); + }); + }); + }); +}); From 26a3a080e1ec749f3b3593c6423b315a48ef02cf Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Dec 2025 14:27:45 +0100 Subject: [PATCH 09/18] feat(mcp-server): add action tools (getActionForm, executeAction) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add getActionForm tool to load action form fields - Add executeAction tool to execute actions with form values - Update describeCollection to include actions info - Update agent-caller to support action endpoints - Update activity-logs-creator for action activity logs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/mcp-server/src/server.ts | 20 ++- .../src/tools/describe-collection.ts | 17 ++- .../mcp-server/src/tools/execute-action.ts | 118 ++++++++++++++++++ .../mcp-server/src/tools/get-action-form.ts | 115 +++++++++++++++++ .../src/utils/activity-logs-creator.ts | 39 +++--- packages/mcp-server/src/utils/agent-caller.ts | 5 +- .../mcp-server/src/utils/schema-fetcher.ts | 85 +++++++++++++ packages/mcp-server/test/server.test.ts | 1 - .../test/tools/describe-collection.test.ts | 6 + 9 files changed, 386 insertions(+), 20 deletions(-) create mode 100644 packages/mcp-server/src/tools/execute-action.ts create mode 100644 packages/mcp-server/src/tools/get-action-form.ts diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 320cf6c24d..9b399fcbe9 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -23,10 +23,12 @@ import { isMcpRoute } from './mcp-paths'; import declareCreateTool from './tools/create'; import declareDeleteTool from './tools/delete'; import declareDescribeCollectionTool from './tools/describe-collection'; +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 declareUpdateTool from './tools/update'; -import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; +import { fetchForestSchema, getActionEndpoints, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; import { NAME, VERSION } from './version'; @@ -121,10 +123,12 @@ export default class ForestMCPServer { private async setupTools(): Promise { let collectionNames: string[] = []; + let actionEndpoints: Record> = {}; try { const schema = await fetchForestSchema(this.forestServerUrl); collectionNames = getCollectionNames(schema); + actionEndpoints = getActionEndpoints(schema); } catch (error) { this.logger( 'Warn', @@ -143,6 +147,20 @@ export default class ForestMCPServer { declareCreateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareUpdateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareDeleteTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareGetActionFormTool( + this.mcpServer, + this.forestServerUrl, + this.logger, + collectionNames, + actionEndpoints, + ); + declareExecuteActionTool( + this.mcpServer, + this.forestServerUrl, + this.logger, + collectionNames, + actionEndpoints, + ); } private ensureSecretsAreSet(): { envSecret: string; authSecret: string } { diff --git a/packages/mcp-server/src/tools/describe-collection.ts b/packages/mcp-server/src/tools/describe-collection.ts index 317e6e0d22..37fa29fa52 100644 --- a/packages/mcp-server/src/tools/describe-collection.ts +++ b/packages/mcp-server/src/tools/describe-collection.ts @@ -4,7 +4,11 @@ import { z } from 'zod'; import { Logger } from '../server.js'; import buildClient from '../utils/agent-caller.js'; -import { fetchForestSchema, getFieldsOfCollection } from '../utils/schema-fetcher.js'; +import { + fetchForestSchema, + getActionsOfCollection, + getFieldsOfCollection, +} from '../utils/schema-fetcher.js'; import registerToolWithLogging from '../utils/tool-with-logging.js'; interface DescribeCollectionArgument { @@ -118,10 +122,21 @@ export default function declareDescribeCollectionTool( }; }); + // Extract actions from schema + const schemaActions = getActionsOfCollection(schema, options.collectionName); + const actions = schemaActions.map(action => ({ + name: action.name, + type: action.type, // 'single', 'bulk', or 'global' + description: action.description || null, + hasForm: action.fields.length > 0 || action.hooks.load, + download: action.download, + })); + const result = { collection: options.collectionName, fields, relations, + actions, }; return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] }; diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts new file mode 100644 index 0000000000..86e9fc35a2 --- /dev/null +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -0,0 +1,118 @@ +import type { Logger } from '../server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import createActivityLog from '../utils/activity-logs-creator.js'; +import buildClient, { ActionEndpointsMap } from '../utils/agent-caller.js'; +import parseAgentError from '../utils/error-parser.js'; +import registerToolWithLogging from '../utils/tool-with-logging.js'; + +// Preprocess to handle LLM sending recordIds as JSON string instead of array +const recordIdsWithPreprocess = z.preprocess(val => { + if (typeof val !== 'string') return val; + + try { + return JSON.parse(val); + } catch { + return val; + } +}, z.array(z.union([z.string(), z.number()])).optional()); + +// Preprocess to handle LLM sending values as JSON string instead of object +const valuesWithPreprocess = z.preprocess(val => { + if (typeof val !== 'string') return val; + + try { + return JSON.parse(val); + } catch { + return val; + } +}, z.record(z.string(), z.unknown()).optional()); + +interface ExecuteActionArgument { + collectionName: string; + actionName: string; + recordIds?: (string | number)[]; + values?: Record; +} + +function createArgumentShape(collectionNames: string[]) { + return { + collectionName: + collectionNames.length > 0 + ? z.enum(collectionNames as [string, ...string[]]).describe('The name of the collection') + : z.string().describe('The name of the collection'), + actionName: z.string().describe('The name of the action to execute'), + recordIds: recordIdsWithPreprocess.describe( + 'The IDs of the records to apply the action to. Required for single/bulk actions, optional for global actions.', + ), + values: valuesWithPreprocess.describe( + 'The form field values to set before executing the action. Keys are field names, values are the field values.', + ), + }; +} + +export default function declareExecuteActionTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], + actionEndpoints: ActionEndpointsMap = {}, +): void { + const argumentShape = createArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'executeAction', + { + title: 'Execute an action', + description: + 'Execute an action on a collection with optional form values. For actions with forms, use getActionForm first to see required fields. For dynamic forms, setting field values may change other fields.', + inputSchema: argumentShape, + }, + async (options: ExecuteActionArgument, extra) => { + const { rpcClient } = await buildClient(extra, actionEndpoints); + + await createActivityLog(forestServerUrl, extra, 'executeAction', { + collectionName: options.collectionName, + label: options.actionName, + }); + + try { + const recordIds = options.recordIds as string[] | number[] | undefined; + const action = await rpcClient + .collection(options.collectionName) + .action(options.actionName, { recordIds }); + + // Set form values if provided + if (options.values && Object.keys(options.values).length > 0) { + await action.setFields(options.values); + } + + // Execute the action + const result = await action.execute(); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: result.success, + html: result.html || null, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + const errorDetail = parseAgentError(error); + throw errorDetail ? new Error(errorDetail) : error; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts new file mode 100644 index 0000000000..cf5ae28d60 --- /dev/null +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -0,0 +1,115 @@ +import type { Logger } from '../server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +import createActivityLog from '../utils/activity-logs-creator.js'; +import buildClient, { ActionEndpointsMap } from '../utils/agent-caller.js'; +import parseAgentError from '../utils/error-parser.js'; +import registerToolWithLogging from '../utils/tool-with-logging.js'; + +// Preprocess to handle LLM sending recordIds as JSON string instead of array +const recordIdsWithPreprocess = z.preprocess(val => { + if (typeof val !== 'string') return val; + + try { + return JSON.parse(val); + } catch { + return val; + } +}, z.array(z.union([z.string(), z.number()])).optional()); + +interface GetActionFormArgument { + collectionName: string; + actionName: string; + recordIds?: (string | number)[]; +} + +function createArgumentShape(collectionNames: string[]) { + return { + collectionName: + collectionNames.length > 0 + ? z.enum(collectionNames as [string, ...string[]]).describe('The name of the collection') + : z.string().describe('The name of the collection'), + actionName: z.string().describe('The name of the action to get the form for'), + recordIds: recordIdsWithPreprocess.describe( + 'The IDs of the records to apply the action to. Required for single/bulk actions, optional for global actions.', + ), + }; +} + +interface ActionFieldInfo { + getName: () => string; + getType: () => string; + getValue: () => unknown; + isRequired: () => boolean; +} + +function formatFieldForResponse(field: ActionFieldInfo) { + return { + name: field.getName(), + type: field.getType(), + value: field.getValue(), + isRequired: field.isRequired(), + }; +} + +export default function declareGetActionFormTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], + actionEndpoints: ActionEndpointsMap = {}, +): void { + const argumentShape = createArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'getActionForm', + { + title: 'Get action form', + description: + 'Load the form fields for an action. Use this to see what fields are required before executing an action. For dynamic forms, field values may affect other fields.', + inputSchema: argumentShape, + }, + async (options: GetActionFormArgument, extra) => { + const { rpcClient } = await buildClient(extra, actionEndpoints); + + await createActivityLog(forestServerUrl, extra, 'getActionForm', { + collectionName: options.collectionName, + label: options.actionName, + }); + + try { + const recordIds = options.recordIds as string[] | number[] | undefined; + const action = await rpcClient + .collection(options.collectionName) + .action(options.actionName, { recordIds }); + + const fields = action.getFields(); + const formattedFields = fields.map(formatFieldForResponse); + + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + collectionName: options.collectionName, + actionName: options.actionName, + fields: formattedFields, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + const errorDetail = parseAgentError(error); + throw errorDetail ? new Error(errorDetail) : error; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 86d5d0b6aa..535f3af3be 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -1,6 +1,25 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; +// Mapping from internal action names to API-accepted action names +// The API only accepts specific action names like 'read', 'action', 'create', 'update', 'delete', etc. +const actionMapping: Record = { + index: { apiAction: 'index', type: 'read' }, + search: { apiAction: 'search', type: 'read' }, + filter: { apiAction: 'filter', type: 'read' }, + listHasMany: { apiAction: 'index', type: 'read' }, + actionForm: { apiAction: 'read', type: 'read' }, + action: { apiAction: 'action', type: 'write' }, + create: { apiAction: 'create', type: 'write' }, + update: { apiAction: 'update', type: 'write' }, + delete: { apiAction: 'delete', type: 'write' }, + availableActions: { apiAction: 'read', type: 'read' }, + availableCollections: { apiAction: 'read', type: 'read' }, + // Action-related MCP tools + getActionForm: { apiAction: 'read', type: 'read' }, + executeAction: { apiAction: 'action', type: 'write' }, +}; + export default async function createActivityLog( forestServerUrl: string, request: RequestHandlerExtra, @@ -12,25 +31,13 @@ export default async function createActivityLog( label?: string; }, ) { - const actionToType = { - index: 'read', - search: 'read', - filter: 'read', - listHasMany: 'read', - actionForm: 'read', - action: 'write', - create: 'write', - update: 'write', - delete: 'write', - availableActions: 'read', - availableCollections: 'read', - }; + const mapping = actionMapping[action]; - if (!actionToType[action]) { + if (!mapping) { throw new Error(`Unknown action type: ${action}`); } - const type = actionToType[action] as 'read' | 'write'; + const { apiAction, type } = mapping; const forestServerToken = request.authInfo?.extra?.forestServerToken as string; const renderingId = request.authInfo?.extra?.renderingId as string; @@ -49,7 +56,7 @@ export default async function createActivityLog( type: 'activity-logs-requests', attributes: { type, - action, + action: apiAction, label: extra?.label, records: (extra?.recordIds || (extra?.recordId ? [extra.recordId] : [])) as string[], }, diff --git a/packages/mcp-server/src/utils/agent-caller.ts b/packages/mcp-server/src/utils/agent-caller.ts index 99fa6470a8..146798277c 100644 --- a/packages/mcp-server/src/utils/agent-caller.ts +++ b/packages/mcp-server/src/utils/agent-caller.ts @@ -3,8 +3,11 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd import { createRemoteAgentClient } from '@forestadmin/agent-client'; +export type ActionEndpointsMap = Record>; + export default function buildClient( request: RequestHandlerExtra, + actionEndpoints: ActionEndpointsMap = {}, ) { const token = request.authInfo?.token; const url = request.authInfo?.extra?.environmentApiEndpoint; @@ -20,7 +23,7 @@ export default function buildClient( const rpcClient = createRemoteAgentClient({ token, url, - actionEndpoints: {}, + actionEndpoints, }); return { diff --git a/packages/mcp-server/src/utils/schema-fetcher.ts b/packages/mcp-server/src/utils/schema-fetcher.ts index 2e21537ea2..baec7ca087 100644 --- a/packages/mcp-server/src/utils/schema-fetcher.ts +++ b/packages/mcp-server/src/utils/schema-fetcher.ts @@ -24,9 +24,53 @@ export interface ForestField { relationship?: 'HasMany' | 'BelongsToMany' | 'BelongsTo' | 'HasOne' | null; } +export interface ForestAction { + id: string; + name: string; + type: 'single' | 'bulk' | 'global'; + endpoint: string; + description?: string; + submitButtonLabel?: string; + download: boolean; + fields: ForestActionField[]; + layout?: ForestActionLayoutElement[]; + hooks: { + load: boolean; + change: unknown[]; + }; +} + +export interface ForestActionField { + field: string; + label: string; + type: string; + description?: string | null; + isRequired: boolean; + isReadOnly: boolean; + value?: unknown; + defaultValue?: unknown; + enums?: string[] | null; + reference?: string | null; + widgetEdit?: { + name: string; + parameters: Record; + } | null; +} + +export interface ForestActionLayoutElement { + component: 'separator' | 'htmlBlock' | 'row' | 'input' | 'page'; + content?: string; + fieldId?: string; + fields?: ForestActionLayoutElement[]; + elements?: ForestActionLayoutElement[]; + nextButtonLabel?: string; + previousButtonLabel?: string; +} + export interface ForestCollection { name: string; fields: ForestField[]; + actions?: ForestAction[]; } export interface ForestSchema { @@ -122,6 +166,47 @@ export function getFieldsOfCollection(schema: ForestSchema, collectionName: stri return collection.fields; } +/** + * Extracts actions from a collection in the Forest Admin schema. + */ +export function getActionsOfCollection( + schema: ForestSchema, + collectionName: string, +): ForestAction[] { + const collection = schema.collections.find(col => col.name === collectionName); + + if (!collection) { + throw new Error(`Collection "${collectionName}" not found in schema`); + } + + return collection.actions || []; +} + +/** + * Builds action endpoints map for all collections in the schema. + * This map is used by the agent-client to resolve action endpoints. + */ +export function getActionEndpoints( + schema: ForestSchema, +): Record> { + const endpoints: Record> = {}; + + for (const collection of schema.collections) { + if (collection.actions && collection.actions.length > 0) { + endpoints[collection.name] = {}; + + for (const action of collection.actions) { + endpoints[collection.name][action.name] = { + name: action.name, + endpoint: action.endpoint, + }; + } + } + } + + return endpoints; +} + /** * Clears the schema cache. Useful for testing. */ diff --git a/packages/mcp-server/test/server.test.ts b/packages/mcp-server/test/server.test.ts index 43b3515551..0b3a359a43 100644 --- a/packages/mcp-server/test/server.test.ts +++ b/packages/mcp-server/test/server.test.ts @@ -4,7 +4,6 @@ import jsonwebtoken from 'jsonwebtoken'; import request from 'supertest'; import MockServer from './test-utils/mock-server'; -import { clearSchemaCache } from '../src/utils/schema-fetcher.js'; import ForestMCPServer from '../src/server'; import { clearSchemaCache } from '../src/utils/schema-fetcher.js'; diff --git a/packages/mcp-server/test/tools/describe-collection.test.ts b/packages/mcp-server/test/tools/describe-collection.test.ts index 336e2a72d2..641e536239 100644 --- a/packages/mcp-server/test/tools/describe-collection.test.ts +++ b/packages/mcp-server/test/tools/describe-collection.test.ts @@ -19,6 +19,9 @@ const mockFetchForestSchema = schemaFetcher.fetchForestSchema as jest.MockedFunc const mockGetFieldsOfCollection = schemaFetcher.getFieldsOfCollection as jest.MockedFunction< typeof schemaFetcher.getFieldsOfCollection >; +const mockGetActionsOfCollection = schemaFetcher.getActionsOfCollection as jest.MockedFunction< + typeof schemaFetcher.getActionsOfCollection +>; describe('declareDescribeCollectionTool', () => { let mcpServer: McpServer; @@ -28,6 +31,9 @@ describe('declareDescribeCollectionTool', () => { beforeEach(() => { jest.clearAllMocks(); + // Default mock for actions - return empty array + mockGetActionsOfCollection.mockReturnValue([]); + // Create a mock MCP server that captures the registered tool mcpServer = { registerTool: jest.fn((name, config, handler) => { From e61e1161c9361052dc9340130eddc370958addfd Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Dec 2025 16:07:16 +0100 Subject: [PATCH 10/18] feat(mcp-server): add dynamic forms and multi-page support to getActionForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional `values` parameter to trigger change hooks for dynamic forms - Return layout information including page structure for multi-page forms - Enhance field response with description, enums, options, and isReadOnly - Enable progressive field discovery by calling getActionForm with values 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../mcp-server/src/tools/get-action-form.ts | 89 ++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index cf5ae28d60..be0f8830ee 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -19,10 +19,22 @@ const recordIdsWithPreprocess = z.preprocess(val => { } }, z.array(z.union([z.string(), z.number()])).optional()); +// Preprocess to handle LLM sending values as JSON string instead of object +const valuesWithPreprocess = z.preprocess(val => { + if (typeof val !== 'string') return val; + + try { + return JSON.parse(val); + } catch { + return val; + } +}, z.record(z.string(), z.unknown()).optional()); + interface GetActionFormArgument { collectionName: string; actionName: string; recordIds?: (string | number)[]; + values?: Record; } function createArgumentShape(collectionNames: string[]) { @@ -35,7 +47,27 @@ function createArgumentShape(collectionNames: string[]) { recordIds: recordIdsWithPreprocess.describe( 'The IDs of the records to apply the action to. Required for single/bulk actions, optional for global actions.', ), + values: valuesWithPreprocess.describe( + 'Optional field values to set. Use this to trigger dynamic form updates and discover fields that depend on other field values. The response will show the updated fields after change hooks are executed.', + ), + }; +} + +interface PlainField { + field: string; + type: string; + description?: string; + value?: unknown; + isRequired: boolean; + isReadOnly: boolean; + widgetEdit?: { + parameters: { + static: { + options?: { label: string; value: string }[]; + }; + }; }; + enums?: string[]; } interface ActionFieldInfo { @@ -43,14 +75,57 @@ interface ActionFieldInfo { getType: () => string; getValue: () => unknown; isRequired: () => boolean; + getPlainField?: () => PlainField; } function formatFieldForResponse(field: ActionFieldInfo) { + const plainField = field.getPlainField?.(); + return { name: field.getName(), type: field.getType(), value: field.getValue(), isRequired: field.isRequired(), + isReadOnly: plainField?.isReadOnly ?? false, + description: plainField?.description ?? null, + enums: plainField?.enums ?? null, + options: plainField?.widgetEdit?.parameters?.static?.options ?? null, + }; +} + +interface LayoutElement { + component: string; + fieldId?: string; + content?: string; + fields?: LayoutElement[]; + elements?: LayoutElement[]; + nextButtonLabel?: string; + previousButtonLabel?: string; +} + +function formatLayoutForResponse(layout: LayoutElement[]): unknown { + if (!layout || layout.length === 0) return null; + + // Check if layout has pages + const hasPages = layout.some(el => el.component === 'page'); + + if (hasPages) { + return { + type: 'multi-page', + pages: layout + .filter(el => el.component === 'page') + .map((page, index) => ({ + pageNumber: index + 1, + elements: page.elements || [], + nextButtonLabel: page.nextButtonLabel, + previousButtonLabel: page.previousButtonLabel, + })), + }; + } + + return { + type: 'single-page', + elements: layout, }; } @@ -69,7 +144,7 @@ export default function declareGetActionFormTool( { title: 'Get action form', description: - 'Load the form fields for an action. Use this to see what fields are required before executing an action. For dynamic forms, field values may affect other fields.', + 'Load the form fields for an action. Supports dynamic forms: pass values to trigger change hooks and discover fields that depend on other fields. For multi-page forms, the layout shows page structure. Call multiple times with progressive values to handle complex dynamic forms across pages.', inputSchema: argumentShape, }, async (options: GetActionFormArgument, extra) => { @@ -86,9 +161,20 @@ export default function declareGetActionFormTool( .collection(options.collectionName) .action(options.actionName, { recordIds }); + // Set field values if provided - this triggers change hooks for dynamic forms + if (options.values && Object.keys(options.values).length > 0) { + await action.setFields(options.values); + } + const fields = action.getFields(); const formattedFields = fields.map(formatFieldForResponse); + // Get layout for multi-page forms + const layout = action.getLayout(); + const formattedLayout = formatLayoutForResponse( + layout?.['layout'] as LayoutElement[] | undefined, + ); + return { content: [ { @@ -98,6 +184,7 @@ export default function declareGetActionFormTool( collectionName: options.collectionName, actionName: options.actionName, fields: formattedFields, + layout: formattedLayout, }, null, 2, From 515b3defaa3d7421111c2aafea9881ff90d852a8 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Dec 2025 16:19:03 +0100 Subject: [PATCH 11/18] feat(mcp-server): improve action result types and widget metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit executeAction improvements: - Handle all result types: Success, Webhook, Redirect - Return invalidatedRelations for cache invalidation - Document that File downloads are not supported via MCP getActionForm improvements: - Add full widget metadata (placeholder, min, max, step, etc.) - Include widget type name for UI hints - Support file picker constraints (extensions, size, count) - Include reference field for Collection type - Add search configuration for dynamic dropdowns 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../mcp-server/src/tools/execute-action.ts | 69 ++++++++++++--- .../mcp-server/src/tools/get-action-form.ts | 84 +++++++++++++++++-- 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index 86e9fc35a2..4222d8479d 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -37,6 +37,59 @@ interface ExecuteActionArgument { values?: Record; } +// Full action result as returned by the agent (agent-client types are too restrictive) +interface ActionResultFromAgent { + // Success result + success?: string; + html?: string; + refresh?: { relationships: string[] }; + // Error result (returned with HTTP 400, but caught and re-thrown) + error?: string; + // Webhook result + webhook?: { + url: string; + method: 'GET' | 'POST'; + headers: Record; + body: unknown; + }; + // Redirect result + redirectTo?: string; + // Note: File results are streams and cannot be handled via JSON-based MCP protocol +} + +function formatActionResult(result: ActionResultFromAgent): { + type: 'Success' | 'Webhook' | 'Redirect'; + message?: string; + html?: string; + invalidatedRelations?: string[]; + webhook?: { url: string; method: string; headers: Record; body: unknown }; + redirectTo?: string; +} { + // Webhook result + if (result.webhook) { + return { + type: 'Webhook', + webhook: result.webhook, + }; + } + + // Redirect result + if (result.redirectTo) { + return { + type: 'Redirect', + redirectTo: result.redirectTo, + }; + } + + // Success result (default) + return { + type: 'Success', + message: result.success || 'Action executed successfully', + html: result.html || null, + invalidatedRelations: result.refresh?.relationships || [], + }; +} + function createArgumentShape(collectionNames: string[]) { return { collectionName: @@ -68,7 +121,7 @@ export default function declareExecuteActionTool( { title: 'Execute an action', description: - 'Execute an action on a collection with optional form values. For actions with forms, use getActionForm first to see required fields. For dynamic forms, setting field values may change other fields.', + 'Execute an action on a collection with optional form values. For actions with forms, use getActionForm first to see required fields. Returns result with type: Success (message, html, invalidatedRelations), Webhook (url, method, headers, body), or Redirect (redirectTo). File downloads are not supported via MCP.', inputSchema: argumentShape, }, async (options: ExecuteActionArgument, extra) => { @@ -90,21 +143,15 @@ export default function declareExecuteActionTool( await action.setFields(options.values); } - // Execute the action - const result = await action.execute(); + // Execute the action - cast to ActionResultFromAgent since agent-client types are too restrictive + const result = (await action.execute()) as unknown as ActionResultFromAgent; + const formattedResult = formatActionResult(result); return { content: [ { type: 'text', - text: JSON.stringify( - { - success: result.success, - html: result.html || null, - }, - null, - 2, - ), + text: JSON.stringify(formattedResult, null, 2), }, ], }; diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index be0f8830ee..11cf1a1b8d 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -53,6 +53,42 @@ function createArgumentShape(collectionNames: string[]) { }; } +// Widget parameters as returned by the agent (varies by widget type) +interface WidgetParameters { + // Common + placeholder?: string | null; + // Options (for dropdown, radio, checkboxes) + static?: { + options?: { label: string; value: string | number }[]; + }; + isSearchable?: boolean; + searchType?: 'dynamic' | null; + // Number fields + min?: number | null; + max?: number | null; + step?: number | null; + // Text area + rows?: number | null; + // Date picker + format?: string | null; + minDate?: string | null; + maxDate?: string | null; + // Color picker + enableOpacity?: boolean; + quickPalette?: string[] | null; + // Currency + currency?: string | null; + base?: 'Unit' | 'Cents' | null; + // File picker + filesExtensions?: string[] | null; + filesSizeLimit?: number | null; + filesCountLimit?: number | null; + // List fields + enableReorder?: boolean; + allowDuplicate?: boolean; + allowEmptyValue?: boolean; +} + interface PlainField { field: string; type: string; @@ -61,13 +97,12 @@ interface PlainField { isRequired: boolean; isReadOnly: boolean; widgetEdit?: { - parameters: { - static: { - options?: { label: string; value: string }[]; - }; - }; + name: string; + parameters: WidgetParameters; }; enums?: string[]; + // For Collection fields + reference?: string | null; } interface ActionFieldInfo { @@ -80,6 +115,39 @@ interface ActionFieldInfo { function formatFieldForResponse(field: ActionFieldInfo) { const plainField = field.getPlainField?.(); + const widgetEdit = plainField?.widgetEdit; + const params = widgetEdit?.parameters; + + // Build widget configuration object with all relevant metadata + const widget: Record = {}; + + if (widgetEdit?.name) { + widget.type = widgetEdit.name; + } + + // Extract all widget parameters that are set + if (params) { + if (params.placeholder != null) widget.placeholder = params.placeholder; + if (params.min != null) widget.min = params.min; + if (params.max != null) widget.max = params.max; + if (params.step != null) widget.step = params.step; + if (params.rows != null) widget.rows = params.rows; + if (params.format != null) widget.format = params.format; + if (params.minDate != null) widget.minDate = params.minDate; + if (params.maxDate != null) widget.maxDate = params.maxDate; + if (params.enableOpacity != null) widget.enableOpacity = params.enableOpacity; + if (params.quickPalette != null) widget.quickPalette = params.quickPalette; + if (params.currency != null) widget.currency = params.currency; + if (params.base != null) widget.currencyBase = params.base; + if (params.filesExtensions != null) widget.allowedExtensions = params.filesExtensions; + if (params.filesSizeLimit != null) widget.maxSizeMb = params.filesSizeLimit; + if (params.filesCountLimit != null) widget.maxFiles = params.filesCountLimit; + if (params.enableReorder != null) widget.enableReorder = params.enableReorder; + if (params.allowDuplicate != null) widget.allowDuplicates = params.allowDuplicate; + if (params.allowEmptyValue != null) widget.allowEmptyValues = params.allowEmptyValue; + if (params.isSearchable != null) widget.isSearchable = params.isSearchable; + if (params.searchType === 'dynamic') widget.hasDynamicSearch = true; + } return { name: field.getName(), @@ -89,7 +157,11 @@ function formatFieldForResponse(field: ActionFieldInfo) { isReadOnly: plainField?.isReadOnly ?? false, description: plainField?.description ?? null, enums: plainField?.enums ?? null, - options: plainField?.widgetEdit?.parameters?.static?.options ?? null, + options: params?.static?.options ?? null, + // Include widget config only if it has any properties + widget: Object.keys(widget).length > 0 ? widget : null, + // For Collection fields, include the reference + reference: plainField?.reference ?? null, }; } From f90965189d39e17adb97f7c8afb79155cf986401 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Dec 2025 18:15:48 +0100 Subject: [PATCH 12/18] feat(mcp-server): add file upload and download support for actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File upload: - Accept file values as { name, mimeType, contentBase64 } - Convert to data URI format expected by the agent - Support both single files and file arrays (FileList) File download: - Add queryWithFileSupport to agent-client http-requester - Add executeWithFileSupport method to Action class - Handle binary responses with Content-Disposition header - Return file content as base64 (max 5MB) - Return FileTooLarge error for files exceeding limit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/agent-client/src/domains/action.ts | 27 ++++ packages/agent-client/src/http-requester.ts | 76 ++++++++++ .../mcp-server/src/tools/execute-action.ts | 139 ++++++++++++++++-- 3 files changed, 227 insertions(+), 15 deletions(-) diff --git a/packages/agent-client/src/domains/action.ts b/packages/agent-client/src/domains/action.ts index 6c28880420..53e4bb9412 100644 --- a/packages/agent-client/src/domains/action.ts +++ b/packages/agent-client/src/domains/action.ts @@ -70,6 +70,33 @@ export default class Action { }); } + /** + * Execute the action with support for file download responses. + * Returns either a JSON result or a file (buffer + metadata). + */ + async executeWithFileSupport(signedApprovalRequest?: Record): Promise< + | { type: 'json'; data: Record } + | { type: 'file'; buffer: Buffer; mimeType: string; fileName: string } + > { + const requestBody = { + data: { + attributes: { + collection_name: this.collectionName, + ids: this.ids, + values: this.fieldsFormStates.getFieldValues(), + signed_approval_request: signedApprovalRequest, + }, + type: 'custom-action-requests', + }, + }; + + return this.httpRequester.queryWithFileSupport>({ + method: 'post', + path: this.actionPath, + body: requestBody, + }); + } + async setFields(fields: Record): Promise { for (const [fieldName, value] of Object.entries(fields)) { // eslint-disable-next-line no-await-in-loop diff --git a/packages/agent-client/src/http-requester.ts b/packages/agent-client/src/http-requester.ts index 9c45d7e59e..ac25495bfb 100644 --- a/packages/agent-client/src/http-requester.ts +++ b/packages/agent-client/src/http-requester.ts @@ -59,6 +59,82 @@ export default class HttpRequester { } } + /** + * Execute a request that may return either JSON or a file (binary data). + * Returns the response with additional metadata to determine the response type. + */ + async queryWithFileSupport({ + method, + path, + body, + query, + maxTimeAllowed, + }: { + method: 'get' | 'post' | 'put' | 'delete'; + path: string; + body?: Record; + query?: Record; + maxTimeAllowed?: number; + }): Promise< + | { type: 'json'; data: Data } + | { type: 'file'; buffer: Buffer; mimeType: string; fileName: string } + > { + try { + const url = new URL(`${this.baseUrl}${HttpRequester.escapeUrlSlug(path)}`).toString(); + + const req = superagent[method](url) + .timeout(maxTimeAllowed ?? 10_000) + .responseType('arraybuffer') // Get raw buffer for any response + .set('Authorization', `Bearer ${this.token}`) + .set('Content-Type', 'application/json') + .query({ timezone: 'Europe/Paris', ...query }); + + if (body) req.send(body); + + const response = await req; + + const contentType = response.headers['content-type'] || ''; + const contentDisposition = response.headers['content-disposition'] || ''; + + // Check if this is a file download (non-JSON content type with attachment) + const isFile = + contentDisposition.includes('attachment') || + (!contentType.includes('application/json') && !contentType.includes('text/')); + + if (isFile) { + // Extract filename from Content-Disposition header + // Format: attachment; filename="report.pdf" or attachment; filename=report.pdf + const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + let fileName = 'download'; + if (fileNameMatch && fileNameMatch[1]) { + fileName = fileNameMatch[1].replace(/['"]/g, ''); + } + + return { + type: 'file', + buffer: Buffer.from(response.body), + mimeType: contentType.split(';')[0].trim(), + fileName, + }; + } + + // Parse as JSON + const jsonString = Buffer.from(response.body).toString('utf-8'); + const jsonBody = JSON.parse(jsonString); + + try { + return { type: 'json', data: (await this.deserializer.deserialize(jsonBody)) as Data }; + } catch { + return { type: 'json', data: jsonBody as Data }; + } + } catch (error: any) { + if (!error.response) throw error; + throw new Error( + JSON.stringify({ error: error.response.error as Record, body }, null, 4), + ); + } + } + async stream({ path: reqPath, query, diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index 4222d8479d..6617fd1e12 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -37,6 +37,54 @@ interface ExecuteActionArgument { values?: Record; } +// File value format accepted from MCP clients +interface FileValue { + name: string; + mimeType: string; + contentBase64: string; +} + +function isFileValue(value: unknown): value is FileValue { + return ( + typeof value === 'object' && + value !== null && + 'name' in value && + 'mimeType' in value && + 'contentBase64' in value && + typeof (value as FileValue).name === 'string' && + typeof (value as FileValue).mimeType === 'string' && + typeof (value as FileValue).contentBase64 === 'string' + ); +} + +function fileToDataUri(file: FileValue): string { + // Format: data:mimeType;name=filename;base64,content + const encodedName = encodeURIComponent(file.name); + return `data:${file.mimeType};name=${encodedName};base64,${file.contentBase64}`; +} + +/** + * Convert file values in the form data to data URI format expected by the agent. + * Supports both single files and file arrays. + */ +function convertFileValuesToDataUri(values: Record): Record { + const converted: Record = {}; + + for (const [key, value] of Object.entries(values)) { + if (isFileValue(value)) { + // Single file + converted[key] = fileToDataUri(value); + } else if (Array.isArray(value) && value.length > 0 && value.every(isFileValue)) { + // File array (FileList) + converted[key] = value.map(fileToDataUri); + } else { + converted[key] = value; + } + } + + return converted; +} + // Full action result as returned by the agent (agent-client types are too restrictive) interface ActionResultFromAgent { // Success result @@ -54,17 +102,42 @@ interface ActionResultFromAgent { }; // Redirect result redirectTo?: string; - // Note: File results are streams and cannot be handled via JSON-based MCP protocol } -function formatActionResult(result: ActionResultFromAgent): { - type: 'Success' | 'Webhook' | 'Redirect'; - message?: string; - html?: string; - invalidatedRelations?: string[]; - webhook?: { url: string; method: string; headers: Record; body: unknown }; - redirectTo?: string; -} { +// Maximum file size to return inline (5MB) +const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; + +type FormattedResult = + | { + type: 'Success'; + message?: string; + html?: string; + invalidatedRelations?: string[]; + } + | { + type: 'Webhook'; + webhook: { url: string; method: string; headers: Record; body: unknown }; + } + | { + type: 'Redirect'; + redirectTo: string; + } + | { + type: 'File'; + fileName: string; + mimeType: string; + contentBase64: string; + sizeBytes: number; + } + | { + type: 'FileTooLarge'; + fileName: string; + mimeType: string; + sizeBytes: number; + maxSizeBytes: number; + }; + +function formatJsonResult(result: ActionResultFromAgent): FormattedResult { // Webhook result if (result.webhook) { return { @@ -90,6 +163,32 @@ function formatActionResult(result: ActionResultFromAgent): { }; } +function formatFileResult( + buffer: Buffer, + mimeType: string, + fileName: string, +): FormattedResult { + const sizeBytes = buffer.length; + + if (sizeBytes > MAX_FILE_SIZE_BYTES) { + return { + type: 'FileTooLarge', + fileName, + mimeType, + sizeBytes, + maxSizeBytes: MAX_FILE_SIZE_BYTES, + }; + } + + return { + type: 'File', + fileName, + mimeType, + contentBase64: buffer.toString('base64'), + sizeBytes, + }; +} + function createArgumentShape(collectionNames: string[]) { return { collectionName: @@ -121,7 +220,7 @@ export default function declareExecuteActionTool( { title: 'Execute an action', description: - 'Execute an action on a collection with optional form values. For actions with forms, use getActionForm first to see required fields. Returns result with type: Success (message, html, invalidatedRelations), Webhook (url, method, headers, body), or Redirect (redirectTo). File downloads are not supported via MCP.', + 'Execute an action on a collection with optional form values. For actions with forms, use getActionForm first to see required fields. File uploads: pass { name, mimeType, contentBase64 } as field value. Returns result with type: Success, Webhook, Redirect, or File (for small files < 5MB).', inputSchema: argumentShape, }, async (options: ExecuteActionArgument, extra) => { @@ -138,14 +237,24 @@ export default function declareExecuteActionTool( .collection(options.collectionName) .action(options.actionName, { recordIds }); - // Set form values if provided + // Set form values if provided (convert file values to data URI format) if (options.values && Object.keys(options.values).length > 0) { - await action.setFields(options.values); + const convertedValues = convertFileValuesToDataUri(options.values); + await action.setFields(convertedValues); } - // Execute the action - cast to ActionResultFromAgent since agent-client types are too restrictive - const result = (await action.execute()) as unknown as ActionResultFromAgent; - const formattedResult = formatActionResult(result); + // Execute the action with file support + const result = await action.executeWithFileSupport(); + + let formattedResult: FormattedResult; + + if (result.type === 'file') { + // File download response + formattedResult = formatFileResult(result.buffer, result.mimeType, result.fileName); + } else { + // JSON response (Success, Webhook, Redirect, Error) + formattedResult = formatJsonResult(result.data as ActionResultFromAgent); + } return { content: [ From 8e1f231f3fcba31eeb63da90244afa72f8814e28 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Dec 2025 18:39:10 +0100 Subject: [PATCH 13/18] fix(mcp-server): address PR review findings and add missing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code fixes: - Fix potential TypeError on error.message in list-related.ts - Add SAFE_ARGUMENTS_FOR_LOGGING for all new tools - Fix html type (null -> undefined) in execute-action.ts - Make activity log creation non-blocking (no longer fails operations) - Add logging for silent deserialization fallback in http-requester New tests: - Add comprehensive tests for execute-action tool (file upload/download) - Add tests for get-action-form tool (widget metadata, layouts) - Add tests for queryWithFileSupport in http-requester - Add tests for executeWithFileSupport in action.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/agent-client/src/http-requester.ts | 8 +- .../agent-client/test/domains/action.test.ts | 89 +++ .../agent-client/test/http-requester.test.ts | 342 +++++++++ packages/mcp-server/src/server.ts | 4 + .../mcp-server/src/tools/execute-action.ts | 2 +- packages/mcp-server/src/tools/list-related.ts | 64 +- .../src/utils/activity-logs-creator.ts | 90 ++- .../test/tools/execute-action.test.ts | 686 ++++++++++++++++++ .../test/tools/get-action-form.test.ts | 674 +++++++++++++++++ 9 files changed, 1893 insertions(+), 66 deletions(-) create mode 100644 packages/mcp-server/test/tools/execute-action.test.ts create mode 100644 packages/mcp-server/test/tools/get-action-form.test.ts diff --git a/packages/agent-client/src/http-requester.ts b/packages/agent-client/src/http-requester.ts index ac25495bfb..f2a46597f4 100644 --- a/packages/agent-client/src/http-requester.ts +++ b/packages/agent-client/src/http-requester.ts @@ -124,7 +124,13 @@ export default class HttpRequester { try { return { type: 'json', data: (await this.deserializer.deserialize(jsonBody)) as Data }; - } catch { + } catch (deserializationError) { + // Log the failure - this is important for debugging schema mismatches + console.warn( + `[HttpRequester] Failed to deserialize JSON:API response, returning raw JSON. ` + + `Error: ${deserializationError instanceof Error ? deserializationError.message : String(deserializationError)}`, + ); + return { type: 'json', data: jsonBody as Data }; } } catch (error: any) { diff --git a/packages/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index 770d66455e..ea61fb7ce6 100644 --- a/packages/agent-client/test/domains/action.test.ts +++ b/packages/agent-client/test/domains/action.test.ts @@ -15,6 +15,7 @@ describe('Action', () => { httpRequester = { query: jest.fn(), + queryWithFileSupport: jest.fn(), } as any; fieldsFormStates = { @@ -316,4 +317,92 @@ describe('Action', () => { expect(action.doesFieldExist('nonexistent')).toBe(false); }); }); + + describe('executeWithFileSupport', () => { + it('should call httpRequester.queryWithFileSupport with correct parameters', async () => { + httpRequester.queryWithFileSupport.mockResolvedValue({ + type: 'json', + data: { success: 'Action executed' }, + }); + + const result = await action.executeWithFileSupport(); + + expect(httpRequester.queryWithFileSupport).toHaveBeenCalledWith({ + method: 'post', + path: '/forest/actions/send-email', + body: { + data: { + attributes: { + collection_name: 'users', + ids: ['1', '2'], + values: { email: 'test@example.com' }, + signed_approval_request: undefined, + }, + type: 'custom-action-requests', + }, + }, + }); + expect(result).toEqual({ + type: 'json', + data: { success: 'Action executed' }, + }); + }); + + it('should include signed approval request when provided', async () => { + httpRequester.queryWithFileSupport.mockResolvedValue({ + type: 'json', + data: { success: 'Action executed' }, + }); + const signedApprovalRequest = { token: 'approval-token', requesterId: '123' }; + + await action.executeWithFileSupport(signedApprovalRequest); + + expect(httpRequester.queryWithFileSupport).toHaveBeenCalledWith({ + method: 'post', + path: '/forest/actions/send-email', + body: { + data: { + attributes: expect.objectContaining({ + signed_approval_request: signedApprovalRequest, + }), + type: 'custom-action-requests', + }, + }, + }); + }); + + it('should return json result type correctly', async () => { + httpRequester.queryWithFileSupport.mockResolvedValue({ + type: 'json', + data: { success: 'Email sent', html: '

Done

' }, + }); + + const result = await action.executeWithFileSupport(); + + expect(result.type).toBe('json'); + expect(result).toHaveProperty('data'); + if (result.type === 'json') { + expect(result.data).toEqual({ success: 'Email sent', html: '

Done

' }); + } + }); + + it('should return file result type correctly', async () => { + const fileBuffer = Buffer.from('Hello, World!'); + httpRequester.queryWithFileSupport.mockResolvedValue({ + type: 'file', + buffer: fileBuffer, + mimeType: 'text/plain', + fileName: 'report.txt', + }); + + const result = await action.executeWithFileSupport(); + + expect(result.type).toBe('file'); + if (result.type === 'file') { + expect(result.buffer).toEqual(fileBuffer); + expect(result.mimeType).toBe('text/plain'); + expect(result.fileName).toBe('report.txt'); + } + }); + }); }); diff --git a/packages/agent-client/test/http-requester.test.ts b/packages/agent-client/test/http-requester.test.ts index a0d6af78e6..71548ad9a7 100644 --- a/packages/agent-client/test/http-requester.test.ts +++ b/packages/agent-client/test/http-requester.test.ts @@ -227,6 +227,348 @@ describe('HttpRequester', () => { }); }); + describe('queryWithFileSupport', () => { + let requester: HttpRequester; + let mockRequest: any; + + beforeEach(() => { + requester = new HttpRequester('test-token', { url: 'https://api.example.com' }); + + mockRequest = { + timeout: jest.fn().mockReturnThis(), + responseType: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + query: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + }; + + mockSuperagent.get = jest.fn().mockReturnValue(mockRequest); + mockSuperagent.post = jest.fn().mockReturnValue(mockRequest); + mockSuperagent.put = jest.fn().mockReturnValue(mockRequest); + mockSuperagent.delete = jest.fn().mockReturnValue(mockRequest); + }); + + it('should make a POST request with responseType arraybuffer', async () => { + const responseBody = { data: { id: '1', type: 'test', attributes: { name: 'Test' } } }; + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: Buffer.from(JSON.stringify(responseBody)), + headers: { + 'content-type': 'application/json', + 'content-disposition': '', + }, + }), + ); + }); + + await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/test', + body: { data: {} }, + }); + + expect(mockSuperagent.post).toHaveBeenCalledWith( + 'https://api.example.com/forest/actions/test', + ); + expect(mockRequest.responseType).toHaveBeenCalledWith('arraybuffer'); + expect(mockRequest.set).toHaveBeenCalledWith('Authorization', 'Bearer test-token'); + expect(mockRequest.set).toHaveBeenCalledWith('Content-Type', 'application/json'); + }); + + it('should return json result type for JSON responses', async () => { + const responseBody = { + data: { + id: '1', + type: 'users', + attributes: { name: 'John', email: 'john@example.com' }, + }, + }; + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: Buffer.from(JSON.stringify(responseBody)), + headers: { + 'content-type': 'application/json', + 'content-disposition': '', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/test', + }); + + expect(result.type).toBe('json'); + if (result.type === 'json') { + expect(result.data).toHaveProperty('name', 'John'); + expect(result.data).toHaveProperty('email', 'john@example.com'); + } + }); + + it('should return file result type for attachment responses', async () => { + const fileContent = Buffer.from('Hello, World!'); + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: fileContent, + headers: { + 'content-type': 'text/plain', + 'content-disposition': 'attachment; filename="test.txt"', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/download', + }); + + expect(result.type).toBe('file'); + if (result.type === 'file') { + expect(result.buffer).toEqual(fileContent); + expect(result.mimeType).toBe('text/plain'); + expect(result.fileName).toBe('test.txt'); + } + }); + + it('should extract filename from Content-Disposition with quotes', async () => { + const fileContent = Buffer.from('PDF content'); + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: fileContent, + headers: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="report.pdf"', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/download', + }); + + expect(result.type).toBe('file'); + if (result.type === 'file') { + expect(result.fileName).toBe('report.pdf'); + } + }); + + it('should extract filename from Content-Disposition without quotes', async () => { + const fileContent = Buffer.from('CSV content'); + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: fileContent, + headers: { + 'content-type': 'text/csv', + 'content-disposition': 'attachment; filename=export.csv', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/download', + }); + + expect(result.type).toBe('file'); + if (result.type === 'file') { + expect(result.fileName).toBe('export.csv'); + } + }); + + it('should use default filename when Content-Disposition is missing', async () => { + const fileContent = Buffer.from('Binary data'); + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: fileContent, + headers: { + 'content-type': 'application/octet-stream', + 'content-disposition': 'attachment', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/download', + }); + + expect(result.type).toBe('file'); + if (result.type === 'file') { + expect(result.fileName).toBe('download'); + } + }); + + it('should detect file response when content-type is not JSON or text', async () => { + const fileContent = Buffer.from('Image data'); + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: fileContent, + headers: { + 'content-type': 'image/png', + 'content-disposition': '', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/download', + }); + + expect(result.type).toBe('file'); + if (result.type === 'file') { + expect(result.mimeType).toBe('image/png'); + } + }); + + it('should return raw JSON when deserialization fails', async () => { + const responseBody = { customData: 'not JSON:API format', value: 42 }; + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: Buffer.from(JSON.stringify(responseBody)), + headers: { + 'content-type': 'application/json', + 'content-disposition': '', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/test', + }); + + expect(result.type).toBe('json'); + if (result.type === 'json') { + expect(result.data).toEqual({ customData: 'not JSON:API format', value: 42 }); + } + }); + + it('should throw error with response details on HTTP error', async () => { + const error = { + response: { + error: { message: 'Not Found', status: 404 }, + }, + }; + mockRequest.then = jest.fn((_onFulfilled: any, onRejected: any) => { + if (onRejected) { + return Promise.resolve(onRejected(error)); + } + + return Promise.reject(error); + }); + + await expect( + requester.queryWithFileSupport({ method: 'post', path: '/forest/actions/test' }), + ).rejects.toThrow(); + }); + + it('should rethrow error if no response', async () => { + const error = new Error('Network error'); + mockRequest.then = jest.fn((_onFulfilled: any, onRejected: any) => { + if (onRejected) { + return Promise.resolve(onRejected(error)); + } + + return Promise.reject(error); + }); + + await expect( + requester.queryWithFileSupport({ method: 'post', path: '/forest/actions/test' }), + ).rejects.toThrow('Network error'); + }); + + it('should use custom timeout if provided', async () => { + const responseBody = { success: true }; + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: Buffer.from(JSON.stringify(responseBody)), + headers: { + 'content-type': 'application/json', + 'content-disposition': '', + }, + }), + ); + }); + + await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/test', + maxTimeAllowed: 30000, + }); + + expect(mockRequest.timeout).toHaveBeenCalledWith(30000); + }); + + it('should include query parameters', async () => { + const responseBody = { success: true }; + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: Buffer.from(JSON.stringify(responseBody)), + headers: { + 'content-type': 'application/json', + 'content-disposition': '', + }, + }), + ); + }); + + await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/test', + query: { customParam: 'value' }, + }); + + expect(mockRequest.query).toHaveBeenCalledWith({ + timezone: 'Europe/Paris', + customParam: 'value', + }); + }); + + it('should strip charset from content-type when extracting mime type', async () => { + const fileContent = Buffer.from('Text content'); + mockRequest.then = jest.fn((onFulfilled: any) => { + return Promise.resolve( + onFulfilled({ + body: fileContent, + headers: { + 'content-type': 'text/plain; charset=utf-8', + 'content-disposition': 'attachment; filename="file.txt"', + }, + }), + ); + }); + + const result = await requester.queryWithFileSupport({ + method: 'post', + path: '/forest/actions/download', + }); + + expect(result.type).toBe('file'); + if (result.type === 'file') { + expect(result.mimeType).toBe('text/plain'); + } + }); + }); + describe('stream', () => { let requester: HttpRequester; let mockRequest: any; diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index 9b399fcbe9..eb84bdff5c 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -55,9 +55,13 @@ const defaultLogger: Logger = (level, message) => { /** Fields that are safe to log for each tool (non-sensitive data) */ const SAFE_ARGUMENTS_FOR_LOGGING: Record = { list: ['collectionName'], + listRelated: ['collectionName', 'relationName', 'parentRecordId'], create: ['collectionName'], update: ['collectionName', 'recordId'], delete: ['collectionName', 'recordIds'], + describeCollection: ['collectionName'], + getActionForm: ['collectionName', 'actionName'], + executeAction: ['collectionName', 'actionName'], }; /** diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index 6617fd1e12..e2a1472dec 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -158,7 +158,7 @@ function formatJsonResult(result: ActionResultFromAgent): FormattedResult { return { type: 'Success', message: result.success || 'Action executed successfully', - html: result.html || null, + html: result.html || undefined, invalidatedRelations: result.refresh?.relationships || [], }; } diff --git a/packages/mcp-server/src/tools/list-related.ts b/packages/mcp-server/src/tools/list-related.ts index 6b669a72e8..39ea3d79f3 100644 --- a/packages/mcp-server/src/tools/list-related.ts +++ b/packages/mcp-server/src/tools/list-related.ts @@ -70,36 +70,48 @@ export default function declareListRelatedTool( } catch (error) { // Parse error text if it's a JSON string from the agent const errorDetail = parseAgentError(error); + const errorMessage = error instanceof Error ? error.message : String(error); - const fields = getFieldsOfCollection( - await fetchForestSchema(forestServerUrl), - options.collectionName, - ); - - const toManyRelations = fields.filter( - field => field.relationship === 'HasMany' || field.relationship === 'BelongsToMany', - ); - - if ( - error.message?.toLowerCase()?.includes('not found') && - !toManyRelations.some(field => field.field === options.relationName) - ) { - throw new Error( - `The relation name provided is invalid for this collection. Available relations for collection ${ - options.collectionName - } are: ${toManyRelations.map(field => field.field).join(', ')}.`, + // Try to provide helpful context, but don't let this fail the error reporting + try { + const fields = getFieldsOfCollection( + await fetchForestSchema(forestServerUrl), + options.collectionName, ); - } - if (errorDetail?.includes('Invalid sort')) { - throw new Error( - `The sort field provided is invalid for this collection. Available fields for the collection ${ - options.collectionName - } are: ${fields - .filter(field => field.isSortable) - .map(field => field.field) - .join(', ')}.`, + const toManyRelations = fields.filter( + field => field.relationship === 'HasMany' || field.relationship === 'BelongsToMany', ); + + if ( + errorMessage?.toLowerCase()?.includes('not found') && + !toManyRelations.some(field => field.field === options.relationName) + ) { + throw new Error( + `The relation name provided is invalid for this collection. Available relations for collection ${ + options.collectionName + } are: ${toManyRelations.map(field => field.field).join(', ')}.`, + ); + } + + if (errorDetail?.includes('Invalid sort')) { + throw new Error( + `The sort field provided is invalid for this collection. Available fields for the collection ${ + options.collectionName + } are: ${fields + .filter(field => field.isSortable) + .map(field => field.field) + .join(', ')}.`, + ); + } + } catch (schemaError) { + // Schema fetch failed in error handler, fall through to return original error + if (schemaError instanceof Error && schemaError.message.includes('relation name')) { + throw schemaError; // Re-throw our custom error messages + } + if (schemaError instanceof Error && schemaError.message.includes('sort field')) { + throw schemaError; + } } throw errorDetail ? new Error(errorDetail) : error; diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 535f3af3be..46ac4a215e 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -20,6 +20,10 @@ const actionMapping: Record, @@ -30,11 +34,14 @@ export default async function createActivityLog( recordIds?: string[] | number[]; label?: string; }, -) { +): Promise { const mapping = actionMapping[action]; if (!mapping) { - throw new Error(`Unknown action type: ${action}`); + // Unknown action type - log warning but don't block the operation + console.warn(`[ActivityLog] Unknown action type: ${action} - skipping activity log`); + + return; } const { apiAction, type } = mapping; @@ -42,45 +49,52 @@ export default async function createActivityLog( const forestServerToken = request.authInfo?.extra?.forestServerToken as string; const renderingId = request.authInfo?.extra?.renderingId as string; - const response = await fetch(`${forestServerUrl}/api/activity-logs-requests`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Forest-Application-Source': 'MCP', - Authorization: `Bearer ${forestServerToken}`, - // 'forest-secret-key': process.env.FOREST_ENV_SECRET || '', - }, - body: JSON.stringify({ - data: { - id: 1, - type: 'activity-logs-requests', - attributes: { - type, - action: apiAction, - label: extra?.label, - records: (extra?.recordIds || (extra?.recordId ? [extra.recordId] : [])) as string[], - }, - relationships: { - rendering: { - data: { - id: renderingId, - type: 'renderings', - }, + try { + const response = await fetch(`${forestServerUrl}/api/activity-logs-requests`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Forest-Application-Source': 'MCP', + Authorization: `Bearer ${forestServerToken}`, + }, + body: JSON.stringify({ + data: { + id: 1, + type: 'activity-logs-requests', + attributes: { + type, + action: apiAction, + label: extra?.label, + records: (extra?.recordIds || (extra?.recordId ? [extra.recordId] : [])) as string[], }, - collection: { - data: extra?.collectionName - ? { - id: extra.collectionName, - type: 'collections', - } - : null, + relationships: { + rendering: { + data: { + id: renderingId, + type: 'renderings', + }, + }, + collection: { + data: extra?.collectionName + ? { + id: extra.collectionName, + type: 'collections', + } + : null, + }, }, }, - }, - }), - }); + }), + }); - if (!response.ok) { - throw new Error(`Failed to create activity log: ${await response.text()}`); + if (!response.ok) { + // Log warning but don't block the main operation + console.warn(`[ActivityLog] Failed to create activity log: ${await response.text()}`); + } + } catch (error) { + // Log error but don't block the main operation + console.warn( + `[ActivityLog] Error creating activity log: ${error instanceof Error ? error.message : String(error)}`, + ); } } diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts new file mode 100644 index 0000000000..7079293346 --- /dev/null +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -0,0 +1,686 @@ +import type { Logger } from '../../src/server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import declareExecuteActionTool from '../../src/tools/execute-action'; +import createActivityLog from '../../src/utils/activity-logs-creator'; +import buildClient from '../../src/utils/agent-caller'; + +jest.mock('../../src/utils/agent-caller'); +jest.mock('../../src/utils/activity-logs-creator'); + +const mockLogger: Logger = jest.fn(); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockCreateActivityLog = createActivityLog as jest.MockedFunction; + +describe('declareExecuteActionTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + mockCreateActivityLog.mockResolvedValue(undefined); + }); + + describe('tool registration', () => { + it('should register a tool named "executeAction"', () => { + declareExecuteActionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'executeAction', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareExecuteActionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('Execute an action'); + expect(registeredToolConfig.description).toContain('Execute an action on a collection'); + }); + + it('should define correct input schema', () => { + declareExecuteActionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('actionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('recordIds'); + expect(registeredToolConfig.inputSchema).toHaveProperty('values'); + }); + + it('should use string type for collectionName when no collection names provided', () => { + declareExecuteActionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options?: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toBeUndefined(); + expect(() => schema.collectionName.parse('any-collection')).not.toThrow(); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareExecuteActionTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + 'users', + 'products', + ]); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toEqual(['users', 'products']); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + beforeEach(() => { + declareExecuteActionTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + }); + + it('should call buildClient with the extra parameter and actionEndpoints', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Action completed' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1] }, + mockExtra, + ); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, {}); + }); + + it('should call rpcClient.collection with the collection name', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Action completed' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1] }, + mockExtra, + ); + + expect(mockCollection).toHaveBeenCalledWith('users'); + }); + + it('should call action with action name and recordIds', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Action completed' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1, 2, 3] }, + mockExtra, + ); + + expect(mockAction).toHaveBeenCalledWith('Send Email', { recordIds: [1, 2, 3] }); + }); + + it('should call setFields when values are provided', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Action completed' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const values = { Subject: 'Hello', Body: 'World' }; + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1], values }, + mockExtra, + ); + + expect(mockSetFields).toHaveBeenCalledWith(values); + }); + + it('should NOT call setFields when no values provided', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Action completed' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1] }, + mockExtra, + ); + + expect(mockSetFields).not.toHaveBeenCalled(); + }); + + describe('result formatting', () => { + it('should return Success result with message', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Email sent successfully' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.type).toBe('Success'); + expect(parsedResult.message).toBe('Email sent successfully'); + }); + + it('should return Success result with html content', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Done', html: '

Result here

' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Generate Report', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.type).toBe('Success'); + expect(parsedResult.html).toBe('

Result here

'); + }); + + it('should return Success result with invalidatedRelations', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Done', refresh: { relationships: ['orders', 'payments'] } }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Update Status', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.type).toBe('Success'); + expect(parsedResult.invalidatedRelations).toEqual(['orders', 'payments']); + }); + + it('should return Webhook result', async () => { + const mockSetFields = jest.fn(); + const webhookData = { + url: 'https://api.example.com/webhook', + method: 'POST', + headers: { 'X-Custom': 'value' }, + body: { data: 'test' }, + }; + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { webhook: webhookData }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Trigger Webhook', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.type).toBe('Webhook'); + expect(parsedResult.webhook).toEqual(webhookData); + }); + + it('should return Redirect result', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { redirectTo: '/users/42' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Go To User', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.type).toBe('Redirect'); + expect(parsedResult.redirectTo).toBe('/users/42'); + }); + + it('should return File result for small files', async () => { + const fileContent = Buffer.from('Hello, World!'); + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'file', + buffer: fileContent, + mimeType: 'text/plain', + fileName: 'test.txt', + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'reports', actionName: 'Download Report', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.type).toBe('File'); + expect(parsedResult.fileName).toBe('test.txt'); + expect(parsedResult.mimeType).toBe('text/plain'); + expect(parsedResult.contentBase64).toBe(fileContent.toString('base64')); + expect(parsedResult.sizeBytes).toBe(fileContent.length); + }); + + it('should return FileTooLarge result for files over 5MB', async () => { + // Create a buffer larger than 5MB + const largeBuffer = Buffer.alloc(6 * 1024 * 1024); + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'file', + buffer: largeBuffer, + mimeType: 'application/zip', + fileName: 'large-export.zip', + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'reports', actionName: 'Download Large Report', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.type).toBe('FileTooLarge'); + expect(parsedResult.fileName).toBe('large-export.zip'); + expect(parsedResult.mimeType).toBe('application/zip'); + expect(parsedResult.sizeBytes).toBe(largeBuffer.length); + expect(parsedResult.maxSizeBytes).toBe(5 * 1024 * 1024); + }); + }); + + describe('file upload conversion', () => { + it('should convert single file value to data URI', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'File uploaded' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const fileValue = { + name: 'config.json', + mimeType: 'application/json', + contentBase64: Buffer.from('{"key":"value"}').toString('base64'), + }; + + await registeredToolHandler( + { + collectionName: 'documents', + actionName: 'Import Config', + recordIds: [1], + values: { 'Config File': fileValue }, + }, + mockExtra, + ); + + expect(mockSetFields).toHaveBeenCalledWith({ + 'Config File': `data:application/json;name=config.json;base64,${fileValue.contentBase64}`, + }); + }); + + it('should convert file array to data URI array', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Files uploaded' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const file1 = { + name: 'doc1.pdf', + mimeType: 'application/pdf', + contentBase64: 'YmFzZTY0Y29udGVudDE=', + }; + const file2 = { + name: 'doc2.pdf', + mimeType: 'application/pdf', + contentBase64: 'YmFzZTY0Y29udGVudDI=', + }; + + await registeredToolHandler( + { + collectionName: 'documents', + actionName: 'Upload Documents', + recordIds: [1], + values: { Documents: [file1, file2] }, + }, + mockExtra, + ); + + expect(mockSetFields).toHaveBeenCalledWith({ + Documents: [ + `data:application/pdf;name=doc1.pdf;base64,${file1.contentBase64}`, + `data:application/pdf;name=doc2.pdf;base64,${file2.contentBase64}`, + ], + }); + }); + + it('should encode special characters in filename', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'File uploaded' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const fileValue = { + name: 'my file (1).json', + mimeType: 'application/json', + contentBase64: 'e30=', + }; + + await registeredToolHandler( + { + collectionName: 'documents', + actionName: 'Import Config', + recordIds: [1], + values: { File: fileValue }, + }, + mockExtra, + ); + + expect(mockSetFields).toHaveBeenCalledWith({ + File: `data:application/json;name=my%20file%20(1).json;base64,e30=`, + }); + }); + + it('should pass through non-file values unchanged', async () => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Done' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { + collectionName: 'users', + actionName: 'Send Email', + recordIds: [1], + values: { Subject: 'Hello', Count: 42, Active: true }, + }, + mockExtra, + ); + + expect(mockSetFields).toHaveBeenCalledWith({ + Subject: 'Hello', + Count: 42, + Active: true, + }); + }); + }); + + describe('activity logging', () => { + beforeEach(() => { + const mockSetFields = jest.fn(); + const mockExecuteWithFileSupport = jest.fn().mockResolvedValue({ + type: 'json', + data: { success: 'Done' }, + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + executeWithFileSupport: mockExecuteWithFileSupport, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should create activity log with "executeAction" action type and label', async () => { + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Reminder', recordIds: [42] }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'executeAction', + { collectionName: 'users', label: 'Send Reminder' }, + ); + }); + }); + + describe('input parsing', () => { + it('should parse recordIds sent as JSON string (LLM workaround)', () => { + const recordIds = [1, 2, 3]; + const recordIdsAsString = JSON.stringify(recordIds); + + const inputSchema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + const parsedRecordIds = inputSchema.recordIds.parse(recordIdsAsString); + + expect(parsedRecordIds).toEqual(recordIds); + }); + + it('should parse values sent as JSON string (LLM workaround)', () => { + const values = { Subject: 'Hello', Body: 'World' }; + const valuesAsString = JSON.stringify(values); + + const inputSchema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + const parsedValues = inputSchema.values.parse(valuesAsString); + + expect(parsedValues).toEqual(values); + }); + }); + + describe('error handling', () => { + it('should parse error with nested error.text structure in message', async () => { + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Field is required' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + const mockAction = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler( + { collectionName: 'users', actionName: 'Test Action', recordIds: [1] }, + mockExtra, + ), + ).rejects.toThrow('Field is required'); + }); + + it('should rethrow original error when no parsable error found', async () => { + const agentError = { unknownProperty: 'some value' }; + const mockAction = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler( + { collectionName: 'users', actionName: 'Test Action', recordIds: [1] }, + mockExtra, + ), + ).rejects.toEqual(agentError); + }); + }); + }); +}); diff --git a/packages/mcp-server/test/tools/get-action-form.test.ts b/packages/mcp-server/test/tools/get-action-form.test.ts new file mode 100644 index 0000000000..f153319c00 --- /dev/null +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -0,0 +1,674 @@ +import type { Logger } from '../../src/server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; +import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; + +import declareGetActionFormTool from '../../src/tools/get-action-form'; +import createActivityLog from '../../src/utils/activity-logs-creator'; +import buildClient from '../../src/utils/agent-caller'; + +jest.mock('../../src/utils/agent-caller'); +jest.mock('../../src/utils/activity-logs-creator'); + +const mockLogger: Logger = jest.fn(); + +const mockBuildClient = buildClient as jest.MockedFunction; +const mockCreateActivityLog = createActivityLog as jest.MockedFunction; + +describe('declareGetActionFormTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + beforeEach(() => { + jest.clearAllMocks(); + + mcpServer = { + registerTool: jest.fn((name, config, handler) => { + registeredToolConfig = config; + registeredToolHandler = handler; + }), + } as unknown as McpServer; + + mockCreateActivityLog.mockResolvedValue(undefined); + }); + + describe('tool registration', () => { + it('should register a tool named "getActionForm"', () => { + declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'getActionForm', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('Get action form'); + expect(registeredToolConfig.description).toContain('Load the form fields for an action'); + }); + + it('should define correct input schema', () => { + declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('actionName'); + expect(registeredToolConfig.inputSchema).toHaveProperty('recordIds'); + expect(registeredToolConfig.inputSchema).toHaveProperty('values'); + }); + + it('should use enum type for collectionName when collection names provided', () => { + declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ + 'users', + 'products', + ]); + + const schema = registeredToolConfig.inputSchema as Record< + string, + { options: string[]; parse: (value: unknown) => unknown } + >; + expect(schema.collectionName.options).toEqual(['users', 'products']); + }); + }); + + describe('tool execution', () => { + const mockExtra = { + authInfo: { + token: 'test-token', + extra: { + forestServerToken: 'forest-token', + renderingId: '123', + }, + }, + } as unknown as RequestHandlerExtra; + + const createMockField = (overrides: Partial<{ + name: string; + type: string; + value: unknown; + isRequired: boolean; + isReadOnly: boolean; + description: string; + enums: string[]; + widgetEdit: { name: string; parameters: Record }; + reference: string; + }> = {}) => { + const plainField = { + field: overrides.name || 'testField', + type: overrides.type || 'String', + value: overrides.value, + isRequired: overrides.isRequired ?? false, + isReadOnly: overrides.isReadOnly ?? false, + description: overrides.description, + enums: overrides.enums, + widgetEdit: overrides.widgetEdit, + reference: overrides.reference, + }; + + return { + getName: () => plainField.field, + getType: () => plainField.type, + getValue: () => plainField.value, + isRequired: () => plainField.isRequired, + getPlainField: () => plainField, + }; + }; + + beforeEach(() => { + declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + }); + + it('should call buildClient with the extra parameter and actionEndpoints', async () => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1] }, + mockExtra, + ); + + expect(mockBuildClient).toHaveBeenCalledWith(mockExtra, {}); + }); + + it('should call action with action name and recordIds', async () => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1, 2, 3] }, + mockExtra, + ); + + expect(mockAction).toHaveBeenCalledWith('Send Email', { recordIds: [1, 2, 3] }); + }); + + it('should call setFields when values are provided', async () => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const values = { Type: 'Pro', Country: 'France' }; + await registeredToolHandler( + { collectionName: 'customers', actionName: 'Create Contract', recordIds: [1], values }, + mockExtra, + ); + + expect(mockSetFields).toHaveBeenCalledWith(values); + }); + + it('should NOT call setFields when no values provided', async () => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1] }, + mockExtra, + ); + + expect(mockSetFields).not.toHaveBeenCalled(); + }); + + describe('field formatting', () => { + it('should format basic field information', async () => { + const mockField = createMockField({ + name: 'Subject', + type: 'String', + value: 'Default Subject', + isRequired: true, + isReadOnly: false, + description: 'The email subject', + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Email', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.fields[0]).toEqual({ + name: 'Subject', + type: 'String', + value: 'Default Subject', + isRequired: true, + isReadOnly: false, + description: 'The email subject', + enums: null, + options: null, + widget: null, + reference: null, + }); + }); + + it('should format field with enums', async () => { + const mockField = createMockField({ + name: 'Priority', + type: 'Enum', + isRequired: true, + enums: ['Low', 'Medium', 'High'], + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'tasks', actionName: 'Set Priority', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.fields[0].enums).toEqual(['Low', 'Medium', 'High']); + }); + + it('should format field with widget parameters', async () => { + const mockField = createMockField({ + name: 'Quantity', + type: 'Number', + widgetEdit: { + name: 'number input', + parameters: { + placeholder: 'Enter quantity', + min: 1, + max: 100, + step: 1, + }, + }, + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'orders', actionName: 'Update Quantity', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.fields[0].widget).toEqual({ + type: 'number input', + placeholder: 'Enter quantity', + min: 1, + max: 100, + step: 1, + }); + }); + + it('should format file field with file constraints', async () => { + const mockField = createMockField({ + name: 'Document', + type: 'File', + widgetEdit: { + name: 'file picker', + parameters: { + filesExtensions: ['.pdf', '.doc', '.docx'], + filesSizeLimit: 10, + filesCountLimit: 5, + }, + }, + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'documents', actionName: 'Upload', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.fields[0].widget).toEqual({ + type: 'file picker', + allowedExtensions: ['.pdf', '.doc', '.docx'], + maxSizeMb: 10, + maxFiles: 5, + }); + }); + + it('should format field with dropdown options', async () => { + const mockField = createMockField({ + name: 'Country', + type: 'String', + widgetEdit: { + name: 'dropdown', + parameters: { + isSearchable: true, + static: { + options: [ + { label: 'France', value: 'FR' }, + { label: 'Germany', value: 'DE' }, + ], + }, + }, + }, + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Set Country', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.fields[0].options).toEqual([ + { label: 'France', value: 'FR' }, + { label: 'Germany', value: 'DE' }, + ]); + expect(parsedResult.fields[0].widget.isSearchable).toBe(true); + }); + + it('should format Collection field with reference', async () => { + const mockField = createMockField({ + name: 'Assigned User', + type: 'Collection', + reference: 'users.id', + widgetEdit: { + name: 'dropdown', + parameters: { + searchType: 'dynamic', + }, + }, + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'tasks', actionName: 'Assign Task', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.fields[0].reference).toBe('users.id'); + expect(parsedResult.fields[0].widget.hasDynamicSearch).toBe(true); + }); + }); + + describe('layout formatting', () => { + it('should return null layout when no layout provided', async () => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Simple Action', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.layout).toBeNull(); + }); + + it('should format single-page layout', async () => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({ + layout: [ + { component: 'input', fieldId: 'field1' }, + { component: 'input', fieldId: 'field2' }, + ], + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'users', actionName: 'Form Action', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.layout).toEqual({ + type: 'single-page', + elements: [ + { component: 'input', fieldId: 'field1' }, + { component: 'input', fieldId: 'field2' }, + ], + }); + }); + + it('should format multi-page layout', async () => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({ + layout: [ + { + component: 'page', + elements: [{ component: 'input', fieldId: 'field1' }], + nextButtonLabel: 'Next', + }, + { + component: 'page', + elements: [{ component: 'input', fieldId: 'field2' }], + previousButtonLabel: 'Back', + nextButtonLabel: 'Submit', + }, + ], + }); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'customers', actionName: 'Wizard Action', recordIds: [1] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.layout).toEqual({ + type: 'multi-page', + pages: [ + { + pageNumber: 1, + elements: [{ component: 'input', fieldId: 'field1' }], + nextButtonLabel: 'Next', + }, + { + pageNumber: 2, + elements: [{ component: 'input', fieldId: 'field2' }], + previousButtonLabel: 'Back', + nextButtonLabel: 'Submit', + }, + ], + }); + }); + }); + + describe('activity logging', () => { + beforeEach(() => { + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + }); + + it('should create activity log with "getActionForm" action type and label', async () => { + await registeredToolHandler( + { collectionName: 'users', actionName: 'Send Reminder', recordIds: [42] }, + mockExtra, + ); + + expect(mockCreateActivityLog).toHaveBeenCalledWith( + 'https://api.forestadmin.com', + mockExtra, + 'getActionForm', + { collectionName: 'users', label: 'Send Reminder' }, + ); + }); + }); + + describe('input parsing', () => { + it('should parse recordIds sent as JSON string (LLM workaround)', () => { + const recordIds = [1, 2, 3]; + const recordIdsAsString = JSON.stringify(recordIds); + + const inputSchema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + const parsedRecordIds = inputSchema.recordIds.parse(recordIdsAsString); + + expect(parsedRecordIds).toEqual(recordIds); + }); + + it('should parse values sent as JSON string (LLM workaround)', () => { + const values = { Type: 'Pro', Country: 'France' }; + const valuesAsString = JSON.stringify(values); + + const inputSchema = registeredToolConfig.inputSchema as Record< + string, + { parse: (value: unknown) => unknown } + >; + const parsedValues = inputSchema.values.parse(valuesAsString); + + expect(parsedValues).toEqual(values); + }); + }); + + describe('error handling', () => { + it('should parse error with nested error.text structure in message', async () => { + const errorPayload = { + error: { + status: 400, + text: JSON.stringify({ + errors: [{ name: 'ValidationError', detail: 'Action not found' }], + }), + }, + }; + const agentError = new Error(JSON.stringify(errorPayload)); + const mockAction = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler( + { collectionName: 'users', actionName: 'Unknown Action', recordIds: [1] }, + mockExtra, + ), + ).rejects.toThrow('Action not found'); + }); + + it('should rethrow original error when no parsable error found', async () => { + const agentError = { unknownProperty: 'some value' }; + const mockAction = jest.fn().mockRejectedValue(agentError); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + await expect( + registeredToolHandler( + { collectionName: 'users', actionName: 'Test Action', recordIds: [1] }, + mockExtra, + ), + ).rejects.toEqual(agentError); + }); + }); + }); +}); From 69f86dc4493874f5a968d6ab8bb95152dae0b40a Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Dec 2025 21:44:34 +0100 Subject: [PATCH 14/18] fix(mcp-server): add .js extensions to imports for ESM lint compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/agent-client/tsconfig.eslint.json | 3 ++ .../test/tools/execute-action.test.ts | 6 ++-- .../test/tools/get-action-form.test.ts | 30 ++++++++++--------- 3 files changed, 22 insertions(+), 17 deletions(-) create mode 100644 packages/agent-client/tsconfig.eslint.json diff --git a/packages/agent-client/tsconfig.eslint.json b/packages/agent-client/tsconfig.eslint.json new file mode 100644 index 0000000000..9bdc52705d --- /dev/null +++ b/packages/agent-client/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.eslint.json" +} diff --git a/packages/mcp-server/test/tools/execute-action.test.ts b/packages/mcp-server/test/tools/execute-action.test.ts index 7079293346..97bf6ce26b 100644 --- a/packages/mcp-server/test/tools/execute-action.test.ts +++ b/packages/mcp-server/test/tools/execute-action.test.ts @@ -3,9 +3,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; -import declareExecuteActionTool from '../../src/tools/execute-action'; -import createActivityLog from '../../src/utils/activity-logs-creator'; -import buildClient from '../../src/utils/agent-caller'; +import declareExecuteActionTool from '../../src/tools/execute-action.js'; +import createActivityLog from '../../src/utils/activity-logs-creator.js'; +import buildClient from '../../src/utils/agent-caller.js'; jest.mock('../../src/utils/agent-caller'); jest.mock('../../src/utils/activity-logs-creator'); 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 f153319c00..691af5c449 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -3,9 +3,9 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; -import declareGetActionFormTool from '../../src/tools/get-action-form'; -import createActivityLog from '../../src/utils/activity-logs-creator'; -import buildClient from '../../src/utils/agent-caller'; +import declareGetActionFormTool from '../../src/tools/get-action-form.js'; +import createActivityLog from '../../src/utils/activity-logs-creator.js'; +import buildClient from '../../src/utils/agent-caller.js'; jest.mock('../../src/utils/agent-caller'); jest.mock('../../src/utils/activity-logs-creator'); @@ -85,17 +85,19 @@ describe('declareGetActionFormTool', () => { }, } as unknown as RequestHandlerExtra; - const createMockField = (overrides: Partial<{ - name: string; - type: string; - value: unknown; - isRequired: boolean; - isReadOnly: boolean; - description: string; - enums: string[]; - widgetEdit: { name: string; parameters: Record }; - reference: string; - }> = {}) => { + const createMockField = ( + overrides: Partial<{ + name: string; + type: string; + value: unknown; + isRequired: boolean; + isReadOnly: boolean; + description: string; + enums: string[]; + widgetEdit: { name: string; parameters: Record }; + reference: string; + }> = {}, + ) => { const plainField = { field: overrides.name || 'testField', type: overrides.type || 'String', From d04c2f97f1ba277aae801258d7f7ed31e66901ee Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sat, 20 Dec 2025 21:45:18 +0100 Subject: [PATCH 15/18] chore: formatting and minor cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/agent-client/src/http-requester.ts | 7 ++++- .../agent-client/test/domains/action.test.ts | 4 ++- .../agent-client/test/http-requester.test.ts | 26 ++++++++++++------- .../mcp-server/src/tools/execute-action.ts | 7 ++--- packages/mcp-server/src/tools/list-related.ts | 1 + .../src/utils/activity-logs-creator.ts | 4 ++- 6 files changed, 31 insertions(+), 18 deletions(-) diff --git a/packages/agent-client/src/http-requester.ts b/packages/agent-client/src/http-requester.ts index f2a46597f4..f40153661a 100644 --- a/packages/agent-client/src/http-requester.ts +++ b/packages/agent-client/src/http-requester.ts @@ -106,6 +106,7 @@ export default class HttpRequester { // Format: attachment; filename="report.pdf" or attachment; filename=report.pdf const fileNameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); let fileName = 'download'; + if (fileNameMatch && fileNameMatch[1]) { fileName = fileNameMatch[1].replace(/['"]/g, ''); } @@ -128,7 +129,11 @@ export default class HttpRequester { // Log the failure - this is important for debugging schema mismatches console.warn( `[HttpRequester] Failed to deserialize JSON:API response, returning raw JSON. ` + - `Error: ${deserializationError instanceof Error ? deserializationError.message : String(deserializationError)}`, + `Error: ${ + deserializationError instanceof Error + ? deserializationError.message + : String(deserializationError) + }`, ); return { type: 'json', data: jsonBody as Data }; diff --git a/packages/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index ea61fb7ce6..e05cb4f68f 100644 --- a/packages/agent-client/test/domains/action.test.ts +++ b/packages/agent-client/test/domains/action.test.ts @@ -1,5 +1,5 @@ -import Action from '../../src/domains/action'; import FieldFormStates from '../../src/action-fields/field-form-states'; +import Action from '../../src/domains/action'; import HttpRequester from '../../src/http-requester'; jest.mock('../../src/http-requester'); @@ -381,6 +381,7 @@ describe('Action', () => { expect(result.type).toBe('json'); expect(result).toHaveProperty('data'); + if (result.type === 'json') { expect(result.data).toEqual({ success: 'Email sent', html: '

Done

' }); } @@ -398,6 +399,7 @@ describe('Action', () => { const result = await action.executeWithFileSupport(); expect(result.type).toBe('file'); + if (result.type === 'file') { expect(result.buffer).toEqual(fileBuffer); expect(result.mimeType).toBe('text/plain'); diff --git a/packages/agent-client/test/http-requester.test.ts b/packages/agent-client/test/http-requester.test.ts index 71548ad9a7..59cad3260d 100644 --- a/packages/agent-client/test/http-requester.test.ts +++ b/packages/agent-client/test/http-requester.test.ts @@ -302,6 +302,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('json'); + if (result.type === 'json') { expect(result.data).toHaveProperty('name', 'John'); expect(result.data).toHaveProperty('email', 'john@example.com'); @@ -328,6 +329,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('file'); + if (result.type === 'file') { expect(result.buffer).toEqual(fileContent); expect(result.mimeType).toBe('text/plain'); @@ -355,6 +357,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('file'); + if (result.type === 'file') { expect(result.fileName).toBe('report.pdf'); } @@ -380,6 +383,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('file'); + if (result.type === 'file') { expect(result.fileName).toBe('export.csv'); } @@ -405,6 +409,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('file'); + if (result.type === 'file') { expect(result.fileName).toBe('download'); } @@ -430,6 +435,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('file'); + if (result.type === 'file') { expect(result.mimeType).toBe('image/png'); } @@ -455,6 +461,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('json'); + if (result.type === 'json') { expect(result.data).toEqual({ customData: 'not JSON:API format', value: 42 }); } @@ -563,6 +570,7 @@ describe('HttpRequester', () => { }); expect(result.type).toBe('file'); + if (result.type === 'file') { expect(result.mimeType).toBe('text/plain'); } @@ -645,17 +653,15 @@ describe('HttpRequester', () => { const mockStream = {} as any; const error = new Error('Stream error'); - mockPipeResult.on = jest.fn().mockImplementation(function ( - this: any, - event: string, - callback: (err?: Error) => void, - ) { - if (event === 'error') { - setImmediate(() => callback(error)); - } + mockPipeResult.on = jest + .fn() + .mockImplementation(function (this: any, event: string, callback: (err?: Error) => void) { + if (event === 'error') { + setImmediate(() => callback(error)); + } - return this; - }); + return this; + }); await expect( requester.stream({ diff --git a/packages/mcp-server/src/tools/execute-action.ts b/packages/mcp-server/src/tools/execute-action.ts index e2a1472dec..ea850d15aa 100644 --- a/packages/mcp-server/src/tools/execute-action.ts +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -60,6 +60,7 @@ function isFileValue(value: unknown): value is FileValue { function fileToDataUri(file: FileValue): string { // Format: data:mimeType;name=filename;base64,content const encodedName = encodeURIComponent(file.name); + return `data:${file.mimeType};name=${encodedName};base64,${file.contentBase64}`; } @@ -163,11 +164,7 @@ function formatJsonResult(result: ActionResultFromAgent): FormattedResult { }; } -function formatFileResult( - buffer: Buffer, - mimeType: string, - fileName: string, -): FormattedResult { +function formatFileResult(buffer: Buffer, mimeType: string, fileName: string): FormattedResult { const sizeBytes = buffer.length; if (sizeBytes > MAX_FILE_SIZE_BYTES) { diff --git a/packages/mcp-server/src/tools/list-related.ts b/packages/mcp-server/src/tools/list-related.ts index 39ea3d79f3..990a9647e0 100644 --- a/packages/mcp-server/src/tools/list-related.ts +++ b/packages/mcp-server/src/tools/list-related.ts @@ -109,6 +109,7 @@ export default function declareListRelatedTool( if (schemaError instanceof Error && schemaError.message.includes('relation name')) { throw schemaError; // Re-throw our custom error messages } + if (schemaError instanceof Error && schemaError.message.includes('sort field')) { throw schemaError; } diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 46ac4a215e..9083a5066a 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -94,7 +94,9 @@ export default async function createActivityLog( } catch (error) { // Log error but don't block the main operation console.warn( - `[ActivityLog] Error creating activity log: ${error instanceof Error ? error.message : String(error)}`, + `[ActivityLog] Error creating activity log: ${ + error instanceof Error ? error.message : String(error) + }`, ); } } From a7e9fc3b6b56905a8a4e781650c2351f64ff8d88 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Dec 2025 12:40:39 +0100 Subject: [PATCH 16/18] fix(mcp-server): simplify form hints for dynamic forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove complex Boolean/Enum field detection from hints - Keep only canExecute and requiredFieldsMissing in hints - Update tool description to explain dynamic form behavior - Fix activity-logs-creator tests to match non-throwing behavior - Add example multi-page dynamic form action in dvd.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../_example/src/forest/customizations/dvd.ts | 216 ++++++++++++++++++ .../mcp-server/src/tools/get-action-form.ts | 32 ++- .../test/tools/get-action-form.test.ts | 100 ++++++++ .../test/utils/activity-logs-creator.test.ts | 25 +- 4 files changed, 364 insertions(+), 9 deletions(-) diff --git a/packages/_example/src/forest/customizations/dvd.ts b/packages/_example/src/forest/customizations/dvd.ts index 7edd243de9..947b4a8531 100644 --- a/packages/_example/src/forest/customizations/dvd.ts +++ b/packages/_example/src/forest/customizations/dvd.ts @@ -36,4 +36,220 @@ export default (collection: DvdCustomizer) => // Customize success message. return resultBuilder.success(`Rental price increased`); }, + }) + // Action with File upload, Multi-page form, and Dynamic fields + .addAction('Add DVD to Collection', { + scope: 'Global', + form: [ + // Page 1: Basic DVD Information + { + type: 'Layout', + component: 'Page', + nextButtonLabel: 'Next: Media Files', + elements: [ + { + type: 'Layout', + component: 'HtmlBlock', + content: '

Add a New DVD

Enter the basic information about the DVD.

', + }, + { type: 'Layout', component: 'Separator' }, + { label: 'Title', type: 'String', isRequired: true }, + { + label: 'Genre', + type: 'Enum', + enumValues: [ + 'Action', + 'Comedy', + 'Drama', + 'Horror', + 'Sci-Fi', + 'Documentary', + 'Animation', + ], + isRequired: true, + }, + { + label: 'Release Year', + type: 'Number', + defaultValue: new Date().getFullYear(), + }, + { + label: 'Is Special Edition', + type: 'Boolean', + widget: 'Checkbox', + defaultValue: false, + }, + // Dynamic field: only visible when special edition is checked + { + label: 'Special Edition Name', + type: 'String', + if: ctx => ctx.formValues['Is Special Edition'] === true, + }, + ], + }, + + // Page 2: Media Files + { + type: 'Layout', + component: 'Page', + nextButtonLabel: 'Next: Details', + previousButtonLabel: 'Back', + elements: [ + { + type: 'Layout', + component: 'HtmlBlock', + content: '

Media Files

Upload cover image and other media.

', + }, + { type: 'Layout', component: 'Separator' }, + { + label: 'Cover Image', + type: 'File', + description: 'Upload the DVD cover image (JPEG, PNG)', + }, + { + label: 'Trailer Video', + type: 'File', + description: 'Optional: Upload a trailer video', + }, + { + type: 'Layout', + component: 'Row', + fields: [ + { label: 'Duration (minutes)', type: 'Number', isRequired: true }, + { label: 'Disc Count', type: 'Number', defaultValue: 1 }, + ], + }, + { + label: 'Audio Languages', + type: 'StringList', + widget: 'CheckboxGroup', + options: [ + { value: 'en', label: 'English' }, + { value: 'fr', label: 'French' }, + { value: 'es', label: 'Spanish' }, + { value: 'de', label: 'German' }, + { value: 'ja', label: 'Japanese' }, + ], + }, + ], + }, + + // Page 3: Additional Details + { + type: 'Layout', + component: 'Page', + nextButtonLabel: 'Create DVD', + previousButtonLabel: 'Back', + elements: [ + { + type: 'Layout', + component: 'HtmlBlock', + content: '

Additional Details

Add any extra information.

', + }, + { type: 'Layout', component: 'Separator' }, + { + label: 'Description', + type: 'String', + widget: 'TextArea', + }, + { label: 'Director', type: 'String' }, + { + label: 'Main Cast', + type: 'StringList', + widget: 'TextInputList', + }, + { + label: 'Rating', + type: 'Enum', + enumValues: ['G', 'PG', 'PG-13', 'R', 'NC-17'], + }, + { + label: 'Price Category', + type: 'String', + widget: 'RadioGroup', + options: [ + { value: 'budget', label: 'Budget ($5-10)' }, + { value: 'standard', label: 'Standard ($10-20)' }, + { value: 'premium', label: 'Premium ($20-30)' }, + { value: 'collector', label: 'Collector ($30+)' }, + ], + defaultValue: 'standard', + }, + // Dynamic field: only visible for Horror genre + { + label: 'Scare Level (1-10)', + type: 'Number', + if: ctx => ctx.formValues.Genre === 'Horror', + }, + { + label: 'Extra Metadata', + type: 'Json', + widget: 'JsonEditor', + }, + ], + }, + ], + execute: async (context, resultBuilder) => { + const title = context.formValues.Title as string; + const genre = context.formValues.Genre as string; + const coverImage = context.formValues['Cover Image'] as { + name: string; + mimeType: string; + } | null; + + // Log all form values for testing + console.log('DVD Form Values:', JSON.stringify(context.formValues, null, 2)); + + // In a real scenario, you would save the DVD to the database + // For now, just return a success message with the details + return resultBuilder.success( + `DVD "${title}" (${genre}) would be added to the collection!${ + coverImage ? ` Cover: ${coverImage.name}` : '' + }`, + ); + }, + }) + // Action that returns a file + .addAction('Generate DVD Label', { + scope: 'Single', + form: [ + { + label: 'Label Format', + type: 'Enum', + enumValues: ['PDF', 'PNG', 'SVG'], + defaultValue: 'PDF', + }, + { + label: 'Include Barcode', + type: 'Boolean', + widget: 'Checkbox', + defaultValue: true, + }, + ], + execute: async (context, resultBuilder) => { + const record = await context.getRecord(['title', 'rentalPrice']); + const format = context.formValues['Label Format'] as string; + const includeBarcode = context.formValues['Include Barcode'] as boolean; + + // Generate a simple label content + const labelContent = ` +DVD LABEL +========= +Title: ${record.title} +Price: $${record.rentalPrice} +${includeBarcode ? 'Barcode: ||||||||||||||||' : ''} + `.trim(); + + const mimeTypes: Record = { + PDF: 'application/pdf', + PNG: 'image/png', + SVG: 'image/svg+xml', + }; + + return resultBuilder.file( + Buffer.from(labelContent), + `${record.title}-label.${format.toLowerCase()}`, + mimeTypes[format], + ); + }, }); diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index 11cf1a1b8d..608c5fc9e9 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -201,6 +201,31 @@ function formatLayoutForResponse(layout: LayoutElement[]): unknown { }; } +interface FormHints { + canExecute: boolean; + requiredFieldsMissing: string[]; +} + +function computeFormHints(fields: ActionFieldInfo[]): FormHints { + const requiredFieldsMissing: string[] = []; + + for (const field of fields) { + const value = field.getValue(); + const isRequired = field.isRequired(); + const name = field.getName(); + + // Check for missing required fields + if (isRequired && (value === undefined || value === null || value === '')) { + requiredFieldsMissing.push(name); + } + } + + return { + canExecute: requiredFieldsMissing.length === 0, + requiredFieldsMissing, + }; +} + export default function declareGetActionFormTool( mcpServer: McpServer, forestServerUrl: string, @@ -216,7 +241,7 @@ export default function declareGetActionFormTool( { title: 'Get action form', description: - 'Load the form fields for an action. Supports dynamic forms: pass values to trigger change hooks and discover fields that depend on other fields. For multi-page forms, the layout shows page structure. Call multiple times with progressive values to handle complex dynamic forms across pages.', + 'Load the form fields for an action. Forms can be dynamic: changing a field value may reveal or hide other fields. To handle this, fill fields from top to bottom and call getActionForm again with the updated values to see if new fields appeared. The response includes "hints" with canExecute (all required fields filled?) and requiredFieldsMissing (list of required fields without values).', inputSchema: argumentShape, }, async (options: GetActionFormArgument, extra) => { @@ -244,9 +269,13 @@ export default function declareGetActionFormTool( // Get layout for multi-page forms const layout = action.getLayout(); const formattedLayout = formatLayoutForResponse( + // eslint-disable-next-line @typescript-eslint/dot-notation layout?.['layout'] as LayoutElement[] | undefined, ); + // Compute hints to guide the LLM on form completion + const hints = computeFormHints(fields); + return { content: [ { @@ -257,6 +286,7 @@ export default function declareGetActionFormTool( actionName: options.actionName, fields: formattedFields, layout: formattedLayout, + hints, }, null, 2, 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 691af5c449..8d2325d75a 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -672,5 +672,105 @@ describe('declareGetActionFormTool', () => { ).rejects.toEqual(agentError); }); }); + + describe('form hints', () => { + it('should indicate canExecute=false when required fields are missing', async () => { + const mockField = createMockField({ + name: 'Title', + type: 'String', + value: undefined, + isRequired: true, + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'dvds', actionName: 'Add DVD', recordIds: [] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.hints.canExecute).toBe(false); + expect(parsedResult.hints.requiredFieldsMissing).toContain('Title'); + }); + + it('should indicate canExecute=true when all required fields are filled', async () => { + const mockField = createMockField({ + name: 'Title', + type: 'String', + value: 'The Matrix', + isRequired: true, + }); + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue([mockField]); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'dvds', actionName: 'Add DVD', recordIds: [] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.hints.canExecute).toBe(true); + expect(parsedResult.hints.requiredFieldsMissing).toHaveLength(0); + }); + + it('should list multiple missing required fields', async () => { + const mockFields = [ + createMockField({ name: 'Title', type: 'String', value: undefined, isRequired: true }), + createMockField({ name: 'Genre', type: 'Enum', value: null, isRequired: true }), + createMockField({ name: 'Description', type: 'String', value: '', isRequired: true }), + createMockField({ + name: 'Optional', + type: 'String', + value: undefined, + isRequired: false, + }), + ]; + const mockSetFields = jest.fn(); + const mockGetFields = jest.fn().mockReturnValue(mockFields); + const mockGetLayout = jest.fn().mockReturnValue({}); + const mockAction = jest.fn().mockResolvedValue({ + setFields: mockSetFields, + getFields: mockGetFields, + getLayout: mockGetLayout, + }); + const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); + mockBuildClient.mockReturnValue({ + rpcClient: { collection: mockCollection }, + authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, + } as unknown as ReturnType); + + const result = (await registeredToolHandler( + { collectionName: 'dvds', actionName: 'Add DVD', recordIds: [] }, + mockExtra, + )) as { content: { type: string; text: string }[] }; + + const parsedResult = JSON.parse(result.content[0].text); + expect(parsedResult.hints.canExecute).toBe(false); + expect(parsedResult.hints.requiredFieldsMissing).toEqual(['Title', 'Genre', 'Description']); + }); + }); }); }); diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index e455c69aa8..93db774fbb 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -56,12 +56,17 @@ describe('createActivityLog', () => { ); }); - it('should throw error for unknown action type', async () => { + it('should warn and skip for unknown action type', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const request = createMockRequest(); - await expect( - createActivityLog('https://api.forestadmin.com', request, 'unknownAction'), - ).rejects.toThrow('Unknown action type: unknownAction'); + await createActivityLog('https://api.forestadmin.com', request, 'unknownAction'); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[ActivityLog] Unknown action type: unknownAction - skipping activity log', + ); + expect(mockFetch).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); }); }); @@ -273,7 +278,8 @@ describe('createActivityLog', () => { }); describe('error handling', () => { - it('should throw error when response is not ok', async () => { + it('should warn but not throw when response is not ok', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); mockFetch.mockResolvedValue({ ok: false, text: () => Promise.resolve('Server error message'), @@ -281,9 +287,12 @@ describe('createActivityLog', () => { const request = createMockRequest(); - await expect( - createActivityLog('https://api.forestadmin.com', request, 'index'), - ).rejects.toThrow('Failed to create activity log: Server error message'); + await createActivityLog('https://api.forestadmin.com', request, 'index'); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[ActivityLog] Failed to create activity log: Server error message', + ); + consoleWarnSpy.mockRestore(); }); it('should not throw when response is ok', async () => { From 50a656161a14c0fab027affe5c5f7c51773a0308 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Dec 2025 22:04:47 +0100 Subject: [PATCH 17/18] refactor(mcp-server): type ActivityLogAction parameter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Export ActivityLogAction type from activity-logs-creator - Use typed action parameter instead of string - Update list.ts to use ActivityLogAction type - Update tests with proper type assertions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/mcp-server/src/tools/list.ts | 4 ++-- .../mcp-server/src/utils/activity-logs-creator.ts | 8 +++++--- .../test/utils/activity-logs-creator.test.ts | 11 ++++++++--- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/mcp-server/src/tools/list.ts b/packages/mcp-server/src/tools/list.ts index 2065bd62fd..c706cb8ccf 100644 --- a/packages/mcp-server/src/tools/list.ts +++ b/packages/mcp-server/src/tools/list.ts @@ -5,7 +5,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; import filterSchema from '../schemas/filter.js'; -import createActivityLog from '../utils/activity-logs-creator.js'; +import createActivityLog, { ActivityLogAction } from '../utils/activity-logs-creator.js'; import buildClient from '../utils/agent-caller.js'; import parseAgentError from '../utils/error-parser.js'; import { fetchForestSchema, getFieldsOfCollection } from '../utils/schema-fetcher.js'; @@ -90,7 +90,7 @@ export default function declareListTool( async (options: ListArgument, extra) => { const { rpcClient } = await buildClient(extra); - let actionType = 'index'; + let actionType: ActivityLogAction = 'index'; if (options.search) { actionType = 'search'; diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 9083a5066a..b85cd662c5 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -3,7 +3,7 @@ import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sd // Mapping from internal action names to API-accepted action names // The API only accepts specific action names like 'read', 'action', 'create', 'update', 'delete', etc. -const actionMapping: Record = { +const actionMapping = { index: { apiAction: 'index', type: 'read' }, search: { apiAction: 'search', type: 'read' }, filter: { apiAction: 'filter', type: 'read' }, @@ -18,7 +18,9 @@ const actionMapping: Record, - action: string, + action: ActivityLogAction, extra?: { collectionName?: string; recordId?: string | number; diff --git a/packages/mcp-server/test/utils/activity-logs-creator.test.ts b/packages/mcp-server/test/utils/activity-logs-creator.test.ts index 93db774fbb..26c4f04eb6 100644 --- a/packages/mcp-server/test/utils/activity-logs-creator.test.ts +++ b/packages/mcp-server/test/utils/activity-logs-creator.test.ts @@ -1,7 +1,7 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol'; import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; -import createActivityLog from '../../src/utils/activity-logs-creator'; +import createActivityLog, { ActivityLogAction } from '../../src/utils/activity-logs-creator'; describe('createActivityLog', () => { const originalFetch = global.fetch; @@ -46,7 +46,7 @@ describe('createActivityLog', () => { ])('should map action "%s" to type "%s"', async (action, expectedType) => { const request = createMockRequest(); - await createActivityLog('https://api.forestadmin.com', request, action); + await createActivityLog('https://api.forestadmin.com', request, action as ActivityLogAction); expect(mockFetch).toHaveBeenCalledWith( 'https://api.forestadmin.com/api/activity-logs-requests', @@ -60,7 +60,12 @@ describe('createActivityLog', () => { const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); const request = createMockRequest(); - await createActivityLog('https://api.forestadmin.com', request, 'unknownAction'); + // Cast to bypass TypeScript - tests runtime safety check + await createActivityLog( + 'https://api.forestadmin.com', + request, + 'unknownAction' as unknown as ActivityLogAction, + ); expect(consoleWarnSpy).toHaveBeenCalledWith( '[ActivityLog] Unknown action type: unknownAction - skipping activity log', From db617b7227de2397cec37cf8c67133cebe9a1533 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Dec 2025 22:15:44 +0100 Subject: [PATCH 18/18] fix(mcp-server): remove activity log from getActionForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove createActivityLog call from getActionForm tool - Remove forestServerUrl parameter from declareGetActionFormTool - Remove getActionForm from ActivityLogAction type - Remove activity logging tests for getActionForm 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/mcp-server/src/server.ts | 8 +-- .../mcp-server/src/tools/get-action-form.ts | 7 --- .../src/utils/activity-logs-creator.ts | 1 - .../test/tools/get-action-form.test.ts | 50 ++----------------- 4 files changed, 6 insertions(+), 60 deletions(-) diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index eb84bdff5c..11411e30ce 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -151,13 +151,7 @@ export default class ForestMCPServer { declareCreateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareUpdateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); declareDeleteTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); - declareGetActionFormTool( - this.mcpServer, - this.forestServerUrl, - this.logger, - collectionNames, - actionEndpoints, - ); + declareGetActionFormTool(this.mcpServer, this.logger, collectionNames, actionEndpoints); declareExecuteActionTool( this.mcpServer, this.forestServerUrl, diff --git a/packages/mcp-server/src/tools/get-action-form.ts b/packages/mcp-server/src/tools/get-action-form.ts index 608c5fc9e9..9aa9ebd960 100644 --- a/packages/mcp-server/src/tools/get-action-form.ts +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -3,7 +3,6 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { z } from 'zod'; -import createActivityLog from '../utils/activity-logs-creator.js'; import buildClient, { ActionEndpointsMap } from '../utils/agent-caller.js'; import parseAgentError from '../utils/error-parser.js'; import registerToolWithLogging from '../utils/tool-with-logging.js'; @@ -228,7 +227,6 @@ function computeFormHints(fields: ActionFieldInfo[]): FormHints { export default function declareGetActionFormTool( mcpServer: McpServer, - forestServerUrl: string, logger: Logger, collectionNames: string[] = [], actionEndpoints: ActionEndpointsMap = {}, @@ -247,11 +245,6 @@ export default function declareGetActionFormTool( async (options: GetActionFormArgument, extra) => { const { rpcClient } = await buildClient(extra, actionEndpoints); - await createActivityLog(forestServerUrl, extra, 'getActionForm', { - collectionName: options.collectionName, - label: options.actionName, - }); - try { const recordIds = options.recordIds as string[] | number[] | undefined; const action = await rpcClient diff --git a/packages/mcp-server/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index b85cd662c5..9107d15a72 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -16,7 +16,6 @@ const actionMapping = { availableActions: { apiAction: 'read', type: 'read' }, availableCollections: { apiAction: 'read', type: 'read' }, // Action-related MCP tools - getActionForm: { apiAction: 'read', type: 'read' }, executeAction: { apiAction: 'action', type: 'write' }, } as const; 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 8d2325d75a..c6248adcb5 100644 --- a/packages/mcp-server/test/tools/get-action-form.test.ts +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -4,16 +4,13 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/proto import type { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types'; import declareGetActionFormTool from '../../src/tools/get-action-form.js'; -import createActivityLog from '../../src/utils/activity-logs-creator.js'; import buildClient from '../../src/utils/agent-caller.js'; jest.mock('../../src/utils/agent-caller'); -jest.mock('../../src/utils/activity-logs-creator'); const mockLogger: Logger = jest.fn(); const mockBuildClient = buildClient as jest.MockedFunction; -const mockCreateActivityLog = createActivityLog as jest.MockedFunction; describe('declareGetActionFormTool', () => { let mcpServer: McpServer; @@ -29,13 +26,11 @@ describe('declareGetActionFormTool', () => { registeredToolHandler = handler; }), } as unknown as McpServer; - - mockCreateActivityLog.mockResolvedValue(undefined); }); describe('tool registration', () => { it('should register a tool named "getActionForm"', () => { - declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareGetActionFormTool(mcpServer, mockLogger); expect(mcpServer.registerTool).toHaveBeenCalledWith( 'getActionForm', @@ -45,14 +40,14 @@ describe('declareGetActionFormTool', () => { }); it('should register tool with correct title and description', () => { - declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareGetActionFormTool(mcpServer, mockLogger); expect(registeredToolConfig.title).toBe('Get action form'); expect(registeredToolConfig.description).toContain('Load the form fields for an action'); }); it('should define correct input schema', () => { - declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareGetActionFormTool(mcpServer, mockLogger); expect(registeredToolConfig.inputSchema).toHaveProperty('collectionName'); expect(registeredToolConfig.inputSchema).toHaveProperty('actionName'); @@ -61,10 +56,7 @@ describe('declareGetActionFormTool', () => { }); it('should use enum type for collectionName when collection names provided', () => { - declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger, [ - 'users', - 'products', - ]); + declareGetActionFormTool(mcpServer, mockLogger, ['users', 'products']); const schema = registeredToolConfig.inputSchema as Record< string, @@ -120,7 +112,7 @@ describe('declareGetActionFormTool', () => { }; beforeEach(() => { - declareGetActionFormTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + declareGetActionFormTool(mcpServer, mockLogger); }); it('should call buildClient with the extra parameter and actionEndpoints', async () => { @@ -569,38 +561,6 @@ describe('declareGetActionFormTool', () => { }); }); - describe('activity logging', () => { - beforeEach(() => { - const mockSetFields = jest.fn(); - const mockGetFields = jest.fn().mockReturnValue([]); - const mockGetLayout = jest.fn().mockReturnValue({}); - const mockAction = jest.fn().mockResolvedValue({ - setFields: mockSetFields, - getFields: mockGetFields, - getLayout: mockGetLayout, - }); - const mockCollection = jest.fn().mockReturnValue({ action: mockAction }); - mockBuildClient.mockReturnValue({ - rpcClient: { collection: mockCollection }, - authData: { userId: 1, renderingId: '123', environmentId: 1, projectId: 1 }, - } as unknown as ReturnType); - }); - - it('should create activity log with "getActionForm" action type and label', async () => { - await registeredToolHandler( - { collectionName: 'users', actionName: 'Send Reminder', recordIds: [42] }, - mockExtra, - ); - - expect(mockCreateActivityLog).toHaveBeenCalledWith( - 'https://api.forestadmin.com', - mockExtra, - 'getActionForm', - { collectionName: 'users', label: 'Send Reminder' }, - ); - }); - }); - describe('input parsing', () => { it('should parse recordIds sent as JSON string (LLM workaround)', () => { const recordIds = [1, 2, 3];