mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-28 07:33:08 +00:00
Compare commits
2 Commits
update/n8n
...
v2.35.4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bad880f44 | ||
|
|
77048347b3 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.35.4] - 2026-02-20
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Defensive JSON.parse for stringified object/array parameters** (Issue #605): Claude Desktop 1.1.3189 serializes JSON object/array MCP parameters as strings, causing ZodError failures for ~60% of tools that accept nested parameters
|
||||||
|
- Added schema-driven `coerceStringifiedJsonParams()` in the central `CallToolRequestSchema` handler
|
||||||
|
- Automatically detects string values where the tool's `inputSchema` expects `object` or `array`, and parses them back
|
||||||
|
- Safe: prefix check before parsing, type verification after, try/catch preserves original on failure
|
||||||
|
- No-op for correct clients: native objects pass through unchanged
|
||||||
|
- Affects 9 tools with object/array params: `validate_node`, `validate_workflow`, `n8n_create_workflow`, `n8n_update_full_workflow`, `n8n_update_partial_workflow`, `n8n_validate_workflow`, `n8n_autofix_workflow`, `n8n_test_workflow`, `n8n_executions`
|
||||||
|
- Added 15 unit tests covering coercion, no-op, safety, and end-to-end scenarios
|
||||||
|
|
||||||
|
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
## [2.35.3] - 2026-02-19
|
## [2.35.3] - 2026-02-19
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.35.3",
|
"version": "2.35.4",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -720,7 +720,12 @@ export class N8NDocumentationMCPServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workaround for Claude Desktop 1.1.3189 string serialization bug.
|
||||||
|
// The MCP client serializes object/array parameters as JSON strings.
|
||||||
|
// Use the tool's inputSchema to detect and parse them back.
|
||||||
|
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
@@ -1125,6 +1130,50 @@ export class N8NDocumentationMCPServer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coerce stringified JSON parameters back to objects/arrays.
|
||||||
|
* Workaround for Claude Desktop 1.1.3189 which serializes object/array
|
||||||
|
* params as JSON strings before sending them to MCP servers.
|
||||||
|
*/
|
||||||
|
private coerceStringifiedJsonParams(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, any> | undefined
|
||||||
|
): Record<string, any> | undefined {
|
||||||
|
if (!args || typeof args !== 'object') return args;
|
||||||
|
|
||||||
|
const allTools = [...n8nDocumentationToolsFinal, ...n8nManagementTools];
|
||||||
|
const tool = allTools.find(t => t.name === toolName);
|
||||||
|
if (!tool?.inputSchema?.properties) return args;
|
||||||
|
|
||||||
|
const properties = tool.inputSchema.properties;
|
||||||
|
const coerced = { ...args };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(coerced)) {
|
||||||
|
if (typeof value !== 'string') continue;
|
||||||
|
const expectedType = (properties as any)[key]?.type;
|
||||||
|
if (expectedType !== 'object' && expectedType !== 'array') continue;
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const validPrefix = (expectedType === 'object' && trimmed.startsWith('{'))
|
||||||
|
|| (expectedType === 'array' && trimmed.startsWith('['));
|
||||||
|
if (!validPrefix) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
const isArray = Array.isArray(parsed);
|
||||||
|
if ((expectedType === 'object' && typeof parsed === 'object' && !isArray)
|
||||||
|
|| (expectedType === 'array' && isArray)) {
|
||||||
|
coerced[key] = parsed;
|
||||||
|
logger.warn(`Coerced stringified ${expectedType} param "${key}" for tool "${toolName}"`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not valid JSON — keep original string, downstream validation will report the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return coerced;
|
||||||
|
}
|
||||||
|
|
||||||
async executeTool(name: string, args: any): Promise<any> {
|
async executeTool(name: string, args: any): Promise<any> {
|
||||||
// Ensure args is an object and validate it
|
// Ensure args is an object and validate it
|
||||||
args = args || {};
|
args = args || {};
|
||||||
|
|||||||
207
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
207
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { N8NDocumentationMCPServer } from '../../../src/mcp/server';
|
||||||
|
|
||||||
|
// Mock the database and dependencies
|
||||||
|
vi.mock('../../../src/database/database-adapter');
|
||||||
|
vi.mock('../../../src/database/node-repository');
|
||||||
|
vi.mock('../../../src/templates/template-service');
|
||||||
|
vi.mock('../../../src/utils/logger');
|
||||||
|
|
||||||
|
class TestableN8NMCPServer extends N8NDocumentationMCPServer {
|
||||||
|
public testCoerceStringifiedJsonParams(
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, any>
|
||||||
|
): Record<string, any> {
|
||||||
|
return (this as any).coerceStringifiedJsonParams(toolName, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('coerceStringifiedJsonParams', () => {
|
||||||
|
let server: TestableN8NMCPServer;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.NODE_DB_PATH = ':memory:';
|
||||||
|
server = new TestableN8NMCPServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.NODE_DB_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Object coercion', () => {
|
||||||
|
it('should coerce stringified object for validate_node config', () => {
|
||||||
|
const args = {
|
||||||
|
nodeType: 'nodes-base.slack',
|
||||||
|
config: '{"resource":"channel","operation":"create"}'
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||||
|
expect(result.config).toEqual({ resource: 'channel', operation: 'create' });
|
||||||
|
expect(result.nodeType).toBe('nodes-base.slack');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should coerce stringified object for n8n_create_workflow connections', () => {
|
||||||
|
const connections = { 'Webhook': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] } };
|
||||||
|
const args = {
|
||||||
|
name: 'Test Workflow',
|
||||||
|
nodes: [{ id: '1', name: 'Webhook', type: 'n8n-nodes-base.webhook' }],
|
||||||
|
connections: JSON.stringify(connections)
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('n8n_create_workflow', args);
|
||||||
|
expect(result.connections).toEqual(connections);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should coerce stringified object for validate_workflow workflow param', () => {
|
||||||
|
const workflow = { nodes: [], connections: {} };
|
||||||
|
const args = {
|
||||||
|
workflow: JSON.stringify(workflow)
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('validate_workflow', args);
|
||||||
|
expect(result.workflow).toEqual(workflow);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Array coercion', () => {
|
||||||
|
it('should coerce stringified array for n8n_update_partial_workflow operations', () => {
|
||||||
|
const operations = [
|
||||||
|
{ type: 'addNode', node: { id: '1', name: 'Test', type: 'n8n-nodes-base.noOp' } }
|
||||||
|
];
|
||||||
|
const args = {
|
||||||
|
id: '123',
|
||||||
|
operations: JSON.stringify(operations)
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('n8n_update_partial_workflow', args);
|
||||||
|
expect(result.operations).toEqual(operations);
|
||||||
|
expect(result.id).toBe('123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should coerce stringified array for n8n_autofix_workflow fixTypes', () => {
|
||||||
|
const fixTypes = ['expression-format', 'typeversion-correction'];
|
||||||
|
const args = {
|
||||||
|
id: '456',
|
||||||
|
fixTypes: JSON.stringify(fixTypes)
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('n8n_autofix_workflow', args);
|
||||||
|
expect(result.fixTypes).toEqual(fixTypes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('No-op cases', () => {
|
||||||
|
it('should not modify object params that are already objects', () => {
|
||||||
|
const config = { resource: 'channel', operation: 'create' };
|
||||||
|
const args = {
|
||||||
|
nodeType: 'nodes-base.slack',
|
||||||
|
config
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||||
|
expect(result.config).toEqual(config);
|
||||||
|
expect(result.config).toBe(config); // same reference
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify string params even if they contain JSON', () => {
|
||||||
|
const args = {
|
||||||
|
query: '{"some":"json"}',
|
||||||
|
limit: 10
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||||
|
expect(result.query).toBe('{"some":"json"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not modify args for tools with no object/array params', () => {
|
||||||
|
const args = {
|
||||||
|
query: 'webhook',
|
||||||
|
limit: 20,
|
||||||
|
mode: 'OR'
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||||
|
expect(result).toEqual(args);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Safety cases', () => {
|
||||||
|
it('should keep original string for invalid JSON', () => {
|
||||||
|
const args = {
|
||||||
|
nodeType: 'nodes-base.slack',
|
||||||
|
config: '{invalid json here}'
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||||
|
expect(result.config).toBe('{invalid json here}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attempt parse when object param starts with [', () => {
|
||||||
|
const args = {
|
||||||
|
nodeType: 'nodes-base.slack',
|
||||||
|
config: '[1, 2, 3]'
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||||
|
expect(result.config).toBe('[1, 2, 3]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not attempt parse when array param starts with {', () => {
|
||||||
|
const args = {
|
||||||
|
id: '123',
|
||||||
|
operations: '{"not":"an array"}'
|
||||||
|
};
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('n8n_update_partial_workflow', args);
|
||||||
|
expect(result.operations).toBe('{"not":"an array"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle null args gracefully', () => {
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('validate_node', null as any);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined args gracefully', () => {
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('validate_node', undefined as any);
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return args unchanged for unknown tool', () => {
|
||||||
|
const args = { config: '{"key":"value"}' };
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('nonexistent_tool', args);
|
||||||
|
expect(result).toEqual(args);
|
||||||
|
expect(result.config).toBe('{"key":"value"}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('End-to-end Claude Desktop scenario', () => {
|
||||||
|
it('should coerce all stringified params for n8n_create_workflow', () => {
|
||||||
|
const nodes = [
|
||||||
|
{
|
||||||
|
id: 'webhook_1',
|
||||||
|
name: 'Webhook',
|
||||||
|
type: 'n8n-nodes-base.webhook',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [250, 300],
|
||||||
|
parameters: { httpMethod: 'POST', path: 'slack-notify' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'slack_1',
|
||||||
|
name: 'Slack',
|
||||||
|
type: 'n8n-nodes-base.slack',
|
||||||
|
typeVersion: 1,
|
||||||
|
position: [450, 300],
|
||||||
|
parameters: { resource: 'message', operation: 'post', channel: '#general' }
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const connections = {
|
||||||
|
'Webhook': { main: [[{ node: 'Slack', type: 'main', index: 0 }]] }
|
||||||
|
};
|
||||||
|
const settings = { executionOrder: 'v1', timezone: 'America/New_York' };
|
||||||
|
|
||||||
|
// Simulate Claude Desktop sending all object/array params as strings
|
||||||
|
const args = {
|
||||||
|
name: 'Webhook to Slack',
|
||||||
|
nodes: JSON.stringify(nodes),
|
||||||
|
connections: JSON.stringify(connections),
|
||||||
|
settings: JSON.stringify(settings)
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = server.testCoerceStringifiedJsonParams('n8n_create_workflow', args);
|
||||||
|
|
||||||
|
expect(result.name).toBe('Webhook to Slack');
|
||||||
|
expect(result.nodes).toEqual(nodes);
|
||||||
|
expect(result.connections).toEqual(connections);
|
||||||
|
expect(result.settings).toEqual(settings);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user