@pagelines/n8n-mcp 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +40 -24
- package/dist/index.js +122 -18
- package/dist/n8n-client.d.ts +3 -2
- package/dist/n8n-client.js +9 -1
- package/dist/n8n-client.test.js +111 -0
- package/dist/response-format.d.ts +84 -0
- package/dist/response-format.js +183 -0
- package/dist/response-format.test.d.ts +1 -0
- package/dist/response-format.test.js +291 -0
- package/dist/tools.js +67 -3
- package/dist/types.d.ts +27 -0
- package/dist/validators.d.ts +9 -1
- package/dist/validators.js +87 -2
- package/dist/validators.test.js +83 -4
- package/docs/best-practices.md +15 -10
- package/docs/node-config.md +3 -1
- package/logo.png +0 -0
- package/package.json +1 -1
- package/plans/architecture.md +69 -26
- package/src/index.ts +159 -20
- package/src/n8n-client.test.ts +135 -0
- package/src/n8n-client.ts +13 -2
- package/src/response-format.test.ts +355 -0
- package/src/response-format.ts +278 -0
- package/src/tools.ts +68 -3
- package/src/types.ts +33 -0
- package/src/validators.test.ts +101 -4
- package/src/validators.ts +112 -3
package/src/tools.ts
CHANGED
|
@@ -29,7 +29,7 @@ export const tools: Tool[] = [
|
|
|
29
29
|
|
|
30
30
|
{
|
|
31
31
|
name: 'workflow_get',
|
|
32
|
-
description: 'Get a workflow by ID.
|
|
32
|
+
description: 'Get a workflow by ID. Use format=summary for minimal response, compact (default) for nodes without parameters, full for everything.',
|
|
33
33
|
inputSchema: {
|
|
34
34
|
type: 'object',
|
|
35
35
|
properties: {
|
|
@@ -37,6 +37,11 @@ export const tools: Tool[] = [
|
|
|
37
37
|
type: 'string',
|
|
38
38
|
description: 'Workflow ID',
|
|
39
39
|
},
|
|
40
|
+
format: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
enum: ['summary', 'compact', 'full'],
|
|
43
|
+
description: 'Response detail level. summary=minimal, compact=nodes without params (default), full=everything',
|
|
44
|
+
},
|
|
40
45
|
},
|
|
41
46
|
required: ['id'],
|
|
42
47
|
},
|
|
@@ -44,7 +49,7 @@ export const tools: Tool[] = [
|
|
|
44
49
|
|
|
45
50
|
{
|
|
46
51
|
name: 'workflow_create',
|
|
47
|
-
description: 'Create a new workflow. Returns the created workflow.',
|
|
52
|
+
description: 'Create a new workflow. Returns the created workflow with validation.',
|
|
48
53
|
inputSchema: {
|
|
49
54
|
type: 'object',
|
|
50
55
|
properties: {
|
|
@@ -81,6 +86,11 @@ export const tools: Tool[] = [
|
|
|
81
86
|
type: 'object',
|
|
82
87
|
description: 'Workflow settings',
|
|
83
88
|
},
|
|
89
|
+
format: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
enum: ['summary', 'compact', 'full'],
|
|
92
|
+
description: 'Response detail level (default: compact)',
|
|
93
|
+
},
|
|
84
94
|
},
|
|
85
95
|
required: ['name', 'nodes', 'connections'],
|
|
86
96
|
},
|
|
@@ -138,6 +148,11 @@ Example: { "operations": [{ "type": "updateNode", "nodeName": "my_node", "proper
|
|
|
138
148
|
required: ['type'],
|
|
139
149
|
},
|
|
140
150
|
},
|
|
151
|
+
format: {
|
|
152
|
+
type: 'string',
|
|
153
|
+
enum: ['summary', 'compact', 'full'],
|
|
154
|
+
description: 'Response detail level (default: compact)',
|
|
155
|
+
},
|
|
141
156
|
},
|
|
142
157
|
required: ['id', 'operations'],
|
|
143
158
|
},
|
|
@@ -229,13 +244,18 @@ Example: { "operations": [{ "type": "updateNode", "nodeName": "my_node", "proper
|
|
|
229
244
|
type: 'number',
|
|
230
245
|
description: 'Max results (default 20)',
|
|
231
246
|
},
|
|
247
|
+
format: {
|
|
248
|
+
type: 'string',
|
|
249
|
+
enum: ['summary', 'compact', 'full'],
|
|
250
|
+
description: 'Response detail level (default: compact)',
|
|
251
|
+
},
|
|
232
252
|
},
|
|
233
253
|
},
|
|
234
254
|
},
|
|
235
255
|
|
|
236
256
|
{
|
|
237
257
|
name: 'execution_get',
|
|
238
|
-
description: 'Get execution details
|
|
258
|
+
description: 'Get execution details. Use format=summary for status only, compact (default) omits runData, full for everything including runData.',
|
|
239
259
|
inputSchema: {
|
|
240
260
|
type: 'object',
|
|
241
261
|
properties: {
|
|
@@ -243,6 +263,11 @@ Example: { "operations": [{ "type": "updateNode", "nodeName": "my_node", "proper
|
|
|
243
263
|
type: 'string',
|
|
244
264
|
description: 'Execution ID',
|
|
245
265
|
},
|
|
266
|
+
format: {
|
|
267
|
+
type: 'string',
|
|
268
|
+
enum: ['summary', 'compact', 'full'],
|
|
269
|
+
description: 'Response detail level. summary=status only, compact=no runData (default), full=everything',
|
|
270
|
+
},
|
|
246
271
|
},
|
|
247
272
|
required: ['id'],
|
|
248
273
|
},
|
|
@@ -315,6 +340,36 @@ Returns the fixed workflow and list of changes made.`,
|
|
|
315
340
|
},
|
|
316
341
|
},
|
|
317
342
|
|
|
343
|
+
// ─────────────────────────────────────────────────────────────
|
|
344
|
+
// Node Discovery
|
|
345
|
+
// ─────────────────────────────────────────────────────────────
|
|
346
|
+
{
|
|
347
|
+
name: 'node_types_list',
|
|
348
|
+
description: `List available n8n node types. Use this to discover valid node types BEFORE creating workflows.
|
|
349
|
+
|
|
350
|
+
Returns: type name, display name, description, category, and version for each node.
|
|
351
|
+
Use the search parameter to filter by keyword (searches type name, display name, and description).
|
|
352
|
+
|
|
353
|
+
IMPORTANT: Always check node types exist before using them in workflow_create or workflow_update.`,
|
|
354
|
+
inputSchema: {
|
|
355
|
+
type: 'object',
|
|
356
|
+
properties: {
|
|
357
|
+
search: {
|
|
358
|
+
type: 'string',
|
|
359
|
+
description: 'Filter nodes by keyword (searches name, type, description)',
|
|
360
|
+
},
|
|
361
|
+
category: {
|
|
362
|
+
type: 'string',
|
|
363
|
+
description: 'Filter by category (e.g., "Core Nodes", "Flow", "AI")',
|
|
364
|
+
},
|
|
365
|
+
limit: {
|
|
366
|
+
type: 'number',
|
|
367
|
+
description: 'Max results (default 50)',
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
|
|
318
373
|
// ─────────────────────────────────────────────────────────────
|
|
319
374
|
// Version Control
|
|
320
375
|
// ─────────────────────────────────────────────────────────────
|
|
@@ -347,6 +402,11 @@ Returns the fixed workflow and list of changes made.`,
|
|
|
347
402
|
type: 'string',
|
|
348
403
|
description: 'Version ID (from version_list)',
|
|
349
404
|
},
|
|
405
|
+
format: {
|
|
406
|
+
type: 'string',
|
|
407
|
+
enum: ['summary', 'compact', 'full'],
|
|
408
|
+
description: 'Response detail level (default: compact)',
|
|
409
|
+
},
|
|
350
410
|
},
|
|
351
411
|
required: ['workflowId', 'versionId'],
|
|
352
412
|
},
|
|
@@ -385,6 +445,11 @@ Returns the fixed workflow and list of changes made.`,
|
|
|
385
445
|
type: 'string',
|
|
386
446
|
description: 'Version ID to restore',
|
|
387
447
|
},
|
|
448
|
+
format: {
|
|
449
|
+
type: 'string',
|
|
450
|
+
enum: ['summary', 'compact', 'full'],
|
|
451
|
+
description: 'Response detail level (default: compact)',
|
|
452
|
+
},
|
|
388
453
|
},
|
|
389
454
|
required: ['workflowId', 'versionId'],
|
|
390
455
|
},
|
package/src/types.ts
CHANGED
|
@@ -105,3 +105,36 @@ export interface N8nListResponse<T> {
|
|
|
105
105
|
data: T[];
|
|
106
106
|
nextCursor?: string;
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
// Node type information from n8n API (GET /api/v1/nodes)
|
|
110
|
+
export interface N8nNodeType {
|
|
111
|
+
name: string; // e.g., "n8n-nodes-base.webhook"
|
|
112
|
+
displayName: string; // e.g., "Webhook"
|
|
113
|
+
description: string;
|
|
114
|
+
group: string[]; // e.g., ["trigger"]
|
|
115
|
+
version: number;
|
|
116
|
+
defaults?: {
|
|
117
|
+
name: string;
|
|
118
|
+
};
|
|
119
|
+
codex?: {
|
|
120
|
+
categories?: string[];
|
|
121
|
+
alias?: string[];
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Simplified node type for tool responses (reduced tokens)
|
|
126
|
+
export interface N8nNodeTypeSummary {
|
|
127
|
+
type: string; // Full type name
|
|
128
|
+
name: string; // Display name
|
|
129
|
+
description: string;
|
|
130
|
+
category: string;
|
|
131
|
+
version: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Node type validation error
|
|
135
|
+
export interface NodeTypeValidationError {
|
|
136
|
+
nodeType: string;
|
|
137
|
+
nodeName: string;
|
|
138
|
+
message: string;
|
|
139
|
+
suggestions?: string[];
|
|
140
|
+
}
|
package/src/validators.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { validateWorkflow, validatePartialUpdate } from './validators.js';
|
|
2
|
+
import { validateWorkflow, validatePartialUpdate, validateNodeTypes } from './validators.js';
|
|
3
3
|
import type { N8nWorkflow } from './types.js';
|
|
4
4
|
|
|
5
5
|
const createWorkflow = (overrides: Partial<N8nWorkflow> = {}): N8nWorkflow => ({
|
|
@@ -68,7 +68,7 @@ describe('validateWorkflow', () => {
|
|
|
68
68
|
);
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it('
|
|
71
|
+
it('warns on hardcoded secrets', () => {
|
|
72
72
|
const workflow = createWorkflow({
|
|
73
73
|
nodes: [
|
|
74
74
|
{
|
|
@@ -83,11 +83,10 @@ describe('validateWorkflow', () => {
|
|
|
83
83
|
});
|
|
84
84
|
|
|
85
85
|
const result = validateWorkflow(workflow);
|
|
86
|
-
expect(result.valid).toBe(false);
|
|
87
86
|
expect(result.warnings).toContainEqual(
|
|
88
87
|
expect.objectContaining({
|
|
89
88
|
rule: 'no_hardcoded_secrets',
|
|
90
|
-
severity: '
|
|
89
|
+
severity: 'info',
|
|
91
90
|
})
|
|
92
91
|
);
|
|
93
92
|
});
|
|
@@ -275,3 +274,101 @@ describe('validatePartialUpdate', () => {
|
|
|
275
274
|
expect(warnings).toHaveLength(0);
|
|
276
275
|
});
|
|
277
276
|
});
|
|
277
|
+
|
|
278
|
+
describe('validateNodeTypes', () => {
|
|
279
|
+
const availableTypes = new Set([
|
|
280
|
+
'n8n-nodes-base.webhook',
|
|
281
|
+
'n8n-nodes-base.set',
|
|
282
|
+
'n8n-nodes-base.code',
|
|
283
|
+
'n8n-nodes-base.httpRequest',
|
|
284
|
+
'@n8n/n8n-nodes-langchain.agent',
|
|
285
|
+
'@n8n/n8n-nodes-langchain.chatTrigger',
|
|
286
|
+
]);
|
|
287
|
+
|
|
288
|
+
it('passes when all node types are valid', () => {
|
|
289
|
+
const nodes = [
|
|
290
|
+
{ name: 'webhook_trigger', type: 'n8n-nodes-base.webhook' },
|
|
291
|
+
{ name: 'set_data', type: 'n8n-nodes-base.set' },
|
|
292
|
+
{ name: 'ai_agent', type: '@n8n/n8n-nodes-langchain.agent' },
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
const errors = validateNodeTypes(nodes, availableTypes);
|
|
296
|
+
expect(errors).toHaveLength(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('returns error for invalid node type', () => {
|
|
300
|
+
const nodes = [
|
|
301
|
+
{ name: 'my_node', type: 'n8n-nodes-base.nonexistent' },
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
const errors = validateNodeTypes(nodes, availableTypes);
|
|
305
|
+
expect(errors).toHaveLength(1);
|
|
306
|
+
expect(errors[0]).toEqual(
|
|
307
|
+
expect.objectContaining({
|
|
308
|
+
nodeType: 'n8n-nodes-base.nonexistent',
|
|
309
|
+
nodeName: 'my_node',
|
|
310
|
+
})
|
|
311
|
+
);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('returns errors for multiple invalid node types', () => {
|
|
315
|
+
const nodes = [
|
|
316
|
+
{ name: 'valid_node', type: 'n8n-nodes-base.webhook' },
|
|
317
|
+
{ name: 'invalid_one', type: 'n8n-nodes-base.fake' },
|
|
318
|
+
{ name: 'invalid_two', type: 'n8n-nodes-base.bogus' },
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
const errors = validateNodeTypes(nodes, availableTypes);
|
|
322
|
+
expect(errors).toHaveLength(2);
|
|
323
|
+
expect(errors.map((e) => e.nodeName)).toEqual(['invalid_one', 'invalid_two']);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('provides suggestions for typos', () => {
|
|
327
|
+
const nodes = [
|
|
328
|
+
{ name: 'trigger', type: 'n8n-nodes-base.webhok' }, // typo: webhok
|
|
329
|
+
];
|
|
330
|
+
|
|
331
|
+
const errors = validateNodeTypes(nodes, availableTypes);
|
|
332
|
+
expect(errors).toHaveLength(1);
|
|
333
|
+
expect(errors[0].suggestions).toContain('n8n-nodes-base.webhook');
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('provides suggestions for partial matches', () => {
|
|
337
|
+
const nodes = [
|
|
338
|
+
{ name: 'code_node', type: 'n8n-nodes-base.cod' }, // partial: cod
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
const errors = validateNodeTypes(nodes, availableTypes);
|
|
342
|
+
expect(errors).toHaveLength(1);
|
|
343
|
+
expect(errors[0].suggestions).toContain('n8n-nodes-base.code');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('returns empty suggestions when no matches found', () => {
|
|
347
|
+
const nodes = [
|
|
348
|
+
{ name: 'xyz_node', type: 'n8n-nodes-base.xyz123completely_random' },
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
const errors = validateNodeTypes(nodes, availableTypes);
|
|
352
|
+
expect(errors).toHaveLength(1);
|
|
353
|
+
expect(errors[0].suggestions).toHaveLength(0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('limits suggestions to 3', () => {
|
|
357
|
+
// Create a set with many similar types
|
|
358
|
+
const manyTypes = new Set([
|
|
359
|
+
'n8n-nodes-base.httpRequest',
|
|
360
|
+
'n8n-nodes-base.httpRequestTool',
|
|
361
|
+
'n8n-nodes-base.httpRequestV1',
|
|
362
|
+
'n8n-nodes-base.httpRequestV2',
|
|
363
|
+
'n8n-nodes-base.httpRequestV3',
|
|
364
|
+
]);
|
|
365
|
+
|
|
366
|
+
const nodes = [
|
|
367
|
+
{ name: 'http', type: 'n8n-nodes-base.http' }, // should match multiple
|
|
368
|
+
];
|
|
369
|
+
|
|
370
|
+
const errors = validateNodeTypes(nodes, manyTypes);
|
|
371
|
+
expect(errors).toHaveLength(1);
|
|
372
|
+
expect(errors[0].suggestions!.length).toBeLessThanOrEqual(3);
|
|
373
|
+
});
|
|
374
|
+
});
|
package/src/validators.ts
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
* Enforces best practices from n8n-best-practices.md
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type {
|
|
7
|
+
N8nWorkflow,
|
|
8
|
+
N8nNode,
|
|
9
|
+
ValidationResult,
|
|
10
|
+
ValidationWarning,
|
|
11
|
+
NodeTypeValidationError,
|
|
12
|
+
} from './types.js';
|
|
7
13
|
|
|
8
14
|
export function validateWorkflow(workflow: N8nWorkflow): ValidationResult {
|
|
9
15
|
const warnings: ValidationWarning[] = [];
|
|
@@ -133,8 +139,8 @@ function checkForHardcodedSecrets(node: N8nNode, warnings: ValidationWarning[]):
|
|
|
133
139
|
warnings.push({
|
|
134
140
|
node: node.name,
|
|
135
141
|
rule: 'no_hardcoded_secrets',
|
|
136
|
-
message: `Node "${node.name}" may contain hardcoded secrets -
|
|
137
|
-
severity: '
|
|
142
|
+
message: `Node "${node.name}" may contain hardcoded secrets - consider using $env.VAR_NAME`,
|
|
143
|
+
severity: 'info',
|
|
138
144
|
});
|
|
139
145
|
break;
|
|
140
146
|
}
|
|
@@ -283,3 +289,106 @@ export function validatePartialUpdate(
|
|
|
283
289
|
|
|
284
290
|
return warnings;
|
|
285
291
|
}
|
|
292
|
+
|
|
293
|
+
// ─────────────────────────────────────────────────────────────
|
|
294
|
+
// Node Type Validation
|
|
295
|
+
// ─────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Validate that all node types in an array exist in the available types
|
|
299
|
+
* Returns errors for any invalid node types with suggestions
|
|
300
|
+
*/
|
|
301
|
+
export function validateNodeTypes(
|
|
302
|
+
nodes: Array<{ name: string; type: string }>,
|
|
303
|
+
availableTypes: Set<string>
|
|
304
|
+
): NodeTypeValidationError[] {
|
|
305
|
+
const errors: NodeTypeValidationError[] = [];
|
|
306
|
+
|
|
307
|
+
for (const node of nodes) {
|
|
308
|
+
if (!availableTypes.has(node.type)) {
|
|
309
|
+
errors.push({
|
|
310
|
+
nodeType: node.type,
|
|
311
|
+
nodeName: node.name,
|
|
312
|
+
message: `Invalid node type "${node.type}" for node "${node.name}"`,
|
|
313
|
+
suggestions: findSimilarTypes(node.type, availableTypes),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return errors;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Find similar node types for suggestions (fuzzy matching)
|
|
323
|
+
* Returns up to 3 suggestions
|
|
324
|
+
*/
|
|
325
|
+
function findSimilarTypes(invalidType: string, availableTypes: Set<string>): string[] {
|
|
326
|
+
const suggestions: string[] = [];
|
|
327
|
+
const searchTerm = invalidType.toLowerCase();
|
|
328
|
+
|
|
329
|
+
// Extract the last part after the dot (e.g., "webhook" from "n8n-nodes-base.webhook")
|
|
330
|
+
const typeParts = searchTerm.split('.');
|
|
331
|
+
const shortName = typeParts[typeParts.length - 1];
|
|
332
|
+
|
|
333
|
+
for (const validType of availableTypes) {
|
|
334
|
+
const validLower = validType.toLowerCase();
|
|
335
|
+
const validShortName = validLower.split('.').pop() || '';
|
|
336
|
+
|
|
337
|
+
// Check for partial matches (substring)
|
|
338
|
+
if (
|
|
339
|
+
validLower.includes(shortName) ||
|
|
340
|
+
shortName.includes(validShortName) ||
|
|
341
|
+
validShortName.includes(shortName)
|
|
342
|
+
) {
|
|
343
|
+
suggestions.push(validType);
|
|
344
|
+
if (suggestions.length >= 3) break;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Check for typos using Levenshtein distance
|
|
349
|
+
const distance = levenshteinDistance(shortName, validShortName);
|
|
350
|
+
const maxLen = Math.max(shortName.length, validShortName.length);
|
|
351
|
+
// Allow up to 2 character differences for short names, or 20% of length for longer ones
|
|
352
|
+
const threshold = Math.max(2, Math.floor(maxLen * 0.2));
|
|
353
|
+
if (distance <= threshold) {
|
|
354
|
+
suggestions.push(validType);
|
|
355
|
+
if (suggestions.length >= 3) break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return suggestions;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Calculate Levenshtein distance between two strings
|
|
364
|
+
*/
|
|
365
|
+
function levenshteinDistance(a: string, b: string): number {
|
|
366
|
+
if (a.length === 0) return b.length;
|
|
367
|
+
if (b.length === 0) return a.length;
|
|
368
|
+
|
|
369
|
+
const matrix: number[][] = [];
|
|
370
|
+
|
|
371
|
+
// Initialize first column
|
|
372
|
+
for (let i = 0; i <= a.length; i++) {
|
|
373
|
+
matrix[i] = [i];
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Initialize first row
|
|
377
|
+
for (let j = 0; j <= b.length; j++) {
|
|
378
|
+
matrix[0][j] = j;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Fill in the rest of the matrix
|
|
382
|
+
for (let i = 1; i <= a.length; i++) {
|
|
383
|
+
for (let j = 1; j <= b.length; j++) {
|
|
384
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
385
|
+
matrix[i][j] = Math.min(
|
|
386
|
+
matrix[i - 1][j] + 1, // deletion
|
|
387
|
+
matrix[i][j - 1] + 1, // insertion
|
|
388
|
+
matrix[i - 1][j - 1] + cost // substitution
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return matrix[a.length][b.length];
|
|
394
|
+
}
|