diff --git a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts index 6d02050c2a8..5ed11073294 100644 --- a/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts +++ b/packages/@n8n/api-types/src/agents/__tests__/agent-json-config.schema.test.ts @@ -1,6 +1,7 @@ import { AgentJsonConfigSchema, findVectorStoreToolNameCollisions, + formatAgentConfigZodError, } from '../agent-json-config.schema'; const minimalConfig = { @@ -542,3 +543,32 @@ describe('AgentJsonConfigSchema — model/credential coupling', () => { expect(result.success).toBe(true); }); }); + +describe('formatAgentConfigZodError', () => { + it('formats an invalid MCP server name as path: message without a Zod JSON dump', () => { + const result = AgentJsonConfigSchema.safeParse({ + ...minimalConfig, + mcpServers: [ + { + name: 'has spaces', + url: 'https://example.com/mcp', + transport: 'streamableHttp', + authentication: 'none', + }, + ], + }); + + expect(result.success).toBe(false); + if (result.success) return; + + const formatted = formatAgentConfigZodError(result.error); + expect(formatted).toContain('mcpServers.0.name'); + expect(formatted).toContain( + 'MCP server name can only contain letters, numbers, hyphens, and underscores', + ); + expect(formatted).not.toContain('"validation": "regex"'); + expect(result.error.issues[0]?.message).toBe( + 'MCP server name can only contain letters, numbers, hyphens, and underscores', + ); + }); +}); diff --git a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts index aed341915c3..8046a24a709 100644 --- a/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts +++ b/packages/@n8n/api-types/src/agents/agent-json-config.schema.ts @@ -90,7 +90,9 @@ const WebSearchConfigSchema = z.object({ credential: z.string().optional(), }); -const HexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/); +const HexColorSchema = z + .string() + .regex(/^#[0-9A-Fa-f]{6}$/, 'Color must be a 6-digit hex value (e.g. #FF5500)'); export const DEFAULT_AGENT_PERSONALISATION = { icon: 'bot', @@ -196,7 +198,10 @@ const AgentJsonSkillConfigSchema = z.object({ id: z .string() .min(1) - .regex(/^[A-Za-z0-9_-]+$/), + .regex( + /^[A-Za-z0-9_-]+$/, + 'Skill id can only contain letters, numbers, hyphens, and underscores', + ), }); const AgentJsonTaskConfigSchema = z.object({ @@ -204,7 +209,10 @@ const AgentJsonTaskConfigSchema = z.object({ id: z .string() .min(1) - .regex(/^[A-Za-z0-9_-]+$/), + .regex( + /^[A-Za-z0-9_-]+$/, + 'Task id can only contain letters, numbers, hyphens, and underscores', + ), enabled: z.boolean(), }); @@ -228,6 +236,10 @@ export const McpServerConfigSchema = z .string() .min(1) .max(64) + .regex( + /^[a-zA-Z0-9_-]+$/, + 'MCP server name can only contain letters, numbers, hyphens, and underscores', + ) .refine((name) => name.trim().length > 0, 'MCP server name cannot be blank') .describe('Unique display name. The SDK normalizes it when building model-facing tool names'), description: z.string().max(512).optional().describe('Human-readable server description'), @@ -323,7 +335,10 @@ const VectorStoreBaseShape = { .string() .min(1) .max(64) - .regex(VECTOR_STORE_NAME_REGEX) + .regex( + VECTOR_STORE_NAME_REGEX, + 'Vector store name can only contain letters, numbers, hyphens, and underscores', + ) .describe('Unique connection name, also used as the SDK tool-name suffix: search_'), credential: CredentialIdSchema, useWhen: z.string().trim().min(1).max(VECTOR_STORE_USE_WHEN_MAX_LENGTH), @@ -365,7 +380,13 @@ export const AgentVectorStoreConfigSchema = z.discriminatedUnion('provider', [ const CustomToolJsonConfigSchema = z.object({ type: z.literal('custom'), - id: z.string().min(1).regex(CUSTOM_TOOL_ID_REGEX), + id: z + .string() + .min(1) + .regex( + CUSTOM_TOOL_ID_REGEX, + 'Custom tool id can only contain letters, numbers, and underscores', + ), requireApproval: z.boolean().optional(), }); @@ -574,3 +595,9 @@ export function formatZodErrors(error: ZodError): ConfigValidationError[] { received: 'received' in issue ? String(issue.received) : undefined, })); } + +export function formatAgentConfigZodError(error: ZodError): string { + return formatZodErrors(error) + .map((issue) => `${issue.path}: ${issue.message}`) + .join('; '); +} diff --git a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts index b1b069bd339..fa8413e27c8 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-config.service.test.ts @@ -162,6 +162,30 @@ describe('AgentConfigService', () => { }), ).resolves.toMatchObject({ valid: true }); }); + + it('returns a human-readable Zod error for an invalid MCP server name', async () => { + const { service } = makeService(); + + const result = await service.validateConfig({ + ...baseConfig, + mcpServers: [ + { + name: 'has spaces', + url: 'https://example.com/mcp', + transport: 'streamableHttp', + authentication: 'none', + }, + ], + }); + + expect(result.valid).toBe(false); + if (result.valid) return; + expect(result.error).toContain('mcpServers.0.name'); + expect(result.error).toContain( + 'MCP server name can only contain letters, numbers, hyphens, and underscores', + ); + expect(result.error).not.toContain('"validation": "regex"'); + }); }); describe('updateConfig', () => { diff --git a/packages/cli/src/modules/agents/agent-config.service.ts b/packages/cli/src/modules/agents/agent-config.service.ts index c8e71128fb5..82528cd22b1 100644 --- a/packages/cli/src/modules/agents/agent-config.service.ts +++ b/packages/cli/src/modules/agents/agent-config.service.ts @@ -2,6 +2,7 @@ import { reconcileNativeWebSearch } from '@n8n/ai-utilities/agent-config'; import { AgentJsonConfigSchema, findVectorStoreToolNameCollisions, + formatAgentConfigZodError, sanitizeAgentJsonConfig, type AgentJsonConfig, type AgentJsonToolConfig, @@ -66,7 +67,7 @@ export class AgentConfigService { const parsed = AgentJsonConfigSchema.safeParse(sanitizeAgentJsonConfig(raw)); if (!parsed.success) { - return { valid: false, error: parsed.error.message }; + return { valid: false, error: formatAgentConfigZodError(parsed.error) }; } const config = parsed.data; diff --git a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts index 957a46ca8e4..b58bcafec55 100644 --- a/packages/cli/src/modules/agents/agent-workflow-execution.service.ts +++ b/packages/cli/src/modules/agents/agent-workflow-execution.service.ts @@ -2,7 +2,7 @@ import type { Agent as RuntimeAgent, BuiltAgent, BuiltTool, CredentialProvider } import type { AgentJsonConfig, AgentSkill } from '@n8n/api-types'; import { AGENT_WORKFLOW_TRIGGER_TYPE, - formatZodErrors, + formatAgentConfigZodError, RunnableInlineAgentConfigSchema, sanitizeAgentJsonConfig, sanitizeAgentSkillBodies, @@ -556,10 +556,9 @@ export class AgentWorkflowExecutionService { ...(payload.skills !== undefined ? { skills: sanitizeAgentSkillBodies(payload.skills) } : {}), }); if (!parsed.success) { - const details = formatZodErrors(parsed.error) - .map((issue) => `${issue.path}: ${issue.message}`) - .join('; '); - throw new UserError(`Invalid inline agent configuration: ${details}`); + throw new UserError( + `Invalid inline agent configuration: ${formatAgentConfigZodError(parsed.error)}`, + ); } const config = parsed.data.config;