diff --git a/packages/@n8n/instance-ai/src/tools/credentials/__tests__/get-credential.tool.test.ts b/packages/@n8n/instance-ai/src/tools/credentials/__tests__/get-credential.tool.test.ts index ea7e80e43e4..6a749201b27 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials/__tests__/get-credential.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials/__tests__/get-credential.tool.test.ts @@ -26,8 +26,6 @@ function makeCredentialDetail(overrides?: Partial): Credential id: 'cred-123', name: 'My Slack Token', type: 'slackApi', - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-06-15T12:00:00.000Z', nodesWithAccess: [{ nodeType: 'n8n-nodes-base.slack' }], ...overrides, }; diff --git a/packages/@n8n/instance-ai/src/tools/credentials/__tests__/list-credentials.tool.test.ts b/packages/@n8n/instance-ai/src/tools/credentials/__tests__/list-credentials.tool.test.ts new file mode 100644 index 00000000000..543dbb42372 --- /dev/null +++ b/packages/@n8n/instance-ai/src/tools/credentials/__tests__/list-credentials.tool.test.ts @@ -0,0 +1,119 @@ +import type { InstanceAiContext, CredentialSummary } from '../../../types'; +import { createListCredentialsTool, listCredentialsInputSchema } from '../list-credentials.tool'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createMockContext(): InstanceAiContext { + return { + userId: 'test-user', + workflowService: {} as InstanceAiContext['workflowService'], + executionService: {} as InstanceAiContext['executionService'], + credentialService: { + list: jest.fn(), + get: jest.fn(), + delete: jest.fn(), + test: jest.fn(), + }, + nodeService: {} as InstanceAiContext['nodeService'], + dataTableService: {} as InstanceAiContext['dataTableService'], + }; +} + +function makeCredential(overrides?: Partial): CredentialSummary { + return { + id: 'cred-1', + name: 'Gmail OAuth', + type: 'gmailOAuth2', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('list-credentials tool', () => { + describe('schema validation', () => { + it('accepts empty input', () => { + const result = listCredentialsInputSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('accepts a type filter', () => { + const result = listCredentialsInputSchema.safeParse({ type: 'gmailOAuth2' }); + expect(result.success).toBe(true); + }); + + it('accepts pagination params', () => { + const result = listCredentialsInputSchema.safeParse({ limit: 10, offset: 20 }); + expect(result.success).toBe(true); + }); + }); + + describe('execute', () => { + it('returns credentials with total count', async () => { + const context = createMockContext(); + const credentials = [makeCredential()]; + (context.credentialService.list as jest.Mock).mockResolvedValue(credentials); + + const tool = createListCredentialsTool(context); + const result = (await tool.execute!({}, {} as never)) as { + credentials: Array<{ id: string; name: string; type: string }>; + total: number; + }; + + expect(result.credentials).toEqual([ + { id: 'cred-1', name: 'Gmail OAuth', type: 'gmailOAuth2' }, + ]); + expect(result.total).toBe(1); + }); + + it('passes type filter to the list call', async () => { + const context = createMockContext(); + (context.credentialService.list as jest.Mock).mockResolvedValue([]); + + const tool = createListCredentialsTool(context); + await tool.execute!({ type: 'gmailOAuth2' }, {} as never); + + expect(context.credentialService.list).toHaveBeenCalledWith({ type: 'gmailOAuth2' }); + }); + + it('paginates results with limit and offset', async () => { + const context = createMockContext(); + const credentials = Array.from({ length: 5 }, (_, i) => + makeCredential({ id: `cred-${i}`, name: `Cred ${i}` }), + ); + (context.credentialService.list as jest.Mock).mockResolvedValue(credentials); + + const tool = createListCredentialsTool(context); + const result = (await tool.execute!({ limit: 2, offset: 1 }, {} as never)) as { + credentials: Array<{ id: string }>; + total: number; + }; + + expect(result.total).toBe(5); + expect(result.credentials).toHaveLength(2); + expect(result.credentials[0].id).toBe('cred-1'); + expect(result.credentials[1].id).toBe('cred-2'); + }); + + it('uses default limit of 50', async () => { + const context = createMockContext(); + const credentials = Array.from({ length: 60 }, (_, i) => + makeCredential({ id: `cred-${i}`, name: `Cred ${i}` }), + ); + (context.credentialService.list as jest.Mock).mockResolvedValue(credentials); + + const tool = createListCredentialsTool(context); + const result = (await tool.execute!({}, {} as never)) as { + credentials: Array<{ id: string }>; + total: number; + }; + + expect(result.total).toBe(60); + expect(result.credentials).toHaveLength(50); + }); + }); +}); diff --git a/packages/@n8n/instance-ai/src/tools/credentials/get-credential.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials/get-credential.tool.ts index 451604d007a..63fe5529b7f 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials/get-credential.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials/get-credential.tool.ts @@ -17,8 +17,6 @@ export function createGetCredentialTool(context: InstanceAiContext) { id: z.string(), name: z.string(), type: z.string(), - createdAt: z.string(), - updatedAt: z.string(), nodesWithAccess: z.array(z.object({ nodeType: z.string() })).optional(), }), execute: async (inputData: z.infer) => { diff --git a/packages/@n8n/instance-ai/src/tools/credentials/list-credentials.tool.ts b/packages/@n8n/instance-ai/src/tools/credentials/list-credentials.tool.ts index adb170ded67..9c210dfdcb3 100644 --- a/packages/@n8n/instance-ai/src/tools/credentials/list-credentials.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/credentials/list-credentials.tool.ts @@ -3,14 +3,33 @@ import { z } from 'zod'; import type { InstanceAiContext } from '../../types'; +const DEFAULT_LIMIT = 50; + export const listCredentialsInputSchema = z.object({ type: z.string().optional().describe('Filter by credential type (e.g. "notionApi")'), + limit: z + .number() + .int() + .min(1) + .max(200) + .optional() + .describe( + `Max credentials to return (default ${DEFAULT_LIMIT}, max 200). Use with offset to paginate.`, + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of credentials to skip (default 0). Use with limit to paginate.'), }); export function createListCredentialsTool(context: InstanceAiContext) { return createTool({ id: 'list-credentials', - description: 'List credentials accessible to the current user. Never exposes secret data.', + description: + 'List credentials accessible to the current user. Never exposes secret data. ' + + 'Results are paginated — use limit/offset to page through large sets, or filter by type to narrow results.', inputSchema: listCredentialsInputSchema, outputSchema: z.object({ credentials: z.array( @@ -18,16 +37,24 @@ export function createListCredentialsTool(context: InstanceAiContext) { id: z.string(), name: z.string(), type: z.string(), - createdAt: z.string(), - updatedAt: z.string(), }), ), + total: z.number().describe('Total number of credentials matching the query'), }), execute: async (inputData: z.infer) => { - const credentials = await context.credentialService.list({ + const allCredentials = await context.credentialService.list({ type: inputData.type, }); - return { credentials }; + + const total = allCredentials.length; + const offset = inputData.offset ?? 0; + const limit = inputData.limit ?? DEFAULT_LIMIT; + const page = allCredentials.slice(offset, offset + limit); + + return { + credentials: page.map(({ id, name, type }) => ({ id, name, type })), + total, + }; }, }); } diff --git a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts index 02c71c7f41f..a084ee55a21 100644 --- a/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts +++ b/packages/@n8n/instance-ai/src/tools/workflows/setup-workflow.service.ts @@ -16,7 +16,7 @@ import type { InstanceAiContext } from '../../types'; /** Cache for deduplicating credential fetches across nodes with the same types. */ export interface CredentialCache { /** Credential list promises, keyed by credential type. */ - lists: Map>>; + lists: Map>>; /** Testability check promises, keyed by credential type. */ testability: Map>; /** Credential test result promises, keyed by credential ID. */ @@ -136,11 +136,7 @@ export async function buildSetupRequests( if (!listPromise) { listPromise = context.credentialService .list({ type: credentialType }) - .then((creds) => - creds - .map((c) => ({ id: c.id, name: c.name, updatedAt: c.updatedAt })) - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), - ); + .then((creds) => creds.map((c) => ({ id: c.id, name: c.name }))); cache?.lists.set(credentialType, listPromise); } const sortedCreds = await listPromise; diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 2299bb5302f..a62981ae6f3 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -88,8 +88,6 @@ export interface CredentialSummary { id: string; name: string; type: string; - createdAt: string; - updatedAt: string; } export interface CredentialDetail extends CredentialSummary { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index d32f264c21c..5076b8bc722 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -824,8 +824,6 @@ export class InstanceAiAdapterService { id: c.id, name: c.name, type: c.type, - createdAt: c.createdAt.toISOString(), - updatedAt: c.updatedAt.toISOString(), }), ); }, @@ -836,8 +834,6 @@ export class InstanceAiAdapterService { id: credential.id, name: credential.name, type: credential.type, - createdAt: credential.createdAt.toISOString(), - updatedAt: credential.updatedAt.toISOString(), } satisfies CredentialDetail; },