mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-02-28 07:33:08 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87f26eef18 | ||
|
|
4bad880f44 |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.35.5] - 2026-02-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Comprehensive parameter type coercion for Claude Desktop / Claude.ai** (Issue #605): Expanded the v2.35.4 fix to handle ALL type mismatches, not just stringified objects/arrays. Testing revealed 6/9 tools still failing in Claude Desktop after the initial fix.
|
||||
- Extended `coerceStringifiedJsonParams()` to coerce every schema type: `string→number`, `string→boolean`, `number→string`, `boolean→string` (in addition to existing `string→object` and `string→array`)
|
||||
- Added top-level safeguard to parse the entire `args` object if it arrives as a JSON string
|
||||
- Added `[Diagnostic]` section to error responses showing received argument types, enabling users to report exactly what their MCP client sends
|
||||
- Added 9 new unit tests (24 total) covering number, boolean, and number-to-string coercion
|
||||
|
||||
Conceived by Romuald Czlonkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [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
|
||||
|
||||
### Changed
|
||||
|
||||
1
dist/mcp/server.d.ts
vendored
1
dist/mcp/server.d.ts
vendored
@@ -30,6 +30,7 @@ export declare class N8NDocumentationMCPServer {
|
||||
private validateToolParams;
|
||||
private validateToolParamsBasic;
|
||||
private validateExtractedArgs;
|
||||
private coerceStringifiedJsonParams;
|
||||
private listNodes;
|
||||
private getNodeInfo;
|
||||
private searchNodes;
|
||||
|
||||
2
dist/mcp/server.d.ts.map
vendored
2
dist/mcp/server.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAmGnE,qBAAa,yBAAyB;IACpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,EAAE,CAAgC;IAC1C,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,qBAAqB,CAAsB;IACnD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,kBAAkB,CAA4B;IACtD,OAAO,CAAC,iBAAiB,CAAkB;IAC3C,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,UAAU,CAAkB;gBAExB,eAAe,CAAC,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,gBAAgB;IAuGvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA+Cd,kBAAkB;YAiDlB,wBAAwB;IA0BtC,OAAO,CAAC,kBAAkB;YA6CZ,iBAAiB;IAa/B,OAAO,CAAC,eAAe,CAAkB;YAE3B,sBAAsB;IAgDpC,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,aAAa;IA8VrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;YAoTf,SAAS;YA2DT,WAAW;YAkFX,WAAW;YA0CX,cAAc;YA8Md,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IA2L7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;IAuFlC,OAAO,CAAC,aAAa;YAQP,qBAAqB;YAwDrB,iBAAiB;YAiKjB,OAAO;YAgDP,cAAc;YAwFd,iBAAiB;IAqC/B,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,kBAAkB;IAiC1B,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,0BAA0B;IAgClC,OAAO,CAAC,4BAA4B;YAKtB,oBAAoB;IAsDlC,OAAO,CAAC,gBAAgB;YAiBV,SAAS;YA6CT,kBAAkB;YAqElB,uBAAuB;YAsDvB,iBAAiB;IAqE/B,OAAO,CAAC,qBAAqB;IA8C7B,OAAO,CAAC,uBAAuB;IA4D/B,OAAO,CAAC,wBAAwB;IAkChC,OAAO,CAAC,iBAAiB;YAoDX,mBAAmB;YAoEnB,qBAAqB;IAS7B,OAAO,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAS9B,aAAa;YAcb,iBAAiB;YAoBjB,WAAW;YAwBX,eAAe;YAqBf,mBAAmB;YAwBnB,yBAAyB;IA4CvC,OAAO,CAAC,kBAAkB;YAiBZ,gBAAgB;YA6HhB,2BAA2B;YAiE3B,2BAA2B;IAyEnC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0BpB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAgEhC"}
|
||||
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/mcp/server.ts"],"names":[],"mappings":"AA0CA,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iCAAiC,CAAC;AAmGnE,qBAAa,yBAAyB;IACpC,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,EAAE,CAAgC;IAC1C,OAAO,CAAC,UAAU,CAA+B;IACjD,OAAO,CAAC,eAAe,CAAgC;IACvD,OAAO,CAAC,WAAW,CAAgB;IACnC,OAAO,CAAC,KAAK,CAAqB;IAClC,OAAO,CAAC,UAAU,CAAa;IAC/B,OAAO,CAAC,eAAe,CAAC,CAAkB;IAC1C,OAAO,CAAC,YAAY,CAAuB;IAC3C,OAAO,CAAC,qBAAqB,CAAsB;IACnD,OAAO,CAAC,WAAW,CAAiC;IACpD,OAAO,CAAC,kBAAkB,CAA4B;IACtD,OAAO,CAAC,iBAAiB,CAAkB;IAC3C,OAAO,CAAC,aAAa,CAAoC;IACzD,OAAO,CAAC,UAAU,CAAkB;gBAExB,eAAe,CAAC,EAAE,eAAe,EAAE,WAAW,CAAC,EAAE,gBAAgB;IAuGvE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;YA+Cd,kBAAkB;YAiDlB,wBAAwB;IA0BtC,OAAO,CAAC,kBAAkB;YA6CZ,iBAAiB;IAa/B,OAAO,CAAC,eAAe,CAAkB;YAE3B,sBAAsB;IAgDpC,OAAO,CAAC,gBAAgB;IAqCxB,OAAO,CAAC,aAAa;IA0XrB,OAAO,CAAC,wBAAwB;IAoFhC,OAAO,CAAC,kBAAkB;IAqE1B,OAAO,CAAC,uBAAuB;IAwB/B,OAAO,CAAC,qBAAqB;IAiF7B,OAAO,CAAC,2BAA2B;YA0UrB,SAAS;YA2DT,WAAW;YAkFX,WAAW;YA0CX,cAAc;YA8Md,gBAAgB;IAqD9B,OAAO,CAAC,mBAAmB;IAwE3B,OAAO,CAAC,eAAe;YAsBT,eAAe;IA2L7B,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,uBAAuB;IA0D/B,OAAO,CAAC,iBAAiB;YAqFX,WAAW;YAgCX,oBAAoB;IAuFlC,OAAO,CAAC,aAAa;YAQP,qBAAqB;YAwDrB,iBAAiB;YAiKjB,OAAO;YAgDP,cAAc;YAwFd,iBAAiB;IAqC/B,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,eAAe;IAwCvB,OAAO,CAAC,kBAAkB;IAiC1B,OAAO,CAAC,aAAa;IAoCrB,OAAO,CAAC,0BAA0B;IAgClC,OAAO,CAAC,4BAA4B;YAKtB,oBAAoB;IAsDlC,OAAO,CAAC,gBAAgB;YAiBV,SAAS;YA6CT,kBAAkB;YAqElB,uBAAuB;YAsDvB,iBAAiB;IAqE/B,OAAO,CAAC,qBAAqB;IA8C7B,OAAO,CAAC,uBAAuB;IA4D/B,OAAO,CAAC,wBAAwB;IAkChC,OAAO,CAAC,iBAAiB;YAoDX,mBAAmB;YAoEnB,qBAAqB;IAS7B,OAAO,CAAC,SAAS,EAAE,GAAG,GAAG,OAAO,CAAC,IAAI,CAAC;YAS9B,aAAa;YAcb,iBAAiB;YAoBjB,WAAW;YAwBX,eAAe;YAqBf,mBAAmB;YAwBnB,yBAAyB;IA4CvC,OAAO,CAAC,kBAAkB;YAiBZ,gBAAgB;YA6HhB,2BAA2B;YAiE3B,2BAA2B;IAyEnC,GAAG,IAAI,OAAO,CAAC,IAAI,CAAC;IA0BpB,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAgEhC"}
|
||||
107
dist/mcp/server.js
vendored
107
dist/mcp/server.js
vendored
@@ -457,6 +457,18 @@ class N8NDocumentationMCPServer {
|
||||
};
|
||||
}
|
||||
let processedArgs = args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
processedArgs = parsed;
|
||||
logger_1.logger.warn(`Coerced stringified args object for tool "${name}"`);
|
||||
}
|
||||
}
|
||||
catch {
|
||||
logger_1.logger.warn(`Tool "${name}" received string args that are not valid JSON`);
|
||||
}
|
||||
}
|
||||
if (args && typeof args === 'object' && 'output' in args) {
|
||||
try {
|
||||
const possibleNestedData = args.output;
|
||||
@@ -485,6 +497,7 @@ class N8NDocumentationMCPServer {
|
||||
});
|
||||
}
|
||||
}
|
||||
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
|
||||
try {
|
||||
logger_1.logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||
const startTime = Date.now();
|
||||
@@ -556,6 +569,13 @@ class N8NDocumentationMCPServer {
|
||||
if (name.startsWith('validate_') && (errorMessage.includes('config') || errorMessage.includes('nodeType'))) {
|
||||
helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})';
|
||||
}
|
||||
try {
|
||||
const argDiag = processedArgs && typeof processedArgs === 'object'
|
||||
? Object.entries(processedArgs).map(([k, v]) => `${k}: ${typeof v}`).join(', ')
|
||||
: `args type: ${typeof processedArgs}`;
|
||||
helpfulMessage += `\n\n[Diagnostic] Received arg types: {${argDiag}}`;
|
||||
}
|
||||
catch { }
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -795,6 +815,93 @@ class N8NDocumentationMCPServer {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
coerceStringifiedJsonParams(toolName, args) {
|
||||
if (!args || typeof args !== 'object')
|
||||
return args;
|
||||
const allTools = [...tools_1.n8nDocumentationToolsFinal, ...tools_n8n_manager_1.n8nManagementTools];
|
||||
const tool = allTools.find(t => t.name === toolName);
|
||||
if (!tool?.inputSchema?.properties)
|
||||
return args;
|
||||
const properties = tool.inputSchema.properties;
|
||||
const coerced = { ...args };
|
||||
let coercedAny = false;
|
||||
for (const [key, value] of Object.entries(coerced)) {
|
||||
if (value === undefined || value === null)
|
||||
continue;
|
||||
const propSchema = properties[key];
|
||||
if (!propSchema)
|
||||
continue;
|
||||
const expectedType = propSchema.type;
|
||||
if (!expectedType)
|
||||
continue;
|
||||
const actualType = typeof value;
|
||||
if (expectedType === 'string' && actualType === 'string')
|
||||
continue;
|
||||
if ((expectedType === 'number' || expectedType === 'integer') && actualType === 'number')
|
||||
continue;
|
||||
if (expectedType === 'boolean' && actualType === 'boolean')
|
||||
continue;
|
||||
if (expectedType === 'object' && actualType === 'object' && !Array.isArray(value))
|
||||
continue;
|
||||
if (expectedType === 'array' && Array.isArray(value))
|
||||
continue;
|
||||
if (actualType === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (expectedType === 'object' && trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
continue;
|
||||
}
|
||||
if (expectedType === 'array' && trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
continue;
|
||||
}
|
||||
if (expectedType === 'number' || expectedType === 'integer') {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num) && trimmed !== '') {
|
||||
coerced[key] = expectedType === 'integer' ? Math.trunc(num) : num;
|
||||
coercedAny = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (expectedType === 'boolean') {
|
||||
if (trimmed === 'true') {
|
||||
coerced[key] = true;
|
||||
coercedAny = true;
|
||||
}
|
||||
else if (trimmed === 'false') {
|
||||
coerced[key] = false;
|
||||
coercedAny = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (expectedType === 'string' && (actualType === 'number' || actualType === 'boolean')) {
|
||||
coerced[key] = String(value);
|
||||
coercedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (coercedAny) {
|
||||
logger_1.logger.warn(`Coerced mistyped params for tool "${toolName}"`, {
|
||||
original: Object.fromEntries(Object.entries(args).map(([k, v]) => [k, `${typeof v}: ${typeof v === 'string' ? v.substring(0, 80) : v}`])),
|
||||
});
|
||||
}
|
||||
return coerced;
|
||||
}
|
||||
async executeTool(name, args) {
|
||||
args = args || {};
|
||||
const disabledTools = this.getDisabledTools();
|
||||
|
||||
2
dist/mcp/server.js.map
vendored
2
dist/mcp/server.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.35.3",
|
||||
"version": "2.35.5",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -687,9 +687,23 @@ export class N8NDocumentationMCPServer {
|
||||
};
|
||||
}
|
||||
|
||||
// Safeguard: if the entire args object arrives as a JSON string, parse it.
|
||||
// Some MCP clients may serialize the arguments object itself.
|
||||
let processedArgs: Record<string, any> | undefined = args;
|
||||
if (typeof args === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(args as unknown as string);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
processedArgs = parsed;
|
||||
logger.warn(`Coerced stringified args object for tool "${name}"`);
|
||||
}
|
||||
} catch {
|
||||
logger.warn(`Tool "${name}" received string args that are not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for n8n's nested output bug
|
||||
// Check if args contains nested 'output' structure from n8n's memory corruption
|
||||
let processedArgs = args;
|
||||
if (args && typeof args === 'object' && 'output' in args) {
|
||||
try {
|
||||
const possibleNestedData = args.output;
|
||||
@@ -721,6 +735,12 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Workaround for Claude Desktop / Claude.ai MCP client bugs that
|
||||
// serialize parameters with wrong types. Coerces ALL mismatched types
|
||||
// (string↔object, string↔number, string↔boolean, etc.) using the
|
||||
// tool's inputSchema as the source of truth.
|
||||
processedArgs = this.coerceStringifiedJsonParams(name, processedArgs);
|
||||
|
||||
try {
|
||||
logger.debug(`Executing tool: ${name}`, { args: processedArgs });
|
||||
const startTime = Date.now();
|
||||
@@ -822,6 +842,14 @@ export class N8NDocumentationMCPServer {
|
||||
helpfulMessage += '\n\nFor validation tools:\n- nodeType should be a string (e.g., "nodes-base.webhook")\n- config should be an object (e.g., {})';
|
||||
}
|
||||
|
||||
// Include diagnostic info about received args to help debug client issues
|
||||
try {
|
||||
const argDiag = processedArgs && typeof processedArgs === 'object'
|
||||
? Object.entries(processedArgs).map(([k, v]) => `${k}: ${typeof v}`).join(', ')
|
||||
: `args type: ${typeof processedArgs}`;
|
||||
helpfulMessage += `\n\n[Diagnostic] Received arg types: {${argDiag}}`;
|
||||
} catch { /* ignore diagnostic errors */ }
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -1125,6 +1153,109 @@ export class N8NDocumentationMCPServer {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce mistyped parameters back to their expected types.
|
||||
* Workaround for Claude Desktop / Claude.ai MCP client bugs that serialize
|
||||
* parameters incorrectly (objects as strings, numbers as strings, etc.).
|
||||
*
|
||||
* Handles ALL type mismatches based on the tool's inputSchema:
|
||||
* string→object, string→array : JSON.parse
|
||||
* string→number, string→integer : Number()
|
||||
* string→boolean : "true"/"false" parsing
|
||||
* number→string, boolean→string : .toString()
|
||||
*/
|
||||
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 };
|
||||
let coercedAny = false;
|
||||
|
||||
for (const [key, value] of Object.entries(coerced)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const propSchema = (properties as any)[key];
|
||||
if (!propSchema) continue;
|
||||
const expectedType = propSchema.type;
|
||||
if (!expectedType) continue;
|
||||
|
||||
const actualType = typeof value;
|
||||
|
||||
// Already correct type — skip
|
||||
if (expectedType === 'string' && actualType === 'string') continue;
|
||||
if ((expectedType === 'number' || expectedType === 'integer') && actualType === 'number') continue;
|
||||
if (expectedType === 'boolean' && actualType === 'boolean') continue;
|
||||
if (expectedType === 'object' && actualType === 'object' && !Array.isArray(value)) continue;
|
||||
if (expectedType === 'array' && Array.isArray(value)) continue;
|
||||
|
||||
// --- Coercion: string value → expected type ---
|
||||
if (actualType === 'string') {
|
||||
const trimmed = (value as string).trim();
|
||||
|
||||
if (expectedType === 'object' && trimmed.startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
} catch { /* keep original */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedType === 'array' && trimmed.startsWith('[')) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (Array.isArray(parsed)) {
|
||||
coerced[key] = parsed;
|
||||
coercedAny = true;
|
||||
}
|
||||
} catch { /* keep original */ }
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedType === 'number' || expectedType === 'integer') {
|
||||
const num = Number(trimmed);
|
||||
if (!isNaN(num) && trimmed !== '') {
|
||||
coerced[key] = expectedType === 'integer' ? Math.trunc(num) : num;
|
||||
coercedAny = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expectedType === 'boolean') {
|
||||
if (trimmed === 'true') { coerced[key] = true; coercedAny = true; }
|
||||
else if (trimmed === 'false') { coerced[key] = false; coercedAny = true; }
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Coercion: number/boolean value → expected string ---
|
||||
if (expectedType === 'string' && (actualType === 'number' || actualType === 'boolean')) {
|
||||
coerced[key] = String(value);
|
||||
coercedAny = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (coercedAny) {
|
||||
logger.warn(`Coerced mistyped params for tool "${toolName}"`, {
|
||||
original: Object.fromEntries(
|
||||
Object.entries(args).map(([k, v]) => [k, `${typeof v}: ${typeof v === 'string' ? v.substring(0, 80) : v}`])
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return coerced;
|
||||
}
|
||||
|
||||
async executeTool(name: string, args: any): Promise<any> {
|
||||
// Ensure args is an object and validate it
|
||||
args = args || {};
|
||||
|
||||
300
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
300
tests/unit/mcp/coerce-stringified-params.test.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
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('Number coercion', () => {
|
||||
it('should coerce string to number for search_nodes limit', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
limit: '10'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.query).toBe('webhook');
|
||||
});
|
||||
|
||||
it('should coerce string to number for n8n_executions limit', () => {
|
||||
const args = {
|
||||
action: 'list',
|
||||
limit: '50'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_executions', args);
|
||||
expect(result.limit).toBe(50);
|
||||
});
|
||||
|
||||
it('should not coerce non-numeric string to number', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
limit: 'abc'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.limit).toBe('abc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Boolean coercion', () => {
|
||||
it('should coerce "true" string to boolean', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
includeExamples: 'true'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.includeExamples).toBe(true);
|
||||
});
|
||||
|
||||
it('should coerce "false" string to boolean', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
includeExamples: 'false'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.includeExamples).toBe(false);
|
||||
});
|
||||
|
||||
it('should not coerce non-boolean string to boolean', () => {
|
||||
const args = {
|
||||
query: 'webhook',
|
||||
includeExamples: 'yes'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('search_nodes', args);
|
||||
expect(result.includeExamples).toBe('yes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Number-to-string coercion', () => {
|
||||
it('should coerce number to string for n8n_get_workflow id', () => {
|
||||
const args = {
|
||||
id: 123,
|
||||
mode: 'minimal'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_get_workflow', args);
|
||||
expect(result.id).toBe('123');
|
||||
expect(result.mode).toBe('minimal');
|
||||
});
|
||||
|
||||
it('should coerce boolean to string when string expected', () => {
|
||||
const args = {
|
||||
id: true
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('n8n_get_workflow', args);
|
||||
expect(result.id).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('should handle mixed type mismatches from Claude Desktop', () => {
|
||||
// Simulate Claude Desktop sending object params as strings
|
||||
const args = {
|
||||
nodeType: 'nodes-base.httpRequest',
|
||||
config: '{"method":"GET","url":"https://example.com"}',
|
||||
mode: 'full',
|
||||
profile: 'ai-friendly'
|
||||
};
|
||||
const result = server.testCoerceStringifiedJsonParams('validate_node', args);
|
||||
expect(result.config).toEqual({ method: 'GET', url: 'https://example.com' });
|
||||
expect(result.nodeType).toBe('nodes-base.httpRequest');
|
||||
expect(result.mode).toBe('full');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user