diff --git a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts index 487aa681c4c..43c4e63cd25 100644 --- a/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agents-builder-tools.service.test.ts @@ -608,6 +608,75 @@ describe('AgentsBuilderToolsService', () => { expect(result).toEqual(expect.objectContaining({ ok: true, configMutated: true, agentId })); }); + it('finish_setup writes the resolved credential into the empty node-tool slots of its type', async () => { + const { service, agentsService, nodeTypes } = makeService(); + const gmailTool = ( + name: string, + credentials?: Record, + ) => ({ + type: 'node' as const, + name, + description: name, + node: { + nodeType: 'n8n-nodes-base.gmailTool', + nodeTypeVersion: 2.2, + nodeParameters: {}, + credentials, + }, + }); + const ownCredential = { gmailOAuth2: { id: 'own', name: 'Own account' } }; + let stored: AgentJsonConfig = { + ...baseConfig, + tools: [gmailTool('search'), gmailTool('reply'), gmailTool('archive', ownCredential)], + }; + agentsService.listChatIntegrations.mockReturnValue([]); + agentsService.findById.mockImplementation(async () => makeAgent(stored)); + agentsService.updateConfig.mockImplementation(async (_agentId, _projectId, config) => { + stored = config as AgentJsonConfig; + return { + config: stored, + configHash: getAgentConfigHash(stored), + updatedAt: '2026-01-02T00:00:00.000Z', + versionId: 'v2', + }; + }); + nodeTypes.getByNameAndVersion.mockReturnValue({ + description: { + displayName: 'Gmail Tool', + name: 'n8n-nodes-base.gmailTool', + group: [], + version: 2.2, + description: '', + defaults: {}, + inputs: [], + outputs: [], + properties: [], + credentials: [{ name: 'gmailOAuth2' }], + }, + } as unknown as ReturnType); + credentialService.list.mockResolvedValue([ + { id: 'g1', name: 'Gmail account', type: 'gmailOAuth2' }, + ]); + + const tool = getJsonTool(service, BUILDER_TOOLS.FINISH_SETUP); + const result = await tool.handler!( + { credentialRequests: [{ credentialType: 'gmailOAuth2', purpose: 'Order emails' }] }, + ctx as never, + ); + + const resolved = { gmailOAuth2: { id: 'g1', name: 'Gmail account' } }; + expect( + stored.tools?.map((t) => (t.type === 'node' ? t.node.credentials : undefined)), + ).toEqual([resolved, resolved, ownCredential]); + expect(result).toEqual( + expect.objectContaining({ + completed: true, + credentialsWrittenToTools: ['gmailOAuth2'], + configHash: getAgentConfigHash(stored), + }), + ); + }); + it('patch_config saves a removed top-level field as removed', async () => { const { service, agentsService } = makeService(); const telegram: AgentIntegrationConfig = { diff --git a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts index 884a5b02851..4fb9da2ce2c 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-tools.service.ts @@ -86,13 +86,20 @@ import { buildFinishSetupTool, buildResolveLlmTool, } from './interactive'; +import type { + NodeToolCredentialWriteResult, + ResolvedCredential, +} from './interactive/finish-setup.tool'; import type { ModelLookup } from './interactive/resolve-llm.tool'; import { SKILL_BODY_GUIDANCE, SKILL_DESCRIPTION_RULE } from './skill-body-template'; import { TASK_OBJECTIVE_GUIDANCE } from './task-objective-template'; import { buildVerifyMcpServerTool, type McpCredentialApplyResult } from './verify-mcp-server.tool'; import { AgentSkillRefsService } from '../agent-skill-refs.service'; import { composeJsonConfig } from '../json-config/agent-config-composition'; -import { listAiGatewayManagedCredentialTypes } from '../json-config/reconcile-node-tool-gateway-credentials'; +import { + assignCredentialToNodeTools, + listAiGatewayManagedCredentialTypes, +} from '../json-config/reconcile-node-tool-gateway-credentials'; import { AgentSecureRuntime } from '../runtime/agent-secure-runtime'; import { getAgentConfigHash, isSameAgentConfig } from '../utils/agent-config-hash'; import { SkillNameTakenError, skillNameTakenToolMessage } from '../skills/skill-name-taken.error'; @@ -559,7 +566,7 @@ export class AgentsBuilderToolsService { ), this.withConfigMutationMarker( this.withConfigSnapshotOnResume( - this.createFinishSetupTool(credentialService, agentId, projectId, track), + this.createFinishSetupTool(credentialService, agentId, projectId, user, track), agentId, projectId, (result) => result.completed === true, @@ -595,6 +602,7 @@ export class AgentsBuilderToolsService { credentialService: InstanceAiCredentialService, agentId: string, projectId: string, + user: User, track: BuilderTrackFn, ): BuiltTool { return buildFinishSetupTool({ @@ -602,6 +610,8 @@ export class AgentsBuilderToolsService { agentId, projectId, track, + writeCredentialsToNodeTools: async (credentials) => + await this.writeCredentialsToNodeTools(agentId, projectId, user, credentials), isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType), listIntegrationCredentialIds: async () => { const agent = await this.agentsService.findById(agentId, projectId); @@ -1375,6 +1385,45 @@ export class AgentsBuilderToolsService { return { applied: true, config: saved.config, configHash: saved.configHash }; } + private async writeCredentialsToNodeTools( + agentId: string, + projectId: string, + user: User, + credentials: ResolvedCredential[], + ): Promise { + const failed = (errors: Array<{ message: string }>): NodeToolCredentialWriteResult => ({ + credentialTypes: [], + error: errors.map((e) => e.message).join('; '), + }); + const editorLock = await this.getEditorLockFailure(agentId); + if (editorLock) return failed(editorLock.errors); + const { config, configHash } = await this.getConfigSnapshot(agentId, projectId); + if (!config) return failed([{ message: 'Agent has no JSON config yet.' }]); + + const tools = structuredClone(config.tools ?? []); + const credentialTypes: string[] = []; + for (const { credentialType, id, name } of credentials) { + if (assignCredentialToNodeTools(tools, this.nodeTypes, credentialType, { id, name }) > 0) { + credentialTypes.push(credentialType); + } + } + if (credentialTypes.length === 0) return { credentialTypes }; + + const next = { ...config, tools }; + const validated = this.validateBuilderConfig(next, config); + if (!validated.ok) return failed(validated.errors); + const saved = await this.saveBuilderConfig( + agentId, + projectId, + user, + validated.config, + configHash, + next, + ); + if (!saved.ok) return failed(saved.errors); + return { credentialTypes, configHash: saved.configHash }; + } + private async getFreshConfigSnapshot( agentId: string, projectId: string, diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts index bafefc546a4..4e3dad2902d 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/finish-setup.tool.test.ts @@ -422,4 +422,80 @@ describe('finish_setup tool', () => { ).rejects.toThrow('Unsupported chat channel "carrier-pigeon"'); expect(ctx.suspend).not.toHaveBeenCalled(); }); + + describe('writing credentials to node tools', () => { + it('writes each resolved credential by its credential type', async () => { + const writeCredentialsToNodeTools = vi + .fn() + .mockResolvedValue({ credentialTypes: ['gmailOAuth2'], configHash: 'hash-2' }); + const tool = buildFinishSetupTool({ + ...BASE_DEPS, + credentialService: makeCredentialService([ + { id: 'g1', name: 'Gmail account', type: 'gmailOAuth2' }, + ]), + writeCredentialsToNodeTools, + }); + + const result = await tool.handler!( + { + credentialRequests: [ + { credentialType: 'gmailOAuth2', purpose: 'Order emails', credentialSlot: 'gmail' }, + ], + }, + makeCtx() as never, + ); + + expect(writeCredentialsToNodeTools).toHaveBeenCalledWith([ + { credentialType: 'gmailOAuth2', id: 'g1', name: 'Gmail account' }, + ]); + expect(result).toEqual({ + completed: true, + credentials: { gmail: { id: 'g1', name: 'Gmail account' } }, + credentialsWrittenToTools: ['gmailOAuth2'], + configHash: 'hash-2', + }); + }); + + it('writes nothing when every credential is skipped', async () => { + const writeCredentialsToNodeTools = vi.fn(); + const tool = buildFinishSetupTool({ + ...BASE_DEPS, + credentialService: makeCredentialService([]), + writeCredentialsToNodeTools, + }); + const input = { + credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }], + }; + + const credentialsPayload = await tool.handler!(input, makeCtx() as never); + const result = await tool.handler!( + input, + makeCtx({ resumeData: { skipped: true }, suspendPayload: credentialsPayload }) as never, + ); + + expect(writeCredentialsToNodeTools).not.toHaveBeenCalled(); + expect(result).toEqual({ completed: true, credentials: { airtableApi: 'skipped' } }); + }); + + it('reports a failed write without failing the setup flow', async () => { + const tool = buildFinishSetupTool({ + ...BASE_DEPS, + credentialService: makeCredentialService([ + { id: 'c1', name: 'My Airtable', type: 'airtableApi' }, + ]), + writeCredentialsToNodeTools: vi.fn().mockRejectedValue(new Error('locked')), + }); + + const result = await tool.handler!( + { credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }] }, + makeCtx() as never, + ); + + expect(result).toEqual({ + completed: true, + credentials: { airtableApi: { id: 'c1', name: 'My Airtable' } }, + credentialWriteError: 'locked', + }); + }); + }); }); diff --git a/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts b/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts index e814fb03dcd..0f4c09895c9 100644 --- a/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/finish-setup.tool.ts @@ -45,6 +45,23 @@ export interface FinishSetupToolDeps extends CredentialSetupDeps, ChannelSetupDe * empty on any tool is excluded, so an uncovered node/operation keeps prompting. */ listAiGatewayManagedCredentialTypes?: () => Promise; + /** Writes each credential into the empty node-tool slots of its type. */ + writeCredentialsToNodeTools?: ( + credentials: ResolvedCredential[], + ) => Promise; +} + +export interface ResolvedCredential { + credentialType: string; + id: string; + name: string; +} + +export interface NodeToolCredentialWriteResult { + /** Credential types written to at least one node-tool slot. */ + credentialTypes: string[]; + configHash?: string; + error?: string; } const finishSetupCredentialRequestInputSchema = z.object({ @@ -151,6 +168,43 @@ type FinishSetupCtx = InterruptibleToolContext { + if (outcome === 'skipped') return []; + const request = input.credentialRequests?.find( + (slot) => (slot.credentialSlot ?? slot.credentialType) === slotKey, + ); + return [{ credentialType: request?.credentialType ?? slotKey, ...outcome }]; + }); +} + +/** Complete the flow and write the resolved credentials into the empty node-tool slots. */ +async function complete( + input: FinishSetupInput, + collected: Collected, + deps: FinishSetupToolDeps, +): Promise { + const result: FinishSetupToolResult = { completed: true, ...collected }; + const credentials = resolvedCredentials(input, collected); + if (!deps.writeCredentialsToNodeTools || credentials.length === 0) return result; + + try { + const written = await deps.writeCredentialsToNodeTools(credentials); + if (written.credentialTypes.length > 0) { + result.credentialsWrittenToTools = written.credentialTypes; + } + if (written.configHash) result.configHash = written.configHash; + if (written.error) result.credentialWriteError = written.error; + } catch (error) { + result.credentialWriteError = error instanceof Error ? error.message : String(error); + } + return result; } /** Normalize checkpoint-only legacy outcomes before they enter the current setup flow. */ @@ -386,7 +440,7 @@ async function startPlan( ): Promise { const { phases, collected } = await computeInitialPlan(input, deps); if (phases.length === 0) { - return { completed: true, ...collected }; + return await complete(input, collected, deps); } const [currentPhase, ...remainingPhases] = phases; @@ -417,7 +471,7 @@ async function resumePlan( ); if (chain.remainingPhases.length === 0) { - return { completed: true, ...collected }; + return await complete(input, collected, deps); } const [nextPhase, ...restPhases] = chain.remainingPhases; @@ -447,11 +501,14 @@ export function buildFinishSetupTool(deps: FinishSetupToolDeps): BuiltTool { 'to configure; do not infer channel names. Each channel card persists the configuration ' + 'or skips it, so channel outcomes are `"configured"` or `"skipped"`. Do not call ' + 'configure_channel again for a channel handled by ' + - 'this flow. Returns { completed, answers, credentials, ' + - 'channels } (plus configMutated/agentId refresh metadata when completed): resolve the ' + - 'model answer with resolve_llm, copy returned credential ids into the config, and verify ' + - 'MCP servers with them. Auto-resolves credential slots that match an existing single ' + - 'credential or configured channel credential.', + 'this flow. Returns { completed, answers, credentials, channels, ' + + 'credentialsWrittenToTools } (plus configMutated/agentId refresh metadata when ' + + 'completed). The tool writes each resolved credential into every empty node-tool slot ' + + 'of its type and lists those types in `credentialsWrittenToTools`; do not patch them ' + + 'again. Resolve the model answer with resolve_llm, verify MCP servers with the returned ' + + 'credential ids, and patch a credential yourself only if `credentialWriteError` is set. ' + + 'Auto-resolves credential slots that match an existing single credential or configured ' + + 'channel credential.', ) .input(finishSetupInputSchema) .suspend(finishSetupSuspendSchema) diff --git a/packages/cli/src/modules/agents/builder/prompts/initial-build.prompt.ts b/packages/cli/src/modules/agents/builder/prompts/initial-build.prompt.ts index 1576eed180c..8bd24b1a5e3 100644 --- a/packages/cli/src/modules/agents/builder/prompts/initial-build.prompt.ts +++ b/packages/cli/src/modules/agents/builder/prompts/initial-build.prompt.ts @@ -55,8 +55,9 @@ During an initial build: pending: the model choice and open decisions as \`questions\`, one \`credentialRequests\` entry per credential slot, and one \`channels\` entry per drafted channel integration — it configures or skips each channel itself, - always as the last cards in the flow. Resolve its results — \`resolve_llm\` - with the model answer, patch returned credential ids into the config, + always as the last cards in the flow. It writes each resolved credential + into the empty node-tool slots of its type; do not patch those again. + Resolve the rest of its results — \`resolve_llm\` with the model answer, verify MCP servers — and finish every build step that waited on that setup. - Do not call \`configure_channel\` again after \`finish_setup\` handles a channel card. - After \`finish_setup\`, end your reply with a short setup checklist for diff --git a/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts b/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts index a85cdc0d870..875f9e1d1fa 100644 --- a/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts +++ b/packages/cli/src/modules/agents/builder/skills/node-tools.skill.ts @@ -83,7 +83,7 @@ conventions. Never add an incomplete tool or use a placeholder URL. - Gateway credits cover many services, including some community nodes. Adding a node tool with its credential slot omitted triggers server-side assignment: for a covered service the server attaches the managed \`Gateway credits\` credential (\`{ id: null, name: "Gateway credits", __aiGatewayManaged: true }\`) to each required, eligible slot on write — but only when the project has no credential of that type; an existing credential of the type wins and the slot stays empty for the normal credential flow below. Add the tool with the credential slot omitted, then check the slot in the write result: when the server assigned a credential, the result carries the saved \`config\`; when the result has no \`config\`, the slot stayed empty. - Exception — when the user explicitly asks to run a tool on Gateway credits, write \`{ "id": null, "name": "Gateway credits", "__aiGatewayManaged": true }\` into that credential slot yourself: the server keeps it when the service is covered (even if the user has their own credential of the type) and removes it when not covered — check the \`config\` in the write result (it is present when the server removed the slot) and resolve a real credential if it was removed. - The \`Gateway credits\` managed credential IS the real, working credential — the tool executes through n8n's gateway on Gateway credits, so NO separate API key is needed. It is NOT a placeholder and NOT "invalid for the service", even for a community node. For a slot that the config (from \`agent-context({ type: "config" })\` or a write result) shows populated with it: the slot is fully connected and the tool WILL run. Do NOT call \`ask_credential\` for it; do NOT include it in \`finish_setup\`; NEVER clear, remove, or replace it via \`patch_config\`; and NEVER seek a "real" API key to swap in for it. Report the tool as ready, running on Gateway credits — exactly like a managed model. Never tell the user the credential is "not connected"/"not set up" or that the tool "won't run until a credential is added". -- Only for a required slot that is still empty after the write (a service Gateway credits do not cover) do you resolve a real credential: call \`ask_credential\` once before the config mutation for an addition to an existing agent. ${INITIAL_BUILD_NOTE} After the trailing \`finish_setup\` resolves the credential, copy the returned credentials into \`node.credentials\` via \`patch_config\`; for resource-locator resolution follow \`agent-builder-resource-locators\` then. Pass the node's credential key as \`credentialSlot\`. On success, copy the returned \`credentials\` object directly to \`node.credentials\`. If skipped, still add the tool and omit only that credential slot. +- Only for a required slot that is still empty after the write (a service Gateway credits do not cover) do you resolve a real credential: call \`ask_credential\` once before the config mutation for an addition to an existing agent. ${INITIAL_BUILD_NOTE} The trailing \`finish_setup\` writes the resolved credential into the empty slot itself; do not patch it, unless the \`finish_setup\` result sets \`credentialWriteError\`. In that case the write failed, so patch the resolved credential into the slot yourself. For resource-locator resolution, follow \`agent-builder-resource-locators\` after \`finish_setup\`. Pass the node's credential key as \`credentialSlot\`. On success, copy the returned \`credentials\` object directly to \`node.credentials\`. If skipped, still add the tool and omit only that credential slot. - When the agent already has a chat channel configured and the tool needs the same credential type, \`ask_credential\` reuses the channel's credential automatically — do not ask the user to pick a different one. diff --git a/packages/cli/src/modules/agents/json-config/__tests__/reconcile-node-tool-gateway-credentials.test.ts b/packages/cli/src/modules/agents/json-config/__tests__/reconcile-node-tool-gateway-credentials.test.ts index d3a807f68c3..fa25ac7225b 100644 --- a/packages/cli/src/modules/agents/json-config/__tests__/reconcile-node-tool-gateway-credentials.test.ts +++ b/packages/cli/src/modules/agents/json-config/__tests__/reconcile-node-tool-gateway-credentials.test.ts @@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended'; import type { NodeTypes } from '@/node-types'; import { + assignCredentialToNodeTools, listAiGatewayManagedCredentialTypes, reconcileNodeToolGatewayCredentials, } from '../reconcile-node-tool-gateway-credentials'; @@ -408,3 +409,61 @@ describe('listAiGatewayManagedCredentialTypes', () => { expect(listAiGatewayManagedCredentialTypes(tools, nodeTypes)).toEqual([]); }); }); + +describe('assignCredentialToNodeTools', () => { + const credential = { id: 'g1', name: 'Gmail account' }; + + it('fills every empty slot of the type and counts them', () => { + const tools = [nodeTool('n8n-nodes-base.gmailTool'), nodeTool('n8n-nodes-base.gmailTool')]; + const filled = assignCredentialToNodeTools( + tools, + nodeTypesWithCredentials(['gmailOAuth2']), + 'gmailOAuth2', + credential, + ); + expect(filled).toBe(2); + expect(tools.map((t) => t.node.credentials)).toEqual([ + { gmailOAuth2: credential }, + { gmailOAuth2: credential }, + ]); + }); + + it('keeps a real credential and a managed marker', () => { + const tools = [ + nodeTool('n8n-nodes-base.gmailTool', { gmailOAuth2: { id: 'other', name: 'Other' } }), + nodeTool('n8n-nodes-base.gmailTool', { gmailOAuth2: { ...SENTINEL } }), + ]; + const filled = assignCredentialToNodeTools( + tools, + nodeTypesWithCredentials(['gmailOAuth2']), + 'gmailOAuth2', + credential, + ); + expect(filled).toBe(0); + expect(tools[0].node.credentials).toEqual({ gmailOAuth2: { id: 'other', name: 'Other' } }); + expect(tools[1].node.credentials).toEqual({ gmailOAuth2: SENTINEL }); + }); + + it('skips a node whose type does not declare the credential', () => { + const tools = [nodeTool('n8n-nodes-base.slackTool')]; + const filled = assignCredentialToNodeTools( + tools, + nodeTypesWithCredentials(['slackApi']), + 'gmailOAuth2', + credential, + ); + expect(filled).toBe(0); + expect(tools[0].node.credentials).toBeUndefined(); + }); + + it('skips a slot hidden by the current authentication option', () => { + const tools = [nodeTool('n8n-nodes-base.service')]; + const filled = assignCredentialToNodeTools( + tools, + nodeTypesWithDescription(multiAuthNodeDescription), + 'serviceApiKey', + { id: 'k1', name: 'Key' }, + ); + expect(filled).toBe(0); + }); +}); diff --git a/packages/cli/src/modules/agents/json-config/reconcile-node-tool-gateway-credentials.ts b/packages/cli/src/modules/agents/json-config/reconcile-node-tool-gateway-credentials.ts index bf5bc599c17..3805284f2ea 100644 --- a/packages/cli/src/modules/agents/json-config/reconcile-node-tool-gateway-credentials.ts +++ b/packages/cli/src/modules/agents/json-config/reconcile-node-tool-gateway-credentials.ts @@ -19,6 +19,11 @@ function aiGatewayManagedCredential(): NodeToolCredential { }; } +/** Sanitization clears inaccessible ids to '', which leaves the slot empty. */ +function isCredentialSlotFilled(ref: NodeToolCredential | undefined): boolean { + return !!ref && (!!ref.id || AI_GATEWAY_MANAGED_CREDENTIAL_FLAG in ref); +} + function setAiGatewayManagedCredential( node: NodeToolConfig, credentialType: string, @@ -155,8 +160,7 @@ export function listAiGatewayManagedCredentialTypes( const resolvedParameters = resolveNodeParameters(node, description); for (const slot of getRequiredNodeCredentialSlots(description)) { const credentialType = slot.credentialType; - const ref = node.credentials?.[credentialType]; - if (ref && (ref.id || AI_GATEWAY_MANAGED_CREDENTIAL_FLAG in ref)) continue; + if (isCredentialSlotFilled(node.credentials?.[credentialType])) continue; if (isCredentialDisplayed(node, description, credentialType, resolvedParameters)) { unsatisfied.add(credentialType); } @@ -166,6 +170,41 @@ export function listAiGatewayManagedCredentialTypes( return [...managed].filter((credentialType) => !unsatisfied.has(credentialType)); } +/** + * Write a resolved credential into every node-tool slot of its type that is + * still empty and displayed for the node's current parameters. A slot with a + * real id or a managed marker is left alone. Returns the number of slots filled. + */ +export function assignCredentialToNodeTools( + tools: AgentJsonToolConfig[] | undefined, + nodeTypes: NodeTypes, + credentialType: string, + credential: { id: string; name: string }, +): number { + let assigned = 0; + for (const tool of tools ?? []) { + if (tool.type !== 'node') continue; + const node = tool.node; + + let description: INodeTypeDescription; + try { + description = nodeTypes.getByNameAndVersion(node.nodeType, node.nodeTypeVersion).description; + } catch { + continue; + } + if (!description.credentials?.some((c) => c.name === credentialType)) continue; + + if (isCredentialSlotFilled(node.credentials?.[credentialType])) continue; + + const resolvedParameters = resolveNodeParameters(node, description); + if (!isCredentialDisplayed(node, description, credentialType, resolvedParameters)) continue; + + node.credentials = { ...(node.credentials ?? {}), [credentialType]: { ...credential } }; + assigned++; + } + return assigned; +} + function reconcileNode( node: NodeToolConfig, nodeTypes: NodeTypes, @@ -226,10 +265,7 @@ function reconcileNode( for (const slot of getRequiredNodeCredentialSlots(description)) { const credentialType = slot.credentialType; - // A ref only occupies the slot with a real id or the managed flag — - // sanitization clears inaccessible ids to '', leaving the slot empty. - const existing = node.credentials?.[credentialType]; - if (existing && (existing.id || AI_GATEWAY_MANAGED_CREDENTIAL_FLAG in existing)) continue; + if (isCredentialSlotFilled(node.credentials?.[credentialType])) continue; // Own credential wins (same precedence as MCP and model slots): leave the // slot empty so the builder resolves the user's credential instead.