mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-02 01:37:07 +02:00
feat: Support agent mcp servers (no-changelog) (#31070)
This commit is contained in:
parent
37e47e3cec
commit
1da2c1b5eb
|
|
@ -0,0 +1,118 @@
|
|||
import { McpConnection } from '../mcp-connection';
|
||||
|
||||
const sseCtor = jest.fn();
|
||||
const streamableHttpCtor = jest.fn();
|
||||
const stdioCtor = jest.fn();
|
||||
|
||||
const clientConnect = jest.fn().mockResolvedValue(undefined);
|
||||
const clientListTools = jest.fn().mockResolvedValue({
|
||||
tools: [
|
||||
{ name: 'echo', description: '', inputSchema: { type: 'object' } },
|
||||
{ name: 'add', description: '', inputSchema: { type: 'object' } },
|
||||
{ name: 'subtract', description: '', inputSchema: { type: 'object' } },
|
||||
],
|
||||
});
|
||||
const clientClose = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
class FakeClient {
|
||||
connect = clientConnect;
|
||||
listTools = clientListTools;
|
||||
close = clientClose;
|
||||
}
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: jest.fn(() => new FakeClient()),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/sse.js', () => ({
|
||||
SSEClientTransport: jest.fn((url: URL, options: unknown) => {
|
||||
sseCtor(url, options);
|
||||
return { type: 'sse', url, options };
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: jest.fn((options: unknown) => {
|
||||
stdioCtor(options);
|
||||
return { type: 'stdio', options };
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
||||
StreamableHTTPClientTransport: jest.fn((url: URL, options: unknown) => {
|
||||
streamableHttpCtor(url, options);
|
||||
return { type: 'streamableHttp', url, options };
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@modelcontextprotocol/sdk/types.js', () => ({
|
||||
CallToolResultSchema: {},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('McpConnection — custom fetch forwarding', () => {
|
||||
beforeEach(() => {
|
||||
sseCtor.mockClear();
|
||||
streamableHttpCtor.mockClear();
|
||||
stdioCtor.mockClear();
|
||||
clientConnect.mockClear();
|
||||
clientListTools.mockClear();
|
||||
});
|
||||
|
||||
it('forwards `fetch` to StreamableHTTPClientTransport when provided', async () => {
|
||||
const customFetch = jest.fn();
|
||||
const conn = new McpConnection({
|
||||
name: 's1',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
fetch: customFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
await conn.connect();
|
||||
|
||||
expect(streamableHttpCtor).toHaveBeenCalledTimes(1);
|
||||
const [, options] = streamableHttpCtor.mock.calls[0] as [URL, { fetch?: typeof fetch }];
|
||||
expect(options.fetch).toBe(customFetch);
|
||||
});
|
||||
|
||||
it('forwards `fetch` to SSEClientTransport and to its eventSourceInit when provided', async () => {
|
||||
const customFetch = jest.fn();
|
||||
const conn = new McpConnection({
|
||||
name: 's2',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'sse',
|
||||
fetch: customFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
await conn.connect();
|
||||
|
||||
expect(sseCtor).toHaveBeenCalledTimes(1);
|
||||
const [, options] = sseCtor.mock.calls[0] as [
|
||||
URL,
|
||||
{ fetch?: typeof fetch; eventSourceInit?: { fetch?: typeof fetch } },
|
||||
];
|
||||
expect(options.fetch).toBe(customFetch);
|
||||
expect(options.eventSourceInit?.fetch).toBe(customFetch);
|
||||
});
|
||||
|
||||
it('omits `fetch` and `eventSourceInit` when no custom fetch is provided', async () => {
|
||||
const conn = new McpConnection({
|
||||
name: 's3',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'sse',
|
||||
});
|
||||
|
||||
await conn.connect();
|
||||
|
||||
expect(sseCtor).toHaveBeenCalledTimes(1);
|
||||
const [, options] = sseCtor.mock.calls[0] as [
|
||||
URL,
|
||||
{ fetch?: typeof fetch; eventSourceInit?: { fetch?: typeof fetch } },
|
||||
];
|
||||
expect(options.fetch).toBeUndefined();
|
||||
expect(options.eventSourceInit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -216,10 +216,17 @@ export class McpConnection {
|
|||
: undefined;
|
||||
|
||||
if (config.transport === 'streamableHttp') {
|
||||
return new sdk.StreamableHTTPClientTransport(url, { requestInit });
|
||||
return new sdk.StreamableHTTPClientTransport(url, {
|
||||
requestInit,
|
||||
fetch: config.fetch,
|
||||
});
|
||||
}
|
||||
|
||||
return new sdk.SSEClientTransport(url, { requestInit });
|
||||
return new sdk.SSEClientTransport(url, {
|
||||
requestInit,
|
||||
fetch: config.fetch,
|
||||
eventSourceInit: config.fetch ? { fetch: config.fetch } : undefined,
|
||||
});
|
||||
}
|
||||
throw new Error(`MCP server "${config.name}": provide either "url" or "command"`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -617,12 +617,21 @@ export class Agent implements BuiltAgent, AgentBuilder {
|
|||
}
|
||||
|
||||
/**
|
||||
* Wait for any in-flight background tasks (title generation, future
|
||||
* observer cycles) to settle. Call before letting the agent go out of
|
||||
* scope to ensure deferred writes land. Safe to call multiple times.
|
||||
* Close the agent and release all held resources.
|
||||
*
|
||||
* - Waits for any in-flight background tasks (title generation, observer
|
||||
* cycles) to settle via the runtime's `dispose()`.
|
||||
* - Disconnects every MCP client attached via `.mcp()`. Errors from
|
||||
* individual client disconnects are swallowed so a single misbehaving
|
||||
* server does not prevent the others from closing.
|
||||
*
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.runtime) await this.runtime.dispose();
|
||||
const tasks: Array<Promise<unknown>> = [];
|
||||
if (this.runtime) tasks.push(this.runtime.dispose());
|
||||
tasks.push(...this.mcpClients.map(async (c) => await c.close()));
|
||||
await Promise.allSettled(tasks);
|
||||
}
|
||||
|
||||
/** Generate a response (non-streaming). Lazy-builds on first call. */
|
||||
|
|
|
|||
|
|
@ -233,6 +233,18 @@ export interface BuiltAgent {
|
|||
/** Cancel the currently running agent. Synchronous — sets an abort flag that the agentic loop checks asynchronously. */
|
||||
abort(): void;
|
||||
|
||||
/**
|
||||
* Close the agent and release all held resources.
|
||||
*
|
||||
* - Waits for any in-flight background tasks (title generation, observer
|
||||
* cycles) to settle via the runtime's `dispose()`.
|
||||
* - Disconnects every MCP client that was attached via `.mcp()`.
|
||||
*
|
||||
* Safe to call multiple times. Should be called when the agent is no
|
||||
* longer needed so MCP transports are not left open indefinitely.
|
||||
*/
|
||||
close(): Promise<void>;
|
||||
|
||||
/** Resume a tool with custom resume data */
|
||||
resume(
|
||||
method: 'generate',
|
||||
|
|
|
|||
|
|
@ -38,4 +38,29 @@ export interface McpServerConfig {
|
|||
* `.requireToolApproval()` flag on the Agent still applies).
|
||||
*/
|
||||
requireApproval?: string[] | boolean;
|
||||
|
||||
/**
|
||||
* Custom fetch implementation used by URL-based transports (SSE,
|
||||
* StreamableHTTP).
|
||||
* Ignored for stdio transport. When omitted, the SDK transports fall back
|
||||
* to the global `fetch`.
|
||||
*/
|
||||
fetch?: typeof fetch;
|
||||
|
||||
/**
|
||||
* Restrict which tools from this server are surfaced to the agent.
|
||||
*
|
||||
* Tools are matched by their original (un-prefixed) name.
|
||||
*
|
||||
* - `{ mode: 'allow', tools: [...] }` — only the listed tools are surfaced.
|
||||
* - `{ mode: 'exclude', tools: [...] }` — every tool except the listed ones
|
||||
* is surfaced.
|
||||
* - omitted — every tool the server advertises is surfaced.
|
||||
*
|
||||
* An empty `tools` array is a no-op for both modes — i.e. an empty allow
|
||||
* list does not hide everything, and an empty exclude list does not hide
|
||||
* anything. This matches the JSON-config semantics ("no filter applied"
|
||||
* is expressed by omitting the field).
|
||||
*/
|
||||
toolFilter?: { mode: 'allow' | 'exclude'; tools: string[] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,101 @@ const AgentJsonSkillConfigSchema = z.object({
|
|||
.regex(/^[A-Za-z0-9_-]+$/),
|
||||
});
|
||||
|
||||
/**
|
||||
* Configuration for a single MCP (Model Context Protocol) server attached to
|
||||
* an agent. Tool entries from MCP servers are sourced separately from the
|
||||
* `tools[]` array — the SDK's `McpClient` prefixes each tool name with the
|
||||
* server name to avoid collisions.
|
||||
*/
|
||||
const McpServerConfigSchema = z
|
||||
.object({
|
||||
/**
|
||||
* Unique-per-agent server name. Also used as the SDK tool-name prefix
|
||||
* (e.g. a server named `github` exposes its `create_issue` tool as
|
||||
* `github_create_issue` to the LLM).
|
||||
*/
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/),
|
||||
description: z.string().max(512).optional(),
|
||||
url: z.string().min(1),
|
||||
transport: z.enum(['sse', 'streamableHttp']).default('streamableHttp'),
|
||||
/**
|
||||
* Authentication method. In addition to the five named variants, any string
|
||||
* ending in `McpOAuth2Api` is accepted to accommodate registry-specific
|
||||
* credential types (e.g. `notionMcpOAuth2Api`, `githubMcpOAuth2Api`).
|
||||
*/
|
||||
authentication: z
|
||||
.union([
|
||||
z.enum(['none', 'bearerAuth', 'headerAuth', 'multipleHeadersAuth', 'mcpOAuth2Api']),
|
||||
z.string().endsWith('McpOAuth2Api'),
|
||||
])
|
||||
.default('none'),
|
||||
/** Credential id required when `authentication` is anything other than `none`. */
|
||||
credential: z.string().optional(),
|
||||
metadata: z
|
||||
.object({
|
||||
/**
|
||||
* The node-type name this server was created from (e.g. `@n8n/mcp-registry.github`).
|
||||
* When present the config modal renders with the registry node type's form
|
||||
* (correct credential selector, preset field visibility) instead of the generic
|
||||
* MCP Client Tool form.
|
||||
*/
|
||||
nodeTypeName: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/**
|
||||
* Restricts which tools from this server are surfaced to the agent.
|
||||
* Tools are matched by their original (un-prefixed) name.
|
||||
*
|
||||
* - `{ mode: 'allow', tools: [...] }` — only the listed tools are surfaced.
|
||||
* - `{ mode: 'exclude', tools: [...] }` — every tool except the listed ones is surfaced.
|
||||
* - absent — every tool the server advertises is surfaced.
|
||||
*/
|
||||
toolFilter: z
|
||||
.discriminatedUnion('mode', [
|
||||
z
|
||||
.object({
|
||||
mode: z.literal('allow'),
|
||||
tools: z.array(z.string().min(1)).default([]),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal('exclude'),
|
||||
tools: z.array(z.string().min(1)).default([]),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
/**
|
||||
* Human-in-the-loop approval requirements.
|
||||
*
|
||||
* - absent — no approval required (default).
|
||||
* - `{ mode: 'global' }` — every tool from this server requires approval.
|
||||
* - `{ mode: 'selected', tools: [...] }` — only listed tool names
|
||||
* (un-prefixed) require approval; non-empty.
|
||||
*
|
||||
* Maps onto the SDK's `McpServerConfig.requireApproval`
|
||||
* (`true` / `string[]`) at reconstruction time.
|
||||
*/
|
||||
approval: z
|
||||
.discriminatedUnion('mode', [
|
||||
z.object({ mode: z.literal('global') }).strict(),
|
||||
z
|
||||
.object({
|
||||
mode: z.literal('selected'),
|
||||
tools: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
.optional(),
|
||||
connectionTimeoutMs: z.number().int().min(1).max(120_000).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const AgentJsonToolConfigSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal('custom'),
|
||||
|
|
@ -144,6 +239,13 @@ export const AgentJsonConfigSchema = z.object({
|
|||
skills: z.array(AgentJsonSkillConfigSchema).optional(),
|
||||
providerTools: z.record(z.record(z.unknown())).optional(),
|
||||
integrations: z.array(AgentIntegrationSchema).optional(),
|
||||
mcpServers: z
|
||||
.array(McpServerConfigSchema)
|
||||
.max(20)
|
||||
.refine((servers) => new Set(servers.map((s) => s.name)).size === servers.length, {
|
||||
message: 'MCP server names must be unique within an agent',
|
||||
})
|
||||
.optional(),
|
||||
config: z
|
||||
.object({
|
||||
thinking: ThinkingConfigSchema.optional(),
|
||||
|
|
@ -184,6 +286,7 @@ export type AgentJsonCustomToolConfig = Extract<AgentJsonToolConfig, { type: 'cu
|
|||
export type AgentJsonSkillConfig = z.infer<typeof AgentJsonSkillConfigSchema>;
|
||||
export type AgentJsonMemoryConfig = z.infer<typeof MemoryConfigSchema>;
|
||||
export type NodeToolConfig = z.infer<typeof NodeConfigSchema>;
|
||||
export type AgentJsonMcpServerConfig = z.infer<typeof McpServerConfigSchema>;
|
||||
|
||||
export interface ConfigValidationError {
|
||||
path: string;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Config, Env } from '../decorators';
|
|||
* `N8N_AGENTS_MODULES`. The backend fails fast on unknown tokens so typos
|
||||
* surface at startup instead of silently disabling a feature.
|
||||
*/
|
||||
export const AGENTS_MODULE_NAMES = ['node-tools-searcher'] as const;
|
||||
export const AGENTS_MODULE_NAMES = ['node-tools-searcher', 'mcp'] as const;
|
||||
|
||||
export type AgentsModuleName = (typeof AGENTS_MODULE_NAMES)[number];
|
||||
|
||||
|
|
@ -33,8 +33,12 @@ export class AgentsConfig {
|
|||
/**
|
||||
* Comma-separated list of agent sub-feature modules to enable. Each entry
|
||||
* gates a specific frontend/runtime capability inside the agents module.
|
||||
* Currently known: `node-tools-searcher` (surfaces the "Built-in node tools"
|
||||
* toggle in the agent editor).
|
||||
* Currently known:
|
||||
* - `node-tools-searcher` — surfaces the "Built-in node tools" toggle in
|
||||
* the agent editor.
|
||||
* - `mcp` — enables the "MCP servers" section in the agent editor and
|
||||
* the runtime wiring that builds `McpClient` instances from
|
||||
* `config.mcpServers[]`.
|
||||
*
|
||||
* Gates the UI surface only — existing agents persisted with a given
|
||||
* capability turned on continue to run even if its token is removed here.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { isMcpOAuth2Authentication, type McpOAuth2CredentialType } from 'n8n-workflow';
|
||||
|
||||
export type McpTool = { name: string; description?: string; inputSchema: JSONSchema7 };
|
||||
|
||||
export type McpServerTransport = 'sse' | 'httpStreamable';
|
||||
|
||||
export type McpOAuth2CredentialType = 'mcpOAuth2Api' | `${string}McpOAuth2Api`;
|
||||
|
||||
export type McpAuthenticationOption =
|
||||
| 'none'
|
||||
| 'headerAuth'
|
||||
|
|
@ -13,8 +12,4 @@ export type McpAuthenticationOption =
|
|||
| 'multipleHeadersAuth'
|
||||
| McpOAuth2CredentialType;
|
||||
|
||||
export function isMcpOAuth2Authentication(
|
||||
authentication: string,
|
||||
): authentication is McpOAuth2CredentialType {
|
||||
return authentication === 'mcpOAuth2Api' || authentication.endsWith('McpOAuth2Api');
|
||||
}
|
||||
export { isMcpOAuth2Authentication, type McpOAuth2CredentialType };
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ function makeService() {
|
|||
workflowRepository,
|
||||
agentsToolsService,
|
||||
builderModelLookupService,
|
||||
mock(),
|
||||
credentialTypes,
|
||||
);
|
||||
|
||||
|
|
@ -601,4 +602,40 @@ describe('AgentsBuilderToolsService', () => {
|
|||
expect(agentsService.createSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP module gating', () => {
|
||||
it('does not include verify_mcp_server when the "mcp" module is not enabled', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const tools = service.getTools(agentId, projectId, credentialProvider, user).json;
|
||||
|
||||
expect(tools.find((t) => t.name === BUILDER_TOOLS.VERIFY_MCP_SERVER)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not include verify_mcp_server when enabledModules is an empty list', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const tools = service.getTools(agentId, projectId, credentialProvider, user, []).json;
|
||||
|
||||
expect(tools.find((t) => t.name === BUILDER_TOOLS.VERIFY_MCP_SERVER)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes verify_mcp_server when the "mcp" module is enabled', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const tools = service.getTools(agentId, projectId, credentialProvider, user, ['mcp']).json;
|
||||
|
||||
expect(tools.find((t) => t.name === BUILDER_TOOLS.VERIFY_MCP_SERVER)).toBeDefined();
|
||||
});
|
||||
|
||||
it('does not include verify_mcp_server when other modules are enabled but not "mcp"', () => {
|
||||
const { service } = makeService();
|
||||
|
||||
const tools = service.getTools(agentId, projectId, credentialProvider, user, [
|
||||
'someOtherModule',
|
||||
]).json;
|
||||
|
||||
expect(tools.find((t) => t.name === BUILDER_TOOLS.VERIFY_MCP_SERVER)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,8 +35,16 @@ import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
|
|||
const builtAgent = mock<agents.Agent>();
|
||||
builtAgent.hasCheckpointStorage.mockReturnValue(true); // skip checkpoint injection branch
|
||||
|
||||
const buildFromJsonMock = jest.fn().mockImplementation(async () => builtAgent);
|
||||
jest.mock('../json-config/from-json-config', () => ({
|
||||
buildFromJson: jest.fn().mockImplementation(async () => builtAgent),
|
||||
buildFromJson: (...args: unknown[]) => buildFromJsonMock(...args),
|
||||
}));
|
||||
|
||||
const buildMcpClientForServerMock = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => mock<agents.McpClient>());
|
||||
jest.mock('../json-config/mcp-client-factory', () => ({
|
||||
buildMcpClientForServer: (...args: unknown[]) => buildMcpClientForServerMock(...args),
|
||||
}));
|
||||
|
||||
// Avoid loading the rich-interaction tool (its import path resolves to runtime code).
|
||||
|
|
@ -72,15 +80,20 @@ function makeService(
|
|||
mock(),
|
||||
mock<Telemetry>(),
|
||||
mock(),
|
||||
mock(),
|
||||
);
|
||||
}
|
||||
|
||||
function makeAgentEntity(schemaConfig?: AgentJsonConfig['config']): Agent {
|
||||
function makeAgentEntity(
|
||||
schemaConfig?: AgentJsonConfig['config'],
|
||||
overrides?: Partial<AgentJsonConfig>,
|
||||
): Agent {
|
||||
const schema: AgentJsonConfig = {
|
||||
name: 'Test',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
instructions: 'Be helpful',
|
||||
...(schemaConfig !== undefined ? { config: schemaConfig } : {}),
|
||||
...(overrides ?? {}),
|
||||
};
|
||||
return {
|
||||
id: 'agent-1',
|
||||
|
|
@ -172,3 +185,88 @@ describe('AgentsService.reconstructFromConfig — node tools gating', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsService.reconstructFromConfig — MCP gating', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
builtAgent.hasCheckpointStorage.mockReturnValue(true);
|
||||
buildFromJsonMock.mockImplementation(async (_config, _descriptors, options) => {
|
||||
// Drive the buildMcpClient callback exactly once per configured server,
|
||||
// matching what the real buildFromJson does — this is what lets the
|
||||
// gating test assert how many MCP clients were created.
|
||||
const cfg = _config as AgentJsonConfig;
|
||||
if (options?.buildMcpClient && cfg.mcpServers) {
|
||||
for (const server of cfg.mcpServers) {
|
||||
await options.buildMcpClient(server);
|
||||
}
|
||||
}
|
||||
return builtAgent;
|
||||
});
|
||||
});
|
||||
|
||||
function setup(options: { mcpModuleEnabled?: boolean } = {}) {
|
||||
const agentsToolsService = mock<AgentsToolsService>();
|
||||
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
const service = makeService(agentsToolsService, options.mcpModuleEnabled ? ['mcp'] : []);
|
||||
return { service, credentialProvider };
|
||||
}
|
||||
|
||||
it('does not call the MCP factory when no mcpServers are configured', async () => {
|
||||
const { service, credentialProvider } = setup({ mcpModuleEnabled: true });
|
||||
const entity = makeAgentEntity();
|
||||
|
||||
await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider);
|
||||
|
||||
expect(buildMcpClientForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips MCP wiring when the module is disabled, even if mcpServers are present', async () => {
|
||||
const { service, credentialProvider } = setup({ mcpModuleEnabled: false });
|
||||
const entity = makeAgentEntity(undefined, {
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://api.example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// We pass an `options` to buildFromJson with `buildMcpClient: undefined`,
|
||||
// so the mock's loop is a no-op and the factory is never called.
|
||||
await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider);
|
||||
|
||||
expect(buildMcpClientForServerMock).not.toHaveBeenCalled();
|
||||
const lastCall = buildFromJsonMock.mock.calls.at(-1) as unknown[];
|
||||
const opts = lastCall[2] as { buildMcpClient: unknown };
|
||||
expect(opts.buildMcpClient).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds one MCP client per configured server when the module is enabled', async () => {
|
||||
const { service, credentialProvider } = setup({ mcpModuleEnabled: true });
|
||||
const entity = makeAgentEntity(undefined, {
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://api.example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
},
|
||||
{
|
||||
name: 'fs',
|
||||
url: 'https://fs.example.test/mcp',
|
||||
transport: 'sse',
|
||||
authentication: 'none',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await (service as unknown as Reconstructable).reconstructFromConfig(entity, credentialProvider);
|
||||
|
||||
expect(buildMcpClientForServerMock).toHaveBeenCalledTimes(2);
|
||||
expect(buildMcpClientForServerMock.mock.calls[0][0]).toMatchObject({ name: 'github' });
|
||||
expect(buildMcpClientForServerMock.mock.calls[1][0]).toMatchObject({ name: 'fs' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ describe('AgentsService — updateName / updateDescription schema sync', () => {
|
|||
mock(),
|
||||
mock<Telemetry>(),
|
||||
mock<ChatIntegrationService>(),
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -172,6 +172,11 @@ describe('AgentsToolsService', () => {
|
|||
expect(isAgentToolNodeType('@n8n/n8n-nodes-langchain.lmChatOpenAi')).toBe(false);
|
||||
expect(isAgentToolNodeType('@n8n/n8n-nodes-langchain.agent')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not allow MCP tool nodes', () => {
|
||||
expect(isAgentToolNodeType('@n8n/n8n-nodes-langchain.mcpClientTool')).toBe(false);
|
||||
expect(isAgentToolNodeType('@n8n/mcp-registry.notion')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get_node_types handler', () => {
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ describe('AgentsService', () => {
|
|||
globalConfig,
|
||||
telemetry,
|
||||
chatIntegrationService,
|
||||
mock(),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1082,7 +1083,7 @@ describe('AgentsService', () => {
|
|||
});
|
||||
jest.spyOn(service as never, 'compileIsolated').mockResolvedValue({
|
||||
ok: true,
|
||||
agent: { name: 'Test Agent', stream },
|
||||
agent: { name: 'Test Agent', stream, close: jest.fn().mockResolvedValue(undefined) },
|
||||
} as never);
|
||||
|
||||
await service.executeForWorkflow(
|
||||
|
|
|
|||
|
|
@ -995,6 +995,101 @@ describe('buildFromJson()', () => {
|
|||
expect(agent.snapshot.hasMemory).toBe(false);
|
||||
expect(getMemoryConfig(agent)).toBeUndefined();
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// MCP servers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('mcpServers', () => {
|
||||
it('does not invoke buildMcpClient when mcpServers is absent', async () => {
|
||||
const buildMcpClient = jest.fn();
|
||||
await buildFromJson(
|
||||
makeConfig(),
|
||||
{},
|
||||
{
|
||||
toolExecutor: makeMockToolExecutor(),
|
||||
credentialProvider: makeMockCredentialProvider(),
|
||||
memoryFactory: makeMockMemoryFactory(),
|
||||
buildMcpClient,
|
||||
},
|
||||
);
|
||||
expect(buildMcpClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not invoke buildMcpClient when mcpServers is an empty array', async () => {
|
||||
const buildMcpClient = jest.fn();
|
||||
await buildFromJson(
|
||||
makeConfig({ mcpServers: [] }),
|
||||
{},
|
||||
{
|
||||
toolExecutor: makeMockToolExecutor(),
|
||||
credentialProvider: makeMockCredentialProvider(),
|
||||
memoryFactory: makeMockMemoryFactory(),
|
||||
buildMcpClient,
|
||||
},
|
||||
);
|
||||
expect(buildMcpClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('silently skips MCP wiring when no buildMcpClient is provided', async () => {
|
||||
// No buildMcpClient -> the loop is a no-op; build still succeeds.
|
||||
await expect(
|
||||
buildFromJson(
|
||||
makeConfig({
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://api.example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{},
|
||||
{
|
||||
toolExecutor: makeMockToolExecutor(),
|
||||
credentialProvider: makeMockCredentialProvider(),
|
||||
memoryFactory: makeMockMemoryFactory(),
|
||||
},
|
||||
),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('calls buildMcpClient once per configured server and passes each entry through', async () => {
|
||||
const buildMcpClient = jest
|
||||
.fn()
|
||||
.mockImplementation(async () => ({ close: jest.fn() }) as never);
|
||||
await buildFromJson(
|
||||
makeConfig({
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://api.example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
},
|
||||
{
|
||||
name: 'fs',
|
||||
url: 'https://fs.example.test/mcp',
|
||||
transport: 'sse',
|
||||
authentication: 'none',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{},
|
||||
{
|
||||
toolExecutor: makeMockToolExecutor(),
|
||||
credentialProvider: makeMockCredentialProvider(),
|
||||
memoryFactory: makeMockMemoryFactory(),
|
||||
buildMcpClient,
|
||||
},
|
||||
);
|
||||
|
||||
expect(buildMcpClient).toHaveBeenCalledTimes(2);
|
||||
expect(buildMcpClient.mock.calls[0][0]).toMatchObject({ name: 'github' });
|
||||
expect(buildMcpClient.mock.calls[1][0]).toMatchObject({ name: 'fs' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1315,4 +1410,101 @@ describe('AgentJsonConfigSchema', () => {
|
|||
credentialId: 'cred-1',
|
||||
});
|
||||
});
|
||||
|
||||
describe('mcpServers', () => {
|
||||
const base = {
|
||||
name: 'test',
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
credential: 'my-key',
|
||||
instructions: 'Be helpful.',
|
||||
};
|
||||
|
||||
it('parses a minimal MCP server entry with defaults', () => {
|
||||
const parsed = AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [{ name: 'github', url: 'https://api.example.test/mcp' }],
|
||||
});
|
||||
expect(parsed.mcpServers?.[0]).toMatchObject({
|
||||
name: 'github',
|
||||
url: 'https://api.example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects duplicate MCP server names', () => {
|
||||
expect(() =>
|
||||
AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [
|
||||
{ name: 'dup', url: 'https://a.example.test/mcp' },
|
||||
{ name: 'dup', url: 'https://b.example.test/mcp' },
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects names with invalid characters', () => {
|
||||
expect(() =>
|
||||
AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [{ name: 'has spaces', url: 'https://a.example.test/mcp' }],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects more than 20 MCP server entries', () => {
|
||||
const servers = Array.from({ length: 21 }, (_, i) => ({
|
||||
name: `s${i}`,
|
||||
url: 'https://a.example.test/mcp',
|
||||
}));
|
||||
expect(() => AgentJsonConfigSchema.parse({ ...base, mcpServers: servers })).toThrow();
|
||||
});
|
||||
|
||||
it('parses an allow-mode toolFilter', () => {
|
||||
const parsed = AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://a.example.test/mcp',
|
||||
toolFilter: { mode: 'allow', tools: ['search_repositories'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.mcpServers?.[0].toolFilter).toEqual({
|
||||
mode: 'allow',
|
||||
tools: ['search_repositories'],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses approval mode "global"', () => {
|
||||
const parsed = AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://a.example.test/mcp',
|
||||
approval: { mode: 'global' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.mcpServers?.[0].approval).toEqual({ mode: 'global' });
|
||||
});
|
||||
|
||||
it('rejects approval mode "selected" with an empty tools array', () => {
|
||||
expect(() =>
|
||||
AgentJsonConfigSchema.parse({
|
||||
...base,
|
||||
mcpServers: [
|
||||
{
|
||||
name: 'github',
|
||||
url: 'https://a.example.test/mcp',
|
||||
approval: { mode: 'selected', tools: [] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ import { isToolType, isTriggerNodeType } from 'n8n-workflow';
|
|||
import type { IDataObject, INodeParameters } from 'n8n-workflow';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EphemeralNodeExecutor, isAgentProviderNode } from '@/node-execution';
|
||||
import { MCP_REGISTRY_PACKAGE_NAME } from '../mcp-registry/node-description-transform';
|
||||
|
||||
import { NodeCatalogService } from '@/node-catalog';
|
||||
import { EphemeralNodeExecutor, isAgentProviderNode } from '@/node-execution';
|
||||
|
||||
type NodeRequest =
|
||||
| string
|
||||
|
|
@ -38,9 +40,18 @@ export const isExecutableNodeType = (nodeId: string): boolean => !isTriggerNodeT
|
|||
* Exported as a stable reference so the catalog service can cache its
|
||||
* filtered search tool per filter identity.
|
||||
*/
|
||||
export const isAgentToolNodeType = (nodeId: string): boolean =>
|
||||
isExecutableNodeType(nodeId) &&
|
||||
(isToolType(nodeId, { includeHitl: false }) || isAgentProviderNode(nodeId));
|
||||
export const isAgentToolNodeType = (nodeId: string): boolean => {
|
||||
if (!isExecutableNodeType(nodeId)) {
|
||||
return false;
|
||||
}
|
||||
const isAllowedTool = isToolType(nodeId, { includeHitl: false }) && !isMcpToolNodeType(nodeId);
|
||||
const isAllowedProviderNode = isAgentProviderNode(nodeId);
|
||||
return isAllowedTool || isAllowedProviderNode;
|
||||
};
|
||||
|
||||
const MCP_CLIENT_TOOL_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpClientTool';
|
||||
const isMcpToolNodeType = (nodeId: string): boolean =>
|
||||
nodeId === MCP_CLIENT_TOOL_NODE_TYPE || nodeId.startsWith(MCP_REGISTRY_PACKAGE_NAME);
|
||||
|
||||
const searchNodesInputSchema = z.object({
|
||||
queries: z.array(z.string()).min(1).describe('Search queries (e.g., ["gmail", "slack", "http"])'),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
type AgentCredentialIntegrationConfig,
|
||||
type AgentIntegrationConfig,
|
||||
type AgentJsonConfig,
|
||||
type AgentJsonMcpServerConfig,
|
||||
type AgentJsonMemoryConfig,
|
||||
type AgentJsonToolConfig,
|
||||
type AgentSkill,
|
||||
|
|
@ -55,6 +56,7 @@ import { ConflictError } from '@/errors/response-errors/conflict.error';
|
|||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
import { resolveBuiltinNodeDefinitionDirs } from '@/modules/instance-ai/node-definition-resolver';
|
||||
import { EphemeralNodeExecutor } from '@/node-execution';
|
||||
import { OauthService } from '@/oauth/oauth.service';
|
||||
import type { PubSubCommandMap } from '@/scaling/pubsub/pubsub.event-map';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
|
|
@ -85,6 +87,7 @@ import {
|
|||
type MemoryFactory,
|
||||
type ToolResolver,
|
||||
} from './json-config/from-json-config';
|
||||
import { buildMcpClientForServer } from './json-config/mcp-client-factory';
|
||||
import { AgentHistoryRepository } from './repositories/agent-history.repository';
|
||||
import { AgentRepository } from './repositories/agent.repository';
|
||||
import { AgentSecureRuntime } from './runtime/agent-secure-runtime';
|
||||
|
|
@ -231,7 +234,9 @@ export class AgentsService {
|
|||
private clearRuntimes(agentId: string, options: { skipBroadcast?: boolean } = {}): void {
|
||||
for (const key of this.runtimes.keys()) {
|
||||
if (key === agentId || key.startsWith(`${agentId}:`)) {
|
||||
const entry = this.runtimes.get(key);
|
||||
this.runtimes.delete(key);
|
||||
if (entry) this.closeAgentResources(entry.agent, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -286,12 +291,31 @@ export class AgentsService {
|
|||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly telemetry: Telemetry,
|
||||
private readonly chatIntegrationService: ChatIntegrationService,
|
||||
private readonly oauthService: OauthService,
|
||||
) {}
|
||||
|
||||
private isNodeToolsModuleEnabled(): boolean {
|
||||
return this.agentsConfig.modules.includes('node-tools-searcher');
|
||||
}
|
||||
|
||||
private isMcpModuleEnabled(): boolean {
|
||||
return this.agentsConfig.modules.includes('mcp');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort close of an agent instance. Delegates to `agent.close()`
|
||||
* which disposes the runtime and disconnects any attached MCP clients.
|
||||
* Errors are logged but never thrown.
|
||||
*/
|
||||
private closeAgentResources(agent: { close(): Promise<void> }, agentId: string): void {
|
||||
agent.close().catch((error) => {
|
||||
this.logger.warn('[AgentsService] Failed to close agent resources on eviction', {
|
||||
agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private createAgentExecutionCounter({
|
||||
agentId,
|
||||
userId,
|
||||
|
|
@ -1360,18 +1384,6 @@ export class AgentsService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an SDK agent within a workflow execution context.
|
||||
*
|
||||
* Streams the run rather than calling `.generate()` so the same
|
||||
* `ExecutionRecorder` used by chat/Slack/schedule paths can collect a full
|
||||
* `MessageRecord` (timeline, tool calls, usage). Without this, sessions
|
||||
* triggered from a workflow node never appear in the agent's session list
|
||||
* because nothing creates the agent execution thread row.
|
||||
*
|
||||
* Compiles a fresh isolated agent per call for credential isolation (does
|
||||
* not use or affect the shared runtime cache).
|
||||
*/
|
||||
async executeForWorkflow(
|
||||
agentId: string,
|
||||
message: string,
|
||||
|
|
@ -1539,6 +1551,22 @@ export class AgentsService {
|
|||
};
|
||||
}
|
||||
|
||||
const mcpServers = config.mcpServers ?? [];
|
||||
if (mcpServers.length > 0 && !this.isMcpModuleEnabled()) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'MCP servers require the "mcp" agents module to be enabled.',
|
||||
};
|
||||
}
|
||||
for (const server of mcpServers) {
|
||||
if (server.authentication !== 'none' && !server.credential) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `MCP server "${server.name}" requires a credential when authentication is not "none".`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.validateNodeToolExpressions(config);
|
||||
} catch (error) {
|
||||
|
|
@ -1606,6 +1634,7 @@ export class AgentsService {
|
|||
const memoryProvided = result.config.memory !== undefined;
|
||||
const providerToolsProvided = result.config.providerTools !== undefined;
|
||||
const configBlockProvided = result.config.config !== undefined;
|
||||
const mcpServersProvided = result.config.mcpServers !== undefined;
|
||||
|
||||
const { schemaConfig: decomposedSchema, integrations: decomposedIntegrations } =
|
||||
decomposeJsonConfig(result.config);
|
||||
|
|
@ -1627,6 +1656,7 @@ export class AgentsService {
|
|||
...(skillsProvided ? { skills: decomposedSchema.skills } : {}),
|
||||
...(providerToolsProvided ? { providerTools: decomposedSchema.providerTools } : {}),
|
||||
...(configBlockProvided ? { config: decomposedSchema.config } : {}),
|
||||
...(mcpServersProvided ? { mcpServers: decomposedSchema.mcpServers } : {}),
|
||||
};
|
||||
|
||||
entity.schema = nextSchema;
|
||||
|
|
@ -1996,6 +2026,19 @@ export class AgentsService {
|
|||
|
||||
const resolvedTools: BuiltTool[] = [];
|
||||
|
||||
// Only attach MCP clients when the module is enabled. Without the gate
|
||||
// a previously-configured agent would still build live MCP connections
|
||||
// after the operator removes the token from N8N_AGENTS_MODULES, which
|
||||
// undermines the kill-switch.
|
||||
const buildMcpClient = this.isMcpModuleEnabled()
|
||||
? async (server: AgentJsonMcpServerConfig) =>
|
||||
await buildMcpClientForServer(server, {
|
||||
credentialProvider,
|
||||
oauthService: this.oauthService,
|
||||
projectId: agentEntity.projectId,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const reconstructed = await buildFromJson(config, toolDescriptors, {
|
||||
toolExecutor,
|
||||
credentialProvider,
|
||||
|
|
@ -2006,6 +2049,7 @@ export class AgentsService {
|
|||
},
|
||||
skills: agentEntity.skills ?? {},
|
||||
memoryFactory: this.getMemoryFactory(agentEntity.id),
|
||||
buildMcpClient,
|
||||
});
|
||||
|
||||
await this.injectRuntimeDependencies({
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ function buildPrompt(modelRecommendationsSection: string | null) {
|
|||
toolList: '(none)',
|
||||
agentPreviewPath: '/projects/project-1/agents/agent-1/preview',
|
||||
modelRecommendationsSection,
|
||||
enabledModules: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -176,14 +177,14 @@ describe('builder model recommendations', () => {
|
|||
});
|
||||
|
||||
it('registers only optional builder runtime skills', () => {
|
||||
expect(getBuilderRuntimeSkills().map((skill) => skill.id)).toEqual([
|
||||
expect(getBuilderRuntimeSkills({ enabledModules: [] }).map((skill) => skill.id)).toEqual([
|
||||
'agent-builder-integrations',
|
||||
'agent-builder-target-skills',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not tell the builder to prefer Slack OAuth credentials for chat integrations', () => {
|
||||
const integrationsSkill = getBuilderRuntimeSkills().find(
|
||||
const integrationsSkill = getBuilderRuntimeSkills({ enabledModules: [] }).find(
|
||||
(skill) => skill.id === 'agent-builder-integrations',
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { getBuilderSkillRoutingSection } from '../agents-builder-prompts';
|
||||
import { getConfigMutationPrompt } from '../prompts/config-mutation.prompt';
|
||||
import { getBuilderRuntimeSkills } from '../skills';
|
||||
|
||||
describe('agents builder integrations prompt', () => {
|
||||
it('does not tell the builder to prefer Slack OAuth credentials for chat integrations', () => {
|
||||
const integrationsSkill = getBuilderRuntimeSkills({ enabledModules: [] }).find(
|
||||
(skill) => skill.id === 'agent-builder-integrations',
|
||||
);
|
||||
|
||||
expect(integrationsSkill?.instructions).not.toContain('slackOAuth2Api');
|
||||
expect(integrationsSkill?.instructions).not.toContain('prefer the OAuth variant');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MCP skill gating', () => {
|
||||
it('does not include the MCP skill when the "mcp" module is disabled', () => {
|
||||
const skills = getBuilderRuntimeSkills({
|
||||
enabledModules: [],
|
||||
});
|
||||
expect(skills.find((s) => s.id === 'agent-builder-mcp')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes the MCP skill when the "mcp" module is enabled', () => {
|
||||
const skills = getBuilderRuntimeSkills({
|
||||
enabledModules: ['mcp'],
|
||||
});
|
||||
expect(skills.find((s) => s.id === 'agent-builder-mcp')).toBeDefined();
|
||||
});
|
||||
|
||||
it('omits the MCP skill from the routing section when the module is disabled', () => {
|
||||
const section = getBuilderSkillRoutingSection([]);
|
||||
expect(section).not.toContain('agent-builder-mcp');
|
||||
});
|
||||
|
||||
it('lists the MCP skill in the routing section when the module is enabled', () => {
|
||||
const section = getBuilderSkillRoutingSection(['mcp']);
|
||||
expect(section).toContain('agent-builder-mcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Config mutation prompt gating', () => {
|
||||
it('doesn\'t include the MCP servers section when the "mcp" module is disabled', () => {
|
||||
const prompt = getConfigMutationPrompt([]);
|
||||
expect(prompt).not.toContain('mcpServers');
|
||||
});
|
||||
|
||||
it('includes the MCP servers section when the "mcp" module is enabled', () => {
|
||||
const prompt = getConfigMutationPrompt(['mcp']);
|
||||
expect(prompt).toContain('mcpServers');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import type { CredentialProvider, McpClient } from '@n8n/agents';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { OauthService } from '@/oauth/oauth.service';
|
||||
|
||||
import { buildVerifyMcpServerTool } from '../verify-mcp-server.tool';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const buildMcpClientForServerMock = jest.fn<Promise<McpClient>, [unknown, unknown]>();
|
||||
|
||||
jest.mock('../../json-config/mcp-client-factory', () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/promise-function-async
|
||||
buildMcpClientForServer: (arg0: unknown, arg1: unknown) =>
|
||||
buildMcpClientForServerMock(arg0, arg1),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeDeps() {
|
||||
return {
|
||||
credentialProvider: mock<CredentialProvider>(),
|
||||
oauthService: mock<OauthService>(),
|
||||
projectId: 'proj-1',
|
||||
};
|
||||
}
|
||||
|
||||
function makeMcpClient(overrides: Partial<McpClient> = {}): McpClient {
|
||||
return {
|
||||
listTools: jest.fn().mockResolvedValue([]),
|
||||
close: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as McpClient;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildVerifyMcpServerTool', () => {
|
||||
beforeEach(() => {
|
||||
buildMcpClientForServerMock.mockReset();
|
||||
});
|
||||
|
||||
it('returns { ok: true, tools } with name and description on success', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: jest.fn().mockResolvedValue([
|
||||
{ name: 'echo', description: 'Echo the input' },
|
||||
{ name: 'add', description: 'Add two numbers' },
|
||||
]),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
const result = await tool.handler!(
|
||||
{ name: 'my-server', url: 'https://example.test/mcp' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
tools: [
|
||||
{ name: 'echo', description: 'Echo the input' },
|
||||
{ name: 'add', description: 'Add two numbers' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('uses an empty string when a tool has no description', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: jest.fn().mockResolvedValue([{ name: 'silent-tool', description: undefined }]),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
const result = await tool.handler!(
|
||||
{ name: 'my-server', url: 'https://example.test/mcp' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
tools: [{ name: 'silent-tool', description: '' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns { ok: false, error } when listTools throws', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: jest.fn().mockRejectedValue(new Error('connection timeout')),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
const result = await tool.handler!(
|
||||
{ name: 'my-server', url: 'https://example.test/mcp' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'connection timeout' });
|
||||
});
|
||||
|
||||
it('returns { ok: false, error } with stringified non-Error rejections', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: jest.fn().mockRejectedValue('plain string error'),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
const result = await tool.handler!(
|
||||
{ name: 'my-server', url: 'https://example.test/mcp' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: false, error: 'plain string error' });
|
||||
});
|
||||
|
||||
it('always closes the client — even when listTools fails', async () => {
|
||||
const closeMock = jest.fn().mockResolvedValue(undefined);
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: jest.fn().mockRejectedValue(new Error('boom')),
|
||||
close: closeMock,
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
await tool.handler!({ name: 'my-server', url: 'https://example.test/mcp' }, {} as never);
|
||||
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes the client even when close() itself rejects', async () => {
|
||||
const mcpClient = makeMcpClient({
|
||||
listTools: jest.fn().mockResolvedValue([]),
|
||||
close: jest.fn().mockRejectedValue(new Error('close error')),
|
||||
});
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
// Should not throw even if close() rejects
|
||||
await expect(
|
||||
tool.handler!({ name: 'my-server', url: 'https://example.test/mcp' }, {} as never),
|
||||
).resolves.toEqual({ ok: true, tools: [] });
|
||||
});
|
||||
|
||||
it('forwards name, url, transport, authentication, credential, and connectionTimeoutMs to the factory', async () => {
|
||||
const mcpClient = makeMcpClient();
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const deps = makeDeps();
|
||||
const tool = buildVerifyMcpServerTool(deps);
|
||||
await tool.handler!(
|
||||
{
|
||||
name: 'my-server',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'sse',
|
||||
authentication: 'bearerAuth',
|
||||
credential: 'cred-42',
|
||||
connectionTimeoutMs: 10_000,
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(buildMcpClientForServerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'my-server',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'sse',
|
||||
authentication: 'bearerAuth',
|
||||
credential: 'cred-42',
|
||||
connectionTimeoutMs: 10_000,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
credentialProvider: deps.credentialProvider,
|
||||
oauthService: deps.oauthService,
|
||||
projectId: deps.projectId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('omits connectionTimeoutMs from the factory call when not provided', async () => {
|
||||
const mcpClient = makeMcpClient();
|
||||
buildMcpClientForServerMock.mockResolvedValue(mcpClient);
|
||||
|
||||
const tool = buildVerifyMcpServerTool(makeDeps());
|
||||
await tool.handler!({ name: 'my-server', url: 'https://example.test/mcp' }, {} as never);
|
||||
|
||||
const [serverArg] = buildMcpClientForServerMock.mock.calls[0] as unknown as [
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(serverArg).not.toHaveProperty('connectionTimeoutMs');
|
||||
});
|
||||
});
|
||||
|
|
@ -56,15 +56,32 @@ Never write empty, placeholder, or guessed \`instructions\`. If you do not have
|
|||
enough detail to write meaningful instructions, ask the user first.`;
|
||||
}
|
||||
|
||||
export const BUILDER_SKILL_ROUTING_SECTION = `\
|
||||
## Builder Runtime Skills
|
||||
/**
|
||||
* Build the routing section that tells the builder LLM which runtime skills
|
||||
* exist and what they cover. Module-gated skills (like `agent-builder-mcp`)
|
||||
* are only listed when their owning module is active so the LLM doesn't try
|
||||
* to load a skill the runtime won't surface.
|
||||
*/
|
||||
export function getBuilderSkillRoutingSection(enabledModules?: ReadonlyArray<string>): string {
|
||||
const lines: string[] = [
|
||||
'- `agent-builder-integrations`: schedule and chat integrations.',
|
||||
'- `agent-builder-target-skills`: creating skills for the target agent.',
|
||||
];
|
||||
|
||||
if (enabledModules?.includes('mcp')) {
|
||||
lines.push(
|
||||
'- `agent-builder-mcp`: adding, removing, or updating MCP (Model Context Protocol) servers.',
|
||||
);
|
||||
}
|
||||
|
||||
return `\
|
||||
## Builder runtime skills
|
||||
|
||||
Additional specialized builder guidance is available through runtime skills.
|
||||
Before these specialized tasks, call \`load_skill\` with
|
||||
\`{ "skillId": "<id>" }\` and follow the returned instructions.
|
||||
|
||||
- \`agent-builder-integrations\`: schedule and chat integrations.
|
||||
- \`agent-builder-target-skills\`: creating skills for the target agent.
|
||||
${lines.join('\n')}
|
||||
|
||||
Requests for "web search", "Brave web search", or "SearXNG web search" are
|
||||
agent config changes, not node-tool tasks. Follow the Config schema reference:
|
||||
|
|
@ -74,6 +91,7 @@ to add a Brave/SearXNG node tool or node integration.
|
|||
|
||||
Do not use \`create_skill\` for your own builder guidance. \`create_skill\`
|
||||
creates a skill for the target agent only.`;
|
||||
}
|
||||
|
||||
export const INTERACTIVE_TOOLS_SECTION = `\
|
||||
## Interactive tools
|
||||
|
|
@ -209,6 +227,7 @@ export interface BuilderPromptContext {
|
|||
toolList: string;
|
||||
agentPreviewPath: string;
|
||||
modelRecommendationsSection: string | null;
|
||||
enabledModules: string[];
|
||||
}
|
||||
|
||||
export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
|
||||
|
|
@ -219,6 +238,7 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
|
|||
toolList,
|
||||
agentPreviewPath,
|
||||
modelRecommendationsSection,
|
||||
enabledModules,
|
||||
} = ctx;
|
||||
|
||||
const sections = [
|
||||
|
|
@ -226,11 +246,11 @@ export function buildBuilderPrompt(ctx: BuilderPromptContext): string {
|
|||
TARGET_AGENT_SECTION,
|
||||
getAgentStateSection(configJson, configHash, configUpdatedAt, toolList),
|
||||
getConversationModeSection(agentPreviewPath),
|
||||
getConfigMutationPrompt(),
|
||||
getConfigMutationPrompt(enabledModules),
|
||||
getLlmSelectionPrompt(modelRecommendationsSection),
|
||||
MEMORY_PROMPT,
|
||||
TOOLS_PROMPT,
|
||||
BUILDER_SKILL_ROUTING_SECTION,
|
||||
getBuilderSkillRoutingSection(enabledModules),
|
||||
INTERACTIVE_TOOLS_SECTION,
|
||||
N8N_EXPRESSIONS_SECTION,
|
||||
READ_CONFIG_FRESHNESS_SECTION,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ import {
|
|||
} from './interactive';
|
||||
import type { ModelLookup } from './interactive/resolve-llm.tool';
|
||||
import { BUILDER_TOOLS } from './builder-tool-names';
|
||||
import { buildVerifyMcpServerTool } from './verify-mcp-server.tool';
|
||||
import { OauthService } from '@/oauth/oauth.service';
|
||||
|
||||
const EMPTY_INSTRUCTIONS_ERROR: ConfigValidationError = {
|
||||
path: '/instructions',
|
||||
|
|
@ -176,6 +178,7 @@ export class AgentsBuilderToolsService {
|
|||
private readonly workflowRepository: WorkflowRepository,
|
||||
private readonly agentsToolsService: AgentsToolsService,
|
||||
private readonly builderModelLookupService: BuilderModelLookupService,
|
||||
private readonly oauthService: OauthService,
|
||||
private readonly credentialTypes: CredentialTypes,
|
||||
) {}
|
||||
|
||||
|
|
@ -184,9 +187,10 @@ export class AgentsBuilderToolsService {
|
|||
projectId: string,
|
||||
credentialProvider: CredentialProvider,
|
||||
user: User,
|
||||
enabledModules?: ReadonlyArray<string>,
|
||||
): BuilderTools {
|
||||
return {
|
||||
json: this.getJsonTools(agentId, projectId, credentialProvider, user),
|
||||
json: this.getJsonTools(agentId, projectId, credentialProvider, user, enabledModules),
|
||||
shared: this.getSharedTools(agentId, projectId, credentialProvider),
|
||||
};
|
||||
}
|
||||
|
|
@ -196,6 +200,7 @@ export class AgentsBuilderToolsService {
|
|||
projectId: string,
|
||||
credentialProvider: CredentialProvider,
|
||||
user: User,
|
||||
enabledModules?: ReadonlyArray<string>,
|
||||
): BuiltTool[] {
|
||||
const readConfigTool = new Tool(BUILDER_TOOLS.READ_CONFIG)
|
||||
.description(
|
||||
|
|
@ -419,7 +424,7 @@ export class AgentsBuilderToolsService {
|
|||
await this.builderModelLookupService.list(user, credentialId, credentialType, lookup),
|
||||
};
|
||||
|
||||
return [
|
||||
const tools: BuiltTool[] = [
|
||||
readConfigTool,
|
||||
writeConfigTool,
|
||||
patchConfigTool,
|
||||
|
|
@ -432,6 +437,18 @@ export class AgentsBuilderToolsService {
|
|||
buildAskLlmTool(),
|
||||
buildAskQuestionTool(),
|
||||
];
|
||||
|
||||
if (enabledModules?.includes('mcp')) {
|
||||
tools.push(
|
||||
buildVerifyMcpServerTool({
|
||||
credentialProvider,
|
||||
oauthService: this.oauthService,
|
||||
projectId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private getSharedTools(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
Agent as RuntimeAgent,
|
||||
} from '@n8n/agents';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { AgentsConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { jsonParse, UserError } from 'n8n-workflow';
|
||||
|
|
@ -44,6 +45,7 @@ export class AgentsBuilderService {
|
|||
private readonly builderSettings: AgentsBuilderSettingsService,
|
||||
private readonly n8nCheckpointStorage: N8NCheckpointStorage,
|
||||
private readonly agentCheckpointRepository: AgentCheckpointRepository,
|
||||
private readonly agentsConfig: AgentsConfig,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -176,6 +178,7 @@ export class AgentsBuilderService {
|
|||
|
||||
const configJson = currentConfig ? JSON.stringify(currentConfig, null, 2) : '(no config yet)';
|
||||
const modelRecommendationsSection = await getModelRecommendationsSection();
|
||||
const enabledModules = this.agentsConfig.modules;
|
||||
const instructions = buildBuilderPrompt({
|
||||
configJson,
|
||||
configHash: getAgentConfigHash(currentConfig),
|
||||
|
|
@ -183,14 +186,18 @@ export class AgentsBuilderService {
|
|||
toolList,
|
||||
agentPreviewPath: buildAgentPreviewPath(projectId, agentId),
|
||||
modelRecommendationsSection,
|
||||
enabledModules,
|
||||
});
|
||||
const runtimeSkills = getBuilderRuntimeSkills({
|
||||
enabledModules,
|
||||
});
|
||||
const runtimeSkills = getBuilderRuntimeSkills();
|
||||
|
||||
const tools = this.agentsBuilderToolsService.getTools(
|
||||
agentId,
|
||||
projectId,
|
||||
credentialProvider,
|
||||
user,
|
||||
enabledModules,
|
||||
);
|
||||
|
||||
const { Agent, Memory } = await import('@n8n/agents');
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const BUILDER_TOOLS = {
|
|||
CREATE_SKILL: 'create_skill',
|
||||
LIST_INTEGRATION_TYPES: 'list_integration_types',
|
||||
RESOLVE_LLM: 'resolve_llm',
|
||||
VERIFY_MCP_SERVER: 'verify_mcp_server',
|
||||
} as const;
|
||||
|
||||
export type BuilderToolName = (typeof BUILDER_TOOLS)[keyof typeof BUILDER_TOOLS];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getConfigRulesSection, getSchemaReferenceSection } from './config-rules.prompt';
|
||||
|
||||
export function getConfigMutationPrompt(): string {
|
||||
export function getConfigMutationPrompt(enabledModules: string[]): string {
|
||||
return `\
|
||||
## Config Mutation Guidance
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ Use this after deciding a config change is needed and before calling
|
|||
|
||||
${getConfigRulesSection()}
|
||||
|
||||
${getSchemaReferenceSection()}
|
||||
${getSchemaReferenceSection(enabledModules)}
|
||||
|
||||
- Follow the Config schema reference exactly; do not invent top-level fields.
|
||||
- Keep each feature in the schema path where it belongs.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { JSONSchema7 } from 'json-schema';
|
||||
import type { ZodObject, ZodRawShape } from 'zod';
|
||||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
|
|
@ -74,10 +75,15 @@ export function getConfigRulesSection(): string {
|
|||
is written.`;
|
||||
}
|
||||
|
||||
export function getSchemaReferenceSection(): string {
|
||||
const jsonSchemaText = jsonSchemaToCompactText(
|
||||
zodToJsonSchema(BuilderPromptAgentJsonConfigSchema) as JSONSchema7,
|
||||
);
|
||||
export function getSchemaReferenceSection(enabledModules: string[]): string {
|
||||
let zodSchema: ZodObject<ZodRawShape> = BuilderPromptAgentJsonConfigSchema;
|
||||
// don't let agent know about MCP servers if it's not enabled
|
||||
if (!enabledModules.includes('mcp')) {
|
||||
zodSchema = zodSchema.omit({
|
||||
mcpServers: true,
|
||||
});
|
||||
}
|
||||
const jsonSchemaText = jsonSchemaToCompactText(zodToJsonSchema(zodSchema) as JSONSchema7);
|
||||
return `\
|
||||
#### Config Schema Reference
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
|
||||
import { integrationsSkill } from './integrations.skill';
|
||||
import { mcpSkill } from './mcp.skill';
|
||||
import { targetSkillsSkill } from './target-skills.skill';
|
||||
|
||||
export function getBuilderRuntimeSkills(): RuntimeSkill[] {
|
||||
return [
|
||||
export function getBuilderRuntimeSkills(options: {
|
||||
enabledModules?: readonly string[];
|
||||
}): RuntimeSkill[] {
|
||||
const skills: RuntimeSkill[] = [
|
||||
integrationsSkill(),
|
||||
targetSkillsSkill(),
|
||||
// FIXME: Research is disabled until the builder has a supported research tool.
|
||||
|
|
@ -12,4 +15,10 @@ export function getBuilderRuntimeSkills(): RuntimeSkill[] {
|
|||
// instead of merely loading instructions that tell it to research.
|
||||
// researchSkill(),
|
||||
];
|
||||
|
||||
if (options.enabledModules?.includes('mcp')) {
|
||||
skills.push(mcpSkill());
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
|
|
|||
92
packages/cli/src/modules/agents/builder/skills/mcp.skill.ts
Normal file
92
packages/cli/src/modules/agents/builder/skills/mcp.skill.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { RuntimeSkill } from '@n8n/agents';
|
||||
import { ASK_QUESTION_TOOL_NAME } from '@n8n/api-types';
|
||||
|
||||
export function mcpSkill(): RuntimeSkill {
|
||||
return {
|
||||
id: 'agent-builder-mcp',
|
||||
name: 'Agent builder MCP servers',
|
||||
description:
|
||||
'Use when adding, removing, or updating MCP (Model Context Protocol) servers on the target agent.',
|
||||
instructions: `\
|
||||
## Purpose
|
||||
|
||||
Use this to manage external MCP server connections in the target agent config.
|
||||
MCP servers expose external tool catalogs to the target agent over HTTP. They
|
||||
live on the top-level \`mcpServers\` array, and each entry maps 1:1 to a
|
||||
connected MCP server.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Prefer existing n8n workflow or node tools when the integration is already
|
||||
available, since they execute inside the n8n runtime with full credential
|
||||
handling.
|
||||
- Use MCP when the user names a specific MCP server (for example "GitHub MCP",
|
||||
"Slack MCP", or "Notion MCP"), or when no equivalent workflow or node tool
|
||||
exists.
|
||||
|
||||
## Workflow
|
||||
|
||||
Each \`mcpServers[]\` entry supports:
|
||||
|
||||
- \`name\` (required, unique within \`mcpServers\`, 1-64 chars, /^[A-Za-z0-9_-]+$/)
|
||||
- \`url\` (required)
|
||||
- \`transport\`: \`"sse"\` | \`"streamableHttp"\` (default \`"streamableHttp"\`)
|
||||
- \`authentication\`: \`"none"\` | \`"bearerAuth"\` | \`"headerAuth"\` |
|
||||
\`"multipleHeadersAuth"\` | \`"mcpOAuth2Api"\` |
|
||||
string ending in \`"McpOAuth2Api"\` (default \`"none"\`)
|
||||
- \`credential\`: required when authentication !== \`"none"\` (must be the id
|
||||
returned by \`ask_credential\`)
|
||||
- \`toolFilter\` (optional): \`{ mode: "allow" | "exclude", tools: string[] }\`,
|
||||
matched against original (un-prefixed) tool names
|
||||
- \`approval\` (optional): \`{ mode: "global" }\` for all tools, or
|
||||
\`{ mode: "selected", tools: [...] }\` for specific tools (must be non-empty)
|
||||
- \`connectionTimeoutMs\` (optional): 1-120000
|
||||
- \`metadata\` (optional): optional server-generated metadata. Do not use this
|
||||
field unless explicitly instructed to do so by instructions
|
||||
|
||||
### Credential flow
|
||||
|
||||
- For \`bearerAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpBearerAuth"\`.
|
||||
- For \`headerAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpHeaderAuth"\`.
|
||||
- For \`multipleHeadersAuth\`, call \`ask_credential\` with
|
||||
\`credentialType: "httpMultipleHeadersAuth"\`.
|
||||
- For \`mcpOAuth2Api\`, call \`ask_credential\` with
|
||||
\`credentialType: "mcpOAuth2Api"\`.
|
||||
- Never invent credential IDs. If the user declines, omit the server entirely
|
||||
rather than persisting a stub.
|
||||
|
||||
### Testing the connection
|
||||
|
||||
Before writing to config, call \`verify_mcp_server\` with server \`name\`,
|
||||
\`url\`, \`transport\`, and (if applicable) the credential id from
|
||||
\`ask_credential\`.
|
||||
|
||||
- Success returns \`{ ok: true, tools: [{ name, description }] }\`.
|
||||
- Use the returned tool list to populate \`toolFilter.tools\` or
|
||||
\`approval.tools\` so the user does not need to type tool names manually.
|
||||
- Failure returns \`{ ok: false, error: "..." }\`.
|
||||
- If verification fails, explain the error and ask the user to check the URL
|
||||
or credentials before proceeding.
|
||||
|
||||
### Selecting credentials
|
||||
|
||||
When connection testing without credentials fails and you do not know which
|
||||
credential type to use, ask the user which credential type to use: OAuth2,
|
||||
Bearer Token, Header Auth, Multiple Headers Auth, or None. Use
|
||||
\`${ASK_QUESTION_TOOL_NAME}\` to ask. Based on the response, call
|
||||
\`ask_credential\` with the appropriate credential type.
|
||||
|
||||
### Patch pattern
|
||||
|
||||
1. Initialize the array if missing:
|
||||
\`{ "op": "add", "path": "/mcpServers", "value": [] }\`
|
||||
2. Append each server:
|
||||
\`{ "op": "add", "path": "/mcpServers/-", "value": { ... } }\`
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Server \`name\` must be unique across \`mcpServers\` within an agent.`,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { Tool } from '@n8n/agents/tool';
|
||||
import type { BuiltTool, CredentialProvider, McpClient } from '@n8n/agents';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { OauthService } from '@/oauth/oauth.service';
|
||||
|
||||
import { buildMcpClientForServer } from '../json-config/mcp-client-factory';
|
||||
import { BUILDER_TOOLS } from './builder-tool-names';
|
||||
|
||||
export interface VerifyMcpServerDeps {
|
||||
credentialProvider: CredentialProvider;
|
||||
oauthService: OauthService;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input schema mirrors the required subset of `McpServerConfigSchema` that the
|
||||
* builder can have in hand before writing the config. The credential field is
|
||||
* optional here so the tool can also be used to test unauthenticated servers.
|
||||
*
|
||||
* `toolFilter` and `approval` are intentionally excluded — they have no bearing
|
||||
* on whether the underlying transport connects, and simplifying the input keeps
|
||||
* the LLM context small.
|
||||
*/
|
||||
const verifyMcpServerInputSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/)
|
||||
.describe('The server name (used as the tool-name prefix)'),
|
||||
url: z.string().min(1).describe('The MCP server endpoint URL'),
|
||||
transport: z
|
||||
.enum(['sse', 'streamableHttp'])
|
||||
.default('streamableHttp')
|
||||
.describe('Transport type. Defaults to streamableHttp'),
|
||||
authentication: z
|
||||
.enum(['none', 'bearerAuth', 'headerAuth', 'multipleHeadersAuth', 'mcpOAuth2Api'])
|
||||
.default('none')
|
||||
.describe('Authentication scheme'),
|
||||
credential: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Credential id returned by ask_credential. Required when authentication is not "none"',
|
||||
),
|
||||
connectionTimeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(120_000)
|
||||
.optional()
|
||||
.describe('Connection timeout in milliseconds'),
|
||||
});
|
||||
|
||||
type VerifyMcpServerInput = z.infer<typeof verifyMcpServerInputSchema>;
|
||||
|
||||
export function buildVerifyMcpServerTool(deps: VerifyMcpServerDeps): BuiltTool {
|
||||
return new Tool(BUILDER_TOOLS.VERIFY_MCP_SERVER)
|
||||
.description(
|
||||
'Test connectivity to an MCP server before adding it to the agent config. ' +
|
||||
'Establishes a temporary connection, lists the available tools, then closes the connection. ' +
|
||||
'Returns { ok: true, tools: [{ name, description }] } on success, or ' +
|
||||
'{ ok: false, error: string } on failure. ' +
|
||||
'Call this after ask_credential (when authentication is not "none") and before patch_config.',
|
||||
)
|
||||
.input(verifyMcpServerInputSchema)
|
||||
.handler(async (input: VerifyMcpServerInput) => {
|
||||
let client: McpClient | undefined;
|
||||
try {
|
||||
client = await buildMcpClientForServer(
|
||||
{
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
transport: input.transport,
|
||||
authentication: input.authentication,
|
||||
credential: input.credential,
|
||||
...(input.connectionTimeoutMs !== undefined && {
|
||||
connectionTimeoutMs: input.connectionTimeoutMs,
|
||||
}),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
const tools = await client.listTools();
|
||||
return {
|
||||
ok: true,
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description ?? '',
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
await client?.close().catch(() => {});
|
||||
}
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
|
@ -0,0 +1,505 @@
|
|||
import type { CredentialProvider } from '@n8n/agents';
|
||||
import type { AgentJsonMcpServerConfig } from '@n8n/api-types';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
|
||||
import type { OauthService } from '@/oauth/oauth.service';
|
||||
|
||||
import { buildMcpClientForServer, createAuthFetch, mapApprovalToSdk } from '../mcp-client-factory';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mcpClientCtor = jest.fn();
|
||||
jest.mock('@n8n/agents', () => ({
|
||||
McpClient: jest.fn(function (configs: unknown) {
|
||||
mcpClientCtor(configs);
|
||||
return { configs, close: jest.fn() };
|
||||
}),
|
||||
}));
|
||||
|
||||
const proxyFetchMock = jest.fn();
|
||||
jest.mock('@n8n/ai-utilities', () => ({
|
||||
proxyFetch: (...args: unknown[]) => proxyFetchMock(...args),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeServer(overrides: Partial<AgentJsonMcpServerConfig> = {}): AgentJsonMcpServerConfig {
|
||||
return {
|
||||
name: 'srv',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'streamableHttp',
|
||||
authentication: 'none',
|
||||
...overrides,
|
||||
} as AgentJsonMcpServerConfig;
|
||||
}
|
||||
|
||||
function makeOk(): Response {
|
||||
return new Response('ok', { status: 200 });
|
||||
}
|
||||
|
||||
function make401(): Response {
|
||||
return new Response('unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mapApprovalToSdk
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('mapApprovalToSdk', () => {
|
||||
it('returns undefined when approval is absent', () => {
|
||||
expect(mapApprovalToSdk(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps mode "global" to literal true (all tools require approval)', () => {
|
||||
expect(mapApprovalToSdk({ mode: 'global' })).toBe(true);
|
||||
});
|
||||
|
||||
it('maps mode "selected" to the literal tools list', () => {
|
||||
expect(mapApprovalToSdk({ mode: 'selected', tools: ['a', 'b'] })).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createAuthFetch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createAuthFetch', () => {
|
||||
beforeEach(() => {
|
||||
proxyFetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('routes through proxyFetch and injects the initial headers', async () => {
|
||||
proxyFetchMock.mockResolvedValueOnce(makeOk());
|
||||
|
||||
const fetchFn = createAuthFetch({ initialHeaders: { Authorization: 'Bearer A' } });
|
||||
const res = await fetchFn('https://example.test/mcp');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(proxyFetchMock).toHaveBeenCalledTimes(1);
|
||||
const [, init] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
expect(init.headers).toMatchObject({ Authorization: 'Bearer A' });
|
||||
});
|
||||
|
||||
it('returns 401 unchanged when no onUnauthorized handler is configured', async () => {
|
||||
proxyFetchMock.mockResolvedValueOnce(make401());
|
||||
|
||||
const fetchFn = createAuthFetch({ initialHeaders: { Authorization: 'Bearer A' } });
|
||||
const res = await fetchFn('https://example.test/mcp');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(proxyFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the original 401 when onUnauthorized returns null', async () => {
|
||||
proxyFetchMock.mockResolvedValueOnce(make401());
|
||||
|
||||
const onUnauthorized = jest.fn().mockResolvedValue(null);
|
||||
const fetchFn = createAuthFetch({
|
||||
initialHeaders: { Authorization: 'Bearer A' },
|
||||
onUnauthorized,
|
||||
});
|
||||
const res = await fetchFn('https://example.test/mcp');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
expect(proxyFetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries once with refreshed headers when onUnauthorized returns new headers', async () => {
|
||||
proxyFetchMock.mockResolvedValueOnce(make401()).mockResolvedValueOnce(makeOk());
|
||||
|
||||
const onUnauthorized = jest.fn().mockResolvedValue({ Authorization: 'Bearer B' });
|
||||
const fetchFn = createAuthFetch({
|
||||
initialHeaders: { Authorization: 'Bearer A' },
|
||||
onUnauthorized,
|
||||
});
|
||||
const res = await fetchFn('https://example.test/mcp');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(proxyFetchMock).toHaveBeenCalledTimes(2);
|
||||
const [, init2] = proxyFetchMock.mock.calls[1] as [unknown, RequestInit];
|
||||
expect(init2.headers).toMatchObject({ Authorization: 'Bearer B' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMcpClientForServer — header derivation per auth type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildMcpClientForServer — header derivation', () => {
|
||||
beforeEach(() => {
|
||||
mcpClientCtor.mockReset();
|
||||
proxyFetchMock.mockReset();
|
||||
proxyFetchMock.mockResolvedValue(makeOk());
|
||||
});
|
||||
|
||||
async function captureInitialHeaders(server: AgentJsonMcpServerConfig, resolved: unknown) {
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
credentialProvider.resolve.mockResolvedValue(resolved as never);
|
||||
const oauthService = mock<OauthService>();
|
||||
|
||||
await buildMcpClientForServer(server, {
|
||||
credentialProvider,
|
||||
oauthService,
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
|
||||
const fetchFn = configs[0].fetch;
|
||||
await fetchFn('https://example.test/mcp');
|
||||
const [, init] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
return (init.headers ?? {}) as Record<string, string>;
|
||||
}
|
||||
|
||||
it('sends no auth headers for authentication: "none"', async () => {
|
||||
const headers = await captureInitialHeaders(makeServer({ authentication: 'none' }), null);
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends a Bearer header for bearerAuth', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
|
||||
{ token: 'tok123' },
|
||||
);
|
||||
expect(headers.Authorization).toBe('Bearer tok123');
|
||||
});
|
||||
|
||||
it('sends the configured name/value pair for headerAuth', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'headerAuth', credential: 'cred-1' }),
|
||||
{ name: 'X-Api-Key', value: 'secret' },
|
||||
);
|
||||
expect(headers['X-Api-Key']).toBe('secret');
|
||||
});
|
||||
|
||||
it('flattens the values array for multipleHeadersAuth', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'multipleHeadersAuth', credential: 'cred-1' }),
|
||||
{
|
||||
headers: {
|
||||
values: [
|
||||
{ name: 'X-One', value: 'one' },
|
||||
{ name: 'X-Two', value: 'two' },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(headers['X-One']).toBe('one');
|
||||
expect(headers['X-Two']).toBe('two');
|
||||
});
|
||||
|
||||
it('uses oauthTokenData.access_token for mcpOAuth2Api', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'mcpOAuth2Api', credential: 'cred-1' }),
|
||||
{ oauthTokenData: { access_token: 'oauth-token' } },
|
||||
);
|
||||
expect(headers.Authorization).toBe('Bearer oauth-token');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMcpClientForServer — OAuth2 refresh path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildMcpClientForServer — OAuth2 refresh on 401', () => {
|
||||
beforeEach(() => {
|
||||
mcpClientCtor.mockReset();
|
||||
proxyFetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('invokes oauthService.refreshOAuth2CredentialById once on 401 and retries with the refreshed header', async () => {
|
||||
proxyFetchMock.mockResolvedValueOnce(make401()).mockResolvedValueOnce(makeOk());
|
||||
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
credentialProvider.resolve.mockResolvedValue({
|
||||
oauthTokenData: { access_token: 'stale-token' },
|
||||
} as never);
|
||||
|
||||
const oauthService = mock<OauthService>();
|
||||
oauthService.refreshOAuth2CredentialById.mockResolvedValue({
|
||||
Authorization: 'Bearer fresh-token',
|
||||
});
|
||||
|
||||
await buildMcpClientForServer(
|
||||
makeServer({ authentication: 'mcpOAuth2Api', credential: 'cred-1' }),
|
||||
{ credentialProvider, oauthService, projectId: 'proj-1' },
|
||||
);
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
|
||||
const fetchFn = configs[0].fetch;
|
||||
const res = await fetchFn('https://example.test/mcp');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(oauthService.refreshOAuth2CredentialById).toHaveBeenCalledWith('cred-1', 'proj-1');
|
||||
// First call uses the stale header, second uses the refreshed one.
|
||||
const [, firstInit] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
const [, secondInit] = proxyFetchMock.mock.calls[1] as [unknown, RequestInit];
|
||||
expect((firstInit.headers as Record<string, string>).Authorization).toBe('Bearer stale-token');
|
||||
expect((secondInit.headers as Record<string, string>).Authorization).toBe('Bearer fresh-token');
|
||||
});
|
||||
|
||||
it('does NOT call refreshOAuth2CredentialById for non-OAuth2 auth schemes', async () => {
|
||||
proxyFetchMock.mockResolvedValueOnce(make401());
|
||||
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
credentialProvider.resolve.mockResolvedValue({ token: 'static' } as never);
|
||||
|
||||
const oauthService = mock<OauthService>();
|
||||
|
||||
await buildMcpClientForServer(
|
||||
makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
|
||||
{ credentialProvider, oauthService, projectId: 'proj-1' },
|
||||
);
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
|
||||
await configs[0].fetch('https://example.test/mcp');
|
||||
|
||||
expect(oauthService.refreshOAuth2CredentialById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMcpClientForServer — SDK config mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildMcpClientForServer — SDK config mapping', () => {
|
||||
beforeEach(() => {
|
||||
mcpClientCtor.mockReset();
|
||||
proxyFetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('forwards toolFilter, approval, transport, and connectionTimeoutMs to the SDK config', async () => {
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
const oauthService = mock<OauthService>();
|
||||
|
||||
await buildMcpClientForServer(
|
||||
makeServer({
|
||||
transport: 'sse',
|
||||
toolFilter: { mode: 'allow', tools: ['echo'] },
|
||||
approval: { mode: 'selected', tools: ['create'] },
|
||||
connectionTimeoutMs: 5_000,
|
||||
}),
|
||||
{ credentialProvider, oauthService, projectId: 'proj-1' },
|
||||
);
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<Record<string, unknown>>];
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0]).toMatchObject({
|
||||
name: 'srv',
|
||||
url: 'https://example.test/mcp',
|
||||
transport: 'sse',
|
||||
toolFilter: { mode: 'allow', tools: ['echo'] },
|
||||
requireApproval: ['create'],
|
||||
connectionTimeoutMs: 5_000,
|
||||
});
|
||||
expect(typeof configs[0].fetch).toBe('function');
|
||||
});
|
||||
|
||||
it('omits connectionTimeoutMs from the SDK config when not provided', async () => {
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
const oauthService = mock<OauthService>();
|
||||
|
||||
await buildMcpClientForServer(makeServer(), {
|
||||
credentialProvider,
|
||||
oauthService,
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<Record<string, unknown>>];
|
||||
expect(configs[0]).not.toHaveProperty('connectionTimeoutMs');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createAuthFetch — header merging and stateful refresh
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createAuthFetch — header merging', () => {
|
||||
beforeEach(() => {
|
||||
proxyFetchMock.mockReset();
|
||||
proxyFetchMock.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
});
|
||||
|
||||
it('merges caller-supplied init.headers with auth headers (auth takes precedence)', async () => {
|
||||
const fetchFn = createAuthFetch({ initialHeaders: { Authorization: 'Bearer A' } });
|
||||
await fetchFn('https://example.test/mcp', { headers: { 'X-Custom': 'value' } });
|
||||
|
||||
const [, init] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
expect(init.headers).toMatchObject({
|
||||
'X-Custom': 'value',
|
||||
Authorization: 'Bearer A',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the refreshed headers on the second call after a successful 401 refresh', async () => {
|
||||
proxyFetchMock
|
||||
.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response('ok', { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
let callCount = 0;
|
||||
const onUnauthorized = jest.fn().mockImplementation(async () => {
|
||||
callCount++;
|
||||
return { Authorization: `Bearer refreshed-${callCount}` };
|
||||
});
|
||||
|
||||
const fetchFn = createAuthFetch({
|
||||
initialHeaders: { Authorization: 'Bearer stale' },
|
||||
onUnauthorized,
|
||||
});
|
||||
|
||||
// First call triggers a 401 → refresh → retry
|
||||
await fetchFn('https://example.test/mcp');
|
||||
|
||||
// Second call should use the refreshed headers without triggering another refresh
|
||||
await fetchFn('https://example.test/mcp');
|
||||
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1);
|
||||
const [, thirdInit] = proxyFetchMock.mock.calls[2] as [unknown, RequestInit];
|
||||
expect((thirdInit.headers as Record<string, string>).Authorization).toBe('Bearer refreshed-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMcpClientForServer — auth header edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildMcpClientForServer — auth header edge cases', () => {
|
||||
beforeEach(() => {
|
||||
mcpClientCtor.mockReset();
|
||||
proxyFetchMock.mockReset();
|
||||
proxyFetchMock.mockResolvedValue(new Response('ok', { status: 200 }));
|
||||
});
|
||||
|
||||
async function captureInitialHeaders(server: AgentJsonMcpServerConfig, resolved: unknown) {
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
credentialProvider.resolve.mockResolvedValue(resolved as never);
|
||||
const oauthService = mock<OauthService>();
|
||||
|
||||
await buildMcpClientForServer(server, {
|
||||
credentialProvider,
|
||||
oauthService,
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
|
||||
await configs[0].fetch('https://example.test/mcp');
|
||||
const [, init] = proxyFetchMock.mock.calls[0] as [unknown, RequestInit];
|
||||
return (init.headers ?? {}) as Record<string, string>;
|
||||
}
|
||||
|
||||
it('sends no Authorization header for bearerAuth when the resolved token is an empty string', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'bearerAuth', credential: 'cred-1' }),
|
||||
{ token: '' },
|
||||
);
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends no header for headerAuth when the name is missing from the resolved credential', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'headerAuth', credential: 'cred-1' }),
|
||||
{ name: '', value: 'secret' },
|
||||
);
|
||||
expect(Object.keys(headers)).not.toContain('');
|
||||
expect(headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips entries with non-string values in multipleHeadersAuth', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'multipleHeadersAuth', credential: 'cred-1' }),
|
||||
{
|
||||
headers: {
|
||||
values: [
|
||||
{ name: 'X-Valid', value: 'yes' },
|
||||
{ name: 42, value: 'ignored' },
|
||||
{ name: 'X-Also-Ignored', value: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(headers['X-Valid']).toBe('yes');
|
||||
expect(Object.keys(headers)).not.toContain('42');
|
||||
expect(headers['X-Also-Ignored']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sends no headers for multipleHeadersAuth when the values array is absent', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'multipleHeadersAuth', credential: 'cred-1' }),
|
||||
{ headers: { values: undefined } },
|
||||
);
|
||||
expect(headers).toEqual({});
|
||||
});
|
||||
|
||||
it('uses the OAuth2 path for service-specific McpOAuth2Api variants (e.g. notionMcpOAuth2Api)', async () => {
|
||||
const headers = await captureInitialHeaders(
|
||||
makeServer({ authentication: 'notionMcpOAuth2Api' as never, credential: 'cred-1' }),
|
||||
{ oauthTokenData: { access_token: 'notion-oauth-token' } },
|
||||
);
|
||||
expect(headers.Authorization).toBe('Bearer notion-oauth-token');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildMcpClientForServer — OAuth2 variant wires refresh handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('buildMcpClientForServer — service-specific McpOAuth2Api refresh', () => {
|
||||
beforeEach(() => {
|
||||
mcpClientCtor.mockReset();
|
||||
proxyFetchMock.mockReset();
|
||||
});
|
||||
|
||||
it('wires the onUnauthorized refresh handler for non-canonical McpOAuth2Api variants', async () => {
|
||||
proxyFetchMock
|
||||
.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }))
|
||||
.mockResolvedValueOnce(new Response('ok', { status: 200 }));
|
||||
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
credentialProvider.resolve.mockResolvedValue({
|
||||
oauthTokenData: { access_token: 'stale' },
|
||||
} as never);
|
||||
|
||||
const oauthService = mock<OauthService>();
|
||||
oauthService.refreshOAuth2CredentialById.mockResolvedValue({
|
||||
Authorization: 'Bearer fresh',
|
||||
});
|
||||
|
||||
await buildMcpClientForServer(
|
||||
makeServer({ authentication: 'notionMcpOAuth2Api' as never, credential: 'cred-1' }),
|
||||
{ credentialProvider, oauthService, projectId: 'proj-1' },
|
||||
);
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
|
||||
const res = await configs[0].fetch('https://example.test/mcp');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(oauthService.refreshOAuth2CredentialById).toHaveBeenCalledWith('cred-1', 'proj-1');
|
||||
});
|
||||
|
||||
it('does NOT wire an onUnauthorized handler when credential is absent for mcpOAuth2Api', async () => {
|
||||
proxyFetchMock.mockResolvedValueOnce(new Response('unauthorized', { status: 401 }));
|
||||
|
||||
const credentialProvider = mock<CredentialProvider>();
|
||||
credentialProvider.resolve.mockResolvedValue({} as never);
|
||||
|
||||
const oauthService = mock<OauthService>();
|
||||
|
||||
// credential field intentionally absent
|
||||
await buildMcpClientForServer(makeServer({ authentication: 'mcpOAuth2Api' }), {
|
||||
credentialProvider,
|
||||
oauthService,
|
||||
projectId: 'proj-1',
|
||||
});
|
||||
|
||||
const [configs] = mcpClientCtor.mock.calls[0] as [Array<{ fetch: typeof fetch }>];
|
||||
await configs[0].fetch('https://example.test/mcp');
|
||||
|
||||
// No refresh should have been attempted
|
||||
expect(oauthService.refreshOAuth2CredentialById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
BuiltMemory,
|
||||
BuiltTool,
|
||||
CredentialProvider,
|
||||
McpClient,
|
||||
ModelConfig,
|
||||
ToolDescriptor,
|
||||
JSONObject,
|
||||
|
|
@ -13,6 +14,7 @@ import { wrapToolForApproval } from '@n8n/agents/tool';
|
|||
import type {
|
||||
AgentSkill,
|
||||
AgentJsonConfig,
|
||||
AgentJsonMcpServerConfig,
|
||||
AgentJsonMemoryConfig,
|
||||
AgentJsonToolConfig,
|
||||
AgentJsonSkillConfig,
|
||||
|
|
@ -47,6 +49,14 @@ export interface ToolExecutor {
|
|||
/** Factory function that reconstructs a BuiltMemory backend from serialized params. */
|
||||
export type MemoryFactory = (params: AgentJsonMemoryConfig) => BuiltMemory | Promise<BuiltMemory>;
|
||||
|
||||
/**
|
||||
* Build an SDK `McpClient` from a single JSON-config MCP server entry. The
|
||||
* platform layer owns this factory because it needs access to the credential
|
||||
* store and OAuth2 refresh infrastructure, both of which are out of scope for
|
||||
* `buildFromJson`.
|
||||
*/
|
||||
export type McpClientBuilder = (server: AgentJsonMcpServerConfig) => Promise<McpClient>;
|
||||
|
||||
type MemoryWorkerModelConfig = {
|
||||
model: string;
|
||||
credential: string;
|
||||
|
|
@ -62,6 +72,14 @@ export interface BuildFromJsonOptions {
|
|||
skills?: Record<string, AgentSkill>;
|
||||
/** Memory backend factories keyed by storage preset name. */
|
||||
memoryFactory: MemoryFactory;
|
||||
/**
|
||||
* When provided, each entry in `config.mcpServers` is built into an
|
||||
* `McpClient` and attached to the agent via `agent.mcp(client)`. The
|
||||
* platform layer is responsible for tracking returned clients for
|
||||
* teardown when the runtime is evicted.
|
||||
*
|
||||
*/
|
||||
buildMcpClient?: McpClientBuilder;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,6 +112,14 @@ export async function buildFromJson(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (config.mcpServers?.length && options.buildMcpClient) {
|
||||
for (const server of config.mcpServers) {
|
||||
const client = await options.buildMcpClient(server);
|
||||
agent.mcp(client);
|
||||
}
|
||||
}
|
||||
|
||||
agent.skills(configuredSkills);
|
||||
|
||||
// Provider tools
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import { proxyFetch } from '@n8n/ai-utilities';
|
||||
import type { CredentialProvider, McpClient, McpServerConfig } from '@n8n/agents';
|
||||
import type { AgentJsonMcpServerConfig } from '@n8n/api-types';
|
||||
import { isMcpOAuth2Authentication } from 'n8n-workflow';
|
||||
|
||||
import type { OauthService } from '@/oauth/oauth.service';
|
||||
|
||||
/**
|
||||
* Convert the JSON-config `approval` shape into the SDK's `requireApproval`
|
||||
* field. The two representations carry the same semantics:
|
||||
*
|
||||
* - `undefined` -> `undefined` (no per-server approval)
|
||||
* - `{ mode: 'global' }` -> `true` (every tool requires approval)
|
||||
* - `{ mode: 'selected' }` -> `string[]` (only listed tools require approval)
|
||||
*/
|
||||
export function mapApprovalToSdk(
|
||||
approval: AgentJsonMcpServerConfig['approval'],
|
||||
): McpServerConfig['requireApproval'] {
|
||||
if (!approval) return undefined;
|
||||
if (approval.mode === 'global') return true;
|
||||
return approval.tools;
|
||||
}
|
||||
|
||||
function isTokenData(tokenData: unknown): tokenData is { access_token: string } {
|
||||
return (
|
||||
typeof tokenData === 'object' &&
|
||||
tokenData !== null &&
|
||||
'access_token' in tokenData &&
|
||||
typeof tokenData.access_token === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive static (non-OAuth2) auth headers from a credential resolved through
|
||||
* the agents `CredentialProvider`. Mirrors the shape of `getAuthHeaders` in
|
||||
* the langchain MCP node — kept inline here so the agents module does not
|
||||
* have to depend on `@n8n/nodes-langchain`.
|
||||
*
|
||||
* For any `*McpOAuth2Api` credential type, the Bearer header is computed from
|
||||
* the already-stored `oauthTokenData.access_token`. Refresh-on-401 is handled
|
||||
* by `createAuthFetch` below; this function only computes the initial set.
|
||||
*/
|
||||
async function deriveAuthHeaders(
|
||||
server: AgentJsonMcpServerConfig,
|
||||
credentialProvider: CredentialProvider,
|
||||
): Promise<Record<string, string>> {
|
||||
if (server.authentication === 'none' || !server.credential) return {};
|
||||
|
||||
const resolved = await credentialProvider.resolve(server.credential).catch(() => null);
|
||||
if (!resolved) return {};
|
||||
|
||||
if (isMcpOAuth2Authentication(server.authentication)) {
|
||||
const tokenData = resolved.oauthTokenData as { access_token: string } | null | undefined;
|
||||
if (!isTokenData(tokenData)) return {};
|
||||
return {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
};
|
||||
}
|
||||
|
||||
switch (server.authentication) {
|
||||
case 'bearerAuth': {
|
||||
const token = typeof resolved.token === 'string' ? resolved.token : '';
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
case 'headerAuth': {
|
||||
const name = typeof resolved.name === 'string' ? resolved.name : '';
|
||||
const value = typeof resolved.value === 'string' ? resolved.value : '';
|
||||
return name && value ? { [name]: value } : {};
|
||||
}
|
||||
case 'multipleHeadersAuth': {
|
||||
const headers = resolved.headers;
|
||||
if (
|
||||
!headers ||
|
||||
typeof headers !== 'object' ||
|
||||
!('values' in headers) ||
|
||||
!Array.isArray((headers as { values: unknown }).values)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
const values = (headers as { values: Array<{ name?: unknown; value?: unknown }> }).values;
|
||||
const out: Record<string, string> = {};
|
||||
for (const entry of values) {
|
||||
if (typeof entry.name === 'string' && typeof entry.value === 'string') {
|
||||
out[entry.name] = entry.value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
interface CreateAuthFetchOptions {
|
||||
initialHeaders: Record<string, string>;
|
||||
/**
|
||||
* Called on a 401 response. Should return a fresh set of auth headers, or
|
||||
* `null` if the refresh failed. The returned headers replace the cached
|
||||
* set used by subsequent requests.
|
||||
*/
|
||||
onUnauthorized?: () => Promise<Record<string, string> | null>;
|
||||
}
|
||||
|
||||
function headersToRecord(headers: HeadersInit | undefined): Record<string, string> {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
if (Array.isArray(headers)) return Object.fromEntries(headers);
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fetch wrapper that:
|
||||
* 1. routes through n8n's `proxyFetch` (so corporate HTTP_PROXY settings
|
||||
* apply uniformly),
|
||||
* 2. injects the latest auth headers on every request,
|
||||
* 3. on a single 401, calls `onUnauthorized` to refresh the token and
|
||||
* retries the request once with the new headers.
|
||||
*
|
||||
* This mirrors the langchain MCP node's `createAuthFetch` so an agent's MCP
|
||||
* connection behaves identically to one configured via the workflow editor.
|
||||
*/
|
||||
export function createAuthFetch({
|
||||
initialHeaders,
|
||||
onUnauthorized,
|
||||
}: CreateAuthFetchOptions): typeof fetch {
|
||||
let headers = initialHeaders;
|
||||
|
||||
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const response = await proxyFetch(input, {
|
||||
...init,
|
||||
headers: { ...headersToRecord(init?.headers), ...headers },
|
||||
});
|
||||
|
||||
if (response.status !== 401 || !onUnauthorized) return response;
|
||||
|
||||
const refreshed = await onUnauthorized();
|
||||
if (!refreshed) return response;
|
||||
|
||||
headers = refreshed;
|
||||
return await proxyFetch(input, {
|
||||
...init,
|
||||
headers: { ...headersToRecord(init?.headers), ...headers },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildMcpClientDeps {
|
||||
credentialProvider: CredentialProvider;
|
||||
/**
|
||||
* Used to refresh OAuth2 tokens on a 401 response without an
|
||||
* `IExecuteFunctions` workflow context. Only invoked when
|
||||
* `server.authentication` is any `*McpOAuth2Api` credential type.
|
||||
*/
|
||||
oauthService: OauthService;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connected-but-lazy SDK `McpClient` for a single JSON-config MCP
|
||||
* server entry. The returned client opens its transport on first use
|
||||
* (`agent.mcp(client)` → `client.listTools()` during agent run).
|
||||
*
|
||||
* Callers are responsible for keeping the returned client referenced for the
|
||||
* lifetime of the runtime and calling `.close()` when the runtime is evicted.
|
||||
*/
|
||||
export async function buildMcpClientForServer(
|
||||
server: AgentJsonMcpServerConfig,
|
||||
deps: BuildMcpClientDeps,
|
||||
): Promise<McpClient> {
|
||||
const { credentialProvider, oauthService, projectId } = deps;
|
||||
const { McpClient } = await import('@n8n/agents');
|
||||
|
||||
const initialHeaders = await deriveAuthHeaders(server, credentialProvider);
|
||||
|
||||
const onUnauthorized =
|
||||
isMcpOAuth2Authentication(server.authentication) && server.credential
|
||||
? async () => {
|
||||
const credentialId = server.credential;
|
||||
if (!credentialId) return null;
|
||||
return await oauthService
|
||||
.refreshOAuth2CredentialById(credentialId, projectId)
|
||||
.catch(() => null);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const authFetch = createAuthFetch({ initialHeaders, onUnauthorized });
|
||||
|
||||
const sdkServerConfig: McpServerConfig = {
|
||||
name: server.name,
|
||||
url: server.url,
|
||||
transport: server.transport,
|
||||
fetch: authFetch,
|
||||
toolFilter: server.toolFilter,
|
||||
requireApproval: mapApprovalToSdk(server.approval),
|
||||
...(server.connectionTimeoutMs !== undefined && {
|
||||
connectionTimeoutMs: server.connectionTimeoutMs,
|
||||
}),
|
||||
};
|
||||
|
||||
return new McpClient([sdkServerConfig]);
|
||||
}
|
||||
|
|
@ -3214,4 +3214,240 @@ describe('OauthService', () => {
|
|||
).toBe('direct@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshOAuth2CredentialById', () => {
|
||||
const credentialId = 'cred-123';
|
||||
const projectId = 'proj-456';
|
||||
|
||||
function makeCredential(
|
||||
overrides: Partial<ICredentialsDb> = {},
|
||||
): ICredentialsDb & { isGlobal: boolean } {
|
||||
return {
|
||||
id: credentialId,
|
||||
isGlobal: false,
|
||||
shared: [],
|
||||
...overrides,
|
||||
} as unknown as ICredentialsDb & { isGlobal: boolean };
|
||||
}
|
||||
|
||||
it('returns null when the credential is not found', async () => {
|
||||
credentialsRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the credential is not accessible to the given project', async () => {
|
||||
const credential = makeCredential({
|
||||
isGlobal: false,
|
||||
shared: [{ projectId: 'other-project' }] as never,
|
||||
});
|
||||
credentialsRepository.findOne.mockResolvedValue(credential as never);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('grants access when the credential is global regardless of project', async () => {
|
||||
const credential = makeCredential({ isGlobal: true, shared: [] });
|
||||
credentialsRepository.findOne.mockResolvedValue(credential as never);
|
||||
// Returns null because there's no oauthTokenData — but the access check passed
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'authorizationCode',
|
||||
authentication: 'header',
|
||||
} as unknown as OAuth2CredentialData);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
// Null because oauthTokenData is missing — not because of an access denial
|
||||
expect(result).toBeNull();
|
||||
expect(service.getOAuthCredentials).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('grants access when the project is a shared member', async () => {
|
||||
const credential = makeCredential({
|
||||
isGlobal: false,
|
||||
shared: [{ projectId }] as never,
|
||||
});
|
||||
credentialsRepository.findOne.mockResolvedValue(credential as never);
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'authorizationCode',
|
||||
authentication: 'header',
|
||||
} as unknown as OAuth2CredentialData);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
// Access was granted — null because there's no oauthTokenData
|
||||
expect(result).toBeNull();
|
||||
expect(service.getOAuthCredentials).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null when the credential has no stored oauthTokenData', async () => {
|
||||
credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'authorizationCode',
|
||||
authentication: 'header',
|
||||
// oauthTokenData intentionally absent
|
||||
} as unknown as OAuth2CredentialData);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('refreshes the token with token.refresh() for authorizationCode grant and returns a Bearer header', async () => {
|
||||
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
|
||||
const refreshed = {
|
||||
data: { access_token: 'new-token', token_type: 'bearer' },
|
||||
accessToken: 'new-token',
|
||||
};
|
||||
const mockToken = { refresh: jest.fn().mockResolvedValue(refreshed), client: {} };
|
||||
jest
|
||||
.mocked(ClientOAuth2)
|
||||
.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);
|
||||
|
||||
credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'authorizationCode',
|
||||
authentication: 'header',
|
||||
oauthTokenData: {
|
||||
access_token: 'stale',
|
||||
refresh_token: 'refresh-tok',
|
||||
token_type: 'bearer',
|
||||
},
|
||||
} as unknown as OAuth2CredentialData);
|
||||
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer new-token' });
|
||||
expect(mockToken.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('persists the refreshed token data after a successful refresh', async () => {
|
||||
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
|
||||
const refreshedData = { access_token: 'new-token', token_type: 'bearer' };
|
||||
const refreshed = { data: refreshedData, accessToken: 'new-token' };
|
||||
const mockToken = { refresh: jest.fn().mockResolvedValue(refreshed), client: {} };
|
||||
const credential = makeCredential({ isGlobal: true });
|
||||
jest
|
||||
.mocked(ClientOAuth2)
|
||||
.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);
|
||||
|
||||
credentialsRepository.findOne.mockResolvedValue(credential as never);
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'authorizationCode',
|
||||
authentication: 'header',
|
||||
oauthTokenData: { access_token: 'stale', refresh_token: 'refresh-tok' },
|
||||
} as unknown as OAuth2CredentialData);
|
||||
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
|
||||
|
||||
await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(service.encryptAndSaveData).toHaveBeenCalledWith(credential, {
|
||||
oauthTokenData: refreshedData,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses credentials.getToken() for clientCredentials grant type', async () => {
|
||||
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
|
||||
const refreshed = { data: { access_token: 'cc-token' }, accessToken: 'cc-token' };
|
||||
const getToken = jest.fn().mockResolvedValue(refreshed);
|
||||
const mockToken = { refresh: jest.fn(), client: { credentials: { getToken } } };
|
||||
jest
|
||||
.mocked(ClientOAuth2)
|
||||
.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);
|
||||
|
||||
credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'clientCredentials',
|
||||
authentication: 'header',
|
||||
oauthTokenData: { access_token: 'stale' },
|
||||
} as unknown as OAuth2CredentialData);
|
||||
jest.spyOn(service, 'encryptAndSaveData').mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer cc-token' });
|
||||
expect(getToken).toHaveBeenCalledTimes(1);
|
||||
expect(mockToken.refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns null and logs a warning when the refresh call throws', async () => {
|
||||
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
|
||||
const mockToken = {
|
||||
refresh: jest.fn().mockRejectedValue(new Error('network timeout')),
|
||||
client: {},
|
||||
};
|
||||
jest
|
||||
.mocked(ClientOAuth2)
|
||||
.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);
|
||||
|
||||
credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'authorizationCode',
|
||||
authentication: 'header',
|
||||
oauthTokenData: { access_token: 'stale', refresh_token: 'refresh-tok' },
|
||||
} as unknown as OAuth2CredentialData);
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to refresh OAuth2 token for credential',
|
||||
expect.objectContaining({ credentialId, error: 'network timeout' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('still returns the auth header even when persisting the new token data fails', async () => {
|
||||
const { ClientOAuth2 } = await import('@n8n/client-oauth2');
|
||||
const refreshed = { data: { access_token: 'new-token' }, accessToken: 'new-token' };
|
||||
const mockToken = { refresh: jest.fn().mockResolvedValue(refreshed), client: {} };
|
||||
jest
|
||||
.mocked(ClientOAuth2)
|
||||
.mockImplementation(() => ({ createToken: jest.fn().mockReturnValue(mockToken) }) as never);
|
||||
|
||||
credentialsRepository.findOne.mockResolvedValue(makeCredential({ isGlobal: true }) as never);
|
||||
jest.spyOn(service, 'getOAuthCredentials').mockResolvedValue({
|
||||
clientId: 'id',
|
||||
clientSecret: 'secret',
|
||||
accessTokenUrl: 'https://example.com/token',
|
||||
grantType: 'authorizationCode',
|
||||
authentication: 'header',
|
||||
oauthTokenData: { access_token: 'stale', refresh_token: 'refresh-tok' },
|
||||
} as unknown as OAuth2CredentialData);
|
||||
jest.spyOn(service, 'encryptAndSaveData').mockRejectedValue(new Error('db write error'));
|
||||
|
||||
const result = await service.refreshOAuth2CredentialById(credentialId, projectId);
|
||||
|
||||
expect(result).toEqual({ Authorization: 'Bearer new-token' });
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'Refreshed OAuth2 token but failed to persist new token data',
|
||||
expect.objectContaining({ credentialId }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import * as WorkflowExecuteAdditionalData from '@/workflow-execute-additional-da
|
|||
import {
|
||||
ClientOAuth2,
|
||||
type ClientOAuth2Options,
|
||||
type ClientOAuth2TokenData,
|
||||
type OAuth2AuthenticationMethod,
|
||||
type OAuth2CredentialData,
|
||||
type OAuth2GrantType,
|
||||
|
|
@ -417,6 +418,77 @@ export class OauthService {
|
|||
return oauthCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the OAuth2 token stored on a credential by id, persist the refreshed token data,
|
||||
* and return the new auth headers to inject into outbound requests.
|
||||
*/
|
||||
async refreshOAuth2CredentialById(
|
||||
credentialId: string,
|
||||
projectId: string,
|
||||
): Promise<Record<string, string> | null> {
|
||||
const credential = await this.credentialsRepository.findOne({
|
||||
where: { id: credentialId },
|
||||
relations: { shared: true },
|
||||
});
|
||||
if (!credential) return null;
|
||||
|
||||
const isAccessible =
|
||||
credential.isGlobal || (credential.shared ?? []).some((s) => s.projectId === projectId);
|
||||
if (!isAccessible) return null;
|
||||
|
||||
const oauthCredentials = await this.getOAuthCredentials<OAuth2CredentialData>(credential);
|
||||
const oauthTokenData = oauthCredentials.oauthTokenData as ClientOAuth2TokenData | undefined;
|
||||
if (!oauthTokenData) return null;
|
||||
|
||||
const scopes = oauthCredentials.scope
|
||||
?.split(' ')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const oAuthClient = new ClientOAuth2({
|
||||
clientId: oauthCredentials.clientId,
|
||||
clientSecret: oauthCredentials.clientSecret,
|
||||
accessTokenUri: oauthCredentials.accessTokenUrl,
|
||||
scopes: scopes?.length ? scopes : undefined,
|
||||
ignoreSSLIssues: oauthCredentials.ignoreSSLIssues,
|
||||
authentication: oauthCredentials.authentication ?? 'header',
|
||||
});
|
||||
|
||||
const token = oAuthClient.createToken(
|
||||
{
|
||||
...oauthTokenData,
|
||||
...(oauthTokenData.access_token ? { access_token: oauthTokenData.access_token } : {}),
|
||||
...(oauthTokenData.refresh_token ? { refresh_token: oauthTokenData.refresh_token } : {}),
|
||||
},
|
||||
oauthTokenData.token_type,
|
||||
);
|
||||
|
||||
let refreshed;
|
||||
try {
|
||||
refreshed =
|
||||
oauthCredentials.grantType === 'clientCredentials'
|
||||
? await token.client.credentials.getToken()
|
||||
: await token.refresh();
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to refresh OAuth2 token for credential', {
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.encryptAndSaveData(credential, { oauthTokenData: refreshed.data });
|
||||
} catch (error) {
|
||||
this.logger.warn('Refreshed OAuth2 token but failed to persist new token data', {
|
||||
credentialId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return { Authorization: `Bearer ${refreshed.accessToken}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the credential type (after merging inherited properties) exposes
|
||||
* a user-editable `scope` property. A property is considered editable when it is
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export * from './from-ai-parse-utils';
|
|||
export * from './node-helpers';
|
||||
export * from './node-validation';
|
||||
export * from './node-grouping-validation';
|
||||
export * from './mcp-helpers';
|
||||
export * from './tool-helpers';
|
||||
export * from './node-reference-parser-utils';
|
||||
export * from './metadata-utils';
|
||||
|
|
|
|||
12
packages/workflow/src/mcp-helpers.ts
Normal file
12
packages/workflow/src/mcp-helpers.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/** Covers `mcpOAuth2Api` and registry-specific variants like `notionMcpOAuth2Api`. */
|
||||
export type McpOAuth2CredentialType = 'mcpOAuth2Api' | `${string}McpOAuth2Api`;
|
||||
|
||||
/**
|
||||
* Returns `true` for `mcpOAuth2Api` and any credential type ending in
|
||||
* `McpOAuth2Api` (e.g. `notionMcpOAuth2Api`, `githubMcpOAuth2Api`).
|
||||
*/
|
||||
export function isMcpOAuth2Authentication(
|
||||
authentication: string,
|
||||
): authentication is McpOAuth2CredentialType {
|
||||
return authentication === 'mcpOAuth2Api' || authentication.endsWith('McpOAuth2Api');
|
||||
}
|
||||
24
packages/workflow/test/mcp-helpers.test.ts
Normal file
24
packages/workflow/test/mcp-helpers.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { isMcpOAuth2Authentication } from '../src/mcp-helpers';
|
||||
|
||||
describe('isMcpOAuth2Authentication', () => {
|
||||
it('returns true for the canonical "mcpOAuth2Api" type', () => {
|
||||
expect(isMcpOAuth2Authentication('mcpOAuth2Api')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for service-specific variants ending in "McpOAuth2Api"', () => {
|
||||
expect(isMcpOAuth2Authentication('notionMcpOAuth2Api')).toBe(true);
|
||||
expect(isMcpOAuth2Authentication('githubMcpOAuth2Api')).toBe(true);
|
||||
expect(isMcpOAuth2Authentication('slackMcpOAuth2Api')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for static auth types', () => {
|
||||
expect(isMcpOAuth2Authentication('bearerAuth')).toBe(false);
|
||||
expect(isMcpOAuth2Authentication('headerAuth')).toBe(false);
|
||||
expect(isMcpOAuth2Authentication('multipleHeadersAuth')).toBe(false);
|
||||
expect(isMcpOAuth2Authentication('none')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty string', () => {
|
||||
expect(isMcpOAuth2Authentication('')).toBe(false);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user