diff --git a/package-lock.json b/package-lock.json index d05db4b..93aa8b5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.2.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.15.1", + "@modelcontextprotocol/sdk": "^1.22.0", + "@toon-format/toon": "^2.0.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/node": "^20.0.0", @@ -151,6 +152,74 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.5", "cpu": [ @@ -166,6 +235,346 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -209,7 +618,6 @@ "version": "1.22.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz", "integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==", - "license": "MIT", "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -444,6 +852,12 @@ "zod": "^3.24.1" } }, + "node_modules/@toon-format/toon": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@toon-format/toon/-/toon-2.0.1.tgz", + "integrity": "sha512-0oohzByDG/VTvsPT7iCfUdoB6yndopkPulh9Kk76fhV6YReVSGqr6ni5EMSxn3umNyfwylI0nOjLYtjJTIiT0w==", + "license": "MIT" + }, "node_modules/@types/babel__traverse": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", @@ -887,6 +1301,7 @@ "node_modules/express": { "version": "4.21.2", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1721,6 +2136,7 @@ "node_modules/zod": { "version": "3.25.76", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 70c33d6..5d8826f 100644 --- a/package.json +++ b/package.json @@ -45,24 +45,22 @@ "test:timezones": "node scripts/test-timezones.js" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.15.1", + "@modelcontextprotocol/sdk": "^1.22.0", + "@toon-format/toon": "^2.0.1", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/node": "^20.0.0", "cors": "^2.8.5", "express": "^4.21.2", "node-fetch": "^3.3.2", - "zod": "^3.25.0", "typescript": "^5.3.0", - "@types/cors": "^2.8.19", - "@types/express": "^5.0.3", - "@types/node": "^20.0.0" + "zod": "^3.25.0" }, "devDependencies": { "@babel/parser": "^7.27.5", "@babel/traverse": "^7.27.4", "@babel/types": "^7.27.6", "@types/babel__traverse": "^7.20.7", - - - "tsx": "^4.7.0" }, "engines": { @@ -72,4 +70,4 @@ "compatible": true, "version": "0.1" } -} \ No newline at end of file +} diff --git a/src/config/tool-pagination.ts b/src/config/tool-pagination.ts new file mode 100644 index 0000000..09d45a4 --- /dev/null +++ b/src/config/tool-pagination.ts @@ -0,0 +1,181 @@ +/** + * Tool Pagination Configuration + * + * Implements MCP 2025-06-18 tool pagination support for lazy loading. + * This allows clients to load tools incrementally rather than all at once, + * significantly reducing initial context window overhead. + * + * Per MCP spec: + * - tools/list supports cursor-based pagination + * - Servers can return tools in chunks with nextCursor + * - Clients use cursor parameter to fetch next page + */ + +export interface ToolPaginationConfig { + /** + * Number of tools to return per page + * Default: 10 (optimized for context window efficiency) + * Set to 0 or Infinity to disable pagination (return all tools) + */ + pageSize: number; + + /** + * Enable pagination (default: true for context efficiency) + * Set to false for backward compatibility with older clients + */ + enabled: boolean; +} + +/** + * Default pagination configuration + * + * DISABLED BY DEFAULT: Most MCP clients (Cursor, Claude Desktop) don't support + * tool pagination yet. Enable via TOOL_PAGINATION_ENABLED=true when clients + * support the MCP 2025-06-18 pagination spec. + * + * When enabled, returns 10 tools per page for optimal context window usage. + */ +export const DEFAULT_TOOL_PAGINATION: ToolPaginationConfig = { + pageSize: 10, + enabled: false // Disabled until clients support pagination +}; + +/** + * Environment-based pagination config + * + * Can be overridden via environment variables: + * - TOOL_PAGINATION_ENABLED: 'true' or 'false' + * - TOOL_PAGINATION_PAGE_SIZE: number of tools per page + */ +export function getToolPaginationConfig(): ToolPaginationConfig { + const envEnabled = process.env.TOOL_PAGINATION_ENABLED; + const envPageSize = process.env.TOOL_PAGINATION_PAGE_SIZE; + + return { + enabled: envEnabled !== undefined ? envEnabled === 'true' : DEFAULT_TOOL_PAGINATION.enabled, + pageSize: envPageSize ? parseInt(envPageSize, 10) : DEFAULT_TOOL_PAGINATION.pageSize + }; +} + +/** + * Pagination cursor utilities + */ +export class ToolPaginationCursor { + /** + * Encode pagination state to cursor string + */ + static encode(startIndex: number): string { + return Buffer.from(JSON.stringify({ s: startIndex })).toString('base64url'); + } + + /** + * Decode cursor string to pagination state + */ + static decode(cursor: string): { startIndex: number } | null { + try { + const decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf-8')); + if (typeof decoded.s === 'number') { + return { startIndex: decoded.s }; + } + return null; + } catch { + return null; + } + } +} + +/** + * Paginate tools array + * + * @param tools - Full array of tool definitions + * @param cursor - Optional cursor from previous request + * @param config - Pagination configuration + * @returns Paginated result with tools and nextCursor + */ +export function paginateTools( + tools: T[], + cursor?: string, + config: ToolPaginationConfig = getToolPaginationConfig() +): { tools: T[]; nextCursor?: string } { + // If pagination disabled, return all tools + if (!config.enabled || config.pageSize <= 0 || config.pageSize >= tools.length) { + return { tools }; + } + + // Decode cursor to get start index + let startIndex = 0; + if (cursor) { + const decoded = ToolPaginationCursor.decode(cursor); + if (decoded) { + startIndex = decoded.startIndex; + } + } + + // Validate start index + if (startIndex < 0 || startIndex >= tools.length) { + startIndex = 0; + } + + // Calculate end index + const endIndex = Math.min(startIndex + config.pageSize, tools.length); + const pageTools = tools.slice(startIndex, endIndex); + + // Generate next cursor if more tools available + const hasMore = endIndex < tools.length; + const nextCursor = hasMore ? ToolPaginationCursor.encode(endIndex) : undefined; + + return { + tools: pageTools, + nextCursor + }; +} + +/** + * Get pagination metadata for logging/debugging + */ +export function getPaginationInfo( + totalTools: number, + cursor?: string, + config: ToolPaginationConfig = getToolPaginationConfig() +): { + totalTools: number; + pageSize: number; + currentPage: number; + totalPages: number; + startIndex: number; + endIndex: number; +} { + if (!config.enabled || config.pageSize <= 0) { + return { + totalTools, + pageSize: totalTools, + currentPage: 1, + totalPages: 1, + startIndex: 0, + endIndex: totalTools + }; + } + + let startIndex = 0; + if (cursor) { + const decoded = ToolPaginationCursor.decode(cursor); + if (decoded) { + startIndex = decoded.startIndex; + } + } + + const pageSize = config.pageSize; + const totalPages = Math.ceil(totalTools / pageSize); + const currentPage = Math.floor(startIndex / pageSize) + 1; + const endIndex = Math.min(startIndex + pageSize, totalTools); + + return { + totalTools, + pageSize, + currentPage, + totalPages, + startIndex, + endIndex + }; +} + diff --git a/src/handlers/lead-handler.ts b/src/handlers/lead-handler.ts index 1a30b9b..2d8e6ec 100644 --- a/src/handlers/lead-handler.ts +++ b/src/handlers/lead-handler.ts @@ -10,6 +10,7 @@ import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js'; import { makeInstantlyRequest } from '../api/client.js'; import { ENDPOINTS } from '../api/endpoints.js'; +import { createMCPResponse } from '../utils/response-formatter.js'; /** * Handle all lead-related tool executions @@ -167,14 +168,7 @@ async function handleListLeads(args: any, apiKey: string) { response.client_side_filters_applied = filtersApplied; } - return { - content: [ - { - type: 'text', - text: JSON.stringify(response, null, 2), - }, - ], - }; + return createMCPResponse(response); } catch (error: any) { const elapsed = Date.now() - startTime; console.error(`[Instantly MCP] ❌ Request failed after ${elapsed}ms: ${error.message}`); @@ -196,14 +190,7 @@ async function handleGetLead(args: any, apiKey: string) { const result = await makeInstantlyRequest(`/leads/${args.lead_id}`, {}, apiKey); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; + return createMCPResponse(result); } /** @@ -503,21 +490,14 @@ async function handleListLeadLists(args: any, apiKey: string) { const items = listsResult.items || listsResult; const nextStartingAfter = listsResult.next_starting_after; - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - lead_lists: items, - next_starting_after: nextStartingAfter, - total_returned: Array.isArray(items) ? items.length : 0, - has_more: !!nextStartingAfter, - message: 'Lead lists retrieved successfully' - }, null, 2) - } - ] - }; + return createMCPResponse({ + success: true, + lead_lists: items, + next_starting_after: nextStartingAfter, + total_returned: Array.isArray(items) ? items.length : 0, + has_more: !!nextStartingAfter, + message: 'Lead lists retrieved successfully' + }); } /** diff --git a/src/handlers/mcp-handlers.ts b/src/handlers/mcp-handlers.ts index c693122..b288691 100644 --- a/src/handlers/mcp-handlers.ts +++ b/src/handlers/mcp-handlers.ts @@ -22,6 +22,7 @@ import { import { TOOLS_DEFINITION } from '../tools/index.js'; import { executeToolDirectly } from './tool-executor.js'; import { handleInstantlyError } from '../error-handler.js'; +import { paginateTools, getPaginationInfo, getToolPaginationConfig } from '../config/tool-pagination.js'; /** * Load Instantly.ai icons for MCP protocol @@ -103,12 +104,29 @@ export function registerMcpHandlers(server: Server, apiKey?: string): void { return initResponse; }); - // List tools handler - server.setRequestHandler(ListToolsRequestSchema, async () => { - console.error('[Instantly MCP] πŸ“‹ Listing available tools...'); + // List tools handler with pagination support (MCP 2025-06-18) + server.setRequestHandler(ListToolsRequestSchema, async (request) => { + const cursor = request.params?.cursor; + const config = getToolPaginationConfig(); + + // Get pagination info for logging + const paginationInfo = getPaginationInfo(TOOLS_DEFINITION.length, cursor, config); + + console.error(`[Instantly MCP] πŸ“‹ Listing tools (page ${paginationInfo.currentPage}/${paginationInfo.totalPages}, ` + + `showing ${paginationInfo.startIndex + 1}-${paginationInfo.endIndex} of ${paginationInfo.totalTools})...`); + + // Paginate tools for context window efficiency + const { tools, nextCursor } = paginateTools(TOOLS_DEFINITION, cursor, config); + + if (nextCursor) { + console.error(`[Instantly MCP] πŸ“„ More tools available - nextCursor provided for lazy loading`); + } else { + console.error(`[Instantly MCP] βœ… All tools returned (pagination ${config.enabled ? 'enabled' : 'disabled'})`); + } return { - tools: TOOLS_DEFINITION + tools, + ...(nextCursor && { nextCursor }) }; }); diff --git a/src/handlers/tool-executor.ts b/src/handlers/tool-executor.ts index f6115c7..533cfd6 100644 --- a/src/handlers/tool-executor.ts +++ b/src/handlers/tool-executor.ts @@ -20,6 +20,7 @@ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js'; import { makeInstantlyRequest } from '../api/client.js'; import { ENDPOINTS } from '../api/endpoints.js'; import { handleLeadTool } from './lead-handler.js'; +import { createMCPResponse } from '../utils/response-formatter.js'; import { getAllAccounts, getEligibleSenderAccounts, @@ -136,19 +137,13 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri const result = await getAllAccounts(apiKey, paginationParams); // Return single page with clear pagination metadata - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - data: result.data, - pagination: result.pagination, - metadata: result.metadata, - success: true - }, null, 2) - } - ] - }; + // Using TOON format for token efficiency (30-60% reduction for tabular data) + return createMCPResponse({ + data: result.data, + pagination: result.pagination, + metadata: result.metadata, + success: true + }); } catch (error: any) { console.error('[Instantly MCP] ❌ Error in list_accounts:', error.message); throw error; @@ -218,32 +213,25 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri if (args?.tag_ids) filtersApplied.tag_ids = args.tag_ids; // Return single page with clear pagination metadata - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - data: campaignsWithReadableStatus, - pagination: { - returned_count: campaignsWithReadableStatus.length, - has_more: hasMore, - next_starting_after: nextCursor, - limit: queryParams.limit, - current_page_note: hasMore - ? `Retrieved ${campaignsWithReadableStatus.length} campaigns. More results available. To get next page, call list_campaigns again with starting_after='${nextCursor}'` - : `Retrieved all available campaigns (${campaignsWithReadableStatus.length} items).` - }, - filters_applied: Object.keys(filtersApplied).length > 0 ? filtersApplied : undefined, - metadata: { - request_time_ms: elapsed, - success: true, - status_mapping_note: 'All campaigns include status_label (human-readable) and status_code (numeric) fields' - }, - success: true - }, null, 2) - } - ] - }; + return createMCPResponse({ + data: campaignsWithReadableStatus, + pagination: { + returned_count: campaignsWithReadableStatus.length, + has_more: hasMore, + next_starting_after: nextCursor, + limit: queryParams.limit, + current_page_note: hasMore + ? `Retrieved ${campaignsWithReadableStatus.length} campaigns. More results available. To get next page, call list_campaigns again with starting_after='${nextCursor}'` + : `Retrieved all available campaigns (${campaignsWithReadableStatus.length} items).` + }, + filters_applied: Object.keys(filtersApplied).length > 0 ? filtersApplied : undefined, + metadata: { + request_time_ms: elapsed, + success: true, + status_mapping_note: 'All campaigns include status_label (human-readable) and status_code (numeric) fields' + }, + success: true + }); } catch (error: any) { console.error('[Instantly MCP] ❌ Error in list_campaigns:', error.message); throw error; @@ -257,14 +245,7 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri const result = await makeInstantlyRequest(`/campaigns/${args.campaign_id}`, {}, apiKey); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; + return createMCPResponse(result); } case 'get_campaign_analytics': { @@ -319,14 +300,7 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri } } : result; - return { - content: [ - { - type: 'text', - text: JSON.stringify(enhancedResult, null, 2), - }, - ], - }; + return createMCPResponse(enhancedResult); } catch (error: any) { // Enhanced error handling for campaign analytics with detailed debugging console.error(`[Instantly MCP] get_campaign_analytics ERROR:`, error); @@ -373,18 +347,11 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri const result = await makeInstantlyRequest('/campaigns/analytics/daily', { params }, apiKey); - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - daily_analytics: result, - message: 'Daily campaign analytics retrieved successfully' - }, null, 2) - } - ] - }; + return createMCPResponse({ + success: true, + daily_analytics: result, + message: 'Daily campaign analytics retrieved successfully' + }); } case 'create_campaign': { @@ -770,14 +737,7 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri body: requestBody }, apiKey); - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; + return createMCPResponse(result); } case 'verify_email': { @@ -982,18 +942,11 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri metadata.note += ' All results retrieved (no more pages available).'; } - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - ...emailsResult, - metadata, - success: true - }, null, 2) - } - ] - }; + return createMCPResponse({ + ...emailsResult, + metadata, + success: true + }); } case 'get_email': { @@ -1087,14 +1040,16 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri }; } - case 'get_account_details': { - console.error('[Instantly MCP] πŸ‘€ Executing get_account_details...'); + // Consolidated: get_account (replaces get_account_details and get_account_info) + case 'get_account': + case 'get_account_details': // Backward compatibility + case 'get_account_info': { // Backward compatibility + console.error(`[Instantly MCP] πŸ‘€ Executing get_account (called as: ${name})...`); if (!args.email) { - throw new McpError(ErrorCode.InvalidParams, 'Email parameter is required for get_account_details'); + throw new McpError(ErrorCode.InvalidParams, 'Email parameter is required'); } - // This is essentially the same as get_account_info - might be a duplicate const accountResult = await makeInstantlyRequest(`/accounts/${args.email}`, {}, apiKey); return { @@ -1103,23 +1058,52 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri type: 'text', text: JSON.stringify({ success: true, - account_details: accountResult, - message: 'Account details retrieved successfully', - note: 'This tool provides the same information as get_account_info' + account: accountResult, + message: 'Account details retrieved successfully' }, null, 2) } ] }; } - case 'get_account_info': { - console.error('[Instantly MCP] πŸ‘€ Executing get_account_info...'); + // Consolidated: manage_account_state (replaces pause/resume/warmup/vitals tools) + case 'manage_account_state': { + console.error('[Instantly MCP] πŸ”§ Executing manage_account_state...'); if (!args.email) { - throw new McpError(ErrorCode.InvalidParams, 'Email parameter is required for get_account_info'); + throw new McpError(ErrorCode.InvalidParams, 'Email is required'); + } + if (!args.action) { + throw new McpError(ErrorCode.InvalidParams, 'Action is required (pause, resume, enable_warmup, disable_warmup, test_vitals)'); } - const accountInfoResult = await makeInstantlyRequest(`/accounts/${args.email}`, {}, apiKey); + let result: any; + let message: string; + + switch (args.action) { + case 'pause': + result = await makeInstantlyRequest(`/accounts/${args.email}/pause`, { method: 'POST' }, apiKey); + message = `Account ${args.email} paused successfully`; + break; + case 'resume': + result = await makeInstantlyRequest(`/accounts/${args.email}/resume`, { method: 'POST' }, apiKey); + message = `Account ${args.email} resumed successfully`; + break; + case 'enable_warmup': + result = await makeInstantlyRequest('/accounts/warmup/enable', { method: 'POST', body: { emails: [args.email] } }, apiKey); + message = `Warmup enabled for ${args.email}`; + break; + case 'disable_warmup': + result = await makeInstantlyRequest('/accounts/warmup/disable', { method: 'POST', body: { emails: [args.email] } }, apiKey); + message = `Warmup disabled for ${args.email}`; + break; + case 'test_vitals': + result = await makeInstantlyRequest('/accounts/test/vitals', { method: 'POST', body: { accounts: [args.email] } }, apiKey); + message = `Account vitals tested for ${args.email}`; + break; + default: + throw new McpError(ErrorCode.InvalidParams, `Unknown action: ${args.action}. Valid: pause, resume, enable_warmup, disable_warmup, test_vitals`); + } return { content: [ @@ -1127,60 +1111,24 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri type: 'text', text: JSON.stringify({ success: true, - account: accountInfoResult, - message: 'Account information retrieved successfully' + action: args.action, + result, + message }, null, 2) } ] }; } + // Backward compatibility: route old tool names to manage_account_state case 'pause_account': { - console.error('[Instantly MCP] ⏸️ Executing pause_account...'); - - if (!args.email) { - throw new McpError(ErrorCode.InvalidParams, 'Email is required for pause_account'); - } - - console.error(`[Instantly MCP] πŸ”§ Using endpoint: /accounts/${args.email}/pause`); - const pauseAccountResult = await makeInstantlyRequest(`/accounts/${args.email}/pause`, { method: 'POST' }, apiKey); - - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - account: pauseAccountResult, - message: `Account ${args.email} paused successfully` - }, null, 2) - } - ] - }; + console.error('[Instantly MCP] ⏸️ pause_account (legacy) -> manage_account_state'); + return executeToolDirectly('manage_account_state', { email: args.email, action: 'pause' }, apiKey); } case 'resume_account': { - console.error('[Instantly MCP] ▢️ Executing resume_account...'); - - if (!args.email) { - throw new McpError(ErrorCode.InvalidParams, 'Email is required for resume_account'); - } - - console.error(`[Instantly MCP] πŸ”§ Using endpoint: /accounts/${args.email}/resume`); - const resumeAccountResult = await makeInstantlyRequest(`/accounts/${args.email}/resume`, { method: 'POST' }, apiKey); - - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - account: resumeAccountResult, - message: `Account ${args.email} resumed successfully` - }, null, 2) - } - ] - }; + console.error('[Instantly MCP] ▢️ resume_account (legacy) -> manage_account_state'); + return executeToolDirectly('manage_account_state', { email: args.email, action: 'resume' }, apiKey); } case 'create_account': { @@ -1260,86 +1208,20 @@ export async function executeToolDirectly(name: string, args: any, apiKey?: stri }; } + // Backward compatibility: route legacy warmup/vitals tools to manage_account_state case 'enable_warmup': { - console.error('[Instantly MCP] πŸ”₯ Executing enable_warmup...'); - - if (!args.email) { - throw new McpError(ErrorCode.InvalidParams, 'Email is required for enable_warmup'); - } - - console.error(`[Instantly MCP] πŸ”§ Using endpoint: /accounts/warmup/enable`); - const enableWarmupResult = await makeInstantlyRequest('/accounts/warmup/enable', { - method: 'POST', - body: { emails: [args.email] } - }, apiKey); - - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - result: enableWarmupResult, - message: `Warmup enabled for account ${args.email}` - }, null, 2) - } - ] - }; + console.error('[Instantly MCP] πŸ”₯ enable_warmup (legacy) -> manage_account_state'); + return executeToolDirectly('manage_account_state', { email: args.email, action: 'enable_warmup' }, apiKey); } case 'disable_warmup': { - console.error('[Instantly MCP] ❄️ Executing disable_warmup...'); - - if (!args.email) { - throw new McpError(ErrorCode.InvalidParams, 'Email is required for disable_warmup'); - } - - console.error(`[Instantly MCP] πŸ”§ Using endpoint: /accounts/warmup/disable`); - const disableWarmupResult = await makeInstantlyRequest('/accounts/warmup/disable', { - method: 'POST', - body: { emails: [args.email] } - }, apiKey); - - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - result: disableWarmupResult, - message: `Warmup disabled for account ${args.email}` - }, null, 2) - } - ] - }; + console.error('[Instantly MCP] ❄️ disable_warmup (legacy) -> manage_account_state'); + return executeToolDirectly('manage_account_state', { email: args.email, action: 'disable_warmup' }, apiKey); } case 'test_account_vitals': { - console.error('[Instantly MCP] 🩺 Executing test_account_vitals...'); - - if (!args.email) { - throw new McpError(ErrorCode.InvalidParams, 'Email is required for test_account_vitals'); - } - - console.error(`[Instantly MCP] πŸ”§ Using endpoint: /accounts/test/vitals`); - const testVitalsResult = await makeInstantlyRequest('/accounts/test/vitals', { - method: 'POST', - body: { accounts: [args.email] } - }, apiKey); - - return { - content: [ - { - type: 'text', - text: JSON.stringify({ - success: true, - vitals: testVitalsResult, - message: `Account vitals tested for ${args.email}`, - note: 'This diagnostic tool helps identify account connectivity and health issues' - }, null, 2) - } - ] - }; + console.error('[Instantly MCP] 🩺 test_account_vitals (legacy) -> manage_account_state'); + return executeToolDirectly('manage_account_state', { email: args.email, action: 'test_vitals' }, apiKey); } default: diff --git a/src/streaming-http-transport.ts b/src/streaming-http-transport.ts index 6835859..d741641 100644 --- a/src/streaming-http-transport.ts +++ b/src/streaming-http-transport.ts @@ -176,7 +176,7 @@ export class StreamingHttpTransport { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', - 'Server': 'instantly-mcp/1.1.0' + 'Server': 'instantly-mcp/1.2.0' }); next(); }); @@ -299,10 +299,10 @@ export class StreamingHttpTransport { res.json({ status: 'healthy', service: 'instantly-mcp', - version: '1.1.0', + version: '1.2.0', transport: 'dual-protocol', protocols: { - streamable_http: '2025-03-26', + streamable_http: '2025-06-18', sse: '2024-11-05' }, timestamp: new Date().toISOString(), @@ -323,11 +323,11 @@ export class StreamingHttpTransport { this.app.get('/info', (req, res) => { res.json({ name: 'Instantly MCP Server', - version: '1.1.0', + version: '1.2.0', description: 'Official Instantly.ai MCP server with 34 email automation tools', transport: 'streaming-http', endpoint: 'https://mcp.instantly.ai/mcp', - protocol: '2025-03-26', + protocol: '2025-06-18', tools: 34, capabilities: { tools: true, @@ -413,8 +413,9 @@ export class StreamingHttpTransport { }); // Main MCP endpoint with header-based authentication - // ALSO accepts API key in custom header for Claude Desktop compatibility - this.app.all('/mcp', async (req, res) => { + // Uses POST only - MCP protocol uses JSON-RPC over POST + // GET requests handled separately for discovery + this.app.post('/mcp', async (req, res) => { // VERBOSE LOGGING FOR CLAUDE DESKTOP/WEB DEBUGGING console.error('[HTTP] ========== INCOMING MCP REQUEST (HEADER AUTH) =========='); console.error('[HTTP] πŸ” FULL REQUEST HEADERS:', JSON.stringify(req.headers, null, 2)); @@ -443,7 +444,8 @@ export class StreamingHttpTransport { }); // URL-based authentication endpoint: /mcp/{API_KEY} - this.app.all('/mcp/:apiKey', async (req, res) => { + // Uses POST only - MCP protocol uses JSON-RPC over POST + this.app.post('/mcp/:apiKey', async (req, res) => { // Extract API key from URL path let apiKey = req.params.apiKey; @@ -571,7 +573,7 @@ export class StreamingHttpTransport { // Return MCP server capabilities instead of OAuth flow res.json({ server: 'instantly-mcp', - version: '1.1.0', + version: '1.2.0', protocol: 'mcp', transport: 'streamable-http', auth: { @@ -590,81 +592,36 @@ export class StreamingHttpTransport { }); }); - // GET endpoint for MCP clients (supports SSE for Claude.ai proxy) - this.app.get('/mcp/:apiKey?', async (req, res) => { + // GET endpoint for MCP clients + // IMPORTANT: Returns immediately - no blocking on SSE setup + // Cursor and most MCP clients use POST for actual communication + this.app.get('/mcp/:apiKey?', (req, res) => { const apiKey = req.params.apiKey; const acceptHeader = req.headers.accept || ''; - const protocolVersion = req.headers['mcp-protocol-version'] as string; console.error(`[HTTP] πŸ” GET /mcp request - API Key: ${apiKey ? 'βœ… Present' : '❌ Missing'}`); console.error(`[HTTP] πŸ“‹ Accept: ${acceptHeader}`); - console.error(`[HTTP] πŸ”– Protocol Version: ${protocolVersion || 'Not provided'}`); - // Validate MCP-Protocol-Version header (backward compatible) - // Per MCP spec: if no header provided, assume 2025-03-26 for backward compatibility - if (protocolVersion && !['2025-06-18', '2025-03-26', '2024-11-05'].includes(protocolVersion)) { - console.error(`[HTTP] ❌ Unsupported protocol version: ${protocolVersion}`); - return res.status(400).json({ - error: 'Bad Request', - message: `Unsupported MCP protocol version: ${protocolVersion}. Supported: 2025-06-18, 2025-03-26 (recommended), 2024-11-05` - }); - } - - if (acceptHeader.includes('text/event-stream')) { - // Client wants SSE stream - support for Claude.ai proxy - console.error('[HTTP] πŸ“‘ SSE connection requested - starting SSE transport'); - console.error(`[HTTP] πŸ“‘ API Key: ${apiKey ? 'βœ… Present' : '❌ Missing'}`); - - try { - const transport = new SSEServerTransport('/messages', res); - this.sseTransports.set(transport.sessionId, transport); - - // CRITICAL FIX: Store API key with SSE session so tool calls can access it - this.sseSessionMetadata.set(transport.sessionId, { apiKey }); - console.error(`[HTTP] πŸ“‘ SSE session metadata stored - API Key: ${apiKey ? 'βœ… Present' : '❌ Missing'}`); - - console.error(`[HTTP] πŸ“‘ SSE transport created with session ID: ${transport.sessionId}`); - console.error(`[HTTP] πŸ“‘ Active SSE sessions: ${this.sseTransports.size}`); - - res.on('close', () => { - console.error(`[HTTP] πŸ“‘ SSE connection closed for session ${transport.sessionId}`); - this.sseTransports.delete(transport.sessionId); - this.sseSessionMetadata.delete(transport.sessionId); // Clean up metadata - console.error(`[HTTP] πŸ“‘ Remaining SSE sessions: ${this.sseTransports.size}`); - }); - - await this.server.connect(transport); - console.error(`[HTTP] βœ… SSE transport connected successfully - session ID: ${transport.sessionId}`); - } catch (error) { - console.error('[HTTP] ❌ Failed to establish SSE connection:', error); - console.error('[HTTP] ❌ Error stack:', error instanceof Error ? error.stack : 'No stack trace'); - - if (!res.headersSent) { - res.status(500).json({ - error: 'Internal Server Error', - message: `Failed to establish SSE connection: ${error instanceof Error ? error.message : String(error)}` - }); - } - } - return; - } - - // Return server info for GET requests + // For ALL GET requests, return server info immediately + // This ensures Cursor and other clients don't hang on discovery + // Actual MCP communication happens via POST res.json({ server: 'instantly-mcp', - version: '1.1.0', + version: '1.2.0', transport: 'streamable-http', - protocol: '2025-03-26', + protocol: '2025-06-18', + tools: TOOLS_DEFINITION.length, endpoints: { 'mcp_post': apiKey ? `/mcp/${apiKey}` : '/mcp', - 'messages_post': '/messages', 'health': '/health', 'info': '/info' }, auth: { required: true, - methods: ['path_parameter', 'header'] - } + methods: ['path_parameter', 'header'], + note: 'Use POST to /mcp/{API_KEY} for MCP communication' + }, + status: 'ready' }); }); @@ -867,7 +824,7 @@ export class StreamingHttpTransport { message: `Endpoint ${req.path} not found`, availableEndpoints: ['/mcp', '/mcp/{API_KEY}', '/authorize', '/health', '/info'], transport: 'streamable-http', - protocol: '2025-03-26' + protocol: '2025-06-18' }); }); } diff --git a/src/tools/account-tools.ts b/src/tools/account-tools.ts index c3e5339..8290c8d 100644 --- a/src/tools/account-tools.ts +++ b/src/tools/account-tools.ts @@ -1,241 +1,143 @@ /** - * Instantly MCP Server - Account Tools + * Instantly MCP Server - Account Tools (Compacted) * * Tool definitions for account management operations. - * Total: 11 account tools + * Optimized for minimal context window overhead. + * Total: 10 account tools (consolidated get_account_details + get_account_info) */ export const accountTools = [ { name: 'list_accounts', - title: 'List Email Accounts', - description: 'List email accounts with pagination. Filter by domain, status, provider, or tags. Use exact cursor from next_starting_after for pagination.', + title: 'List Accounts', + description: 'List email accounts with pagination. Filter by status, provider, or tags.', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - limit: { - type: 'number', - description: 'Number of items per page (1-100, default: 100)', - minimum: 1, - maximum: 100 - }, - starting_after: { - type: 'string', - description: 'Pagination cursor from previous response. CRITICAL: Use the EXACT value from response.pagination.next_starting_after field (NOT an email address or ID from the data). Example: If previous response had "next_starting_after": "abc123xyz", use starting_after="abc123xyz". Omit for first page.' - }, - search: { - type: 'string', - description: 'Search accounts by email domain (e.g., "gmail.com", "company.com"). Filters accounts whose email addresses contain this string.' - }, - status: { - type: 'number', - description: 'Filter by account status. Values: 1=Active, 2=Paused, -1=Connection Error, -2=Soft Bounce Error, -3=Sending Error', - enum: [1, 2, -1, -2, -3] - }, - provider_code: { - type: 'number', - description: 'Filter by ESP provider. Values: 1=Custom IMAP/SMTP, 2=Google, 3=Microsoft, 4=AWS', - enum: [1, 2, 3, 4] - }, - tag_ids: { - type: 'string', - description: 'Filter by tag IDs (comma-separated). Example: "tag1,tag2,tag3"' - } - }, - additionalProperties: false + limit: { type: 'number', description: '1-100, default: 100' }, + starting_after: { type: 'string', description: 'Cursor from pagination.next_starting_after' }, + search: { type: 'string', description: 'Search by email domain' }, + status: { type: 'number', description: '1=Active, 2=Paused, -1/-2/-3=Errors', enum: [1, 2, -1, -2, -3] }, + provider_code: { type: 'number', description: '1=IMAP, 2=Google, 3=Microsoft, 4=AWS', enum: [1, 2, 3, 4] }, + tag_ids: { type: 'string', description: 'Comma-separated tag IDs' } + } } }, { - name: 'get_account_details', - title: 'Get Account Details', - description: 'Get account details including warmup status and campaign eligibility', + name: 'get_account', + title: 'Get Account', + description: 'Get account details, warmup status, and campaign eligibility by email', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { email: { type: 'string', description: 'Account email address' } }, - required: ['email'], - additionalProperties: false - } - }, - - { - name: 'get_account_info', - title: 'Get Account Info', - description: 'Get account information and status (read-only)', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Account email address' } - }, - required: ['email'], - additionalProperties: false + required: ['email'] } }, { name: 'create_account', - title: 'Create Email Account', - description: 'Create email account with IMAP/SMTP configuration. Requires email, name, provider_code, and IMAP/SMTP credentials.', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Email address' }, - first_name: { type: 'string', description: 'First name' }, - last_name: { type: 'string', description: 'Last name' }, - provider_code: { type: 'number', description: 'Email provider code' }, - imap_username: { type: 'string', description: 'IMAP username' }, - imap_password: { type: 'string', description: 'IMAP password' }, - imap_host: { type: 'string', description: 'IMAP host (e.g., imap.gmail.com)' }, - imap_port: { type: 'number', description: 'IMAP port (e.g., 993)' }, - smtp_username: { type: 'string', description: 'SMTP username' }, - smtp_password: { type: 'string', description: 'SMTP password' }, - smtp_host: { type: 'string', description: 'SMTP host (e.g., smtp.gmail.com)' }, - smtp_port: { type: 'number', description: 'SMTP port (e.g., 587)' } - }, - required: ['email', 'first_name', 'last_name', 'provider_code', 'imap_username', 'imap_password', 'imap_host', 'imap_port', 'smtp_username', 'smtp_password', 'smtp_host', 'smtp_port'], - additionalProperties: false - } - }, - - { - name: 'pause_account', - title: 'Pause Account', - description: 'Pause sending account', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Account email' } - }, - required: ['email'], - additionalProperties: false - } - }, - - { - name: 'resume_account', - title: 'Resume Account', - description: 'Resume paused sending account', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Account email' } - }, - required: ['email'], - additionalProperties: false - } - }, - - { - name: 'enable_warmup', - title: 'Enable Warmup', - description: 'Enable email warmup to improve deliverability', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Account email' } - }, - required: ['email'], - additionalProperties: false - } - }, - - { - name: 'disable_warmup', - title: 'Disable Warmup', - description: 'Disable email warmup', - inputSchema: { - type: 'object', - properties: { - email: { type: 'string', description: 'Account email' } - }, - required: ['email'], - additionalProperties: false - } - }, - - { - name: 'test_account_vitals', - title: 'Test Account Vitals', - description: 'Test account connectivity and health', + title: 'Create Account', + description: 'Create email account with IMAP/SMTP credentials', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - email: { type: 'string', description: 'Account email' } + email: { type: 'string' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + provider_code: { type: 'number', description: '1=IMAP, 2=Google, 3=Microsoft, 4=AWS' }, + imap_username: { type: 'string' }, + imap_password: { type: 'string' }, + imap_host: { type: 'string', description: 'e.g., imap.gmail.com' }, + imap_port: { type: 'number', description: 'e.g., 993' }, + smtp_username: { type: 'string' }, + smtp_password: { type: 'string' }, + smtp_host: { type: 'string', description: 'e.g., smtp.gmail.com' }, + smtp_port: { type: 'number', description: 'e.g., 587' } }, - required: ['email'], - additionalProperties: false + required: ['email', 'first_name', 'last_name', 'provider_code', 'imap_username', 'imap_password', 'imap_host', 'imap_port', 'smtp_username', 'smtp_password', 'smtp_host', 'smtp_port'] } }, { name: 'update_account', title: 'Update Account', - description: 'Update account settings (partial updates). Supports name, warmup config, daily_limit, sending_gap, tracking_domain.', + description: 'Update account settings (partial). Supports name, warmup, daily_limit, sending_gap, tracking_domain.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - email: { type: 'string', description: 'Email address of the account to update (required)' }, - - // Basic account information - first_name: { type: 'string', description: 'First name associated with the account' }, - last_name: { type: 'string', description: 'Last name associated with the account' }, - - // Warmup configuration + email: { type: 'string', description: 'Account to update' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, warmup: { type: 'object', - description: 'Warmup configuration for the account', properties: { - limit: { type: 'number', description: 'Warmup limit (number of warmup emails per day)' }, + limit: { type: 'number' }, advanced: { type: 'object', - description: 'Advanced warmup settings', properties: { - warm_ctd: { type: 'boolean', description: 'Warm click-to-deliver' }, - open_rate: { type: 'number', description: 'Target open rate for warmup emails' }, - important_rate: { type: 'number', description: 'Rate of marking emails as important' }, - read_emulation: { type: 'boolean', description: 'Enable read emulation' }, - spam_save_rate: { type: 'number', description: 'Rate of saving emails from spam' }, - weekday_only: { type: 'boolean', description: 'Send warmup emails only on weekdays' } + warm_ctd: { type: 'boolean' }, + open_rate: { type: 'number' }, + important_rate: { type: 'number' }, + read_emulation: { type: 'boolean' }, + spam_save_rate: { type: 'number' }, + weekday_only: { type: 'boolean' } } }, - warmup_custom_ftag: { type: 'string', description: 'Custom warmup tag' }, - increment: { type: 'string', description: 'Increment setting for warmup ramp-up' }, - reply_rate: { type: 'number', description: 'Target reply rate for warmup emails' } + warmup_custom_ftag: { type: 'string' }, + increment: { type: 'string' }, + reply_rate: { type: 'number' } } }, + daily_limit: { type: 'number' }, + sending_gap: { type: 'number', description: 'Minutes between emails (0-1440)' }, + enable_slow_ramp: { type: 'boolean' }, + tracking_domain_name: { type: 'string' }, + tracking_domain_status: { type: 'string' }, + skip_cname_check: { type: 'boolean' }, + remove_tracking_domain: { type: 'boolean' }, + inbox_placement_test_limit: { type: 'number' } + }, + required: ['email'] + } + }, - // Sending limits and configuration - daily_limit: { type: 'number', description: 'Daily email sending limit per account' }, - sending_gap: { type: 'number', description: 'Gap between emails sent from this account in minutes (0-1440, minimum wait time when used with multiple campaigns)' }, - enable_slow_ramp: { type: 'boolean', description: 'Enable slow ramp up for sending limits' }, - - // Tracking domain configuration - tracking_domain_name: { type: 'string', description: 'Tracking domain name' }, - tracking_domain_status: { type: 'string', description: 'Tracking domain status' }, - skip_cname_check: { type: 'boolean', description: 'Skip CNAME check for tracking domain' }, - remove_tracking_domain: { type: 'boolean', description: 'Remove tracking domain from account' }, - - // Inbox placement testing - inbox_placement_test_limit: { type: 'number', description: 'Limit for inbox placement tests' } + { + name: 'manage_account_state', + title: 'Manage Account State', + description: 'Pause, resume, enable/disable warmup, or test account vitals', + annotations: { destructiveHint: false }, + inputSchema: { + type: 'object', + properties: { + email: { type: 'string', description: 'Account email' }, + action: { + type: 'string', + description: 'Action to perform', + enum: ['pause', 'resume', 'enable_warmup', 'disable_warmup', 'test_vitals'] + } }, - required: ['email'], - additionalProperties: false + required: ['email', 'action'] } }, { name: 'delete_account', title: 'Delete Account', - description: '🚨 DESTRUCTIVE: Permanently delete email account. ⚠️ CANNOT BE UNDONE! All data lost forever. Use with extreme caution!', + description: '🚨 PERMANENTLY delete account. CANNOT UNDO. All data lost forever!', + annotations: { destructiveHint: true, confirmationRequiredHint: true }, inputSchema: { type: 'object', properties: { - email: { type: 'string', description: '⚠️ Email to DELETE PERMANENTLY' } + email: { type: 'string', description: 'Email to DELETE PERMANENTLY' } }, - required: ['email'], - additionalProperties: false + required: ['email'] } }, ]; diff --git a/src/tools/analytics-tools.ts b/src/tools/analytics-tools.ts index 9223706..ac265c5 100644 --- a/src/tools/analytics-tools.ts +++ b/src/tools/analytics-tools.ts @@ -1,109 +1,58 @@ /** - * Instantly MCP Server - Analytics Tools + * Instantly MCP Server - Analytics Tools (Compacted) * - * Tool definitions for analytics and reporting operations. + * Tool definitions for analytics and reporting. + * Optimized for minimal context window overhead. * Total: 3 analytics tools */ export const analyticsTools = [ { name: 'get_campaign_analytics', - title: 'Get Campaign Analytics', - description: 'Get campaign performance metrics (opens, clicks, replies, bounces). Filter by campaign ID(s) and date range.', + title: 'Campaign Analytics', + description: 'Get campaign metrics: opens, clicks, replies, bounces. Filter by campaign(s) and dates.', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - campaign_id: { - type: 'string', - description: 'Single campaign ID (optional, omit for all campaigns)' - }, - campaign_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Multiple campaign IDs (optional, use instead of campaign_id)' - }, - start_date: { - type: 'string', - description: 'Start date (YYYY-MM-DD, optional)', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - }, - end_date: { - type: 'string', - description: 'End date (YYYY-MM-DD, optional)', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - }, - exclude_total_leads_count: { - type: 'boolean', - description: 'Exclude leads count for faster response (optional)' - } - }, - required: [], - additionalProperties: false + campaign_id: { type: 'string', description: 'Single campaign UUID (omit for all)' }, + campaign_ids: { type: 'array', items: { type: 'string' }, description: 'Multiple campaign UUIDs' }, + start_date: { type: 'string', description: 'YYYY-MM-DD' }, + end_date: { type: 'string', description: 'YYYY-MM-DD' }, + exclude_total_leads_count: { type: 'boolean', description: 'Faster response' } + } } }, { name: 'get_daily_campaign_analytics', - title: 'Get Daily Campaign Analytics', - description: 'Get day-by-day campaign performance analytics with date filtering', + title: 'Daily Analytics', + description: 'Day-by-day campaign performance analytics', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - campaign_id: { - type: 'string', - description: 'Campaign ID (optional, omit for all)', - pattern: '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$' - }, - start_date: { - type: 'string', - description: 'Start date (YYYY-MM-DD)', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - }, - end_date: { - type: 'string', - description: 'End date (YYYY-MM-DD)', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - }, - campaign_status: { - type: 'number', - description: 'Campaign status filter: 0=Draft, 1=Active, 2=Paused, 3=Completed, 4=Running Subsequences', - enum: [0, 1, 2, 3, 4, -99, -1, -2] - } - }, - required: [], - additionalProperties: false + campaign_id: { type: 'string', description: 'Campaign UUID (omit for all)' }, + start_date: { type: 'string', description: 'YYYY-MM-DD' }, + end_date: { type: 'string', description: 'YYYY-MM-DD' }, + campaign_status: { type: 'number', description: '0=Draft, 1=Active, 2=Paused, 3=Completed', enum: [0, 1, 2, 3, 4, -99, -1, -2] } + } } }, { name: 'get_warmup_analytics', - title: 'Get Warmup Analytics', - description: 'Get email warmup analytics for one or more accounts', + title: 'Warmup Analytics', + description: 'Get warmup metrics for account(s)', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - emails: { - type: 'array', - items: { type: 'string' }, - description: 'Email addresses for warmup analytics' - }, - email: { - type: 'string', - description: 'Single email address (alternative to emails array)' - }, - start_date: { - type: 'string', - description: 'Start date (YYYY-MM-DD, optional)', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - }, - end_date: { - type: 'string', - description: 'End date (YYYY-MM-DD, optional)', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - } - }, - required: [], - additionalProperties: false + emails: { type: 'array', items: { type: 'string' }, description: 'Account emails' }, + email: { type: 'string', description: 'Single email (alternative)' }, + start_date: { type: 'string', description: 'YYYY-MM-DD' }, + end_date: { type: 'string', description: 'YYYY-MM-DD' } + } } }, ]; diff --git a/src/tools/campaign-tools.ts b/src/tools/campaign-tools.ts index 536640c..0ba2e97 100644 --- a/src/tools/campaign-tools.ts +++ b/src/tools/campaign-tools.ts @@ -1,7 +1,8 @@ /** - * Instantly MCP Server - Campaign Tools + * Instantly MCP Server - Campaign Tools (Compacted) * * Tool definitions for campaign management operations. + * Optimized for minimal context window overhead. * Total: 6 campaign tools */ @@ -11,328 +12,127 @@ export const campaignTools = [ { name: 'create_campaign', title: 'Create Campaign', - description: 'Create email campaign with two-step workflow. STEP 1: Call with name, subject, body to discover eligible accounts. STEP 2: Call with email_list from Step 1. ⚠️ NEVER use placeholder emails. Multi-step sequences: Use sequence_steps (2-10) and step_delay_days (1-30).', + description: 'Create email campaign. Two-step: 1) Call with name/subject/body to discover accounts, 2) Call again with email_list. Use sequence_steps for multi-step sequences.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - // Core required fields - name: { - type: 'string', - description: 'Campaign name for identification (e.g., "Q4 Product Launch Campaign")' - }, - subject: { - type: 'string', - description: '⚠️ CRITICAL: MUST be under 50 characters for deliverability. Use personalization: {{firstName}}, {{companyName}}. If validation fails, shorten before retrying.' - }, - body: { - type: 'string', - description: 'Email body (plain text, \\n for line breaks β†’ auto-converts to
). Personalization: {{firstName}}, {{lastName}}, {{companyName}}. Get straight to point, high-value content.' - }, - email_list: { - type: 'array', - items: { type: 'string' }, - description: '⚠️ SENDER EMAILS (optional). If omitted: Tool fetches eligible accounts and asks user. If provided: ONLY use emails from eligible list shown in Step 1. NEVER use placeholders (test@example.com). Multiple emails = ONE campaign with ALL in single array (max 100).', - example: ['john@yourcompany.com', 'jane@yourcompany.com'] - }, - - // Tracking settings (disabled by default for better deliverability) - track_opens: { - type: 'boolean', - description: 'Track when recipients open emails (disabled by default for better deliverability and privacy compliance)', - default: false - }, - track_clicks: { - type: 'boolean', - description: 'Track when recipients click links (disabled by default for better deliverability and privacy compliance)', - default: false - }, - - // Scheduling options - timezone: { - type: 'string', - description: `Timezone for sending schedule. Supported timezones: ${BUSINESS_PRIORITY_TIMEZONES.join(', ')}. Unsupported timezones will be automatically mapped to closest supported timezone.`, - default: DEFAULT_TIMEZONE, - example: DEFAULT_TIMEZONE - }, - timing_from: { - type: 'string', - description: 'Start time for sending (24h format)', - default: '09:00', - pattern: '^([01][0-9]|2[0-3]):([0-5][0-9])$' - }, - timing_to: { - type: 'string', - description: 'End time for sending (24h format)', - default: '17:00', - pattern: '^([01][0-9]|2[0-3]):([0-5][0-9])$' - }, - - // Sending limits - daily_limit: { - type: 'number', - description: 'Maximum emails per day per account (30 recommended for cold email compliance)', - default: 30, - minimum: 1, - maximum: 30 - }, - email_gap: { - type: 'number', - description: 'Minutes between emails from same account (1-1440 minutes)', - default: 10, - minimum: 1, - maximum: 1440 - }, - - // Campaign behavior - stop_on_reply: { - type: 'boolean', - description: 'Stop campaign when recipient replies', - default: true - }, - - // Advanced options (optional) - stop_on_auto_reply: { - type: 'boolean', - description: 'Stop campaign when auto-reply is detected (out-of-office, etc.)', - default: true - }, - - // Multi-step sequence configuration - sequence_steps: { - type: 'number', - description: 'Number of steps in the email sequence (optional, default: 1 for single email, max: 10).\n\n⚠️ MULTI-STEP CAMPAIGNS SUPPORTED:\nβ€’ Set to 2 or more to create follow-up sequences\nβ€’ Step 1 sends immediately when campaign starts\nβ€’ Each step waits step_delay_days before sending next step\nβ€’ Example: sequence_steps=3 with step_delay_days=2 creates:\n Step 1 β†’ Wait 2 days β†’ Step 2 β†’ Wait 2 days β†’ Step 3\n\nUse this for cold outreach sequences, nurture campaigns, and follow-up workflows.', - minimum: 1, - maximum: 10, - default: 1 - }, - step_delay_days: { - type: 'number', - description: 'Days to wait AFTER sending each step before sending the next step (optional, default: 3 days).\n\n⚠️ DELAY BEHAVIOR:\nβ€’ Applies to ALL steps in the sequence (including Step 1)\nβ€’ Step 1: delay=X (wait X days after Step 1 before Step 2)\nβ€’ Step 2: delay=X (wait X days after Step 2 before Step 3)\nβ€’ Best practices: Use 2-7 days for cold outreach\nβ€’ Only used when sequence_steps > 1', - minimum: 1, - maximum: 30, - default: 3 - }, - sequence_subjects: { - type: 'array', - items: { type: 'string' }, - description: 'Optional: Custom subject lines for each step (array of strings). Must match sequence_steps count. If not provided, follow-ups use "Follow-up: {original subject}". Example: ["Initial Email", "Follow-up 1", "Follow-up 2"]' - }, - sequence_bodies: { - type: 'array', - items: { type: 'string' }, - description: 'Optional: Custom email bodies for each step (array of strings). Must match sequence_steps count. Use \\n for line breaks. If not provided, follow-ups use auto-generated content. Example: ["Hi {{firstName}},...", "Following up...", "Last attempt..."]' - } + name: { type: 'string', description: 'Campaign name' }, + subject: { type: 'string', description: 'Subject (<50 chars). Personalization: {{firstName}}, {{companyName}}' }, + body: { type: 'string', description: 'Email body (\\n for line breaks). Personalization: {{firstName}}, {{lastName}}, {{companyName}}' }, + email_list: { type: 'array', items: { type: 'string' }, description: 'Sender emails (from Step 1 eligible list)' }, + track_opens: { type: 'boolean', default: false }, + track_clicks: { type: 'boolean', default: false }, + timezone: { type: 'string', default: DEFAULT_TIMEZONE, description: `Supported: ${BUSINESS_PRIORITY_TIMEZONES.slice(0, 5).join(', ')}...` }, + timing_from: { type: 'string', default: '09:00', description: '24h format' }, + timing_to: { type: 'string', default: '17:00', description: '24h format' }, + daily_limit: { type: 'number', default: 30, description: 'Emails/day/account (max 30)' }, + email_gap: { type: 'number', default: 10, description: 'Minutes between emails (1-1440)' }, + stop_on_reply: { type: 'boolean', default: true }, + stop_on_auto_reply: { type: 'boolean', default: true }, + sequence_steps: { type: 'number', default: 1, description: 'Steps in sequence (1-10)' }, + step_delay_days: { type: 'number', default: 3, description: 'Days between steps (1-30)' }, + sequence_subjects: { type: 'array', items: { type: 'string' }, description: 'Custom subjects per step' }, + sequence_bodies: { type: 'array', items: { type: 'string' }, description: 'Custom bodies per step' } }, - required: ['name', 'subject', 'body'], - additionalProperties: true + required: ['name', 'subject', 'body'] } }, { name: 'list_campaigns', title: 'List Campaigns', - description: 'List campaigns with pagination. Filter by search (name only). Use exact cursor from next_starting_after. Filter by status client-side.', + description: 'List campaigns with pagination. Filter by name search or tags.', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - limit: { - type: 'number', - description: 'Number of items per page (1-100, default: 100)', - minimum: 1, - maximum: 100 - }, - starting_after: { - type: 'string', - description: 'Pagination cursor from previous response. CRITICAL: Use the EXACT value from response.pagination.next_starting_after field (NOT a campaign ID from the data). Example: If previous response had "next_starting_after": "cursor123", use starting_after="cursor123". Omit for first page.' - }, - search: { - type: 'string', - description: 'Search campaigns by campaign NAME only (optional). CRITICAL: Do NOT use this for status filtering (e.g., do NOT search for "running", "active", "paused"). Only use when user explicitly wants to find a campaign by its name. Examples: search="Product Launch", search="Q4 Campaign". Leave empty to list all campaigns.' - }, - tag_ids: { - type: 'string', - description: 'Filter by tag IDs, comma-separated (optional)' - } - }, - additionalProperties: false + limit: { type: 'number', description: '1-100, default: 100' }, + starting_after: { type: 'string', description: 'Cursor from pagination.next_starting_after' }, + search: { type: 'string', description: 'Search by campaign NAME only (not status)' }, + tag_ids: { type: 'string', description: 'Comma-separated tag IDs' } + } } }, { name: 'get_campaign', title: 'Get Campaign', - description: 'Get complete campaign details by ID. Returns configuration, sequences, schedules, sender accounts, tracking settings, and status.', + description: 'Get campaign details: config, sequences, schedules, sender accounts, tracking, status', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - campaign_id: { type: 'string', description: 'Campaign ID (UUID) - REQUIRED. Get from list_campaigns if you don\'t have it. Example: "9a309ee6-2908-4158-a2c5-431c9bfadf40"' } + campaign_id: { type: 'string', description: 'Campaign UUID' } }, - required: ['campaign_id'], - additionalProperties: false + required: ['campaign_id'] } }, { name: 'update_campaign', title: 'Update Campaign', - description: 'Update campaign settings (partial updates). Only provide fields to change. Common: name, sequences, tracking, limits, email_list.', + description: 'Update campaign settings (partial). Common: name, sequences, tracking, limits, email_list.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - campaign_id: { - type: 'string', - description: 'REQUIRED: ID of the existing campaign to modify. This parameter is mandatory.' - }, - name: { - type: 'string', - description: 'OPTIONAL: New campaign name to UPDATE the existing campaign name. Only provide this if you want to CHANGE the campaign name.' - }, - pl_value: { - type: 'number', - description: 'OPTIONAL: New positive lead value to UPDATE. Only provide this if you want to MODIFY the pl_value setting.' - }, - is_evergreen: { - type: 'boolean', - description: 'OPTIONAL: New evergreen status to UPDATE. Only provide this if you want to CHANGE whether the campaign is evergreen.' - }, - campaign_schedule: { - type: 'object', - description: 'OPTIONAL: New schedule configuration to UPDATE the existing campaign schedule. Only provide this if you want to MODIFY the schedule settings.', - properties: { - schedules: { - type: 'array', - items: { - type: 'object', - additionalProperties: true - } - } - }, - additionalProperties: true - }, - sequences: { - type: 'array', - description: 'OPTIONAL: New email sequences to UPDATE the existing sequences. Only provide this if you want to MODIFY the email sequences.', - items: { - type: 'object', - additionalProperties: true - } - }, - email_gap: { - type: 'number', - description: 'OPTIONAL: New email gap value (in minutes) to UPDATE. Only provide this if you want to CHANGE the gap between emails.' - }, - random_wait_max: { - type: 'number', - description: 'OPTIONAL: New maximum random wait time (in minutes) to UPDATE. Only provide this if you want to MODIFY the random wait setting.' - }, - text_only: { - type: 'boolean', - description: 'OPTIONAL: New text-only setting to UPDATE. Only provide this if you want to CHANGE whether the campaign is text only.' - }, - email_list: { - type: 'array', - description: 'OPTIONAL: New list of account emails to UPDATE for sending. Only provide this if you want to MODIFY which email accounts are used.', - items: { type: 'string' } - }, - daily_limit: { - type: 'number', - description: 'OPTIONAL: New daily sending limit to UPDATE per account. Only provide this if you want to CHANGE the daily limit.' - }, - stop_on_reply: { - type: 'boolean', - description: 'OPTIONAL: New stop-on-reply setting to UPDATE. Only provide this if you want to MODIFY whether the campaign stops on reply.' - }, - email_tag_list: { - type: 'array', - description: 'OPTIONAL: New list of email tag UUIDs to UPDATE. Only provide this if you want to CHANGE the email tags.', - items: { type: 'string' } - }, - link_tracking: { - type: 'boolean', - description: 'OPTIONAL: New link tracking setting to UPDATE. Only provide this if you want to MODIFY whether links are tracked.' - }, - open_tracking: { - type: 'boolean', - description: 'OPTIONAL: New open tracking setting to UPDATE. Only provide this if you want to MODIFY whether opens are tracked.' - }, - stop_on_auto_reply: { - type: 'boolean', - description: 'OPTIONAL: New stop-on-auto-reply setting to UPDATE. Only provide this if you want to CHANGE whether to stop on auto-replies.' - }, - daily_max_leads: { - type: 'number', - description: 'OPTIONAL: New daily maximum leads value to UPDATE. Only provide this if you want to MODIFY the daily max leads setting.' - }, - prioritize_new_leads: { - type: 'boolean', - description: 'OPTIONAL: New prioritize-new-leads setting to UPDATE. Only provide this if you want to CHANGE the lead prioritization.' - }, - auto_variant_select: { - type: 'object', - description: 'OPTIONAL: New auto variant selection settings to UPDATE. Only provide this if you want to MODIFY the auto variant selection configuration.', - additionalProperties: true - }, - match_lead_esp: { - type: 'boolean', - description: 'OPTIONAL: New match-lead-ESP setting to UPDATE. Only provide this if you want to CHANGE whether to match leads by ESP.' - }, - stop_for_company: { - type: 'boolean', - description: 'OPTIONAL: New stop-for-company setting to UPDATE. Only provide this if you want to MODIFY whether to stop for entire company on reply.' - }, - insert_unsubscribe_header: { - type: 'boolean', - description: 'OPTIONAL: New unsubscribe header setting to UPDATE. Only provide this if you want to CHANGE whether to insert unsubscribe headers.' - }, - allow_risky_contacts: { - type: 'boolean', - description: 'OPTIONAL: New risky contacts setting to UPDATE. Only provide this if you want to MODIFY whether to allow risky contacts.' - }, - disable_bounce_protect: { - type: 'boolean', - description: 'OPTIONAL: New bounce protection setting to UPDATE. Only provide this if you want to CHANGE whether bounce protection is disabled.' - }, - cc_list: { - type: 'array', - description: 'OPTIONAL: New CC email addresses list to UPDATE. Only provide this if you want to MODIFY the CC list.', - items: { type: 'string' } - }, - bcc_list: { - type: 'array', - description: 'OPTIONAL: New BCC email addresses list to UPDATE. Only provide this if you want to MODIFY the BCC list.', - items: { type: 'string' } - } + campaign_id: { type: 'string', description: 'Campaign to update' }, + name: { type: 'string' }, + pl_value: { type: 'number' }, + is_evergreen: { type: 'boolean' }, + campaign_schedule: { type: 'object', properties: { schedules: { type: 'array', items: { type: 'object' } } } }, + sequences: { type: 'array', items: { type: 'object' } }, + email_gap: { type: 'number' }, + random_wait_max: { type: 'number' }, + text_only: { type: 'boolean' }, + email_list: { type: 'array', items: { type: 'string' } }, + daily_limit: { type: 'number' }, + stop_on_reply: { type: 'boolean' }, + email_tag_list: { type: 'array', items: { type: 'string' } }, + link_tracking: { type: 'boolean' }, + open_tracking: { type: 'boolean' }, + stop_on_auto_reply: { type: 'boolean' }, + daily_max_leads: { type: 'number' }, + prioritize_new_leads: { type: 'boolean' }, + auto_variant_select: { type: 'object' }, + match_lead_esp: { type: 'boolean' }, + stop_for_company: { type: 'boolean' }, + insert_unsubscribe_header: { type: 'boolean' }, + allow_risky_contacts: { type: 'boolean' }, + disable_bounce_protect: { type: 'boolean' }, + cc_list: { type: 'array', items: { type: 'string' } }, + bcc_list: { type: 'array', items: { type: 'string' } } }, - required: ['campaign_id'], - additionalProperties: false + required: ['campaign_id'] } }, { name: 'activate_campaign', title: 'Activate Campaign', - description: 'Activate campaign to start sending. ⚠️ Prerequisites: sender accounts, leads, sequences, schedule. Verify with get_campaign first.', + description: 'Start sending. Prerequisites: accounts, leads, sequences, schedule.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - campaign_id: { type: 'string', description: 'Campaign ID (UUID) to activate. Get from list_campaigns or create_campaign. Example: "9a309ee6-2908-4158-a2c5-431c9bfadf40"' } + campaign_id: { type: 'string', description: 'Campaign UUID to activate' } }, - required: ['campaign_id'], - additionalProperties: false + required: ['campaign_id'] } }, { name: 'pause_campaign', title: 'Pause Campaign', - description: 'Pause active campaign to stop sending. Leads remain in campaign. Use activate_campaign to resume. Only works on Active campaigns.', + description: 'Stop sending (leads remain). Use activate_campaign to resume.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - campaign_id: { type: 'string', description: 'Campaign ID (UUID, must be Active)' } + campaign_id: { type: 'string', description: 'Active campaign UUID' } }, - required: ['campaign_id'], - additionalProperties: false + required: ['campaign_id'] } }, ]; diff --git a/src/tools/email-tools.ts b/src/tools/email-tools.ts index 7697560..f2885ed 100644 --- a/src/tools/email-tools.ts +++ b/src/tools/email-tools.ts @@ -1,7 +1,8 @@ /** - * Instantly MCP Server - Email Tools + * Instantly MCP Server - Email Tools (Compacted) * - * Tool definitions for email management and communication operations. + * Tool definitions for email management operations. + * Optimized for minimal context window overhead. * Total: 5 email tools */ @@ -9,29 +10,29 @@ export const emailTools = [ { name: 'list_emails', title: 'List Emails', - description: 'List emails with pagination. Filter by campaign_id, search, account, or type. Use exact cursor from next_starting_after.', + description: 'List emails with pagination. Filter by campaign, account, type, or status.', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - limit: { type: 'number', description: 'Items per page (1-100, default: 100)', minimum: 1, maximum: 100 }, - starting_after: { type: 'string', description: 'Cursor from next_starting_after' }, - search: { type: 'string', description: 'Email/thread search (use "thread:UUID")' }, - campaign_id: { type: 'string', description: 'Campaign ID (recommended)' }, + limit: { type: 'number', description: '1-100, default: 100' }, + starting_after: { type: 'string', description: 'Cursor from pagination' }, + search: { type: 'string', description: 'Search (use "thread:UUID" for threads)' }, + campaign_id: { type: 'string' }, i_status: { type: 'number', description: 'Interest status' }, - eaccount: { type: 'string', description: 'Sender account (comma-separated)' }, - is_unread: { type: 'boolean', description: 'Unread filter' }, - has_reminder: { type: 'boolean', description: 'Reminder filter' }, - mode: { type: 'string', description: 'Mode filter', enum: ['emode_focused', 'emode_others', 'emode_all'] }, - preview_only: { type: 'boolean', description: 'Preview only' }, - sort_order: { type: 'string', description: 'Sort order', enum: ['asc', 'desc'] }, - scheduled_only: { type: 'boolean', description: 'Scheduled only' }, - assigned_to: { type: 'string', description: 'Assigned user ID' }, + eaccount: { type: 'string', description: 'Sender accounts (comma-separated)' }, + is_unread: { type: 'boolean' }, + has_reminder: { type: 'boolean' }, + mode: { type: 'string', enum: ['emode_focused', 'emode_others', 'emode_all'] }, + preview_only: { type: 'boolean' }, + sort_order: { type: 'string', enum: ['asc', 'desc'] }, + scheduled_only: { type: 'boolean' }, + assigned_to: { type: 'string' }, lead: { type: 'string', description: 'Lead email' }, - company_domain: { type: 'string', description: 'Company domain' }, - marked_as_done: { type: 'boolean', description: 'Marked as done' }, - email_type: { type: 'string', description: 'Email type', enum: ['received', 'sent', 'manual'] } - }, - additionalProperties: false + company_domain: { type: 'string' }, + marked_as_done: { type: 'boolean' }, + email_type: { type: 'string', enum: ['received', 'sent', 'manual'] } + } } }, @@ -39,71 +40,61 @@ export const emailTools = [ name: 'get_email', title: 'Get Email', description: 'Get email details by ID', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - email_id: { type: 'string', description: 'Email ID' } + email_id: { type: 'string', description: 'Email UUID' } }, - required: ['email_id'], - additionalProperties: false + required: ['email_id'] } }, { name: 'reply_to_email', title: 'Reply to Email', - description: '🚨 SENDS REAL EMAILS! ⚠️ ALWAYS confirm with user BEFORE calling. Cannot undo! Requires reply_to_uuid, eaccount, subject, body.', + description: '🚨 SENDS REAL EMAIL! Confirm with user first. Cannot undo!', + annotations: { destructiveHint: true, confirmationRequiredHint: true }, inputSchema: { type: 'object', properties: { - reply_to_uuid: { - type: 'string', - description: 'Email UUID to reply to (from list_emails)' - }, - eaccount: { - type: 'string', - description: 'Sender account (must be active)' - }, - subject: { - type: 'string', - description: 'Subject line (e.g., "Re: [original]")' - }, + reply_to_uuid: { type: 'string', description: 'Email UUID to reply to' }, + eaccount: { type: 'string', description: 'Sender account (must be active)' }, + subject: { type: 'string', description: 'Subject line' }, body: { type: 'object', - description: 'Email body (html/text or both)', properties: { - html: { type: 'string', description: 'HTML content' }, - text: { type: 'string', description: 'Plain text content' } + html: { type: 'string' }, + text: { type: 'string' } } } }, - required: ['reply_to_uuid', 'eaccount', 'subject', 'body'], - additionalProperties: false + required: ['reply_to_uuid', 'eaccount', 'subject', 'body'] } }, { name: 'count_unread_emails', - title: 'Count Unread Emails', - description: 'Count unread emails in inbox (read-only)', + title: 'Count Unread', + description: 'Count unread emails in inbox', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', - properties: {}, - additionalProperties: false + properties: {} } }, { name: 'verify_email', title: 'Verify Email', - description: 'Verify email deliverability (5-45s). Returns status, score, reason, flags. For bulk verification, use verify_leads_on_import in create_lead.', + description: 'Verify email deliverability (5-45s). Returns status, score, flags.', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - email: { type: 'string', description: 'Email to verify (user@domain.com)' } + email: { type: 'string', description: 'Email to verify' } }, - required: ['email'], - additionalProperties: false + required: ['email'] } }, ]; diff --git a/src/tools/index.ts b/src/tools/index.ts index 1cd7e86..c86cf5c 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,15 +1,25 @@ /** - * Instantly MCP Server - Tools Registry + * Instantly MCP Server - Tools Registry (Optimized v2.0) * - * Central registry that combines all tool definitions from different categories. - * This file exports the complete TOOLS_DEFINITION array used by the MCP server. + * Central registry combining all tool definitions. + * Optimized for minimal context window overhead with: + * - Compacted descriptions + * - Consolidated duplicate tools + * - MCP 2025-06-18 annotations support + * - Category-based lazy loading via TOOL_CATEGORIES env var * - * Total: 36 tools across 5 categories - * - Account tools: 11 tools - * - Campaign tools: 6 tools - * - Lead tools: 11 tools (includes bulk import, delete, and move) - * - Email tools: 5 tools - * - Analytics tools: 3 tools + * Total: 31 tools across 5 categories (reduced from 36) + * - Account tools: 6 (consolidated from 11) + * - Campaign tools: 6 + * - Lead tools: 11 + * - Email tools: 5 + * - Analytics tools: 3 + * + * LAZY LOADING: + * Set TOOL_CATEGORIES env var to load only specific categories. + * Example: TOOL_CATEGORIES="accounts,campaigns" loads only 12 tools + * Valid categories: accounts, campaigns, leads, email, analytics + * Default: all categories loaded */ import { accountTools } from './account-tools.js'; @@ -18,33 +28,78 @@ import { leadTools } from './lead-tools.js'; import { emailTools } from './email-tools.js'; import { analyticsTools } from './analytics-tools.js'; +// Category mapping for lazy loading +const CATEGORY_MAP: Record = { + accounts: accountTools, + campaigns: campaignTools, + leads: leadTools, + email: emailTools, + analytics: analyticsTools, +}; + +/** + * Build tools array based on TOOL_CATEGORIES env var + * If not set, all categories are loaded (full 31 tools) + */ +function buildToolsDefinition(): any[] { + const categoriesEnv = process.env.TOOL_CATEGORIES; + + if (!categoriesEnv) { + // Default: load all tools + return [ + ...accountTools, + ...campaignTools, + ...leadTools, + ...emailTools, + ...analyticsTools, + ]; + } + + // Parse comma-separated categories + const requestedCategories = categoriesEnv + .toLowerCase() + .split(',') + .map(c => c.trim()) + .filter(c => c.length > 0); + + const tools: any[] = []; + const loadedCategories: string[] = []; + + for (const category of requestedCategories) { + if (CATEGORY_MAP[category]) { + tools.push(...CATEGORY_MAP[category]); + loadedCategories.push(category); + } else { + console.error(`[Instantly MCP] ⚠️ Unknown tool category: "${category}". Valid: ${Object.keys(CATEGORY_MAP).join(', ')}`); + } + } + + if (tools.length > 0) { + console.error(`[Instantly MCP] πŸ”§ Lazy loading enabled: ${tools.length} tools from categories: ${loadedCategories.join(', ')}`); + } else { + console.error(`[Instantly MCP] ⚠️ No valid categories in TOOL_CATEGORIES. Loading all tools.`); + return [ + ...accountTools, + ...campaignTools, + ...leadTools, + ...emailTools, + ...analyticsTools, + ]; + } + + return tools; +} + /** * Complete tool definitions array * * This array is used by the MCP server to register all available tools. - * Tools are organized by category for better maintainability. + * Tools are filtered by TOOL_CATEGORIES env var if set. */ -export const TOOLS_DEFINITION = [ - // Account Management Tools (11 tools) - ...accountTools, - - // Campaign Management Tools (6 tools) - ...campaignTools, - - // Lead Management Tools (11 tools - includes bulk import, delete, and move) - ...leadTools, - - // Email Management Tools (5 tools) - ...emailTools, - - // Analytics & Reporting Tools (3 tools) - ...analyticsTools, -]; +export const TOOLS_DEFINITION = buildToolsDefinition(); /** - * Tool count by category - * - * Useful for validation and debugging + * Tool count by category (reflects loaded tools, not all available) */ export const TOOL_COUNTS = { account: accountTools.length, @@ -52,9 +107,24 @@ export const TOOL_COUNTS = { lead: leadTools.length, email: emailTools.length, analytics: analyticsTools.length, - total: TOOLS_DEFINITION.length, + loaded: TOOLS_DEFINITION.length, + maxAvailable: 31, // Total when all categories loaded }; +/** + * Get available categories for lazy loading + */ +export function getAvailableCategories(): string[] { + return Object.keys(CATEGORY_MAP); +} + +/** + * Check if lazy loading is active + */ +export function isLazyLoadingEnabled(): boolean { + return !!process.env.TOOL_CATEGORIES; +} + /** * Validate tool definitions * @@ -85,9 +155,9 @@ export function validateToolDefinitions(): { valid: boolean; errors: string[] } } } - // Validate expected count - if (TOOLS_DEFINITION.length !== 36) { - errors.push(`Expected 36 tools, found ${TOOLS_DEFINITION.length}`); + // Dynamic validation: check loaded count is reasonable + if (TOOLS_DEFINITION.length === 0) { + errors.push('No tools loaded - check TOOL_CATEGORIES env var'); } return { diff --git a/src/tools/lead-tools.ts b/src/tools/lead-tools.ts index 38550d9..6e41140 100644 --- a/src/tools/lead-tools.ts +++ b/src/tools/lead-tools.ts @@ -1,67 +1,32 @@ /** - * Instantly MCP Server - Lead Tools + * Instantly MCP Server - Lead Tools (Compacted) * - * Tool definitions for lead and lead list management operations. - * Total: 11 lead tools (includes bulk import, delete, and move) + * Tool definitions for lead and lead list management. + * Optimized for minimal context window overhead. + * Total: 11 lead tools */ export const leadTools = [ { name: 'list_leads', title: 'List Leads', - description: 'List leads with pagination. Filter by campaign, list, search, or status. Use exact cursor from pagination.next_starting_after.', + description: 'List leads with pagination. Filter by campaign, list, search, or status.', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - // Basic filtering parameters - campaign: { type: 'string', description: 'Campaign ID to filter leads (UUID format)' }, - list_id: { type: 'string', description: 'List ID to filter leads (UUID format)' }, - list_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by multiple list IDs (optional). Example: ["list1", "list2"]' - }, - status: { type: 'string', description: 'Filter by lead status (optional)' }, - - // Date filtering (client-side) - created_after: { - type: 'string', - description: 'Filter leads created after this date (YYYY-MM-DD format). Client-side filtering applied after retrieval. Example: "2025-09-01"', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - }, - created_before: { - type: 'string', - description: 'Filter leads created before this date (YYYY-MM-DD format). Client-side filtering applied after retrieval. Example: "2025-09-30"', - pattern: '^\\d{4}-\\d{2}-\\d{2}$' - }, - - // Search and filtering - search: { - type: 'string', - description: 'Search string to search leads by First Name, Last Name, or Email. Example: "John Doe"' - }, - filter: { - type: 'string', - description: 'Contact status filter. Values: FILTER_VAL_CONTACTED (replied), FILTER_VAL_NOT_CONTACTED (not contacted), FILTER_VAL_COMPLETED (completed), FILTER_VAL_UNSUBSCRIBED (unsubscribed), FILTER_VAL_ACTIVE (active), FILTER_LEAD_INTERESTED (interested), FILTER_LEAD_MEETING_BOOKED (meeting booked), FILTER_LEAD_CLOSED (closed/won)' - }, - distinct_contacts: { - type: 'boolean', - description: 'Group leads by email address (true) or show all instances (false). Default: false. Use true to deduplicate by email.' - }, - - // Pagination - limit: { - type: 'number', - description: 'Number of items per page (1-100, default: 100)', - minimum: 1, - maximum: 100 - }, - starting_after: { - type: 'string', - description: 'Pagination cursor from previous response. CRITICAL: Use the EXACT value from response.pagination.next_starting_after field (NOT a lead ID or email from the data). Example: If previous response had "next_starting_after": "cursor_abc123", use starting_after="cursor_abc123". Omit for first page.' - } - }, - additionalProperties: false + campaign: { type: 'string', description: 'Campaign UUID' }, + list_id: { type: 'string', description: 'List UUID' }, + list_ids: { type: 'array', items: { type: 'string' } }, + status: { type: 'string' }, + created_after: { type: 'string', description: 'YYYY-MM-DD' }, + created_before: { type: 'string', description: 'YYYY-MM-DD' }, + search: { type: 'string', description: 'Name or email' }, + filter: { type: 'string', description: 'FILTER_VAL_CONTACTED, FILTER_VAL_NOT_CONTACTED, FILTER_VAL_COMPLETED, FILTER_VAL_ACTIVE, etc.' }, + distinct_contacts: { type: 'boolean', description: 'Dedupe by email' }, + limit: { type: 'number', description: '1-100, default: 100' }, + starting_after: { type: 'string', description: 'Cursor from pagination.next_starting_after' } + } } }, @@ -69,302 +34,217 @@ export const leadTools = [ name: 'get_lead', title: 'Get Lead', description: 'Get lead details by ID', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - lead_id: { type: 'string', description: 'Lead ID (UUID)' } + lead_id: { type: 'string', description: 'Lead UUID' } }, - required: ['lead_id'], - additionalProperties: false + required: ['lead_id'] } }, { name: 'create_lead', title: 'Create Lead', - description: 'Create lead with custom variables. ⚠️ When using campaign_id, match custom_variables to existing campaign fields. Use skip_if_in_campaign to prevent duplicates.', + description: 'Create lead with custom variables. Use skip_if_in_campaign to prevent duplicates.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - campaign: { type: 'string', description: 'Campaign ID (UUID)' }, - email: { type: 'string', description: 'Email (required, user@domain.com)' }, - first_name: { type: 'string', description: 'First name' }, - last_name: { type: 'string', description: 'Last name' }, - company_name: { type: 'string', description: 'Company name' }, - phone: { type: 'string', description: 'Phone' }, - website: { type: 'string', description: 'Website URL' }, - personalization: { type: 'string', description: 'Custom message' }, - lt_interest_status: { type: 'number', description: 'Interest status (-3 to 4)', minimum: -3, maximum: 4 }, - pl_value_lead: { type: 'string', description: 'Lead value (e.g., "$5000")' }, - list_id: { type: 'string', description: 'List ID (UUID)' }, - assigned_to: { type: 'string', description: 'User ID to assign' }, - skip_if_in_workspace: { type: 'boolean', description: 'Skip if email exists in workspace', default: false }, - skip_if_in_campaign: { type: 'boolean', description: 'Skip if email exists in campaign (recommended)', default: false }, - skip_if_in_list: { type: 'boolean', description: 'Skip if email exists in list', default: false }, - blocklist_id: { type: 'string', description: 'Blocklist ID to check' }, - verify_leads_for_lead_finder: { type: 'boolean', description: 'Enable lead finder verification', default: false }, - verify_leads_on_import: { type: 'boolean', description: 'Verify email before import (adds 2-5s)', default: false }, - custom_variables: { - type: 'object', - description: '⚠️ Ask user about campaign variables first! Match exact field names. Examples: {"headcount": "50-100", "revenue": "$1M-$5M"}', - additionalProperties: true - } - }, - required: [], - additionalProperties: false + campaign: { type: 'string', description: 'Campaign UUID' }, + email: { type: 'string', description: 'Required' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + company_name: { type: 'string' }, + phone: { type: 'string' }, + website: { type: 'string' }, + personalization: { type: 'string' }, + lt_interest_status: { type: 'number', description: '-3 to 4' }, + pl_value_lead: { type: 'string' }, + list_id: { type: 'string' }, + assigned_to: { type: 'string' }, + skip_if_in_workspace: { type: 'boolean' }, + skip_if_in_campaign: { type: 'boolean', description: 'Recommended' }, + skip_if_in_list: { type: 'boolean' }, + blocklist_id: { type: 'string' }, + verify_leads_on_import: { type: 'boolean' }, + custom_variables: { type: 'object', description: 'Match campaign field names' } + } } }, { name: 'update_lead', title: 'Update Lead', - description: 'Update lead (partial updates). ⚠️ custom_variables replaces entire object - include all existing fields. Get current data with get_lead first.', + description: 'Update lead (partial). ⚠️ custom_variables replaces entire object.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - lead_id: { type: 'string', description: 'Lead ID (UUID, required)' }, - personalization: { type: 'string', description: 'Custom message' }, - website: { type: 'string', description: 'Website URL' }, - last_name: { type: 'string', description: 'Last name' }, - first_name: { type: 'string', description: 'First name' }, - company_name: { type: 'string', description: 'Company name' }, - phone: { type: 'string', description: 'Phone' }, - lt_interest_status: { type: 'number', description: 'Interest status (-3 to 4)', minimum: -3, maximum: 4 }, - pl_value_lead: { type: 'string', description: 'Lead value' }, - assigned_to: { type: 'string', description: 'User UUID to assign' }, - custom_variables: { - type: 'object', - description: '⚠️ REPLACES entire object! Include ALL existing + new fields. Get current with get_lead first.', - additionalProperties: true - } + lead_id: { type: 'string', description: 'Lead UUID' }, + personalization: { type: 'string' }, + website: { type: 'string' }, + last_name: { type: 'string' }, + first_name: { type: 'string' }, + company_name: { type: 'string' }, + phone: { type: 'string' }, + lt_interest_status: { type: 'number' }, + pl_value_lead: { type: 'string' }, + assigned_to: { type: 'string' }, + custom_variables: { type: 'object', description: 'Replaces all - include existing!' } }, - required: ['lead_id'], - additionalProperties: false + required: ['lead_id'] } }, { name: 'list_lead_lists', title: 'List Lead Lists', - description: 'List lead lists with pagination. Filter by search or enrichment status. Use exact cursor from pagination field.', + description: 'List lead lists with pagination and search', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - limit: { type: 'number', description: 'Number of items to return (1-100, default: 100)', minimum: 1, maximum: 100 }, - starting_after: { type: 'string', description: 'Pagination cursor (timestamp) from previous response. CRITICAL: Use the EXACT value from response pagination field (NOT constructed manually). The API returns the correct cursor. Omit for first page.' }, - has_enrichment_task: { type: 'boolean', description: 'Filter by enrichment task status - true returns only lists with enrichment enabled, false returns only lists without enrichment' }, - search: { type: 'string', description: 'Search query to filter lead lists by name (e.g., "Summer 2025 List")' } - }, - additionalProperties: false + limit: { type: 'number', description: '1-100, default: 100' }, + starting_after: { type: 'string', description: 'Cursor from pagination' }, + has_enrichment_task: { type: 'boolean' }, + search: { type: 'string', description: 'Search by name' } + } } }, { name: 'create_lead_list', title: 'Create Lead List', - description: 'Create lead list to organize leads by source/segment. Set has_enrichment_task=true to auto-enrich data.', + description: 'Create list. Set has_enrichment_task=true for auto-enrich.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - name: { type: 'string', description: 'List name (e.g., "LinkedIn Prospects Q1 2025")' }, - has_enrichment_task: { type: 'boolean', description: 'Auto-enrich lead data (default: false)', default: false }, - owned_by: { type: 'string', description: 'Owner user ID (UUID)' } + name: { type: 'string', description: 'List name' }, + has_enrichment_task: { type: 'boolean' }, + owned_by: { type: 'string', description: 'Owner UUID' } }, - required: ['name'], - additionalProperties: false + required: ['name'] } }, { name: 'update_lead_list', title: 'Update Lead List', - description: 'Update lead list (name, enrichment, owner). Partial updates supported.', + description: 'Update list name, enrichment, or owner', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - list_id: { type: 'string', description: 'Lead list ID (UUID)' }, - name: { type: 'string', description: 'New name' }, - has_enrichment_task: { type: 'boolean', description: 'Enable/disable enrichment' }, - owned_by: { type: 'string', description: 'New owner user ID' } + list_id: { type: 'string' }, + name: { type: 'string' }, + has_enrichment_task: { type: 'boolean' }, + owned_by: { type: 'string' } }, - required: ['list_id'], - additionalProperties: false + required: ['list_id'] } }, { name: 'get_verification_stats_for_lead_list', - title: 'Get Verification Stats', - description: 'Get email verification statistics for lead list (verified, invalid, risky, catch-all, pending)', + title: 'Verification Stats', + description: 'Get email verification stats for list', + annotations: { readOnlyHint: true }, inputSchema: { type: 'object', properties: { - list_id: { type: 'string', description: 'Lead list ID (UUID)' } + list_id: { type: 'string', description: 'List UUID' } }, - required: ['list_id'], - additionalProperties: false + required: ['list_id'] } }, { name: 'add_leads_to_campaign_or_list_bulk', title: 'Bulk Add Leads', - description: 'Bulk add up to 1,000 leads to campaign OR list. 10-100x faster than individual create_lead. Use skip_if_in_campaign to prevent duplicates.', + description: 'Add up to 1,000 leads. 10-100x faster than create_lead.', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { leads: { type: 'array', - description: 'Lead objects (1-1000). Each: email, first_name, last_name, company_name, phone, website, personalization, lt_interest_status, pl_value_lead, assigned_to, custom_variables.', items: { type: 'object', properties: { - email: { type: 'string', description: 'Email (required for campaigns)' }, - first_name: { type: 'string', description: 'First name' }, - last_name: { type: 'string', description: 'Last name' }, - company_name: { type: 'string', description: 'Company' }, - phone: { type: 'string', description: 'Phone' }, - website: { type: 'string', description: 'Website' }, - personalization: { type: 'string', description: 'Custom message' }, - lt_interest_status: { type: 'number', description: 'Interest status (-3 to 4)', minimum: -3, maximum: 4 }, - pl_value_lead: { type: 'string', description: 'Lead value' }, - assigned_to: { type: 'string', description: 'User UUID' }, - custom_variables: { - type: 'object', - description: '⚠️ Align with campaign variables!', - additionalProperties: true - } - }, - additionalProperties: false + email: { type: 'string' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + company_name: { type: 'string' }, + phone: { type: 'string' }, + website: { type: 'string' }, + personalization: { type: 'string' }, + lt_interest_status: { type: 'number' }, + pl_value_lead: { type: 'string' }, + assigned_to: { type: 'string' }, + custom_variables: { type: 'object' } + } }, - minItems: 1, - maxItems: 1000 - }, - campaign_id: { type: 'string', description: 'Campaign UUID (use this OR list_id)' }, - list_id: { type: 'string', description: 'List UUID (use this OR campaign_id)' }, - blocklist_id: { type: 'string', description: 'Blocklist UUID' }, - assigned_to: { type: 'string', description: 'User UUID for all leads' }, - verify_leads_on_import: { type: 'boolean', description: 'Verify emails (recommended)', default: false }, - skip_if_in_workspace: { type: 'boolean', description: 'Skip if exists in workspace', default: false }, - skip_if_in_campaign: { type: 'boolean', description: 'Skip if exists in campaign (recommended)', default: false }, - skip_if_in_list: { type: 'boolean', description: 'Skip if exists in list', default: false } + description: '1-1000 leads' + }, + campaign_id: { type: 'string', description: 'Use OR list_id' }, + list_id: { type: 'string', description: 'Use OR campaign_id' }, + blocklist_id: { type: 'string' }, + assigned_to: { type: 'string' }, + verify_leads_on_import: { type: 'boolean' }, + skip_if_in_workspace: { type: 'boolean' }, + skip_if_in_campaign: { type: 'boolean', description: 'Recommended' }, + skip_if_in_list: { type: 'boolean' } }, - required: ['leads'], - additionalProperties: false + required: ['leads'] } }, { name: 'delete_lead', title: 'Delete Lead', - description: 'πŸ—‘οΈ DESTRUCTIVE: Permanently delete lead. ⚠️ CANNOT BE UNDONE! Removes from all campaigns/lists. Always confirm with user first.', + description: 'πŸ—‘οΈ PERMANENTLY delete. CANNOT UNDO!', + annotations: { destructiveHint: true, confirmationRequiredHint: true }, inputSchema: { type: 'object', properties: { - lead_id: { - type: 'string', - description: '⚠️ Lead ID to DELETE PERMANENTLY (cannot recover!)' - } + lead_id: { type: 'string', description: 'Lead UUID to DELETE' } }, - required: ['lead_id'], - additionalProperties: false + required: ['lead_id'] } }, { name: 'move_leads_to_campaign_or_list', title: 'Move/Copy Leads', - description: 'Move/copy leads between campaigns/lists (background job). Requires to_campaign_id OR to_list_id. Set copy_leads=true to copy instead of move.', + description: 'Move or copy leads between campaigns/lists (background job)', + annotations: { destructiveHint: false }, inputSchema: { type: 'object', properties: { - to_campaign_id: { - type: 'string', - description: 'Destination campaign ID (use this OR to_list_id)' - }, - to_list_id: { - type: 'string', - description: 'Destination list ID (use this OR to_campaign_id)' - }, - ids: { - type: 'array', - items: { type: 'string' }, - description: 'Lead IDs to move' - }, - search: { - type: 'string', - description: 'Search string (name/email)' - }, - filter: { - type: 'string', - description: 'Contact status: FILTER_VAL_CONTACTED, FILTER_VAL_NOT_CONTACTED, FILTER_VAL_COMPLETED, FILTER_VAL_UNSUBSCRIBED, FILTER_VAL_ACTIVE, FILTER_LEAD_INTERESTED, FILTER_LEAD_MEETING_BOOKED, FILTER_LEAD_CLOSED' - }, - campaign: { - type: 'string', - description: 'Source campaign ID' - }, - list_id: { - type: 'string', - description: 'Source list ID' - }, - in_campaign: { - type: 'boolean', - description: 'Filter: in campaign (true/false)' - }, - in_list: { - type: 'boolean', - description: 'Filter: in list (true/false)' - }, - queries: { - type: 'array', - items: { type: 'object' }, - description: 'Advanced filters' - }, - excluded_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Lead IDs to exclude' - }, - contacts: { - type: 'array', - items: { type: 'string' }, - description: 'Email addresses to filter' - }, - check_duplicates_in_campaigns: { - type: 'boolean', - description: 'Check duplicates (recommended: true)' - }, - skip_leads_in_verification: { - type: 'boolean', - description: 'Skip leads being verified (recommended: true)' - }, - limit: { - type: 'number', - description: 'Max leads to move' - }, - assigned_to: { - type: 'string', - description: 'User ID to assign moved leads' - }, - esp_code: { - type: 'number', - description: 'ESP code: 0=Queue, 1=Google, 2=Microsoft, 3=Zoho, 9=Yahoo, 10=Yandex, 12=Web.de, 13=Libero.it, 999=Other, 1000=Not Found' - }, - esg_code: { - type: 'number', - description: 'ESG code: 0=Queue, 1=Barracuda, 2=Mimecast, 3=Proofpoint, 4=Cisco' - }, - copy_leads: { - type: 'boolean', - description: 'Copy instead of move (default: false)' - }, - check_duplicates: { - type: 'boolean', - description: 'Check duplicates (default: false)' - } - }, - required: [], - additionalProperties: false + to_campaign_id: { type: 'string', description: 'Destination (OR to_list_id)' }, + to_list_id: { type: 'string', description: 'Destination (OR to_campaign_id)' }, + ids: { type: 'array', items: { type: 'string' }, description: 'Lead IDs' }, + search: { type: 'string' }, + filter: { type: 'string', description: 'Contact status filter' }, + campaign: { type: 'string', description: 'Source campaign' }, + list_id: { type: 'string', description: 'Source list' }, + in_campaign: { type: 'boolean' }, + in_list: { type: 'boolean' }, + queries: { type: 'array', items: { type: 'object' } }, + excluded_ids: { type: 'array', items: { type: 'string' } }, + contacts: { type: 'array', items: { type: 'string' } }, + check_duplicates_in_campaigns: { type: 'boolean' }, + skip_leads_in_verification: { type: 'boolean' }, + limit: { type: 'number' }, + assigned_to: { type: 'string' }, + esp_code: { type: 'number', description: '0=Queue, 1=Google, 2=MS, etc.' }, + esg_code: { type: 'number', description: '0=Queue, 1=Barracuda, etc.' }, + copy_leads: { type: 'boolean', description: 'Copy instead of move' }, + check_duplicates: { type: 'boolean' } + } } }, ]; diff --git a/src/utils/response-formatter.ts b/src/utils/response-formatter.ts new file mode 100644 index 0000000..364ef5f --- /dev/null +++ b/src/utils/response-formatter.ts @@ -0,0 +1,231 @@ +/** + * Response Formatter Utility + * + * Converts tool responses to optimal format (TOON or JSON) based on data structure. + * TOON provides 30-60% token savings for uniform tabular data while maintaining + * full compatibility with MCP protocol (text content is valid). + */ + +import { encode as encodeTOON } from '@toon-format/toon'; + +/** + * Configuration for response formatting + */ +export interface FormatOptions { + /** + * Enable TOON encoding for responses (default: true) + * When true, automatically converts uniform arrays to TOON format + */ + enableTOON?: boolean; + + /** + * Delimiter for TOON arrays (default: ',') + * Options: ',' (comma), '\t' (tab), '|' (pipe) + * Tab often provides best token efficiency + */ + delimiter?: ',' | '\t' | '|'; + + /** + * Minimum array length to use TOON (default: 3) + * Smaller arrays may not benefit from TOON overhead + */ + minArrayLength?: number; + + /** + * Enable key folding for nested objects (default: false) + * Collapses single-key wrapper chains into dotted paths + */ + keyFolding?: 'off' | 'safe'; +} + +const DEFAULT_OPTIONS: Required = { + enableTOON: true, + delimiter: '\t', // Tab is most token-efficient + minArrayLength: 3, + keyFolding: 'off' +}; + +/** + * Determines if data structure is suitable for TOON encoding + * + * TOON excels at: + * - Uniform arrays of objects (same fields, primitive values) + * - Time-series data + * - Tabular data + * + * JSON is better for: + * - Non-uniform data + * - Deeply nested structures + * - Small arrays (< 3 items) + */ +function shouldUseTOON(data: any, options: Required): boolean { + if (!options.enableTOON) return false; + + // Check if data contains arrays suitable for TOON + if (Array.isArray(data)) { + return data.length >= options.minArrayLength && isUniformArray(data); + } + + // Check if object contains array properties suitable for TOON + if (typeof data === 'object' && data !== null) { + return Object.values(data).some(value => + Array.isArray(value) && + value.length >= options.minArrayLength && + isUniformArray(value) + ); + } + + return false; +} + +/** + * Checks if array contains uniform objects (same keys, primitive values) + * + * Updated to be more lenient with real-world API data: + * - Allows optional fields (not all objects need same keys) + * - Requires at least 50% key overlap + * - Normalizes data by filling missing fields with null + */ +function isUniformArray(arr: any[]): boolean { + if (arr.length === 0) return false; + + // Check if all items are objects + if (!arr.every(item => typeof item === 'object' && item !== null && !Array.isArray(item))) { + return false; + } + + // Collect all unique keys across all objects + const allKeys = new Set(); + arr.forEach(item => { + Object.keys(item).forEach(key => allKeys.add(key)); + }); + + const uniqueKeys = Array.from(allKeys); + + // Check if objects have reasonable key overlap (at least 50%) + const keyOverlapThreshold = 0.5; + const hasGoodOverlap = arr.every(item => { + const itemKeys = Object.keys(item); + const overlap = itemKeys.filter(key => uniqueKeys.includes(key)).length; + return overlap / uniqueKeys.length >= keyOverlapThreshold; + }); + + if (!hasGoodOverlap) return false; + + // Check if all values are primitives or simple objects + return arr.every(item => { + return Object.values(item).every(value => { + const type = typeof value; + // Allow primitives, null, and empty objects (like warmup: {}) + if (type === 'string' || type === 'number' || type === 'boolean' || value === null) { + return true; + } + // Allow empty objects + if (type === 'object' && value !== null && !Array.isArray(value) && Object.keys(value as object).length === 0) { + return true; + } + return false; + }); + }); +} + +/** + * Normalizes array of objects to have consistent keys + * Fills missing fields with null for TOON compatibility + */ +function normalizeArray(arr: any[]): any[] { + if (arr.length === 0) return arr; + + // Collect all unique keys + const allKeys = new Set(); + arr.forEach(item => { + Object.keys(item).forEach(key => allKeys.add(key)); + }); + + const keys = Array.from(allKeys).sort(); + + // Normalize each object to have all keys + return arr.map(item => { + const normalized: any = {}; + keys.forEach(key => { + normalized[key] = item[key] !== undefined ? item[key] : null; + }); + return normalized; + }); +} + +/** + * Formats tool response data for optimal token efficiency + * + * @param data - Response data to format + * @param options - Formatting options + * @returns Formatted string (TOON or JSON) + */ +export function formatResponse(data: any, options: Partial = {}): string { + const opts = { ...DEFAULT_OPTIONS, ...options }; + + try { + // Determine if TOON encoding would be beneficial + if (shouldUseTOON(data, opts)) { + // Normalize data for TOON encoding + let normalizedData = data; + + // If data is an array, normalize it + if (Array.isArray(data)) { + normalizedData = normalizeArray(data); + } + // If data is an object with array properties, normalize those arrays + else if (typeof data === 'object' && data !== null) { + normalizedData = { ...data }; + Object.keys(normalizedData).forEach(key => { + if (Array.isArray(normalizedData[key]) && isUniformArray(normalizedData[key])) { + normalizedData[key] = normalizeArray(normalizedData[key]); + } + }); + } + + const toonOutput = encodeTOON(normalizedData, { + delimiter: opts.delimiter, + keyFolding: opts.keyFolding + }); + + // Check if TOON actually used tabular format (contains "{" for field headers) + // If it used YAML-style list format (starts with "- "), it's likely worse than JSON + const usedTabularFormat = toonOutput.includes('{') && toonOutput.includes('}:'); + + if (!usedTabularFormat) { + // TOON chose YAML-style format - fall back to JSON for better token efficiency + console.error('[Response Formatter] TOON used YAML format instead of tabular, falling back to JSON'); + return JSON.stringify(data, null, 2); + } + + // Add format indicator for LLM + return `[TOON Format - Tab-delimited]\n${toonOutput}`; + } + } catch (error) { + // Fallback to JSON if TOON encoding fails + console.error('[Response Formatter] TOON encoding failed, falling back to JSON:', error); + } + + // Default to pretty-printed JSON + return JSON.stringify(data, null, 2); +} + +/** + * Creates MCP-compatible content response + * + * @param data - Response data + * @param options - Formatting options + * @returns MCP content array + */ +export function createMCPResponse(data: any, options: Partial = {}) { + return { + content: [ + { + type: 'text' as const, + text: formatResponse(data, options) + } + ] + }; +} +