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/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/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/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/src/http-requester.ts b/packages/agent-client/src/http-requester.ts index 9c45d7e59e..f40153661a 100644 --- a/packages/agent-client/src/http-requester.ts +++ b/packages/agent-client/src/http-requester.ts @@ -59,6 +59,93 @@ 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 (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) { + 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/agent-client/test/domains/action.test.ts b/packages/agent-client/test/domains/action.test.ts index 770d66455e..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'); @@ -15,6 +15,7 @@ describe('Action', () => { httpRequester = { query: jest.fn(), + queryWithFileSupport: jest.fn(), } as any; fieldsFormStates = { @@ -316,4 +317,94 @@ 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/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/agent-client/test/http-requester.test.ts b/packages/agent-client/test/http-requester.test.ts index a0d6af78e6..59cad3260d 100644 --- a/packages/agent-client/test/http-requester.test.ts +++ b/packages/agent-client/test/http-requester.test.ts @@ -227,6 +227,356 @@ 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; @@ -303,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/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/src/server.ts b/packages/mcp-server/src/server.ts index de3e815f5b..11411e30ce 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -20,8 +20,15 @@ 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 declareDescribeCollectionTool from './tools/describe-collection'; +import declareExecuteActionTool from './tools/execute-action'; +import declareGetActionFormTool from './tools/get-action-form'; import declareListTool from './tools/list'; -import { fetchForestSchema, getCollectionNames } from './utils/schema-fetcher'; +import declareListRelatedTool from './tools/list-related'; +import declareUpdateTool from './tools/update'; +import { fetchForestSchema, getActionEndpoints, getCollectionNames } from './utils/schema-fetcher'; import interceptResponseForErrorLogging from './utils/sse-error-logger'; import { NAME, VERSION } from './version'; @@ -48,6 +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'], }; /** @@ -113,10 +127,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', @@ -124,7 +140,25 @@ 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); + declareUpdateTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareDeleteTool(this.mcpServer, this.forestServerUrl, this.logger, collectionNames); + declareGetActionFormTool(this.mcpServer, 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/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/describe-collection.ts b/packages/mcp-server/src/tools/describe-collection.ts new file mode 100644 index 0000000000..37fa29fa52 --- /dev/null +++ b/packages/mcp-server/src/tools/describe-collection.ts @@ -0,0 +1,146 @@ +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, + getActionsOfCollection, + 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. Use this tool first before querying data to understand the collection structure and build accurate filters.', + 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, + }; + }); + + // 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) }] }; + }, + logger, + ); +} 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..ea850d15aa --- /dev/null +++ b/packages/mcp-server/src/tools/execute-action.ts @@ -0,0 +1,271 @@ +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; +} + +// 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 + 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; +} + +// 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 { + 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 || undefined, + invalidatedRelations: result.refresh?.relationships || [], + }; +} + +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: + 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. 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) => { + 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 (convert file values to data URI format) + if (options.values && Object.keys(options.values).length > 0) { + const convertedValues = convertFileValuesToDataUri(options.values); + await action.setFields(convertedValues); + } + + // 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: [ + { + type: 'text', + text: JSON.stringify(formattedResult, 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..9aa9ebd960 --- /dev/null +++ b/packages/mcp-server/src/tools/get-action-form.ts @@ -0,0 +1,297 @@ +import type { Logger } from '../server'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; + +import { z } from 'zod'; + +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 GetActionFormArgument { + 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 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.', + ), + 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.', + ), + }; +} + +// 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; + description?: string; + value?: unknown; + isRequired: boolean; + isReadOnly: boolean; + widgetEdit?: { + name: string; + parameters: WidgetParameters; + }; + enums?: string[]; + // For Collection fields + reference?: string | null; +} + +interface ActionFieldInfo { + getName: () => string; + getType: () => string; + getValue: () => unknown; + isRequired: () => boolean; + getPlainField?: () => PlainField; +} + +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(), + type: field.getType(), + value: field.getValue(), + isRequired: field.isRequired(), + isReadOnly: plainField?.isReadOnly ?? false, + description: plainField?.description ?? null, + enums: plainField?.enums ?? 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, + }; +} + +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, + }; +} + +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, + 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. 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) => { + const { rpcClient } = await buildClient(extra, actionEndpoints); + + try { + const recordIds = options.recordIds as string[] | number[] | undefined; + const action = await rpcClient + .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( + // 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: [ + { + type: 'text', + text: JSON.stringify( + { + collectionName: options.collectionName, + actionName: options.actionName, + fields: formattedFields, + layout: formattedLayout, + hints, + }, + null, + 2, + ), + }, + ], + }; + } catch (error) { + const errorDetail = parseAgentError(error); + throw errorDetail ? new Error(errorDetail) : error; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/src/tools/list-related.ts b/packages/mcp-server/src/tools/list-related.ts new file mode 100644 index 0000000000..990a9647e0 --- /dev/null +++ b/packages/mcp-server/src/tools/list-related.ts @@ -0,0 +1,123 @@ +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 { 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 declareListRelatedTool( + mcpServer: McpServer, + forestServerUrl: string, + logger: Logger, + collectionNames: string[] = [], +): void { + const listArgumentShape = createHasManyArgumentShape(collectionNames); + + registerToolWithLogging( + mcpServer, + 'listRelated', + { + title: 'List records from a relation', + description: 'Retrieve a list of records from a one-to-many or many-to-many relation.', + inputSchema: listArgumentShape, + }, + async (options: HasManyArgument, extra) => { + const { rpcClient } = await buildClient(extra); + + await createActivityLog(forestServerUrl, extra, 'index', { + collectionName: options.collectionName, + recordId: options.parentRecordId, + label: `list relation "${options.relationName}"`, + }); + + try { + const relation = rpcClient + .collection(options.collectionName) + .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({ records }) }] }; + } 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); + + // Try to provide helpful context, but don't let this fail the error reporting + try { + const fields = getFieldsOfCollection( + await fetchForestSchema(forestServerUrl), + options.collectionName, + ); + + 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; + } + }, + logger, + ); +} diff --git a/packages/mcp-server/src/tools/list.ts b/packages/mcp-server/src/tools/list.ts index fd29d6fa95..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'; @@ -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: @@ -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/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/src/utils/activity-logs-creator.ts b/packages/mcp-server/src/utils/activity-logs-creator.ts index 86d5d0b6aa..9107d15a72 100644 --- a/packages/mcp-server/src/utils/activity-logs-creator.ts +++ b/packages/mcp-server/src/utils/activity-logs-creator.ts @@ -1,79 +1,103 @@ 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 = { + 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 + executeAction: { apiAction: 'action', type: 'write' }, +} as const; + +export type ActivityLogAction = keyof typeof actionMapping; + +/** + * Creates an activity log entry in Forest Admin. + * This function is non-blocking - failures are logged but don't prevent the main operation. + */ export default async function createActivityLog( forestServerUrl: string, request: RequestHandlerExtra, - action: string, + action: ActivityLogAction, extra?: { collectionName?: string; recordId?: string | number; recordIds?: string[] | number[]; 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', - }; +): Promise { + const mapping = actionMapping[action]; + + if (!mapping) { + // Unknown action type - log warning but don't block the operation + console.warn(`[ActivityLog] Unknown action type: ${action} - skipping activity log`); - if (!actionToType[action]) { - throw new Error(`Unknown action type: ${action}`); + return; } - 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; - 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, - 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/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 f386f13818..baec7ca087 100644 --- a/packages/mcp-server/src/utils/schema-fetcher.ts +++ b/packages/mcp-server/src/utils/schema-fetcher.ts @@ -21,11 +21,56 @@ export interface ForestField { validations?: unknown[]; defaultValue?: unknown; isPrimaryKey: boolean; + 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 { @@ -121,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/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..0b3a359a43 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(); @@ -2151,4 +2152,287 @@ describe('ForestMCPServer Instance', () => { expect(responseIndex).toBeGreaterThan(toolCallIndex); }); }); + + /** + * Integration tests for the listRelated tool + * Tests that the listRelated tool is properly registered and accessible + */ + describe('listRelated tool integration', () => { + let hasManyServer: ForestMCPServer; + 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 ForestMCPServer(); + 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 listRelated 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 'listRelated' tool is registered + const listRelatedTool = responseData.result.tools.find( + (tool: { name: string }) => tool.name === 'listRelated', + ); + expect(listRelatedTool).toBeDefined(); + expect(listRelatedTool.description).toBe( + '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'); + 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 = listRelatedTool.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 listRelated 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: 'listRelated', + 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 relation "orders"'); + expect(body.data.relationships.collection.data).toEqual({ + id: 'users', + type: 'collections', + }); + }); + + 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'; + + 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: 'listRelated', + 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 relation "orders"'); + }); + }); }); 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/describe-collection.test.ts b/packages/mcp-server/test/tools/describe-collection.test.ts new file mode 100644 index 0000000000..641e536239 --- /dev/null +++ b/packages/mcp-server/test/tools/describe-collection.test.ts @@ -0,0 +1,643 @@ +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 +>; +const mockGetActionsOfCollection = schemaFetcher.getActionsOfCollection as jest.MockedFunction< + typeof schemaFetcher.getActionsOfCollection +>; + +describe('declareDescribeCollectionTool', () => { + let mcpServer: McpServer; + let registeredToolHandler: (options: unknown, extra: unknown) => Promise; + let registeredToolConfig: { title: string; description: string; inputSchema: unknown }; + + 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) => { + 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); + }); + }); + }); +}); 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..97bf6ce26b --- /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.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('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..c6248adcb5 --- /dev/null +++ b/packages/mcp-server/test/tools/get-action-form.test.ts @@ -0,0 +1,736 @@ +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.js'; +import buildClient from '../../src/utils/agent-caller.js'; + +jest.mock('../../src/utils/agent-caller'); + +const mockLogger: Logger = jest.fn(); + +const mockBuildClient = buildClient 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; + }); + + describe('tool registration', () => { + it('should register a tool named "getActionForm"', () => { + declareGetActionFormTool(mcpServer, mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'getActionForm', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + 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, 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, 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, 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('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); + }); + }); + + 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/tools/list-related.test.ts b/packages/mcp-server/test/tools/list-related.test.ts new file mode 100644 index 0000000000..55ce653506 --- /dev/null +++ b/packages/mcp-server/test/tools/list-related.test.ts @@ -0,0 +1,783 @@ +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 { 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'; + +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('declareListRelatedTool', () => { + 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 "listRelated"', () => { + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(mcpServer.registerTool).toHaveBeenCalledWith( + 'listRelated', + expect.any(Object), + expect.any(Function), + ); + }); + + it('should register tool with correct title and description', () => { + declareListRelatedTool(mcpServer, 'https://api.forestadmin.com', mockLogger); + + expect(registeredToolConfig.title).toBe('List records from a relation'); + expect(registeredToolConfig.description).toBe( + 'Retrieve a list of records from a one-to-many or many-to-many relation.', + ); + }); + + it('should define correct input schema', () => { + declareListRelatedTool(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', () => { + declareListRelatedTool(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', () => { + declareListRelatedTool(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', () => { + declareListRelatedTool(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', () => { + declareListRelatedTool(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', () => { + declareListRelatedTool(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(() => { + declareListRelatedTool(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 with records wrapper', 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({ 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([]); + 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 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 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); + + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + + 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); + + mockFetchForestSchema.mockResolvedValue({ collections: [] }); + mockGetFieldsOfCollection.mockReturnValue([]); + + 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 relations for collection users are: orders, reviews.', + ); + }); + + 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); + + 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); + }); + }); + }); +}); 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); + }); + }); + }); +}); 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..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', @@ -56,12 +56,22 @@ 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'); + // 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', + ); + expect(mockFetch).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); }); }); @@ -273,7 +283,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 +292,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 () => {