mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-04-01 07:03:08 +00:00
feat: add includeOperations to search_nodes and patterns mode to search_templates
- Add `includeOperations` flag to search_nodes that returns resource/operation trees per result (e.g., Slack: 7 resources, 44 operations). Saves a get_node round-trip when building workflows. - Add `searchMode: "patterns"` to search_templates — lightweight workflow pattern summaries mined from 2,700+ templates (node frequency, co-occurrence, connection chains across 10 task categories). - Fix operations extraction: use filter() instead of find() to capture ALL operation properties, each mapped to its resource via displayOptions. - Fix FTS-to-LIKE fallback silently dropping search options. - Add mine-workflow-patterns.ts script (npm run mine:patterns). - Restore 584 community nodes in database after rebuild. Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
ListResourcesRequestSchema,
|
||||
ReadResourceRequestSchema,
|
||||
} from '@modelcontextprotocol/sdk/types.js';
|
||||
import { existsSync, promises as fs } from 'fs';
|
||||
import { existsSync, readFileSync, promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { n8nDocumentationToolsFinal } from './tools';
|
||||
import { UIAppRegistry } from './ui';
|
||||
@@ -1310,6 +1310,7 @@ export class N8NDocumentationMCPServer {
|
||||
return this.searchNodes(args.query, limit, {
|
||||
mode: args.mode,
|
||||
includeExamples: args.includeExamples,
|
||||
includeOperations: args.includeOperations,
|
||||
source: args.source
|
||||
});
|
||||
case 'get_node':
|
||||
@@ -1414,6 +1415,8 @@ export class N8NDocumentationMCPServer {
|
||||
requiredService: args.requiredService,
|
||||
targetAudience: args.targetAudience
|
||||
}, searchLimit, searchOffset);
|
||||
case 'patterns':
|
||||
return this.getWorkflowPatterns(args.task as string | undefined, searchLimit);
|
||||
case 'keyword':
|
||||
default:
|
||||
if (!args.query) {
|
||||
@@ -1681,6 +1684,7 @@ export class N8NDocumentationMCPServer {
|
||||
mode?: 'OR' | 'AND' | 'FUZZY';
|
||||
includeSource?: boolean;
|
||||
includeExamples?: boolean;
|
||||
includeOperations?: boolean;
|
||||
source?: 'all' | 'core' | 'community' | 'verified';
|
||||
}
|
||||
): Promise<any> {
|
||||
@@ -1723,6 +1727,7 @@ export class N8NDocumentationMCPServer {
|
||||
options?: {
|
||||
includeSource?: boolean;
|
||||
includeExamples?: boolean;
|
||||
includeOperations?: boolean;
|
||||
source?: 'all' | 'core' | 'community' | 'verified';
|
||||
}
|
||||
): Promise<any> {
|
||||
@@ -1736,7 +1741,7 @@ export class N8NDocumentationMCPServer {
|
||||
|
||||
// For FUZZY mode, use LIKE search with typo patterns
|
||||
if (mode === 'FUZZY') {
|
||||
return this.searchNodesFuzzy(cleanedQuery, limit);
|
||||
return this.searchNodesFuzzy(cleanedQuery, limit, { includeOperations: options?.includeOperations });
|
||||
}
|
||||
|
||||
let ftsQuery: string;
|
||||
@@ -1827,7 +1832,7 @@ export class N8NDocumentationMCPServer {
|
||||
if (cleanedQuery.toLowerCase().includes('http') && !hasHttpRequest) {
|
||||
// FTS missed HTTP Request, fall back to LIKE search
|
||||
logger.debug('FTS missed HTTP Request node, augmenting with LIKE search');
|
||||
return this.searchNodesLIKE(query, limit);
|
||||
return this.searchNodesLIKE(query, limit, options);
|
||||
}
|
||||
|
||||
const result: any = {
|
||||
@@ -1855,6 +1860,14 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Add operations tree if requested
|
||||
if (options?.includeOperations) {
|
||||
const opsTree = this.buildOperationsTree(node.operations);
|
||||
if (opsTree) {
|
||||
nodeResult.operationsTree = opsTree;
|
||||
}
|
||||
}
|
||||
|
||||
return nodeResult;
|
||||
}),
|
||||
totalCount: scoredNodes.length
|
||||
@@ -1922,7 +1935,13 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
private async searchNodesFuzzy(query: string, limit: number): Promise<any> {
|
||||
private async searchNodesFuzzy(
|
||||
query: string,
|
||||
limit: number,
|
||||
options?: {
|
||||
includeOperations?: boolean;
|
||||
}
|
||||
): Promise<any> {
|
||||
if (!this.db) throw new Error('Database not initialized');
|
||||
|
||||
// Split into words for fuzzy matching
|
||||
@@ -1963,14 +1982,26 @@ export class N8NDocumentationMCPServer {
|
||||
return {
|
||||
query,
|
||||
mode: 'FUZZY',
|
||||
results: matchingNodes.map(node => ({
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
})),
|
||||
results: matchingNodes.map(node => {
|
||||
const nodeResult: any = {
|
||||
nodeType: node.node_type,
|
||||
workflowNodeType: getWorkflowNodeType(node.package_name, node.node_type),
|
||||
displayName: node.display_name,
|
||||
description: node.description,
|
||||
category: node.category,
|
||||
package: node.package_name
|
||||
};
|
||||
|
||||
// Add operations tree if requested
|
||||
if (options?.includeOperations) {
|
||||
const opsTree = this.buildOperationsTree(node.operations);
|
||||
if (opsTree) {
|
||||
nodeResult.operationsTree = opsTree;
|
||||
}
|
||||
}
|
||||
|
||||
return nodeResult;
|
||||
}),
|
||||
totalCount: matchingNodes.length
|
||||
};
|
||||
}
|
||||
@@ -2075,6 +2106,7 @@ export class N8NDocumentationMCPServer {
|
||||
options?: {
|
||||
includeSource?: boolean;
|
||||
includeExamples?: boolean;
|
||||
includeOperations?: boolean;
|
||||
source?: 'all' | 'core' | 'community' | 'verified';
|
||||
}
|
||||
): Promise<any> {
|
||||
@@ -2134,6 +2166,14 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Add operations tree if requested
|
||||
if (options?.includeOperations) {
|
||||
const opsTree = this.buildOperationsTree(node.operations);
|
||||
if (opsTree) {
|
||||
nodeResult.operationsTree = opsTree;
|
||||
}
|
||||
}
|
||||
|
||||
return nodeResult;
|
||||
}),
|
||||
totalCount: rankedNodes.length
|
||||
@@ -2220,6 +2260,14 @@ export class N8NDocumentationMCPServer {
|
||||
}
|
||||
}
|
||||
|
||||
// Add operations tree if requested
|
||||
if (options?.includeOperations) {
|
||||
const opsTree = this.buildOperationsTree(node.operations);
|
||||
if (opsTree) {
|
||||
nodeResult.operationsTree = opsTree;
|
||||
}
|
||||
}
|
||||
|
||||
return nodeResult;
|
||||
}),
|
||||
totalCount: rankedNodes.length
|
||||
@@ -2590,6 +2638,51 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw operations data and group by resource into a compact tree.
|
||||
* Returns undefined when there are no operations (e.g. trigger nodes, Code node).
|
||||
*/
|
||||
private buildOperationsTree(operationsRaw: string | any[] | null | undefined): Array<{resource: string, operations: string[]}> | undefined {
|
||||
if (!operationsRaw) return undefined;
|
||||
|
||||
let ops: any[];
|
||||
if (typeof operationsRaw === 'string') {
|
||||
try {
|
||||
ops = JSON.parse(operationsRaw);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
} else if (Array.isArray(operationsRaw)) {
|
||||
ops = operationsRaw;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!Array.isArray(ops) || ops.length === 0) return undefined;
|
||||
|
||||
// Group by resource
|
||||
const byResource = new Map<string, string[]>();
|
||||
for (const op of ops) {
|
||||
const resource = op.resource || 'default';
|
||||
const opName = op.name || op.operation;
|
||||
if (!opName) continue;
|
||||
if (!byResource.has(resource)) {
|
||||
byResource.set(resource, []);
|
||||
}
|
||||
const list = byResource.get(resource)!;
|
||||
if (!list.includes(opName)) {
|
||||
list.push(opName);
|
||||
}
|
||||
}
|
||||
|
||||
if (byResource.size === 0) return undefined;
|
||||
|
||||
return Array.from(byResource.entries()).map(([resource, operations]) => ({
|
||||
resource,
|
||||
operations
|
||||
}));
|
||||
}
|
||||
|
||||
private async getNodeEssentials(nodeType: string, includeExamples?: boolean): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
@@ -3787,6 +3880,65 @@ Full documentation is being prepared. For now, use get_node_essentials for confi
|
||||
};
|
||||
}
|
||||
|
||||
private workflowPatternsCache: {
|
||||
generatedAt: string;
|
||||
templateCount: number;
|
||||
categories: Record<string, {
|
||||
templateCount: number;
|
||||
pattern: string;
|
||||
nodes?: Array<{ type: string; frequency: number; role: string; displayName: string }>;
|
||||
commonChains?: Array<{ chain: string[]; count: number; frequency: number }>;
|
||||
}>;
|
||||
} | null = null;
|
||||
|
||||
private getWorkflowPatterns(category?: string, limit: number = 10): any {
|
||||
// Load patterns file (cached after first load)
|
||||
if (!this.workflowPatternsCache) {
|
||||
try {
|
||||
const patternsPath = path.join(__dirname, '..', '..', 'data', 'workflow-patterns.json');
|
||||
if (existsSync(patternsPath)) {
|
||||
this.workflowPatternsCache = JSON.parse(readFileSync(patternsPath, 'utf-8'));
|
||||
} else {
|
||||
return { error: 'Workflow patterns not generated yet. Run: npm run mine:patterns' };
|
||||
}
|
||||
} catch (e) {
|
||||
return { error: `Failed to load workflow patterns: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
const patterns = this.workflowPatternsCache!;
|
||||
|
||||
if (category) {
|
||||
// Return specific category pattern data
|
||||
const categoryData = patterns.categories[category];
|
||||
if (!categoryData) {
|
||||
const available = Object.keys(patterns.categories);
|
||||
return { error: `Unknown category "${category}". Available: ${available.join(', ')}` };
|
||||
}
|
||||
return {
|
||||
category,
|
||||
...categoryData,
|
||||
nodes: categoryData.nodes?.slice(0, limit),
|
||||
commonChains: categoryData.commonChains?.slice(0, limit),
|
||||
};
|
||||
}
|
||||
|
||||
// Return overview of all categories
|
||||
const overview = Object.entries(patterns.categories).map(([name, data]) => ({
|
||||
category: name,
|
||||
templateCount: data.templateCount,
|
||||
pattern: data.pattern,
|
||||
topNodes: data.nodes?.slice(0, 5).map(n => n.displayName || n.type),
|
||||
}));
|
||||
|
||||
return {
|
||||
templateCount: patterns.templateCount,
|
||||
generatedAt: patterns.generatedAt,
|
||||
categories: overview,
|
||||
tip: 'Use search_templates({searchMode: "patterns", task: "category_name"}) for full pattern data with nodes, chains, and tips.',
|
||||
};
|
||||
}
|
||||
|
||||
private async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise<any> {
|
||||
await this.ensureInitialized();
|
||||
if (!this.templateService) throw new Error('Template service not initialized');
|
||||
|
||||
@@ -5,7 +5,7 @@ export const searchNodesDoc: ToolDocumentation = {
|
||||
category: 'discovery',
|
||||
essentials: {
|
||||
description: 'Text search across node names and descriptions. Returns most relevant nodes first, with frequently-used nodes (HTTP Request, Webhook, Set, Code, Slack) prioritized in results. Searches all 800+ nodes including 300+ verified community nodes.',
|
||||
keyParameters: ['query', 'mode', 'limit', 'source', 'includeExamples'],
|
||||
keyParameters: ['query', 'mode', 'limit', 'source', 'includeExamples', 'includeOperations'],
|
||||
example: 'search_nodes({query: "webhook"})',
|
||||
performance: '<20ms even for complex queries',
|
||||
tips: [
|
||||
@@ -14,7 +14,8 @@ export const searchNodesDoc: ToolDocumentation = {
|
||||
'FUZZY mode: Handles typos and spelling errors',
|
||||
'Use quotes for exact phrases: "google sheets"',
|
||||
'Use source="community" to search only community nodes',
|
||||
'Use source="verified" for verified community nodes only'
|
||||
'Use source="verified" for verified community nodes only',
|
||||
'Use includeOperations=true to get resource/operation trees without a separate get_node call'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
@@ -24,7 +25,8 @@ export const searchNodesDoc: ToolDocumentation = {
|
||||
limit: { type: 'number', description: 'Maximum results to return. Default: 20, Max: 100', required: false },
|
||||
mode: { type: 'string', description: 'Search mode: "OR" (any word matches, default), "AND" (all words required), "FUZZY" (typo-tolerant)', required: false },
|
||||
source: { type: 'string', description: 'Filter by node source: "all" (default, everything), "core" (n8n base nodes only), "community" (community nodes only), "verified" (verified community nodes only)', required: false },
|
||||
includeExamples: { type: 'boolean', description: 'Include top 2 real-world configuration examples from popular templates for each node. Default: false. Adds ~200-400 tokens per node.', required: false }
|
||||
includeExamples: { type: 'boolean', description: 'Include top 2 real-world configuration examples from popular templates for each node. Default: false. Adds ~200-400 tokens per node.', required: false },
|
||||
includeOperations: { type: 'boolean', description: 'Include resource/operation tree per node. Default: false. Adds ~100-300 tokens per result but saves a get_node round-trip.', required: false }
|
||||
},
|
||||
returns: 'Array of node objects sorted by relevance score. Each object contains: nodeType, displayName, description, category, relevance score. For community nodes, also includes: isCommunity (boolean), isVerified (boolean), authorName (string), npmDownloads (number). Common nodes appear first when relevance is similar.',
|
||||
examples: [
|
||||
@@ -37,7 +39,8 @@ export const searchNodesDoc: ToolDocumentation = {
|
||||
'search_nodes({query: "scraping", source: "community"}) - Find community scraping nodes',
|
||||
'search_nodes({query: "pdf", source: "verified"}) - Find verified community PDF nodes',
|
||||
'search_nodes({query: "brightdata"}) - Find BrightData community node',
|
||||
'search_nodes({query: "slack", includeExamples: true}) - Get Slack with template examples'
|
||||
'search_nodes({query: "slack", includeExamples: true}) - Get Slack with template examples',
|
||||
'search_nodes({query: "slack", includeOperations: true}) - Get Slack with resource/operation tree'
|
||||
],
|
||||
useCases: [
|
||||
'Finding nodes when you know partial names',
|
||||
|
||||
@@ -57,6 +57,11 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
description: 'Include top 2 real-world configuration examples from popular templates (default: false)',
|
||||
default: false,
|
||||
},
|
||||
includeOperations: {
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
description: 'Include resource/operation tree per node. Adds ~100-300 tokens per result but saves a get_node round-trip.',
|
||||
},
|
||||
source: {
|
||||
type: 'string',
|
||||
enum: ['all', 'core', 'community', 'verified'],
|
||||
@@ -242,14 +247,14 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: 'search_templates',
|
||||
description: `Search templates with multiple modes. Use searchMode='keyword' for text search, 'by_nodes' to find templates using specific nodes, 'by_task' for curated task-based templates, 'by_metadata' for filtering by complexity/setup time/services.`,
|
||||
description: `Search templates with multiple modes. Use searchMode='keyword' for text search, 'by_nodes' to find templates using specific nodes, 'by_task' for curated task-based templates, 'by_metadata' for filtering by complexity/setup time/services, 'patterns' for lightweight workflow pattern summaries mined from 2700+ templates.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
searchMode: {
|
||||
type: 'string',
|
||||
enum: ['keyword', 'by_nodes', 'by_task', 'by_metadata'],
|
||||
description: 'Search mode. keyword=text search (default), by_nodes=find by node types, by_task=curated task templates, by_metadata=filter by complexity/services',
|
||||
enum: ['keyword', 'by_nodes', 'by_task', 'by_metadata', 'patterns'],
|
||||
description: 'Search mode. keyword=text search (default), by_nodes=find by node types, by_task=curated task templates, by_metadata=filter by complexity/services, patterns=lightweight workflow pattern summaries',
|
||||
default: 'keyword',
|
||||
},
|
||||
// For searchMode='keyword'
|
||||
@@ -271,7 +276,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
items: { type: 'string' },
|
||||
description: 'For searchMode=by_nodes: array of node types (e.g., ["n8n-nodes-base.httpRequest", "n8n-nodes-base.slack"])',
|
||||
},
|
||||
// For searchMode='by_task'
|
||||
// For searchMode='by_task' or 'patterns'
|
||||
task: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
@@ -286,7 +291,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [
|
||||
'api_integration',
|
||||
'database_operations'
|
||||
],
|
||||
description: 'For searchMode=by_task: the type of task',
|
||||
description: 'For searchMode=by_task: the type of task. For searchMode=patterns: optional category filter (omit for overview of all categories).',
|
||||
},
|
||||
// For searchMode='by_metadata'
|
||||
category: {
|
||||
|
||||
Reference in New Issue
Block a user