feat(core): Add MCP tool to read data table rows (#35956)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Svetoslav Dekov 2026-08-14 11:00:49 +00:00 committed by GitHub
parent dc55ed1e9e
commit de944d21d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 523 additions and 6 deletions

View File

@ -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 {

View File

@ -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<typeof createGetDataTableRowsTool>;
const callHandler = async (args: Omit<Parameters<typeof tool.handler>[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<Record<string, unknown>> };
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<Record<string, unknown>> };
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<Record<string, unknown>> };
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<Record<string, unknown>>;
count: number;
};
expect(output.rows.map((row) => row.name)).toEqual(['Alice', 'Bob']);
expect(output.count).toBe(3);
});
});

View File

@ -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<Record<string, unknown>>;
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<typeof createGetDataTableRowsTool>,
args: Parameters<typeof tool.handler>[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' },
}),
);
});
});

View File

@ -73,7 +73,7 @@ export const TOOLS_BY_SCOPE: Record<McpScope, readonly string[]> = {
// 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',

View File

@ -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(

View File

@ -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(

View File

@ -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 <columnName>:<asc|desc>')
.refine((value) => dataTableColumnNameSchema.safeParse(value.split(':')[0]).success, {
message: 'Invalid sort column name',
})
.optional()
.describe("Sort order as '<columnName>:<asc|desc>', 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<string, unknown>) =>
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<typeof getRowsInputSchema> => ({
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,
};
}
},
});

View File

@ -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';

View File

@ -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'),