From de944d21d1532b07f349edef8dbbe18c611efae7 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Fri, 14 Aug 2026 11:00:49 +0000 Subject: [PATCH] feat(core): Add MCP tool to read data table rows (#35956) Co-authored-by: Claude Fable 5 --- packages/@n8n/api-types/src/index.ts | 8 +- .../get-data-table-rows.integration.test.ts | 122 ++++++++++ .../get-data-table-rows.tool.test.ts | 217 ++++++++++++++++++ packages/cli/src/modules/mcp/mcp-scopes.ts | 2 +- packages/cli/src/modules/mcp/mcp.service.ts | 4 + .../data-table/add-data-table-rows.tool.ts | 4 +- .../data-table/get-data-table-rows.tool.ts | 166 ++++++++++++++ .../src/modules/mcp/tools/data-table/index.ts | 1 + packages/cli/src/modules/mcp/tools/schemas.ts | 5 + 9 files changed, 523 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.integration.test.ts create mode 100644 packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.tool.test.ts create mode 100644 packages/cli/src/modules/mcp/tools/data-table/get-data-table-rows.tool.ts diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index f8671c657e5..93a1ad9b08c 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -214,9 +214,11 @@ export { dataTableNameSchema, } from './schemas/data-table.schema'; -export type { - DataTableFilter, - DataTableFilterConditionType, +export { + type DataTableFilter, + type DataTableFilterConditionType, + FilterConditionSchema, + dataTableFilterTypeSchema, } from './schemas/data-table-filter.schema'; export type { diff --git a/packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.integration.test.ts b/packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.integration.test.ts new file mode 100644 index 00000000000..26b0345e4ea --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.integration.test.ts @@ -0,0 +1,122 @@ +import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils'; +import { GLOBAL_OWNER_ROLE, type Project, type User } from '@n8n/db'; +import { Container } from '@n8n/di'; + +import { DataTableProxyService } from '@/modules/data-table/data-table-proxy.service'; +import { DataTableService } from '@/modules/data-table/data-table.service'; +import { createUser } from '@test-integration/db/users'; + +import { createTelemetry } from './test-utils'; +import { createGetDataTableRowsTool } from '../../tools/data-table'; + +beforeAll(async () => { + await testModules.loadModules(['data-table']); + await testDb.init(); +}); + +beforeEach(async () => { + await testDb.truncate(['DataTable', 'DataTableColumn']); +}); + +afterAll(async () => { + await testDb.terminate(); +}); + +describe('get_data_table_rows against a real database', () => { + let user: User; + let project: Project; + let dataTableId: string; + let insertedIds: number[]; + let tool: ReturnType; + + const callHandler = async (args: Omit[0], 'dataTableId'>) => + await tool.handler({ dataTableId, ...args }, {} as never); + + beforeEach(async () => { + user = await createUser({ role: GLOBAL_OWNER_ROLE }); + project = await createTeamProject(undefined, user); + const dataTableOps = Container.get(DataTableProxyService).makeDataTableOperationsForUser(user); + tool = createGetDataTableRowsTool(user, dataTableOps, createTelemetry()); + + const created = await Container.get(DataTableService).createDataTable(project.id, { + name: 'people', + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + { name: 'joinedAt', type: 'date' }, + ], + }); + dataTableId = created.id; + + const inserted = await dataTableOps.insertRows( + dataTableId, + project.id, + [ + { name: 'Alice', age: 30, joinedAt: '2024-03-01T00:00:00.000Z' }, + { name: 'Bob', age: 25, joinedAt: null }, + { name: 'Carol', age: 35, joinedAt: '2024-05-01T00:00:00.000Z' }, + ], + 'id', + ); + insertedIds = inserted.map((row) => row.id); + }); + + it('filters on the id system column', async () => { + const result = await callHandler({ + projectId: project.id, + filter: { + type: 'and', + filters: [{ columnName: 'id', condition: 'gt', value: insertedIds[0] }], + }, + }); + + expect(result.isError).toBeUndefined(); + const output = result.structuredContent as { rows: Array> }; + expect(output.rows.map((row) => row.name)).toEqual(['Bob', 'Carol']); + }); + + it('matches null values with eq', async () => { + const result = await callHandler({ + projectId: project.id, + filter: { + type: 'and', + filters: [{ columnName: 'joinedAt', condition: 'eq', value: null }], + }, + }); + + expect(result.isError).toBeUndefined(); + const output = result.structuredContent as { rows: Array> }; + expect(output.rows.map((row) => row.name)).toEqual(['Bob']); + }); + + it('filters date columns by ISO 8601 string', async () => { + const result = await callHandler({ + projectId: project.id, + filter: { + type: 'and', + filters: [{ columnName: 'joinedAt', condition: 'gte', value: '2024-04-01T00:00:00.000Z' }], + }, + }); + + expect(result.isError).toBeUndefined(); + const output = result.structuredContent as { rows: Array> }; + expect(output.rows.map((row) => row.name)).toEqual(['Carol']); + }); + + it('sorts descending and paginates', async () => { + const result = await callHandler({ + projectId: project.id, + sortBy: 'age:desc', + limit: 2, + skip: 1, + }); + + expect(result.isError).toBeUndefined(); + const output = result.structuredContent as { + rows: Array>; + count: number; + }; + expect(output.rows.map((row) => row.name)).toEqual(['Alice', 'Bob']); + expect(output.count).toBe(3); + }); +}); diff --git a/packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.tool.test.ts b/packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.tool.test.ts new file mode 100644 index 00000000000..dbc54167453 --- /dev/null +++ b/packages/cli/src/modules/mcp/__tests__/data-table/get-data-table-rows.tool.test.ts @@ -0,0 +1,217 @@ +import z from 'zod'; + +import type { DataTableUserOperations } from '@/modules/data-table/data-table-proxy.service'; + +import { createTelemetry, user } from './test-utils'; +import { createGetDataTableRowsTool } from '../../tools/data-table'; + +const createdAt = new Date('2024-01-01T00:00:00.000Z'); +const updatedAt = new Date('2024-01-02T00:00:00.000Z'); + +const sampleRows = [ + { id: 1, name: 'Alice', age: 30, createdAt, updatedAt }, + { id: 2, name: 'Bob', age: 25, createdAt, updatedAt }, +]; + +const createMocks = (overrides?: { + rows?: Array>; + count?: number; + error?: Error; +}) => { + const dataTableOps = { + getManyRowsAndCount: overrides?.error + ? vi.fn().mockRejectedValue(overrides.error) + : vi.fn().mockResolvedValue({ + data: overrides?.rows ?? sampleRows, + count: overrides?.count ?? sampleRows.length, + }), + } as unknown as DataTableUserOperations; + + const telemetry = createTelemetry(); + + return { dataTableOps, telemetry }; +}; + +const callHandler = async ( + tool: ReturnType, + args: Parameters[0], +) => await tool.handler(args, {} as never); + +describe('get_data_table_rows MCP tool', () => { + test('creates tool correctly', () => { + const { dataTableOps, telemetry } = createMocks(); + const tool = createGetDataTableRowsTool(user, dataTableOps, telemetry); + + expect(tool.name).toBe('get_data_table_rows'); + expect(tool.config).toBeDefined(); + expect(typeof tool.config.description).toBe('string'); + expect(tool.config.inputSchema).toBeDefined(); + expect(tool.config.outputSchema).toBeDefined(); + expect(tool.config.annotations?.readOnlyHint).toBe(true); + expect(typeof tool.handler).toBe('function'); + }); + + test('returns rows with dates serialized to ISO strings', async () => { + const { dataTableOps, telemetry } = createMocks(); + const tool = createGetDataTableRowsTool(user, dataTableOps, telemetry); + + const result = await callHandler(tool, { dataTableId: 'dt-1', projectId: 'proj-1' }); + + expect(result.structuredContent).toEqual({ + rows: [ + { + id: 1, + name: 'Alice', + age: 30, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + { + id: 2, + name: 'Bob', + age: 25, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ], + count: 2, + }); + + expect(dataTableOps.getManyRowsAndCount).toHaveBeenCalledWith('dt-1', 'proj-1', { + take: 100, + }); + }); + + test('passes filter, sort and pagination options through', async () => { + const { dataTableOps, telemetry } = createMocks(); + const tool = createGetDataTableRowsTool(user, dataTableOps, telemetry); + + await callHandler(tool, { + dataTableId: 'dt-1', + projectId: 'proj-1', + filter: { + type: 'or', + filters: [ + { columnName: 'name', condition: 'ilike', value: 'ali' }, + { columnName: 'age', condition: 'gte', value: 21 }, + ], + }, + sortBy: 'age:desc', + limit: 10, + skip: 5, + }); + + expect(dataTableOps.getManyRowsAndCount).toHaveBeenCalledWith('dt-1', 'proj-1', { + filter: { + type: 'or', + filters: [ + { columnName: 'name', condition: 'ilike', value: 'ali' }, + { columnName: 'age', condition: 'gte', value: 21 }, + ], + }, + sortBy: ['age', 'DESC'], + take: 10, + skip: 5, + }); + }); + + test('defaults filter type to and, condition to eq', async () => { + const { dataTableOps, telemetry } = createMocks(); + const tool = createGetDataTableRowsTool(user, dataTableOps, telemetry); + + // Defaults are applied when the registration layer parses args against + // the input schema, so parse here the same way before calling the handler. + const args = z.object(tool.config.inputSchema!).parse({ + dataTableId: 'dt-1', + projectId: 'proj-1', + filter: { filters: [{ columnName: 'name', value: 'Alice' }] }, + sortBy: 'name:asc', + }); + await callHandler(tool, args); + + expect(dataTableOps.getManyRowsAndCount).toHaveBeenCalledWith('dt-1', 'proj-1', { + filter: { + type: 'and', + filters: [{ columnName: 'name', condition: 'eq', value: 'Alice' }], + }, + sortBy: ['name', 'ASC'], + take: 100, + }); + }); + + test('returns error response on failure', async () => { + const { dataTableOps, telemetry } = createMocks({ + error: new Error('Unknown column'), + }); + const tool = createGetDataTableRowsTool(user, dataTableOps, telemetry); + + const result = await callHandler(tool, { dataTableId: 'dt-1', projectId: 'proj-1' }); + + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ + rows: [], + count: 0, + error: 'Unknown column', + }); + }); + + // MCP clients validate structuredContent against the advertised output + // schema with additionalProperties forbidden, so both response shapes must + // parse strictly or the client rejects the response outright. + test('success and error responses match the declared output schema', async () => { + const strictOutputSchema = z + .object( + createGetDataTableRowsTool(user, createMocks().dataTableOps, createTelemetry()).config + .outputSchema!, + ) + .strict(); + + const success = createMocks(); + const successResult = await callHandler( + createGetDataTableRowsTool(user, success.dataTableOps, success.telemetry), + { dataTableId: 'dt-1', projectId: 'proj-1' }, + ); + expect(() => strictOutputSchema.parse(successResult.structuredContent)).not.toThrow(); + + const failure = createMocks({ error: new Error('Unknown column') }); + const errorResult = await callHandler( + createGetDataTableRowsTool(user, failure.dataTableOps, failure.telemetry), + { dataTableId: 'dt-1', projectId: 'proj-1' }, + ); + expect(() => strictOutputSchema.parse(errorResult.structuredContent)).not.toThrow(); + }); + + test('tracks telemetry on success', async () => { + const { dataTableOps, telemetry } = createMocks(); + const tool = createGetDataTableRowsTool(user, dataTableOps, telemetry); + + await callHandler(tool, { dataTableId: 'dt-1', projectId: 'proj-1' }); + + expect(telemetry.track).toHaveBeenCalledWith( + 'User called mcp tool', + expect.objectContaining({ + user_id: 'user-1', + tool_name: 'get_data_table_rows', + results: { success: true, data: { count: 2, returned: 2 } }, + }), + ); + }); + + test('tracks telemetry on error', async () => { + const { dataTableOps, telemetry } = createMocks({ + error: new Error('Unknown column'), + }); + const tool = createGetDataTableRowsTool(user, dataTableOps, telemetry); + + await callHandler(tool, { dataTableId: 'dt-1', projectId: 'proj-1' }); + + expect(telemetry.track).toHaveBeenCalledWith( + 'User called mcp tool', + expect.objectContaining({ + user_id: 'user-1', + tool_name: 'get_data_table_rows', + results: { success: false, error: 'Unknown column' }, + }), + ); + }); +}); diff --git a/packages/cli/src/modules/mcp/mcp-scopes.ts b/packages/cli/src/modules/mcp/mcp-scopes.ts index d66d68a24df..47fe6a5adb9 100644 --- a/packages/cli/src/modules/mcp/mcp-scopes.ts +++ b/packages/cli/src/modules/mcp/mcp-scopes.ts @@ -73,7 +73,7 @@ export const TOOLS_BY_SCOPE: Record = { // explore_node_resources queries external services with stored credentials, // so it must sit behind the credential scope rather than a workflow one. 'credential:read': ['list_credentials', 'list_n8n_connect_services', 'explore_node_resources'], - 'dataTable:read': ['search_data_tables'], + 'dataTable:read': ['search_data_tables', 'get_data_table_rows'], // Writing requires finding tables, so search rides along. 'dataTable:write': [ 'search_data_tables', diff --git a/packages/cli/src/modules/mcp/mcp.service.ts b/packages/cli/src/modules/mcp/mcp.service.ts index 5d01bb19183..e80c6fa2a45 100644 --- a/packages/cli/src/modules/mcp/mcp.service.ts +++ b/packages/cli/src/modules/mcp/mcp.service.ts @@ -67,6 +67,7 @@ import { createAddDataTableRowsTool, createCreateDataTableTool, createDeleteDataTableColumnTool, + createGetDataTableRowsTool, createRenameDataTableColumnTool, createRenameDataTableTool, createSearchDataTablesTool, @@ -591,6 +592,9 @@ export class McpService { const addDataTableRowsTool = createAddDataTableRowsTool(user, dataTableOps, this.telemetry); registerIfAllowed(addDataTableRowsTool); + const getDataTableRowsTool = createGetDataTableRowsTool(user, dataTableOps, this.telemetry); + registerIfAllowed(getDataTableRowsTool); + // Workflow builder tools (enabled via N8N_MCP_BUILDER_ENABLED) if (builderEnabled) { await this.registerBuilderTools( diff --git a/packages/cli/src/modules/mcp/tools/data-table/add-data-table-rows.tool.ts b/packages/cli/src/modules/mcp/tools/data-table/add-data-table-rows.tool.ts index 4c3db4e4b13..0da516362ab 100644 --- a/packages/cli/src/modules/mcp/tools/data-table/add-data-table-rows.tool.ts +++ b/packages/cli/src/modules/mcp/tools/data-table/add-data-table-rows.tool.ts @@ -6,7 +6,7 @@ import type { Telemetry } from '@/telemetry'; import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; -import { dataTableProjectIdSchema } from '../schemas'; +import { dataTableProjectIdSchema, dataTableRowSchema } from '../schemas'; const ADD_ROWS_MAX = 1000; @@ -14,7 +14,7 @@ const addRowsInputSchema = { dataTableId: z.string().describe('The ID of the data table to insert rows into'), projectId: dataTableProjectIdSchema, rows: z - .array(z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]))) + .array(dataTableRowSchema) .min(1) .max(ADD_ROWS_MAX) .describe( diff --git a/packages/cli/src/modules/mcp/tools/data-table/get-data-table-rows.tool.ts b/packages/cli/src/modules/mcp/tools/data-table/get-data-table-rows.tool.ts new file mode 100644 index 00000000000..4d81c535008 --- /dev/null +++ b/packages/cli/src/modules/mcp/tools/data-table/get-data-table-rows.tool.ts @@ -0,0 +1,166 @@ +import { + dataTableColumnNameSchema, + dataTableFilterTypeSchema, + FilterConditionSchema, +} from '@n8n/api-types'; +import type { User } from '@n8n/db'; +import z from 'zod'; + +import type { DataTableUserOperations } from '@/modules/data-table/data-table-proxy.service'; +import type { Telemetry } from '@/telemetry'; + +import { USER_CALLED_MCP_TOOL_EVENT } from '../../mcp.constants'; +import type { ToolDefinition, UserCalledMCPToolEventPayload } from '../../mcp.types'; +import { createLimitSchema, dataTableProjectIdSchema, dataTableRowSchema } from '../schemas'; + +const GET_ROWS_MAX = 100; + +const filterSchema = z + .object({ + type: dataTableFilterTypeSchema + .default('and') + .describe('How to combine the filters: all must match (and) or any may match (or)'), + filters: z + .array( + z.object({ + columnName: dataTableColumnNameSchema.describe( + "Column to filter on. System columns 'id', 'createdAt' and 'updatedAt' are also allowed", + ), + condition: FilterConditionSchema.default('eq').describe( + "Comparison operator. 'like' (case-sensitive) and 'ilike' (case-insensitive) match substrings; include % wildcards for custom patterns. 'neq' also matches rows where the column is null", + ), + value: z + .union([z.string(), z.number(), z.boolean(), z.null()]) + .describe( + 'Value to compare against. For date columns, pass an ISO 8601 string. Pass null with eq/neq to match rows where the column is null / not null', + ), + }), + ) + .min(1), + }) + .optional() + .describe('Filter conditions to select rows. Omit to return all rows'); + +const sortBySchema = z + .string() + .regex(/^[^:]+:(asc|desc)$/i, 'Expected format :') + .refine((value) => dataTableColumnNameSchema.safeParse(value.split(':')[0]).success, { + message: 'Invalid sort column name', + }) + .optional() + .describe("Sort order as ':', e.g. 'createdAt:desc'"); + +const getRowsInputSchema = { + dataTableId: z.string().describe('The ID of the data table to read rows from'), + projectId: dataTableProjectIdSchema, + filter: filterSchema, + sortBy: sortBySchema, + limit: createLimitSchema(GET_ROWS_MAX), + skip: z + .number() + .int() + .min(0) + .optional() + .describe('Number of rows to skip, for paginating through large result sets'), +} satisfies z.ZodRawShape; + +const getRowsOutputSchema = { + rows: z + .array(dataTableRowSchema) + .describe( + "Matching rows, mapping column names to values. Includes the system columns 'id', 'createdAt' and 'updatedAt'; dates are ISO 8601 strings", + ), + count: z + .number() + .int() + .min(0) + .describe('Total number of rows matching the filter, ignoring limit and skip'), + error: z.string().optional().describe('Error message if the read failed'), +} satisfies z.ZodRawShape; + +const toSortByTuple = (sortBy: string): [string, 'ASC' | 'DESC'] => { + const [column, direction] = sortBy.split(':'); + return [column, direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC']; +}; + +const serializeRow = (row: Record) => + Object.fromEntries( + Object.entries(row).map(([key, value]) => [ + key, + value instanceof Date ? value.toISOString() : value, + ]), + ); + +export const createGetDataTableRowsTool = ( + user: User, + dataTableOps: DataTableUserOperations, + telemetry: Telemetry, +): ToolDefinition => ({ + name: 'get_data_table_rows', + config: { + description: + 'Read rows from a data table, with optional filtering, sorting and pagination. Use search_data_tables to find the data table ID and its columns first.', + inputSchema: getRowsInputSchema, + outputSchema: getRowsOutputSchema, + annotations: { + title: 'Get Data Table Rows', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + handler: async ({ dataTableId, projectId, filter, sortBy, limit = GET_ROWS_MAX, skip }) => { + const telemetryPayload: UserCalledMCPToolEventPayload = { + user_id: user.id, + tool_name: 'get_data_table_rows', + parameters: { + dataTableId, + projectId, + filterCount: filter?.filters.length, + sortBy, + limit, + skip, + }, + }; + + try { + const result = await dataTableOps.getManyRowsAndCount(dataTableId, projectId, { + filter, + sortBy: sortBy ? toSortByTuple(sortBy) : undefined, + take: limit, + skip, + }); + + const output = { + rows: result.data.map(serializeRow), + count: result.count, + }; + + telemetryPayload.results = { + success: true, + data: { count: result.count, returned: output.rows.length }, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + telemetryPayload.results = { + success: false, + error: errorMessage, + }; + telemetry.track(USER_CALLED_MCP_TOOL_EVENT, telemetryPayload); + + const output = { rows: [], count: 0, error: errorMessage }; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + structuredContent: output, + isError: true, + }; + } + }, +}); diff --git a/packages/cli/src/modules/mcp/tools/data-table/index.ts b/packages/cli/src/modules/mcp/tools/data-table/index.ts index 4c027af02f3..b23c50b9d35 100644 --- a/packages/cli/src/modules/mcp/tools/data-table/index.ts +++ b/packages/cli/src/modules/mcp/tools/data-table/index.ts @@ -5,3 +5,4 @@ export { createAddDataTableColumnTool } from './add-data-table-column.tool'; export { createDeleteDataTableColumnTool } from './delete-data-table-column.tool'; export { createRenameDataTableColumnTool } from './rename-data-table-column.tool'; export { createAddDataTableRowsTool } from './add-data-table-rows.tool'; +export { createGetDataTableRowsTool } from './get-data-table-rows.tool'; diff --git a/packages/cli/src/modules/mcp/tools/schemas.ts b/packages/cli/src/modules/mcp/tools/schemas.ts index 4ba8bbd642f..3e538d84816 100644 --- a/packages/cli/src/modules/mcp/tools/schemas.ts +++ b/packages/cli/src/modules/mcp/tools/schemas.ts @@ -109,6 +109,11 @@ export const columnNameSchema = z 'Column name. Must start with a letter, contain only letters, numbers, and underscores (max 63 chars)', ); +export const dataTableRowSchema = z.record( + z.string(), + z.union([z.string(), z.number(), z.boolean(), z.null()]), +); + export const successMessageOutputSchema = { success: z.boolean().describe('Whether the operation succeeded'), message: z.string().describe('Description of the result'),