diff --git a/data/nodes.db b/data/nodes.db index 0838191..0a7a04d 100644 Binary files a/data/nodes.db and b/data/nodes.db differ diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 7ce3b25..41a36d6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -725,21 +725,32 @@ export class N8NDocumentationMCPServer { case 'get_node_as_tool_info': this.validateToolParams(name, args, ['nodeType']); return this.getNodeAsToolInfo(args.nodeType); + case 'list_templates': + // No required params + const listLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); + const listOffset = Math.max(Number(args.offset) || 0, 0); + const sortBy = args.sortBy || 'views'; + return this.listTemplates(listLimit, listOffset, sortBy); case 'list_node_templates': this.validateToolParams(name, args, ['nodeTypes']); - const templateLimit = args.limit !== undefined ? Number(args.limit) || 10 : 10; - return this.listNodeTemplates(args.nodeTypes, templateLimit); + const templateLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); + const templateOffset = Math.max(Number(args.offset) || 0, 0); + return this.listNodeTemplates(args.nodeTypes, templateLimit, templateOffset); case 'get_template': this.validateToolParams(name, args, ['templateId']); const templateId = Number(args.templateId); - return this.getTemplate(templateId); + const mode = args.mode || 'full'; + return this.getTemplate(templateId, mode); case 'search_templates': this.validateToolParams(name, args, ['query']); - const searchLimit = args.limit !== undefined ? Number(args.limit) || 20 : 20; - return this.searchTemplates(args.query, searchLimit); + const searchLimit = Math.min(Math.max(Number(args.limit) || 20, 1), 100); + const searchOffset = Math.max(Number(args.offset) || 0, 0); + return this.searchTemplates(args.query, searchLimit, searchOffset); case 'get_templates_for_task': this.validateToolParams(name, args, ['task']); - return this.getTemplatesForTask(args.task); + const taskLimit = Math.min(Math.max(Number(args.limit) || 10, 1), 100); + const taskOffset = Math.max(Number(args.offset) || 0, 0); + return this.getTemplatesForTask(args.task, taskLimit, taskOffset); case 'validate_workflow': this.validateToolParams(name, args, ['workflow']); return this.validateWorkflow(args.workflow, args.options); @@ -1594,8 +1605,19 @@ Full documentation is being prepared. For now, use get_node_essentials for confi GROUP BY package_name `).all() as any[]; + // Get template statistics + const templateStats = this.db!.prepare(` + SELECT + COUNT(*) as total_templates, + AVG(views) as avg_views, + MIN(views) as min_views, + MAX(views) as max_views + FROM templates + `).get() as any; + return { totalNodes: stats.total, + totalTemplates: templateStats.total_templates || 0, statistics: { aiTools: stats.ai_tools, triggers: stats.triggers, @@ -1604,6 +1626,12 @@ Full documentation is being prepared. For now, use get_node_essentials for confi documentationCoverage: Math.round((stats.with_docs / stats.total) * 100) + '%', uniquePackages: stats.packages, uniqueCategories: stats.categories, + templates: { + total: templateStats.total_templates || 0, + avgViews: Math.round(templateStats.avg_views || 0), + minViews: templateStats.min_views || 0, + maxViews: templateStats.max_views || 0 + } }, packageBreakdown: packages.map(pkg => ({ package: pkg.package_name, @@ -2300,76 +2328,95 @@ Full documentation is being prepared. For now, use get_node_essentials for confi } // Template-related methods - private async listNodeTemplates(nodeTypes: string[], limit: number = 10): Promise { + private async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const templates = await this.templateService.listNodeTemplates(nodeTypes, limit); + const result = await this.templateService.listTemplates(limit, offset, sortBy); - if (templates.length === 0) { + return { + ...result, + tip: result.items.length > 0 ? + `Use get_template(templateId) to get full workflow details. Total: ${result.total} templates available.` : + "No templates found. Run 'npm run fetch:templates' to update template database" + }; + } + + private async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise { + await this.ensureInitialized(); + if (!this.templateService) throw new Error('Template service not initialized'); + + const result = await this.templateService.listNodeTemplates(nodeTypes, limit, offset); + + if (result.items.length === 0 && offset === 0) { return { + ...result, message: `No templates found using nodes: ${nodeTypes.join(', ')}`, - tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database", - templates: [] + tip: "Try searching with more common nodes or run 'npm run fetch:templates' to update template database" }; } return { - templates, - count: templates.length, - tip: `Use get_template(templateId) to get the full workflow JSON for any template` + ...result, + tip: `Showing ${result.items.length} of ${result.total} templates. Use offset for pagination.` }; } - private async getTemplate(templateId: number): Promise { + private async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const template = await this.templateService.getTemplate(templateId); + const template = await this.templateService.getTemplate(templateId, mode); if (!template) { return { error: `Template ${templateId} not found`, - tip: "Use list_node_templates or search_templates to find available templates" + tip: "Use list_templates, list_node_templates or search_templates to find available templates" }; } + const usage = mode === 'nodes_only' ? "Node list for quick overview" : + mode === 'structure' ? "Workflow structure without full details" : + "Complete workflow JSON ready to import into n8n"; + return { + mode, template, - usage: "Import this workflow JSON directly into n8n or use it as a reference for building workflows" + usage }; } - private async searchTemplates(query: string, limit: number = 20): Promise { + private async searchTemplates(query: string, limit: number = 20, offset: number = 0): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const templates = await this.templateService.searchTemplates(query, limit); + const result = await this.templateService.searchTemplates(query, limit, offset); - if (templates.length === 0) { + if (result.items.length === 0 && offset === 0) { return { + ...result, message: `No templates found matching: "${query}"`, - tip: "Try different keywords or run 'npm run fetch:templates' to update template database", - templates: [] + tip: "Try different keywords or run 'npm run fetch:templates' to update template database" }; } return { - templates, - count: templates.length, - query + ...result, + query, + tip: `Found ${result.total} templates matching "${query}". Showing ${result.items.length}.` }; } - private async getTemplatesForTask(task: string): Promise { + private async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise { await this.ensureInitialized(); if (!this.templateService) throw new Error('Template service not initialized'); - const templates = await this.templateService.getTemplatesForTask(task); + const result = await this.templateService.getTemplatesForTask(task, limit, offset); const availableTasks = this.templateService.listAvailableTasks(); - if (templates.length === 0) { + if (result.items.length === 0 && offset === 0) { return { + ...result, message: `No templates found for task: ${task}`, availableTasks, tip: "Try a different task or use search_templates for custom searches" @@ -2377,10 +2424,10 @@ Full documentation is being prepared. For now, use get_node_essentials for confi } return { + ...result, task, - templates, - count: templates.length, - description: this.getTaskDescription(task) + description: this.getTaskDescription(task), + tip: `${result.total} templates available for ${task}. Showing ${result.items.length}.` }; } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 2ed2700..2b62c86 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -323,9 +323,37 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ required: ['nodeType'], }, }, + { + name: 'list_templates', + description: `List all templates with minimal data (id, name, views, node count). Use for browsing available templates.`, + inputSchema: { + type: 'object', + properties: { + limit: { + type: 'number', + description: 'Number of results (1-100). Default 10.', + default: 10, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, + }, + sortBy: { + type: 'string', + enum: ['views', 'created_at', 'name'], + description: 'Sort field. Default: views (popularity).', + default: 'views', + }, + }, + }, + }, { name: 'list_node_templates', - description: `Find templates using specific nodes. 399 community workflows. Use FULL types: "n8n-nodes-base.httpRequest".`, + description: `Find templates using specific nodes. Returns paginated results. Use FULL types: "n8n-nodes-base.httpRequest".`, inputSchema: { type: 'object', properties: { @@ -338,6 +366,14 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ type: 'number', description: 'Maximum number of templates to return. Default 10.', default: 10, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, }, }, required: ['nodeTypes'], @@ -345,7 +381,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ }, { name: 'get_template', - description: `Get complete workflow JSON by ID. Ready to import. IDs from list_node_templates or search_templates.`, + description: `Get template by ID. Use mode to control response size: nodes_only (minimal), structure (nodes+connections), full (complete workflow).`, inputSchema: { type: 'object', properties: { @@ -353,13 +389,19 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ type: 'number', description: 'The template ID to retrieve', }, + mode: { + type: 'string', + enum: ['nodes_only', 'structure', 'full'], + description: 'Response detail level. nodes_only: just node list, structure: nodes+connections, full: complete workflow JSON.', + default: 'full', + }, }, required: ['templateId'], }, }, { name: 'search_templates', - description: `Search templates by name/description keywords. NOT for node types! For nodes use list_node_templates. Example: "chatbot".`, + description: `Search templates by name/description keywords. Returns paginated results. NOT for node types! For nodes use list_node_templates.`, inputSchema: { type: 'object', properties: { @@ -371,6 +413,14 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ type: 'number', description: 'Maximum number of results. Default 20.', default: 20, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, }, }, required: ['query'], @@ -378,7 +428,7 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ }, { name: 'get_templates_for_task', - description: `Curated templates by task: ai_automation, data_sync, webhooks, email, slack, data_transform, files, scheduling, api, database.`, + description: `Curated templates by task. Returns paginated results sorted by popularity.`, inputSchema: { type: 'object', properties: { @@ -398,6 +448,19 @@ export const n8nDocumentationToolsFinal: ToolDefinition[] = [ ], description: 'The type of task to get templates for', }, + limit: { + type: 'number', + description: 'Maximum number of results. Default 10.', + default: 10, + minimum: 1, + maximum: 100, + }, + offset: { + type: 'number', + description: 'Pagination offset. Default 0.', + default: 0, + minimum: 0, + }, }, required: ['task'], }, diff --git a/src/templates/template-repository.ts b/src/templates/template-repository.ts index 0a6baa3..de49a84 100644 --- a/src/templates/template-repository.ts +++ b/src/templates/template-repository.ts @@ -173,17 +173,17 @@ export class TemplateRepository { /** * Get templates that use specific node types */ - getTemplatesByNodes(nodeTypes: string[], limit: number = 10): StoredTemplate[] { + getTemplatesByNodes(nodeTypes: string[], limit: number = 10, offset: number = 0): StoredTemplate[] { // Build query for multiple node types const conditions = nodeTypes.map(() => "nodes_used LIKE ?").join(" OR "); const query = ` SELECT * FROM templates WHERE ${conditions} ORDER BY views DESC, created_at DESC - LIMIT ? + LIMIT ? OFFSET ? `; - const params = [...nodeTypes.map(n => `%"${n}"%`), limit]; + const params = [...nodeTypes.map(n => `%"${n}"%`), limit, offset]; const results = this.db.prepare(query).all(...params) as StoredTemplate[]; return results.map(t => this.decompressWorkflow(t)); } @@ -232,13 +232,13 @@ export class TemplateRepository { /** * Search templates by name or description */ - searchTemplates(query: string, limit: number = 20): StoredTemplate[] { + searchTemplates(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] { logger.debug(`Searching templates for: "${query}" (FTS5: ${this.hasFTS5Support})`); // If FTS5 is not supported, go straight to LIKE search if (!this.hasFTS5Support) { logger.debug('Using LIKE search (FTS5 not available)'); - return this.searchTemplatesLIKE(query, limit); + return this.searchTemplatesLIKE(query, limit, offset); } try { @@ -255,8 +255,8 @@ export class TemplateRepository { JOIN templates_fts ON t.id = templates_fts.rowid WHERE templates_fts MATCH ? ORDER BY rank, t.views DESC - LIMIT ? - `).all(ftsQuery, limit) as StoredTemplate[]; + LIMIT ? OFFSET ? + `).all(ftsQuery, limit, offset) as StoredTemplate[]; logger.debug(`FTS5 search returned ${results.length} results`); return results.map(t => this.decompressWorkflow(t)); @@ -267,14 +267,14 @@ export class TemplateRepository { query: query, ftsQuery: query.split(' ').map(term => `"${term}"`).join(' OR ') }); - return this.searchTemplatesLIKE(query, limit); + return this.searchTemplatesLIKE(query, limit, offset); } } /** * Fallback search using LIKE when FTS5 is not available */ - private searchTemplatesLIKE(query: string, limit: number = 20): StoredTemplate[] { + private searchTemplatesLIKE(query: string, limit: number = 20, offset: number = 0): StoredTemplate[] { const likeQuery = `%${query}%`; logger.debug(`Using LIKE search with pattern: ${likeQuery}`); @@ -282,8 +282,8 @@ export class TemplateRepository { SELECT * FROM templates WHERE name LIKE ? OR description LIKE ? ORDER BY views DESC, created_at DESC - LIMIT ? - `).all(likeQuery, likeQuery, limit) as StoredTemplate[]; + LIMIT ? OFFSET ? + `).all(likeQuery, likeQuery, limit, offset) as StoredTemplate[]; logger.debug(`LIKE search returned ${results.length} results`); return results.map(t => this.decompressWorkflow(t)); @@ -292,7 +292,7 @@ export class TemplateRepository { /** * Get templates for a specific task/use case */ - getTemplatesForTask(task: string): StoredTemplate[] { + getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): StoredTemplate[] { // Map tasks to relevant node combinations const taskNodeMap: Record = { 'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'], @@ -312,18 +312,21 @@ export class TemplateRepository { return []; } - return this.getTemplatesByNodes(nodes, 10); + return this.getTemplatesByNodes(nodes, limit, offset); } /** * Get all templates with limit */ - getAllTemplates(limit: number = 10): StoredTemplate[] { + getAllTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): StoredTemplate[] { + const orderClause = sortBy === 'name' ? 'name ASC' : + sortBy === 'created_at' ? 'created_at DESC' : + 'views DESC, created_at DESC'; const results = this.db.prepare(` SELECT * FROM templates - ORDER BY views DESC, created_at DESC - LIMIT ? - `).all(limit) as StoredTemplate[]; + ORDER BY ${orderClause} + LIMIT ? OFFSET ? + `).all(limit, offset) as StoredTemplate[]; return results.map(t => this.decompressWorkflow(t)); } @@ -335,6 +338,77 @@ export class TemplateRepository { return result.count; } + /** + * Get count for search results + */ + getSearchCount(query: string): number { + if (!this.hasFTS5Support) { + const likeQuery = `%${query}%`; + const result = this.db.prepare(` + SELECT COUNT(*) as count FROM templates + WHERE name LIKE ? OR description LIKE ? + `).get(likeQuery, likeQuery) as { count: number }; + return result.count; + } + + try { + const ftsQuery = query.split(' ').map(term => { + const escaped = term.replace(/"/g, '""'); + return `"${escaped}"`; + }).join(' OR '); + + const result = this.db.prepare(` + SELECT COUNT(*) as count FROM templates t + JOIN templates_fts ON t.id = templates_fts.rowid + WHERE templates_fts MATCH ? + `).get(ftsQuery) as { count: number }; + return result.count; + } catch { + const likeQuery = `%${query}%`; + const result = this.db.prepare(` + SELECT COUNT(*) as count FROM templates + WHERE name LIKE ? OR description LIKE ? + `).get(likeQuery, likeQuery) as { count: number }; + return result.count; + } + } + + /** + * Get count for node templates + */ + getNodeTemplatesCount(nodeTypes: string[]): number { + const conditions = nodeTypes.map(() => "nodes_used LIKE ?").join(" OR "); + const query = `SELECT COUNT(*) as count FROM templates WHERE ${conditions}`; + const params = nodeTypes.map(n => `%"${n}"%`); + const result = this.db.prepare(query).get(...params) as { count: number }; + return result.count; + } + + /** + * Get count for task templates + */ + getTaskTemplatesCount(task: string): number { + const taskNodeMap: Record = { + 'ai_automation': ['@n8n/n8n-nodes-langchain.openAi', '@n8n/n8n-nodes-langchain.agent', 'n8n-nodes-base.openAi'], + 'data_sync': ['n8n-nodes-base.googleSheets', 'n8n-nodes-base.postgres', 'n8n-nodes-base.mysql'], + 'webhook_processing': ['n8n-nodes-base.webhook', 'n8n-nodes-base.httpRequest'], + 'email_automation': ['n8n-nodes-base.gmail', 'n8n-nodes-base.emailSend', 'n8n-nodes-base.emailReadImap'], + 'slack_integration': ['n8n-nodes-base.slack', 'n8n-nodes-base.slackTrigger'], + 'data_transformation': ['n8n-nodes-base.code', 'n8n-nodes-base.set', 'n8n-nodes-base.merge'], + 'file_processing': ['n8n-nodes-base.readBinaryFile', 'n8n-nodes-base.writeBinaryFile', 'n8n-nodes-base.googleDrive'], + 'scheduling': ['n8n-nodes-base.scheduleTrigger', 'n8n-nodes-base.cron'], + 'api_integration': ['n8n-nodes-base.httpRequest', 'n8n-nodes-base.graphql'], + 'database_operations': ['n8n-nodes-base.postgres', 'n8n-nodes-base.mysql', 'n8n-nodes-base.mongodb'] + }; + + const nodes = taskNodeMap[task]; + if (!nodes) { + return 0; + } + + return this.getNodeTemplatesCount(nodes); + } + /** * Get all existing template IDs for comparison * Used in update mode to skip already fetched templates diff --git a/src/templates/template-service.ts b/src/templates/template-service.ts index 3dfefd4..4c4e20e 100644 --- a/src/templates/template-service.ts +++ b/src/templates/template-service.ts @@ -21,6 +21,21 @@ export interface TemplateWithWorkflow extends TemplateInfo { workflow: any; } +export interface PaginatedResponse { + items: T[]; + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +export interface TemplateMinimal { + id: number; + name: string; + views: number; + nodeCount: number; +} + export class TemplateService { private repository: TemplateRepository; @@ -31,40 +46,115 @@ export class TemplateService { /** * List templates that use specific node types */ - async listNodeTemplates(nodeTypes: string[], limit: number = 10): Promise { - const templates = this.repository.getTemplatesByNodes(nodeTypes, limit); - return templates.map(this.formatTemplateInfo); + async listNodeTemplates(nodeTypes: string[], limit: number = 10, offset: number = 0): Promise> { + const templates = this.repository.getTemplatesByNodes(nodeTypes, limit, offset); + const total = this.repository.getNodeTemplatesCount(nodeTypes); + + return { + items: templates.map(this.formatTemplateInfo), + total, + limit, + offset, + hasMore: offset + limit < total + }; } /** - * Get a specific template with full workflow + * Get a specific template with different detail levels */ - async getTemplate(templateId: number): Promise { + async getTemplate(templateId: number, mode: 'nodes_only' | 'structure' | 'full' = 'full'): Promise { const template = this.repository.getTemplate(templateId); if (!template) { return null; } + const workflow = JSON.parse(template.workflow_json || '{}'); + + if (mode === 'nodes_only') { + return { + id: template.id, + name: template.name, + nodes: workflow.nodes?.map((n: any) => ({ + type: n.type, + name: n.name + })) || [] + }; + } + + if (mode === 'structure') { + return { + id: template.id, + name: template.name, + nodes: workflow.nodes?.map((n: any) => ({ + id: n.id, + type: n.type, + name: n.name, + position: n.position + })) || [], + connections: workflow.connections || {} + }; + } + + // Full mode return { ...this.formatTemplateInfo(template), - workflow: JSON.parse(template.workflow_json || '{}') + workflow }; } /** * Search templates by query */ - async searchTemplates(query: string, limit: number = 20): Promise { - const templates = this.repository.searchTemplates(query, limit); - return templates.map(this.formatTemplateInfo); + async searchTemplates(query: string, limit: number = 20, offset: number = 0): Promise> { + const templates = this.repository.searchTemplates(query, limit, offset); + const total = this.repository.getSearchCount(query); + + return { + items: templates.map(this.formatTemplateInfo), + total, + limit, + offset, + hasMore: offset + limit < total + }; } /** * Get templates for a specific task */ - async getTemplatesForTask(task: string): Promise { - const templates = this.repository.getTemplatesForTask(task); - return templates.map(this.formatTemplateInfo); + async getTemplatesForTask(task: string, limit: number = 10, offset: number = 0): Promise> { + const templates = this.repository.getTemplatesForTask(task, limit, offset); + const total = this.repository.getTaskTemplatesCount(task); + + return { + items: templates.map(this.formatTemplateInfo), + total, + limit, + offset, + hasMore: offset + limit < total + }; + } + + /** + * List all templates with minimal data + */ + async listTemplates(limit: number = 10, offset: number = 0, sortBy: 'views' | 'created_at' | 'name' = 'views'): Promise> { + const templates = this.repository.getAllTemplates(limit, offset, sortBy); + const total = this.repository.getTemplateCount(); + + const items = templates.map(t => ({ + id: t.id, + name: t.name, + views: t.views, + nodeCount: JSON.parse(t.nodes_used).length + })); + + return { + items, + total, + limit, + offset, + hasMore: offset + limit < total + }; } /**