fix(ai-builder): Paginate list-credentials tool and drop unused fields (#28108)

This commit is contained in:
Albert Alises
2026-04-07 12:52:49 +00:00
committed by GitHub
parent b0484a1555
commit 4a3fc7d27c
7 changed files with 153 additions and 21 deletions
@@ -26,8 +26,6 @@ function makeCredentialDetail(overrides?: Partial<CredentialDetail>): 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,
};
@@ -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>): 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);
});
});
});
@@ -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<typeof getCredentialInputSchema>) => {
@@ -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<typeof listCredentialsInputSchema>) => {
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,
};
},
});
}
@@ -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<string, Promise<Array<{ id: string; name: string; updatedAt: string }>>>;
lists: Map<string, Promise<Array<{ id: string; name: string }>>>;
/** Testability check promises, keyed by credential type. */
testability: Map<string, Promise<boolean>>;
/** 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;
-2
View File
@@ -88,8 +88,6 @@ export interface CredentialSummary {
id: string;
name: string;
type: string;
createdAt: string;
updatedAt: string;
}
export interface CredentialDetail extends CredentialSummary {
@@ -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;
},