From 6d342a15455c29c873a921bd49cd88b870133531 Mon Sep 17 00:00:00 2001 From: Jake Shore Date: Sat, 14 Feb 2026 05:47:14 -0500 Subject: [PATCH] =?UTF-8?q?Phase=201:=20Tier=202=20complete=20=E2=80=94=20?= =?UTF-8?q?13=20servers=20upgraded=20to=20gold=20standard=20architecture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - READMEs added: asana, close, freshdesk, google-console, gusto, square - main.ts + server.ts (lazy loading): activecampaign, clickup, klaviyo, mailchimp, pipedrive, trello, touchbistro, closebot, close, google-console - All 13 compile with 0 TSC errors --- servers/activecampaign/package.json | 5 +- .../src/{index.ts => index.ts.bak} | 0 servers/activecampaign/src/main.ts | 55 ++ servers/activecampaign/src/server.ts | 241 +++++++ servers/apollo/package.json | 21 +- servers/apollo/src/{index.ts => index.ts.bak} | 0 servers/apollo/src/main.ts | 87 +++ servers/apollo/src/server.ts | 139 ++++ servers/apollo/src/tools/accounts.ts | 442 +++++++------ servers/apollo/src/tools/contacts.ts | 609 +++++++++++------- servers/apollo/src/tools/emails.ts | 229 ++++--- servers/apollo/src/tools/enrichment.ts | 132 ++++ servers/apollo/src/tools/opportunities.ts | 265 ++++---- servers/apollo/src/tools/search.ts | 155 +++++ servers/apollo/src/tools/sequences.ts | 424 ++++++++---- servers/apollo/src/tools/tasks.ts | 242 ++++--- servers/clickup/package.json | 4 +- .../clickup/src/{index.ts => index.ts.bak} | 0 servers/clickup/src/main.ts | 70 ++ servers/clickup/src/server.ts | 203 ++++++ servers/close/package.json | 6 +- servers/close/src/{index.ts => index.ts.bak} | 0 servers/close/src/main.ts | 56 ++ servers/close/src/server.ts | 206 ++++++ servers/closebot/package.json | 10 +- .../closebot/src/{index.ts => index.ts.bak} | 0 servers/closebot/src/main.ts | 54 ++ servers/closebot/src/server.ts | 190 ++++++ servers/google-console/package.json | 6 +- .../src/{index.ts => index.ts.bak} | 0 servers/google-console/src/main.ts | 80 +++ servers/greenhouse/README.md | 191 +++--- servers/greenhouse/package.json | 21 +- .../greenhouse/src/{index.ts => index.ts.bak} | 0 servers/greenhouse/src/main.ts | 69 ++ servers/greenhouse/src/server.ts | 137 ++++ servers/greenhouse/src/tools/applications.ts | 256 ++++---- servers/greenhouse/src/tools/candidates.ts | 356 ++++++---- servers/greenhouse/src/tools/interviews.ts | 112 ++++ servers/greenhouse/src/tools/jobs.ts | 283 ++++---- servers/greenhouse/src/tools/offers.ts | 161 ++--- servers/greenhouse/src/tools/organization.ts | 180 ++++++ servers/greenhouse/src/tools/scorecards.ts | 149 +++++ servers/greenhouse/src/tools/users.ts | 102 +-- servers/klaviyo/package.json | 5 +- .../klaviyo/src/{index.ts => index.ts.bak} | 0 servers/klaviyo/src/main.ts | 53 ++ servers/klaviyo/src/server.ts | 175 +++++ servers/lever/src/{index.ts => index.ts.bak} | 0 .../lever/src/tools/opportunities-tools.ts | 540 +++++++++------- servers/lever/src/tools/postings-tools.ts | 392 +++++------ servers/lever/src/tools/sources-tools.ts | 35 + servers/lever/src/tools/tags-tools.ts | 67 ++ servers/mailchimp/package.json | 4 +- .../mailchimp/src/{index.ts => index.ts.bak} | 0 servers/mailchimp/src/main.ts | 55 ++ servers/mailchimp/src/server.ts | 294 +++++++++ servers/pandadoc/package.json | 31 +- .../pandadoc/src/client/pandadoc-client.ts | 13 + .../pandadoc/src/{index.ts => index.ts.bak} | 0 servers/pandadoc/src/main.ts | 44 ++ servers/pandadoc/src/server.ts | 136 ++++ .../src/tools/document-advanced-tools.ts | 231 +++++++ servers/pipedrive/package.json | 4 +- .../pipedrive/src/{index.ts => index.ts.bak} | 0 servers/pipedrive/src/main.ts | 53 ++ servers/pipedrive/src/server.ts | 186 ++++++ servers/reonomy/package.json | 27 +- .../reonomy/src/{index.ts => index.ts.bak} | 0 servers/reonomy/src/main.ts | 44 ++ servers/reonomy/src/server.ts | 131 ++++ .../src/tools/property-financials-tools.ts | 234 +++++++ servers/salesloft/package.json | 30 +- .../salesloft/src/{index.ts => index.ts.bak} | 0 servers/salesloft/src/main.ts | 44 ++ servers/salesloft/src/server.ts | 141 ++++ .../src/tools/cadence-membership-tools.ts | 299 +++++++++ servers/sendgrid/README.md | 156 ++++- servers/sendgrid/package.json | 19 +- .../sendgrid/src/{index.ts => index.ts.bak} | 0 servers/sendgrid/src/main.ts | 60 ++ servers/sendgrid/src/server.ts | 140 ++++ servers/sendgrid/src/tools/campaigns.ts | 129 ++++ servers/sendgrid/src/tools/contacts.ts | 147 +++++ servers/sendgrid/src/tools/lists.ts | 90 +++ servers/sendgrid/src/tools/messages.ts | 140 ++++ servers/sendgrid/src/tools/senders.ts | 121 ++++ servers/sendgrid/src/tools/stats.ts | 31 + servers/sendgrid/src/tools/suppressions.ts | 104 +++ servers/sendgrid/src/tools/templates.ts | 87 +++ servers/supabase/src/tools/database.ts | 145 +++++ servers/supabase/src/tools/projects.ts | 115 ++++ servers/touchbistro/package.json | 5 +- .../src/{index.ts => index.ts.bak} | 0 servers/touchbistro/src/main.ts | 69 ++ servers/touchbistro/src/server.ts | 225 +++++++ servers/trello/package.json | 10 +- servers/trello/src/{index.ts => index.ts.bak} | 0 servers/trello/src/main.ts | 58 ++ servers/trello/src/server.ts | 328 ++++++++++ servers/typeform/README.md | 181 ++++-- servers/typeform/package.json | 11 +- .../typeform/src/{index.ts => index.ts.bak} | 0 servers/typeform/src/main.ts | 82 +++ servers/typeform/src/server.ts | 184 ++++++ servers/typeform/src/tools/forms.ts | 361 +++++++---- servers/typeform/src/tools/images.ts | 147 +++-- servers/typeform/src/tools/insights.ts | 112 +++- servers/typeform/src/tools/responses.ts | 189 ++++-- servers/typeform/src/tools/themes.ts | 265 +++++--- servers/typeform/src/tools/webhooks.ts | 280 +++++--- servers/typeform/src/tools/workspaces.ts | 254 +++++--- 112 files changed, 10868 insertions(+), 2593 deletions(-) rename servers/activecampaign/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/activecampaign/src/main.ts create mode 100644 servers/activecampaign/src/server.ts rename servers/apollo/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/apollo/src/main.ts create mode 100644 servers/apollo/src/server.ts create mode 100644 servers/apollo/src/tools/enrichment.ts create mode 100644 servers/apollo/src/tools/search.ts rename servers/clickup/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/clickup/src/main.ts create mode 100644 servers/clickup/src/server.ts rename servers/close/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/close/src/main.ts create mode 100644 servers/close/src/server.ts rename servers/closebot/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/closebot/src/main.ts create mode 100644 servers/closebot/src/server.ts rename servers/google-console/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/google-console/src/main.ts rename servers/greenhouse/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/greenhouse/src/main.ts create mode 100644 servers/greenhouse/src/server.ts create mode 100644 servers/greenhouse/src/tools/interviews.ts create mode 100644 servers/greenhouse/src/tools/organization.ts create mode 100644 servers/greenhouse/src/tools/scorecards.ts rename servers/klaviyo/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/klaviyo/src/main.ts create mode 100644 servers/klaviyo/src/server.ts rename servers/lever/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/lever/src/tools/sources-tools.ts create mode 100644 servers/lever/src/tools/tags-tools.ts rename servers/mailchimp/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/mailchimp/src/main.ts create mode 100644 servers/mailchimp/src/server.ts rename servers/pandadoc/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/pandadoc/src/main.ts create mode 100644 servers/pandadoc/src/server.ts create mode 100644 servers/pandadoc/src/tools/document-advanced-tools.ts rename servers/pipedrive/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/pipedrive/src/main.ts create mode 100644 servers/pipedrive/src/server.ts rename servers/reonomy/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/reonomy/src/main.ts create mode 100644 servers/reonomy/src/server.ts create mode 100644 servers/reonomy/src/tools/property-financials-tools.ts rename servers/salesloft/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/salesloft/src/main.ts create mode 100644 servers/salesloft/src/server.ts create mode 100644 servers/salesloft/src/tools/cadence-membership-tools.ts rename servers/sendgrid/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/sendgrid/src/main.ts create mode 100644 servers/sendgrid/src/server.ts create mode 100644 servers/sendgrid/src/tools/campaigns.ts create mode 100644 servers/sendgrid/src/tools/contacts.ts create mode 100644 servers/sendgrid/src/tools/lists.ts create mode 100644 servers/sendgrid/src/tools/messages.ts create mode 100644 servers/sendgrid/src/tools/senders.ts create mode 100644 servers/sendgrid/src/tools/stats.ts create mode 100644 servers/sendgrid/src/tools/suppressions.ts create mode 100644 servers/sendgrid/src/tools/templates.ts create mode 100644 servers/supabase/src/tools/database.ts create mode 100644 servers/supabase/src/tools/projects.ts rename servers/touchbistro/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/touchbistro/src/main.ts create mode 100644 servers/touchbistro/src/server.ts rename servers/trello/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/trello/src/main.ts create mode 100644 servers/trello/src/server.ts rename servers/typeform/src/{index.ts => index.ts.bak} (100%) create mode 100644 servers/typeform/src/main.ts create mode 100644 servers/typeform/src/server.ts diff --git a/servers/activecampaign/package.json b/servers/activecampaign/package.json index 73fe8e6..979a32d 100644 --- a/servers/activecampaign/package.json +++ b/servers/activecampaign/package.json @@ -2,7 +2,10 @@ "name": "@mcpengine/activecampaign", "version": "1.0.0", "description": "Complete ActiveCampaign MCP Server with 50+ tools and 16 apps", - "main": "dist/index.js", + "main": "dist/main.js", + "bin": { + "activecampaign-mcp": "./dist/main.js" + }, "type": "module", "scripts": { "build": "tsc", diff --git a/servers/activecampaign/src/index.ts b/servers/activecampaign/src/index.ts.bak similarity index 100% rename from servers/activecampaign/src/index.ts rename to servers/activecampaign/src/index.ts.bak diff --git a/servers/activecampaign/src/main.ts b/servers/activecampaign/src/main.ts new file mode 100644 index 0000000..375f7d0 --- /dev/null +++ b/servers/activecampaign/src/main.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +/** + * ActiveCampaign MCP Server - Main Entry Point + * Complete integration with 60+ tools and 16 apps + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ActiveCampaignClient } from './client/index.js'; +import { ActiveCampaignMCPServer } from './server.js'; + +// Validate environment variables +const account = process.env.ACTIVECAMPAIGN_ACCOUNT; +const apiKey = process.env.ACTIVECAMPAIGN_API_KEY; + +if (!account || !apiKey) { + console.error('Error: Missing required environment variables'); + console.error(''); + console.error('Required:'); + console.error(' ACTIVECAMPAIGN_ACCOUNT - Your ActiveCampaign account name'); + console.error(' ACTIVECAMPAIGN_API_KEY - Your ActiveCampaign API key'); + console.error(''); + console.error('Get your API key from: https://www.activecampaign.com/api/overview'); + console.error('Settings → Developer → API Access'); + process.exit(1); +} + +// Create API client +const client = new ActiveCampaignClient(account, apiKey); + +// Create and start server +const server = new ActiveCampaignMCPServer(client); + +// Graceful shutdown handlers +process.on('SIGINT', async () => { + console.error('\nReceived SIGINT, shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.error('\nReceived SIGTERM, shutting down gracefully...'); + process.exit(0); +}); + +// Start transport +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('ActiveCampaign MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/activecampaign/src/server.ts b/servers/activecampaign/src/server.ts new file mode 100644 index 0000000..f77b4cd --- /dev/null +++ b/servers/activecampaign/src/server.ts @@ -0,0 +1,241 @@ +/** + * ActiveCampaign MCP Server Class + * Implements lazy-loaded tool modules for optimal performance + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { ActiveCampaignClient } from './client/index.js'; + +type ToolModule = Record; + +// Available app resources +const APPS = [ + 'contact-manager', + 'deal-pipeline', + 'list-builder', + 'campaign-dashboard', + 'automation-builder', + 'form-manager', + 'tag-organizer', + 'task-center', + 'notes-viewer', + 'pipeline-settings', + 'account-directory', + 'webhook-manager', + 'email-analytics', + 'segment-viewer', + 'site-tracking', + 'score-dashboard', +]; + +export class ActiveCampaignMCPServer { + private server: Server; + private client: ActiveCampaignClient; + private toolModules: Map Promise>; + private loadedTools: ToolModule | null = null; + + constructor(client: ActiveCampaignClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'activecampaign-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules() { + // Register lazy-loaded tool modules + this.toolModules.set('contacts', async () => { + const module = await import('./tools/contacts.js'); + return module.createContactTools(this.client); + }); + + this.toolModules.set('deals', async () => { + const module = await import('./tools/deals.js'); + return module.createDealTools(this.client); + }); + + this.toolModules.set('lists', async () => { + const module = await import('./tools/lists.js'); + return module.createListTools(this.client); + }); + + this.toolModules.set('campaigns', async () => { + const module = await import('./tools/campaigns.js'); + return module.createCampaignTools(this.client); + }); + + this.toolModules.set('automations', async () => { + const module = await import('./tools/automations.js'); + return module.createAutomationTools(this.client); + }); + + this.toolModules.set('forms', async () => { + const module = await import('./tools/forms.js'); + return module.createFormTools(this.client); + }); + + this.toolModules.set('tags', async () => { + const module = await import('./tools/tags.js'); + return module.createTagTools(this.client); + }); + + this.toolModules.set('tasks', async () => { + const module = await import('./tools/tasks.js'); + return module.createTaskTools(this.client); + }); + + this.toolModules.set('notes', async () => { + const module = await import('./tools/notes.js'); + return module.createNoteTools(this.client); + }); + + this.toolModules.set('pipelines', async () => { + const module = await import('./tools/pipelines.js'); + return module.createPipelineTools(this.client); + }); + + this.toolModules.set('accounts', async () => { + const module = await import('./tools/accounts.js'); + return module.createAccountTools(this.client); + }); + + this.toolModules.set('webhooks', async () => { + const module = await import('./tools/webhooks.js'); + return module.createWebhookTools(this.client); + }); + } + + private async loadAllTools(): Promise { + if (this.loadedTools) { + return this.loadedTools; + } + + const allTools: ToolModule = {}; + + for (const [name, loader] of this.toolModules.entries()) { + const tools = await loader(); + Object.assign(allTools, tools); + } + + this.loadedTools = allTools; + return allTools; + } + + private setupHandlers() { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const allTools = await this.loadAllTools(); + + return { + tools: Object.entries(allTools).map(([name, tool]) => ({ + name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Execute tool + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const allTools = await this.loadAllTools(); + const toolName = request.params.name; + const tool = allTools[toolName]; + + if (!tool) { + throw new Error(`Unknown tool: ${toolName}`); + } + + try { + const result = await tool.handler(request.params.arguments || {}); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + return { + content: [ + { + type: 'text', + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + }); + + // List app resources + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: APPS.map((app) => ({ + uri: `activecampaign://app/${app}`, + mimeType: 'text/html', + name: app + .split('-') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '), + })), + }; + }); + + // Read app resource + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + const match = uri.match(/^activecampaign:\/\/app\/(.+)$/); + + if (!match) { + throw new Error(`Invalid resource URI: ${uri}`); + } + + const appName = match[1]; + if (!APPS.includes(appName)) { + throw new Error(`Unknown app: ${appName}`); + } + + try { + // Dynamically import the app + const appModule = await import(`./apps/${appName}/index.js`); + const html = appModule.default(); + + return { + contents: [ + { + uri, + mimeType: 'text/html', + text: html, + }, + ], + }; + } catch (error) { + throw new Error(`Failed to load app ${appName}: ${error}`); + } + }); + } + + async connect(transport: any) { + await this.server.connect(transport); + } +} diff --git a/servers/apollo/package.json b/servers/apollo/package.json index f5fddda..b9bbf21 100644 --- a/servers/apollo/package.json +++ b/servers/apollo/package.json @@ -1,27 +1,30 @@ { - "name": "@mcpengine/apollo-server", + "name": "@mcpengine/apollo", "version": "1.0.0", "description": "MCP server for Apollo.io sales engagement platform", "type": "module", + "main": "dist/main.js", "bin": { - "apollo-mcp": "./dist/index.js" + "@mcpengine/apollo": "dist/main.js" }, - "main": "./dist/index.js", "scripts": { "build": "tsc", "watch": "tsc --watch", - "start": "node dist/index.js" + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts" }, "keywords": ["mcp", "apollo", "sales", "crm"], "author": "MCPEngine", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "axios": "^1.6.0", - "bottleneck": "^2.19.5" + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "bottleneck": "^2.19.5", + "zod": "^3.23.0" }, "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" } } diff --git a/servers/apollo/src/index.ts b/servers/apollo/src/index.ts.bak similarity index 100% rename from servers/apollo/src/index.ts rename to servers/apollo/src/index.ts.bak diff --git a/servers/apollo/src/main.ts b/servers/apollo/src/main.ts new file mode 100644 index 0000000..c92f0e2 --- /dev/null +++ b/servers/apollo/src/main.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +/** + * Apollo.io MCP Server - Entry Point + * Provides AI-powered access to Apollo.io sales intelligence platform + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ApolloMCPServer } from './server.js'; +import { ApolloClient } from './client/apollo-client.js'; + +// Environment validation +const apiKey = process.env.APOLLO_API_KEY; + +if (!apiKey) { + console.error('āŒ APOLLO_API_KEY environment variable is required'); + console.error(''); + console.error('Get your API key from:'); + console.error(' 1. Log in to Apollo.io'); + console.error(' 2. Go to Settings > Integrations > API'); + console.error(' 3. Generate or copy your API key'); + console.error(''); + console.error('Then set it:'); + console.error(' export APOLLO_API_KEY="your-api-key-here"'); + console.error(''); + process.exit(1); +} + +// Create Apollo API client +const apolloClient = new ApolloClient({ + apiKey, + baseUrl: process.env.APOLLO_BASE_URL, +}); + +// Create MCP server +const server = new Server( + { + name: 'apollo-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } +); + +// Initialize Apollo MCP server with tool handlers +const apolloServer = new ApolloMCPServer(server, apolloClient); +await apolloServer.setupHandlers(); + +// Graceful shutdown handler +let isShuttingDown = false; +const shutdown = async (signal: string) => { + if (isShuttingDown) return; + isShuttingDown = true; + + console.error(`\nšŸ“” Received ${signal}, shutting down gracefully...`); + + try { + await server.close(); + console.error('āœ… Server closed successfully'); + process.exit(0); + } catch (error) { + console.error('āŒ Error during shutdown:', error); + process.exit(1); + } +}; + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// Health check support +process.on('message', (msg: any) => { + if (msg === 'health_check') { + process.send?.({ status: 'healthy', server: 'apollo-mcp' }); + } +}); + +// Start server with stdio transport +const transport = new StdioServerTransport(); +await server.connect(transport); + +console.error('šŸš€ Apollo MCP Server running on stdio'); +console.error(`šŸ“Š ${apolloServer.getToolCount()} tools available`); +console.error(''); diff --git a/servers/apollo/src/server.ts b/servers/apollo/src/server.ts new file mode 100644 index 0000000..3f653d4 --- /dev/null +++ b/servers/apollo/src/server.ts @@ -0,0 +1,139 @@ +/** + * Apollo MCP Server Class + * Handles tool registration and request routing with lazy-loaded modules + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { ApolloClient } from './client/apollo-client.js'; + +type ToolModule = { + name: string; + description: string; + inputSchema: any; + handler: (input: unknown, client: ApolloClient) => Promise; +}; + +export class ApolloMCPServer { + private toolModules: Map Promise> = new Map(); + private toolsCache: ToolModule[] | null = null; + + constructor( + private server: Server, + private client: ApolloClient + ) { + this.setupToolModules(); + } + + /** + * Register tool modules with lazy loading via dynamic imports + */ + private setupToolModules(): void { + this.toolModules.set('contacts', async () => { + const module = await import('./tools/contacts.js'); + return module.default; + }); + + this.toolModules.set('accounts', async () => { + const module = await import('./tools/accounts.js'); + return module.default; + }); + + this.toolModules.set('sequences', async () => { + const module = await import('./tools/sequences.js'); + return module.default; + }); + + this.toolModules.set('emails', async () => { + const module = await import('./tools/emails.js'); + return module.default; + }); + + this.toolModules.set('tasks', async () => { + const module = await import('./tools/tasks.js'); + return module.default; + }); + + this.toolModules.set('opportunities', async () => { + const module = await import('./tools/opportunities.js'); + return module.default; + }); + + this.toolModules.set('enrichment', async () => { + const module = await import('./tools/enrichment.js'); + return module.default; + }); + + this.toolModules.set('search', async () => { + const module = await import('./tools/search.js'); + return module.default; + }); + } + + /** + * Load all tool modules and cache them + */ + private async loadAllTools(): Promise { + if (this.toolsCache) { + return this.toolsCache; + } + + const allTools: ToolModule[] = []; + for (const [moduleName, loader] of this.toolModules.entries()) { + try { + const tools = await loader(); + allTools.push(...tools); + } catch (error) { + console.error(`Failed to load ${moduleName} tools:`, error); + } + } + + this.toolsCache = allTools; + return allTools; + } + + /** + * Setup MCP request handlers + */ + async setupHandlers(): Promise { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.loadAllTools(); + return { + tools: tools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const tools = await this.loadAllTools(); + const tool = tools.find(t => t.name === request.params.name); + + if (!tool) { + throw new Error(`Unknown tool: ${request.params.name}`); + } + + try { + const result = await tool.handler(request.params.arguments ?? {}, this.client); + return result; + } catch (error: any) { + throw new Error(`Tool execution failed: ${error.message}`); + } + }); + } + + /** + * Get total tool count + */ + getToolCount(): number { + return this.toolsCache?.length ?? 0; + } +} diff --git a/servers/apollo/src/tools/accounts.ts b/servers/apollo/src/tools/accounts.ts index ad25475..51917e4 100644 --- a/servers/apollo/src/tools/accounts.ts +++ b/servers/apollo/src/tools/accounts.ts @@ -2,209 +2,265 @@ * Apollo.io Account/Organization Tools */ -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; -export const listAccountsTool: Tool = { - name: 'list_accounts', - description: 'Lists accounts (companies/organizations) from Apollo.io with pagination support. Use when the user wants to browse their account database, review target companies, or export organization data. Returns paginated results with up to 100 accounts per page. Supports filtering by labels, owner, or industry.', - inputSchema: { - type: 'object', - properties: { - page: { - type: 'number', - description: 'Page number to retrieve (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Number of accounts per page (max 100)', - default: 25, - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by label IDs', - }, - owner_id: { - type: 'string', - description: 'Filter by account owner user ID', - }, - }, - }, - _meta: { - category: 'accounts', - access_level: 'read', - complexity: 'low', - }, -}; +const ListAccountsInput = z.object({ + page: z.number().min(1).default(1).describe('Page number to retrieve (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Number of accounts per page (max 100)'), + label_ids: z.array(z.string()).optional().describe('Filter by label IDs'), + owner_id: z.string().optional().describe('Filter by account owner user ID'), +}); -export const getAccountTool: Tool = { - name: 'get_account', - description: 'Retrieves a single account/organization by ID from Apollo.io. Use when the user asks for detailed information about a specific company, including employee count, revenue, industry, technologies, funding, and all custom fields. Returns complete account record with all available enrichment data.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the account to retrieve', - }, - }, - required: ['id'], - }, - _meta: { - category: 'accounts', - access_level: 'read', - complexity: 'low', - }, -}; +const GetAccountInput = z.object({ + id: z.string().describe('The unique ID of the account to retrieve'), +}); -export const searchAccountsTool: Tool = { - name: 'search_accounts', - description: 'Searches for accounts/companies in Apollo.io using advanced filters including industry, employee count, revenue, location, technology stack, and funding. Use when the user wants to find companies matching specific criteria (e.g., "find all SaaS companies in NYC with 50-200 employees"). Supports complex boolean queries and returns paginated results with up to 100 matches per page. Essential for account-based prospecting and market research.', - inputSchema: { - type: 'object', - properties: { - q_keywords: { - type: 'string', - description: 'Keywords to search in company name, domain, or description', - }, - organization_locations: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by location (city, state, or country)', - }, - organization_num_employees_ranges: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by employee count ranges (e.g., ["1,10", "11,50", "201,500"])', - }, - organization_industry_tag_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by industry tag IDs', - }, - revenue_range: { - type: 'object', - properties: { - min: { type: 'number', description: 'Minimum annual revenue' }, - max: { type: 'number', description: 'Maximum annual revenue' }, +const SearchAccountsInput = z.object({ + q_keywords: z.string().optional().describe('Keywords to search in company name, domain, or description'), + organization_locations: z.array(z.string()).optional().describe('Filter by location (city, state, or country)'), + organization_num_employees_ranges: z.array(z.string()).optional().describe('Filter by employee count ranges (e.g., ["1,10", "11,50", "201,500"])'), + organization_industry_tag_ids: z.array(z.string()).optional().describe('Filter by industry tag IDs'), + revenue_range: z.object({ + min: z.number().optional().describe('Minimum annual revenue'), + max: z.number().optional().describe('Maximum annual revenue'), + }).optional().describe('Filter by revenue range'), + label_ids: z.array(z.string()).optional().describe('Filter by label IDs'), + page: z.number().min(1).default(1).describe('Page number (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Results per page (max 100)'), +}); + +const CreateAccountInput = z.object({ + name: z.string().describe('Company/organization name'), + domain: z.string().optional().describe('Company website domain (e.g., "example.com")'), + website_url: z.string().url().optional().describe('Full website URL'), + phone_number: z.string().optional().describe('Primary phone number'), + industry: z.string().optional().describe('Industry or sector'), + city: z.string().optional().describe('City'), + state: z.string().optional().describe('State or province'), + country: z.string().optional().describe('Country'), + label_ids: z.array(z.string()).optional().describe('Array of label IDs to assign'), +}); + +const UpdateAccountInput = z.object({ + id: z.string().describe('The unique ID of the account to update'), + name: z.string().optional().describe('Updated company name'), + domain: z.string().optional().describe('Updated domain'), + industry: z.string().optional().describe('Updated industry'), + owner_id: z.string().optional().describe('Updated owner user ID'), + label_ids: z.array(z.string()).optional().describe('Updated array of label IDs (replaces existing)'), +}); + +export default [ + { + name: 'apollo_list_accounts', + description: 'Lists accounts (companies/organizations) from Apollo.io with pagination support. Use when the user wants to browse their account database, review target companies, or export organization data. Returns paginated results with up to 100 accounts per page. Supports filtering by labels, owner, or industry.', + inputSchema: { + type: 'object' as const, + properties: { + page: { + type: 'number', + description: 'Page number to retrieve (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of accounts per page (max 100)', + default: 25, + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by label IDs', + }, + owner_id: { + type: 'string', + description: 'Filter by account owner user ID', }, - description: 'Filter by revenue range', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by label IDs', - }, - page: { - type: 'number', - description: 'Page number (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Results per page (max 100)', - default: 25, }, }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListAccountsInput.parse(input); + const result = await client.get('/accounts', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, - _meta: { - category: 'accounts', - access_level: 'read', - complexity: 'medium', + { + name: 'apollo_get_account', + description: 'Retrieves a single account/organization by ID from Apollo.io. Use when the user asks for detailed information about a specific company, including employee count, revenue, industry, technologies, funding, and all custom fields. Returns complete account record with all available enrichment data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the account to retrieve', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = GetAccountInput.parse(input); + const result = await client.get(`/accounts/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; - -export const createAccountTool: Tool = { - name: 'create_account', - description: 'Creates a new account/organization in Apollo.io. Use when the user wants to add a new company to their target account list, such as after identifying a prospect company or importing from external sources. Accepts account details including name, domain, industry, location, and custom fields. Apollo will attempt to enrich the account with additional data. Returns the newly created account with assigned ID.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Company/organization name', - }, - domain: { - type: 'string', - description: 'Company website domain (e.g., "example.com")', - }, - website_url: { - type: 'string', - description: 'Full website URL', - }, - phone_number: { - type: 'string', - description: 'Primary phone number', - }, - industry: { - type: 'string', - description: 'Industry or sector', - }, - city: { - type: 'string', - description: 'City', - }, - state: { - type: 'string', - description: 'State or province', - }, - country: { - type: 'string', - description: 'Country', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Array of label IDs to assign', + { + name: 'apollo_search_accounts', + description: 'Searches for accounts/companies in Apollo.io using advanced filters including industry, employee count, revenue, location, technology stack, and funding. Use when the user wants to find companies matching specific criteria (e.g., "find all SaaS companies in NYC with 50-200 employees"). Supports complex boolean queries and returns paginated results with up to 100 matches per page. Essential for account-based prospecting and market research.', + inputSchema: { + type: 'object' as const, + properties: { + q_keywords: { + type: 'string', + description: 'Keywords to search in company name, domain, or description', + }, + organization_locations: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by location (city, state, or country)', + }, + organization_num_employees_ranges: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by employee count ranges (e.g., ["1,10", "11,50", "201,500"])', + }, + organization_industry_tag_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by industry tag IDs', + }, + revenue_range: { + type: 'object', + properties: { + min: { type: 'number', description: 'Minimum annual revenue' }, + max: { type: 'number', description: 'Maximum annual revenue' }, + }, + description: 'Filter by revenue range', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by label IDs', + }, + page: { + type: 'number', + description: 'Page number (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Results per page (max 100)', + default: 25, + }, }, }, - required: ['name'], - }, - _meta: { - category: 'accounts', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const updateAccountTool: Tool = { - name: 'update_account', - description: 'Updates an existing account/organization in Apollo.io. Use when the user needs to modify company information such as updating industry classification, changing ownership, adding labels, or correcting account details. Only specified fields will be updated; others remain unchanged. Returns the updated account record.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the account to update', - }, - name: { - type: 'string', - description: 'Updated company name', - }, - domain: { - type: 'string', - description: 'Updated domain', - }, - industry: { - type: 'string', - description: 'Updated industry', - }, - owner_id: { - type: 'string', - description: 'Updated owner user ID', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Updated array of label IDs (replaces existing)', - }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = SearchAccountsInput.parse(input); + const result = await client.post('/accounts/search', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'accounts', - access_level: 'write', - complexity: 'medium', + { + name: 'apollo_create_account', + description: 'Creates a new account/organization in Apollo.io. Use when the user wants to add a new company to their target account list, such as after identifying a prospect company or importing from external sources. Accepts account details including name, domain, industry, location, and custom fields. Apollo will attempt to enrich the account with additional data. Returns the newly created account with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Company/organization name', + }, + domain: { + type: 'string', + description: 'Company website domain (e.g., "example.com")', + }, + website_url: { + type: 'string', + description: 'Full website URL', + }, + phone_number: { + type: 'string', + description: 'Primary phone number', + }, + industry: { + type: 'string', + description: 'Industry or sector', + }, + city: { + type: 'string', + description: 'City', + }, + state: { + type: 'string', + description: 'State or province', + }, + country: { + type: 'string', + description: 'Country', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of label IDs to assign', + }, + }, + required: ['name'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = CreateAccountInput.parse(input); + const result = await client.post('/accounts', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'apollo_update_account', + description: 'Updates an existing account/organization in Apollo.io. Use when the user needs to modify company information such as updating industry classification, changing ownership, adding labels, or correcting account details. Only specified fields will be updated; others remain unchanged. Returns the updated account record.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the account to update', + }, + name: { + type: 'string', + description: 'Updated company name', + }, + domain: { + type: 'string', + description: 'Updated domain', + }, + industry: { + type: 'string', + description: 'Updated industry', + }, + owner_id: { + type: 'string', + description: 'Updated owner user ID', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Updated array of label IDs (replaces existing)', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = UpdateAccountInput.parse(input); + const { id, ...updateData } = validated; + const result = await client.put(`/accounts/${id}`, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/apollo/src/tools/contacts.ts b/servers/apollo/src/tools/contacts.ts index b24707c..88fc156 100644 --- a/servers/apollo/src/tools/contacts.ts +++ b/servers/apollo/src/tools/contacts.ts @@ -2,253 +2,390 @@ * Apollo.io Contact Tools */ -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; -export const listContactsTool: Tool = { - name: 'list_contacts', - description: 'Lists contacts from Apollo.io with pagination support. Use when the user wants to browse their contact database, export contacts, or get an overview of contacts. Returns paginated results with cursor-based navigation. Supports filtering by stage, labels, or owner. Each page can contain up to 100 contacts.', - inputSchema: { - type: 'object', - properties: { - page: { - type: 'number', - description: 'Page number to retrieve (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Number of contacts per page (max 100)', - default: 25, - }, - contact_stage_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by contact stage IDs', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by label IDs', - }, - owner_id: { - type: 'string', - description: 'Filter by contact owner user ID', - }, - }, - }, - _meta: { - category: 'contacts', - access_level: 'read', - complexity: 'low', - }, -}; +const ListContactsInput = z.object({ + page: z.number().min(1).default(1).describe('Page number to retrieve (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Number of contacts per page (max 100)'), + contact_stage_ids: z.array(z.string()).optional().describe('Filter by contact stage IDs'), + label_ids: z.array(z.string()).optional().describe('Filter by label IDs'), + owner_id: z.string().optional().describe('Filter by contact owner user ID'), +}); -export const getContactTool: Tool = { - name: 'get_contact', - description: 'Retrieves a single contact by ID from Apollo.io. Use when the user asks for detailed information about a specific contact, including all custom fields, phone numbers, social profiles, and associated organization. Returns complete contact record with all available fields.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the contact to retrieve', - }, - }, - required: ['id'], - }, - _meta: { - category: 'contacts', - access_level: 'read', - complexity: 'low', - }, -}; +const GetContactInput = z.object({ + id: z.string().describe('The unique ID of the contact to retrieve'), +}); -export const searchContactsTool: Tool = { - name: 'search_contacts', - description: 'Searches for contacts in Apollo.io using advanced filters including keywords, titles, locations, company attributes, and more. Use when the user wants to find contacts matching specific criteria (e.g., "find all CTOs in San Francisco at Series A startups"). Supports complex boolean queries and returns paginated results with up to 100 matches per page. More powerful than list_contacts for targeted prospecting.', - inputSchema: { - type: 'object', - properties: { - q_keywords: { - type: 'string', - description: 'Keywords to search in contact name, email, title, or company', - }, - person_titles: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by job titles (e.g., ["CEO", "CTO", "VP Sales"])', - }, - organization_locations: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by organization location (city, state, or country)', - }, - organization_num_employees_ranges: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by employee count ranges (e.g., ["1,10", "11,50", "51,200"])', - }, - contact_stage_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by contact stage IDs', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Filter by label IDs', - }, - page: { - type: 'number', - description: 'Page number (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Results per page (max 100)', - default: 25, - }, - }, - }, - _meta: { - category: 'contacts', - access_level: 'read', - complexity: 'medium', - }, -}; +const SearchContactsInput = z.object({ + q_keywords: z.string().optional().describe('Keywords to search in contact name, email, title, or company'), + person_titles: z.array(z.string()).optional().describe('Filter by job titles (e.g., ["CEO", "CTO", "VP Sales"])'), + organization_locations: z.array(z.string()).optional().describe('Filter by organization location (city, state, or country)'), + organization_num_employees_ranges: z.array(z.string()).optional().describe('Filter by employee count ranges (e.g., ["1,10", "11,50", "51,200"])'), + contact_stage_ids: z.array(z.string()).optional().describe('Filter by contact stage IDs'), + label_ids: z.array(z.string()).optional().describe('Filter by label IDs'), + page: z.number().min(1).default(1).describe('Page number (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Results per page (max 100)'), +}); -export const createContactTool: Tool = { - name: 'create_contact', - description: 'Creates a new contact in Apollo.io. Use when the user wants to add a new person to their database, such as after meeting someone at an event or discovering a new prospect. Accepts contact details including name, email, title, organization, phone numbers, and custom fields. Returns the newly created contact with assigned ID.', - inputSchema: { - type: 'object', - properties: { - first_name: { - type: 'string', - description: 'First name of the contact', - }, - last_name: { - type: 'string', - description: 'Last name of the contact', - }, - email: { - type: 'string', - description: 'Email address', - }, - title: { - type: 'string', - description: 'Job title', - }, - organization_name: { - type: 'string', - description: 'Company/organization name', - }, - phone_numbers: { - type: 'array', - items: { - type: 'object', - properties: { - raw_number: { type: 'string' }, - }, +const CreateContactInput = z.object({ + first_name: z.string().describe('First name of the contact'), + last_name: z.string().describe('Last name of the contact'), + email: z.string().email().optional().describe('Email address'), + title: z.string().optional().describe('Job title'), + organization_name: z.string().optional().describe('Company/organization name'), + phone_numbers: z.array(z.object({ raw_number: z.string() })).optional().describe('Array of phone number objects'), + linkedin_url: z.string().url().optional().describe('LinkedIn profile URL'), + city: z.string().optional().describe('City'), + state: z.string().optional().describe('State or province'), + country: z.string().optional().describe('Country'), + label_ids: z.array(z.string()).optional().describe('Array of label IDs to assign'), +}); + +const UpdateContactInput = z.object({ + id: z.string().describe('The unique ID of the contact to update'), + first_name: z.string().optional().describe('Updated first name'), + last_name: z.string().optional().describe('Updated last name'), + email: z.string().email().optional().describe('Updated email address'), + title: z.string().optional().describe('Updated job title'), + organization_name: z.string().optional().describe('Updated company/organization name'), + contact_stage_id: z.string().optional().describe('Updated contact stage ID'), + label_ids: z.array(z.string()).optional().describe('Updated array of label IDs (replaces existing)'), +}); + +const DeleteContactInput = z.object({ + id: z.string().describe('The unique ID of the contact to delete'), +}); + +const EnrichContactInput = z.object({ + id: z.string().describe('The contact ID to enrich with additional data from Apollo database'), +}); + +const ListAccountContactsInput = z.object({ + account_id: z.string().describe('The account/organization ID to list contacts for'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + per_page: z.number().min(1).max(100).default(25).describe('Number of contacts per page (max 100)'), + q_keywords: z.string().optional().describe('Keywords to filter contacts'), + contact_stage_ids: z.array(z.string()).optional().describe('Filter by contact stage IDs'), +}); + +export default [ + { + name: 'apollo_list_contacts', + description: 'Lists contacts from Apollo.io with pagination support. Use when the user wants to browse their contact database, export contacts, or get an overview of contacts. Returns paginated results with cursor-based navigation. Supports filtering by stage, labels, or owner. Each page can contain up to 100 contacts.', + inputSchema: { + type: 'object' as const, + properties: { + page: { + type: 'number', + description: 'Page number to retrieve (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of contacts per page (max 100)', + default: 25, + }, + contact_stage_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by contact stage IDs', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by label IDs', + }, + owner_id: { + type: 'string', + description: 'Filter by contact owner user ID', }, - description: 'Array of phone number objects', - }, - linkedin_url: { - type: 'string', - description: 'LinkedIn profile URL', - }, - city: { - type: 'string', - description: 'City', - }, - state: { - type: 'string', - description: 'State or province', - }, - country: { - type: 'string', - description: 'Country', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Array of label IDs to assign', }, }, - required: ['first_name', 'last_name'], + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListContactsInput.parse(input); + const result = await client.get('/contacts', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, - _meta: { - category: 'contacts', - access_level: 'write', - complexity: 'medium', + { + name: 'apollo_get_contact', + description: 'Retrieves a single contact by ID from Apollo.io. Use when the user asks for detailed information about a specific contact, including all custom fields, phone numbers, social profiles, and associated organization. Returns complete contact record with all available fields.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the contact to retrieve', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = GetContactInput.parse(input); + const result = await client.get(`/contacts/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; - -export const updateContactTool: Tool = { - name: 'update_contact', - description: 'Updates an existing contact in Apollo.io. Use when the user needs to modify contact information such as changing a job title after a promotion, updating contact details, adding labels, or moving a contact to a different stage. Only specified fields will be updated; others remain unchanged. Returns the updated contact record.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the contact to update', - }, - first_name: { - type: 'string', - description: 'Updated first name', - }, - last_name: { - type: 'string', - description: 'Updated last name', - }, - email: { - type: 'string', - description: 'Updated email address', - }, - title: { - type: 'string', - description: 'Updated job title', - }, - organization_name: { - type: 'string', - description: 'Updated company/organization name', - }, - contact_stage_id: { - type: 'string', - description: 'Updated contact stage ID', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Updated array of label IDs (replaces existing)', + { + name: 'apollo_search_contacts', + description: 'Searches for contacts in Apollo.io using advanced filters including keywords, titles, locations, company attributes, and more. Use when the user wants to find contacts matching specific criteria (e.g., "find all CTOs in San Francisco at Series A startups"). Supports complex boolean queries and returns paginated results with up to 100 matches per page. More powerful than list_contacts for targeted prospecting.', + inputSchema: { + type: 'object' as const, + properties: { + q_keywords: { + type: 'string', + description: 'Keywords to search in contact name, email, title, or company', + }, + person_titles: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by job titles (e.g., ["CEO", "CTO", "VP Sales"])', + }, + organization_locations: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by organization location (city, state, or country)', + }, + organization_num_employees_ranges: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by employee count ranges (e.g., ["1,10", "11,50", "51,200"])', + }, + contact_stage_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by contact stage IDs', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by label IDs', + }, + page: { + type: 'number', + description: 'Page number (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Results per page (max 100)', + default: 25, + }, }, }, - required: ['id'], - }, - _meta: { - category: 'contacts', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const deleteContactTool: Tool = { - name: 'delete_contact', - description: 'Permanently deletes a contact from Apollo.io. Use with caution when the user explicitly wants to remove a contact from the database (e.g., at their request, duplicate cleanup, or GDPR compliance). This action cannot be undone. Returns confirmation of deletion.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the contact to delete', - }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = SearchContactsInput.parse(input); + const result = await client.post('/contacts/search', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'contacts', - access_level: 'delete', - complexity: 'low', + { + name: 'apollo_create_contact', + description: 'Creates a new contact in Apollo.io. Use when the user wants to add a new person to their database, such as after meeting someone at an event or discovering a new prospect. Accepts contact details including name, email, title, organization, phone numbers, and custom fields. Returns the newly created contact with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + first_name: { + type: 'string', + description: 'First name of the contact', + }, + last_name: { + type: 'string', + description: 'Last name of the contact', + }, + email: { + type: 'string', + description: 'Email address', + }, + title: { + type: 'string', + description: 'Job title', + }, + organization_name: { + type: 'string', + description: 'Company/organization name', + }, + phone_numbers: { + type: 'array', + items: { + type: 'object', + properties: { + raw_number: { type: 'string' }, + }, + }, + description: 'Array of phone number objects', + }, + linkedin_url: { + type: 'string', + description: 'LinkedIn profile URL', + }, + city: { + type: 'string', + description: 'City', + }, + state: { + type: 'string', + description: 'State or province', + }, + country: { + type: 'string', + description: 'Country', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of label IDs to assign', + }, + }, + required: ['first_name', 'last_name'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = CreateContactInput.parse(input); + const result = await client.post('/contacts', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'apollo_update_contact', + description: 'Updates an existing contact in Apollo.io. Use when the user needs to modify contact information such as changing a job title after a promotion, updating contact details, adding labels, or moving a contact to a different stage. Only specified fields will be updated; others remain unchanged. Returns the updated contact record.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the contact to update', + }, + first_name: { + type: 'string', + description: 'Updated first name', + }, + last_name: { + type: 'string', + description: 'Updated last name', + }, + email: { + type: 'string', + description: 'Updated email address', + }, + title: { + type: 'string', + description: 'Updated job title', + }, + organization_name: { + type: 'string', + description: 'Updated company/organization name', + }, + contact_stage_id: { + type: 'string', + description: 'Updated contact stage ID', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Updated array of label IDs (replaces existing)', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = UpdateContactInput.parse(input); + const { id, ...updateData } = validated; + const result = await client.put(`/contacts/${id}`, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_delete_contact', + description: 'Permanently deletes a contact from Apollo.io. Use with caution when the user explicitly wants to remove a contact from the database (e.g., at their request, duplicate cleanup, or GDPR compliance). This action cannot be undone. Returns confirmation of deletion.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the contact to delete', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = DeleteContactInput.parse(input); + const result = await client.delete(`/contacts/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, message: 'Contact deleted successfully' }, null, 2) }], + }; + }, + }, + { + name: 'apollo_enrich_contact', + description: 'Enriches an existing contact with additional data from Apollo.io database including verified emails, phone numbers, social profiles, and employment history. Use when you have a contact in your database but need more complete information. Returns the enriched contact record with all newly discovered fields.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The contact ID to enrich with additional data from Apollo database', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = EnrichContactInput.parse(input); + const result = await client.post(`/contacts/${validated.id}/enrich`, {}); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_list_account_contacts', + description: 'Lists all contacts associated with a specific account/organization in Apollo.io. Use when the user wants to see all people from a particular company, build an org chart, or identify decision makers within a target account. Returns paginated results with contact details and roles. Supports filtering by keywords and contact stages.', + inputSchema: { + type: 'object' as const, + properties: { + account_id: { + type: 'string', + description: 'The account/organization ID to list contacts for', + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of contacts per page (max 100)', + default: 25, + }, + q_keywords: { + type: 'string', + description: 'Keywords to filter contacts', + }, + contact_stage_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Filter by contact stage IDs', + }, + }, + required: ['account_id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListAccountContactsInput.parse(input); + const { account_id, ...params } = validated; + const result = await client.get(`/accounts/${account_id}/contacts`, params); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/apollo/src/tools/emails.ts b/servers/apollo/src/tools/emails.ts index 646d5fd..e597442 100644 --- a/servers/apollo/src/tools/emails.ts +++ b/servers/apollo/src/tools/emails.ts @@ -2,114 +2,141 @@ * Apollo.io Email Tools */ -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; -export const sendEmailTool: Tool = { - name: 'send_email', - description: 'Sends a one-off email through Apollo.io outside of sequences. Use when the user wants to send a manual, personalized email to contacts (e.g., responding to an inquiry, following up on a meeting, or sending a custom proposal). Supports CC, BCC, and can be associated with a contact record for tracking. Returns confirmation with message ID.', - inputSchema: { - type: 'object', - properties: { - to: { - type: 'array', - items: { type: 'string' }, - description: 'Array of recipient email addresses', - }, - subject: { - type: 'string', - description: 'Email subject line', - }, - body: { - type: 'string', - description: 'Email body (HTML or plain text)', - }, - cc: { - type: 'array', - items: { type: 'string' }, - description: 'Array of CC email addresses (optional)', - }, - bcc: { - type: 'array', - items: { type: 'string' }, - description: 'Array of BCC email addresses (optional)', - }, - emailaccount_id: { - type: 'string', - description: 'Email account ID to send from (optional, uses default)', - }, - contact_id: { - type: 'string', - description: 'Contact ID to associate this email with (optional)', +const SendEmailInput = z.object({ + to: z.array(z.string().email()).describe('Array of recipient email addresses'), + subject: z.string().describe('Email subject line'), + body: z.string().describe('Email body (HTML or plain text)'), + cc: z.array(z.string().email()).optional().describe('Array of CC email addresses'), + bcc: z.array(z.string().email()).optional().describe('Array of BCC email addresses'), + emailaccount_id: z.string().optional().describe('Email account ID to send from'), + contact_id: z.string().optional().describe('Contact ID to associate this email with'), +}); + +const ListEmailThreadsInput = z.object({ + page: z.number().min(1).default(1).describe('Page number to retrieve (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Number of threads per page (max 100)'), + contact_id: z.string().optional().describe('Filter threads by contact ID'), +}); + +const GetEmailThreadInput = z.object({ + id: z.string().describe('The unique ID of the email thread to retrieve'), +}); + +export default [ + { + name: 'apollo_send_email', + description: 'Sends a one-off email through Apollo.io outside of sequences. Use when the user wants to send a manual, personalized email to contacts (e.g., responding to an inquiry, following up on a meeting, or sending a custom proposal). Supports CC, BCC, and can be associated with a contact record for tracking. Returns confirmation with message ID.', + inputSchema: { + type: 'object' as const, + properties: { + to: { + type: 'array', + items: { type: 'string' }, + description: 'Array of recipient email addresses', + }, + subject: { + type: 'string', + description: 'Email subject line', + }, + body: { + type: 'string', + description: 'Email body (HTML or plain text)', + }, + cc: { + type: 'array', + items: { type: 'string' }, + description: 'Array of CC email addresses (optional)', + }, + bcc: { + type: 'array', + items: { type: 'string' }, + description: 'Array of BCC email addresses (optional)', + }, + emailaccount_id: { + type: 'string', + description: 'Email account ID to send from (optional, uses default)', + }, + contact_id: { + type: 'string', + description: 'Contact ID to associate this email with (optional)', + }, }, + required: ['to', 'subject', 'body'], }, - required: ['to', 'subject', 'body'], - }, - _meta: { - category: 'emails', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const listEmailAccountsTool: Tool = { - name: 'list_email_accounts', - description: 'Lists all connected email accounts in Apollo.io. Use when the user wants to see which email addresses are configured for sending, check account status, or select an account for sending emails. Returns list of email accounts with active status and configuration details.', - inputSchema: { - type: 'object', - properties: {}, - }, - _meta: { - category: 'emails', - access_level: 'read', - complexity: 'low', - }, -}; - -export const listEmailThreadsTool: Tool = { - name: 'list_email_threads', - description: 'Lists email conversation threads from Apollo.io with pagination support. Use when the user wants to review recent email conversations, check inbox activity, or track communication history. Returns paginated results showing thread subjects, participants, message counts, and last activity. Supports up to 100 threads per page.', - inputSchema: { - type: 'object', - properties: { - page: { - type: 'number', - description: 'Page number to retrieve (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Number of threads per page (max 100)', - default: 25, - }, - contact_id: { - type: 'string', - description: 'Filter threads by contact ID (optional)', - }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = SendEmailInput.parse(input); + const result = await client.post('/emailer_messages', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, }, - _meta: { - category: 'emails', - access_level: 'read', - complexity: 'low', + { + name: 'apollo_list_email_accounts', + description: 'Lists all connected email accounts in Apollo.io. Use when the user wants to see which email addresses are configured for sending, check account status, or select an account for sending emails. Returns list of email accounts with active status and configuration details.', + inputSchema: { + type: 'object' as const, + properties: {}, + }, + handler: async (input: unknown, client: ApolloClient) => { + const result = await client.get('/emailer_accounts'); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; - -export const getEmailThreadTool: Tool = { - name: 'get_email_thread', - description: 'Retrieves a single email thread by ID with all messages in the conversation. Use when the user wants to read a complete email exchange, review conversation history, or get context before replying. Returns full thread with all messages, timestamps, and participants.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the email thread to retrieve', + { + name: 'apollo_list_email_threads', + description: 'Lists email conversation threads from Apollo.io with pagination support. Use when the user wants to review recent email conversations, check inbox activity, or track communication history. Returns paginated results showing thread subjects, participants, message counts, and last activity. Supports up to 100 threads per page.', + inputSchema: { + type: 'object' as const, + properties: { + page: { + type: 'number', + description: 'Page number to retrieve (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of threads per page (max 100)', + default: 25, + }, + contact_id: { + type: 'string', + description: 'Filter threads by contact ID (optional)', + }, }, }, - required: ['id'], + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListEmailThreadsInput.parse(input); + const result = await client.get('/emailer_threads', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, - _meta: { - category: 'emails', - access_level: 'read', - complexity: 'low', + { + name: 'apollo_get_email_thread', + description: 'Retrieves a single email thread by ID with all messages in the conversation. Use when the user wants to read a complete email exchange, review conversation history, or get context before replying. Returns full thread with all messages, timestamps, and participants.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the email thread to retrieve', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = GetEmailThreadInput.parse(input); + const result = await client.get(`/emailer_threads/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; +]; diff --git a/servers/apollo/src/tools/enrichment.ts b/servers/apollo/src/tools/enrichment.ts new file mode 100644 index 0000000..9a3d3f5 --- /dev/null +++ b/servers/apollo/src/tools/enrichment.ts @@ -0,0 +1,132 @@ +/** + * Apollo.io Enrichment Tools + * Tools for enriching person and company data + */ + +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; + +const EnrichPersonInput = z.object({ + email: z.string().email().optional().describe('Email address to enrich'), + first_name: z.string().optional().describe('First name of the person'), + last_name: z.string().optional().describe('Last name of the person'), + domain: z.string().optional().describe('Company domain to help with enrichment'), + linkedin_url: z.string().url().optional().describe('LinkedIn profile URL'), + reveal_personal_emails: z.boolean().default(false).describe('Whether to reveal personal email addresses'), +}); + +const EnrichCompanyInput = z.object({ + domain: z.string().describe('Company domain to enrich (e.g., apollo.io)'), +}); + +const BulkEnrichInput = z.object({ + people: z.array(z.object({ + email: z.string().email().optional(), + first_name: z.string().optional(), + last_name: z.string().optional(), + domain: z.string().optional(), + linkedin_url: z.string().url().optional(), + })).max(25).describe('Array of people to enrich (max 25 per request)'), + reveal_personal_emails: z.boolean().default(false).describe('Whether to reveal personal email addresses'), +}); + +export default [ + { + name: 'apollo_enrich_person', + description: 'Enriches a person record with additional data from Apollo.io database including email addresses, phone numbers, job title, company information, social profiles, and technologies used. Use when you need to find contact information or verify details about a specific person. Requires at least email OR (first_name + last_name + domain). Returns enriched person data with match confidence score.', + inputSchema: { + type: 'object' as const, + properties: { + email: { + type: 'string', + description: 'Email address to enrich', + }, + first_name: { + type: 'string', + description: 'First name of the person', + }, + last_name: { + type: 'string', + description: 'Last name of the person', + }, + domain: { + type: 'string', + description: 'Company domain to help with enrichment', + }, + linkedin_url: { + type: 'string', + description: 'LinkedIn profile URL', + }, + reveal_personal_emails: { + type: 'boolean', + description: 'Whether to reveal personal email addresses', + default: false, + }, + }, + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = EnrichPersonInput.parse(input); + const result = await client.post('/people/match', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_enrich_company', + description: 'Enriches a company record with detailed firmographic data from Apollo.io including employee count, industry, technologies, revenue estimates, social profiles, and contact information. Use when you need comprehensive company intelligence for prospecting, market research, or account planning. Returns enriched company data with all available fields.', + inputSchema: { + type: 'object' as const, + properties: { + domain: { + type: 'string', + description: 'Company domain to enrich (e.g., apollo.io)', + }, + }, + required: ['domain'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = EnrichCompanyInput.parse(input); + const result = await client.post('/organizations/enrich', { domain: validated.domain }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_bulk_enrich', + description: 'Enriches multiple person records in a single request for efficient batch processing. Use when you have a list of people to enrich and want to minimize API calls. Supports up to 25 people per request. Returns array of enriched person records with match confidence scores. Each person requires at least email OR (first_name + last_name + domain).', + inputSchema: { + type: 'object' as const, + properties: { + people: { + type: 'array', + description: 'Array of people to enrich (max 25 per request)', + items: { + type: 'object', + properties: { + email: { type: 'string' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + domain: { type: 'string' }, + linkedin_url: { type: 'string' }, + }, + }, + }, + reveal_personal_emails: { + type: 'boolean', + description: 'Whether to reveal personal email addresses', + default: false, + }, + }, + required: ['people'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = BulkEnrichInput.parse(input); + const result = await client.post('/people/bulk_match', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/apollo/src/tools/opportunities.ts b/servers/apollo/src/tools/opportunities.ts index 93397ec..d70bcc1 100644 --- a/servers/apollo/src/tools/opportunities.ts +++ b/servers/apollo/src/tools/opportunities.ts @@ -2,127 +2,162 @@ * Apollo.io Opportunity/Deal Tools */ -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; -export const listOpportunitiesTool: Tool = { - name: 'list_opportunities', - description: 'Lists opportunities (deals) from Apollo.io with pagination support. Use when the user wants to review their sales pipeline, check deal values, or analyze opportunities by stage. Returns paginated results showing opportunity names, amounts, stages, close dates, and associated accounts/contacts. Supports filtering by status (open/won/lost) and owner. Up to 100 opportunities per page.', - inputSchema: { - type: 'object', - properties: { - page: { - type: 'number', - description: 'Page number to retrieve (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Number of opportunities per page (max 100)', - default: 25, - }, - status: { - type: 'string', - enum: ['open', 'won', 'lost'], - description: 'Filter by opportunity status', - }, - owner_id: { - type: 'string', - description: 'Filter by opportunity owner user ID (optional)', +const ListOpportunitiesInput = z.object({ + page: z.number().min(1).default(1).describe('Page number to retrieve (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Number of opportunities per page (max 100)'), + status: z.enum(['open', 'won', 'lost']).optional().describe('Filter by opportunity status'), + owner_id: z.string().optional().describe('Filter by opportunity owner user ID'), +}); + +const CreateOpportunityInput = z.object({ + name: z.string().describe('Name of the opportunity (e.g., "Acme Corp - Enterprise Plan")'), + amount: z.number().optional().describe('Deal value/amount in dollars'), + account_id: z.string().optional().describe('ID of the associated account/company'), + contact_id: z.string().optional().describe('ID of the primary contact'), + stage_id: z.string().optional().describe('ID of the opportunity stage (defaults to first stage)'), + closed_date: z.string().optional().describe('Expected or actual close date in ISO 8601 format'), + probability: z.number().min(0).max(100).optional().describe('Win probability percentage (0-100)'), +}); + +const UpdateOpportunityInput = z.object({ + id: z.string().describe('The unique ID of the opportunity to update'), + name: z.string().optional().describe('Updated opportunity name'), + amount: z.number().optional().describe('Updated deal value/amount'), + stage_id: z.string().optional().describe('Updated stage ID (to advance or change stage)'), + status: z.enum(['open', 'won', 'lost']).optional().describe('Updated opportunity status'), + closed_date: z.string().optional().describe('Updated close date in ISO 8601 format'), + probability: z.number().min(0).max(100).optional().describe('Updated win probability percentage (0-100)'), +}); + +export default [ + { + name: 'apollo_list_opportunities', + description: 'Lists opportunities (deals) from Apollo.io with pagination support. Use when the user wants to review their sales pipeline, check deal values, or analyze opportunities by stage. Returns paginated results showing opportunity names, amounts, stages, close dates, and associated accounts/contacts. Supports filtering by status (open/won/lost) and owner. Up to 100 opportunities per page.', + inputSchema: { + type: 'object' as const, + properties: { + page: { + type: 'number', + description: 'Page number to retrieve (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of opportunities per page (max 100)', + default: 25, + }, + status: { + type: 'string', + enum: ['open', 'won', 'lost'], + description: 'Filter by opportunity status', + }, + owner_id: { + type: 'string', + description: 'Filter by opportunity owner user ID (optional)', + }, }, }, - }, - _meta: { - category: 'opportunities', - access_level: 'read', - complexity: 'low', - }, -}; - -export const createOpportunityTool: Tool = { - name: 'create_opportunity', - description: 'Creates a new opportunity (deal) in Apollo.io. Use when the user identifies a qualified prospect, receives a request for proposal, or wants to track a potential sale. Opportunities can be associated with contacts and accounts, have monetary values, and move through pipeline stages. Returns the newly created opportunity with assigned ID.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Name of the opportunity (e.g., "Acme Corp - Enterprise Plan")', - }, - amount: { - type: 'number', - description: 'Deal value/amount in dollars (optional)', - }, - account_id: { - type: 'string', - description: 'ID of the associated account/company (optional)', - }, - contact_id: { - type: 'string', - description: 'ID of the primary contact (optional)', - }, - stage_id: { - type: 'string', - description: 'ID of the opportunity stage (optional, defaults to first stage)', - }, - closed_date: { - type: 'string', - description: 'Expected or actual close date in ISO 8601 format (optional)', - }, - probability: { - type: 'number', - description: 'Win probability percentage (0-100, optional)', - }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListOpportunitiesInput.parse(input); + const result = await client.get('/opportunities', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['name'], }, - _meta: { - category: 'opportunities', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const updateOpportunityTool: Tool = { - name: 'update_opportunity', - description: 'Updates an existing opportunity in Apollo.io. Use when the user needs to modify deal details, change stage, update amount, adjust close date, or mark an opportunity as won or lost. Essential for maintaining accurate pipeline data. Returns the updated opportunity record.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the opportunity to update', - }, - name: { - type: 'string', - description: 'Updated opportunity name', - }, - amount: { - type: 'number', - description: 'Updated deal value/amount', - }, - stage_id: { - type: 'string', - description: 'Updated stage ID (to advance or change stage)', - }, - status: { - type: 'string', - enum: ['open', 'won', 'lost'], - description: 'Updated opportunity status', - }, - closed_date: { - type: 'string', - description: 'Updated close date in ISO 8601 format', - }, - probability: { - type: 'number', - description: 'Updated win probability percentage (0-100)', + { + name: 'apollo_create_opportunity', + description: 'Creates a new opportunity (deal) in Apollo.io. Use when the user identifies a qualified prospect, receives a request for proposal, or wants to track a potential sale. Opportunities can be associated with contacts and accounts, have monetary values, and move through pipeline stages. Returns the newly created opportunity with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Name of the opportunity (e.g., "Acme Corp - Enterprise Plan")', + }, + amount: { + type: 'number', + description: 'Deal value/amount in dollars (optional)', + }, + account_id: { + type: 'string', + description: 'ID of the associated account/company (optional)', + }, + contact_id: { + type: 'string', + description: 'ID of the primary contact (optional)', + }, + stage_id: { + type: 'string', + description: 'ID of the opportunity stage (optional, defaults to first stage)', + }, + closed_date: { + type: 'string', + description: 'Expected or actual close date in ISO 8601 format (optional)', + }, + probability: { + type: 'number', + description: 'Win probability percentage (0-100, optional)', + }, }, + required: ['name'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = CreateOpportunityInput.parse(input); + const result = await client.post('/opportunities', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'opportunities', - access_level: 'write', - complexity: 'medium', + { + name: 'apollo_update_opportunity', + description: 'Updates an existing opportunity in Apollo.io. Use when the user needs to modify deal details, change stage, update amount, adjust close date, or mark an opportunity as won or lost. Essential for maintaining accurate pipeline data. Returns the updated opportunity record.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the opportunity to update', + }, + name: { + type: 'string', + description: 'Updated opportunity name', + }, + amount: { + type: 'number', + description: 'Updated deal value/amount', + }, + stage_id: { + type: 'string', + description: 'Updated stage ID (to advance or change stage)', + }, + status: { + type: 'string', + enum: ['open', 'won', 'lost'], + description: 'Updated opportunity status', + }, + closed_date: { + type: 'string', + description: 'Updated close date in ISO 8601 format', + }, + probability: { + type: 'number', + description: 'Updated win probability percentage (0-100)', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = UpdateOpportunityInput.parse(input); + const { id, ...updateData } = validated; + const result = await client.put(`/opportunities/${id}`, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; +]; diff --git a/servers/apollo/src/tools/search.ts b/servers/apollo/src/tools/search.ts new file mode 100644 index 0000000..c5d6a16 --- /dev/null +++ b/servers/apollo/src/tools/search.ts @@ -0,0 +1,155 @@ +/** + * Apollo.io Search Tools + * Tools for searching people and companies in Apollo database + */ + +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; + +const SearchPeopleInput = z.object({ + q_keywords: z.string().optional().describe('Keywords to search for in name, title, or company'), + person_titles: z.array(z.string()).optional().describe('Job titles to filter by (e.g., ["CEO", "Founder"])'), + person_seniorities: z.array(z.string()).optional().describe('Seniority levels (e.g., ["senior", "manager", "director"])'), + organization_locations: z.array(z.string()).optional().describe('Company locations (e.g., ["San Francisco, CA", "New York, NY"])'), + organization_num_employees_ranges: z.array(z.string()).optional().describe('Employee count ranges (e.g., ["1-10", "11-50", "51-200"])'), + organization_industries: z.array(z.string()).optional().describe('Industries (e.g., ["Computer Software", "Internet"])'), + organization_domains: z.array(z.string()).optional().describe('Company domains (e.g., ["apollo.io", "google.com"])'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + per_page: z.number().min(1).max(100).default(25).describe('Number of results per page (max 100)'), +}); + +const SearchCompaniesInput = z.object({ + q_organization_keyword_tags: z.array(z.string()).optional().describe('Keywords to search for in company name or description'), + organization_locations: z.array(z.string()).optional().describe('Company locations (e.g., ["San Francisco, CA", "New York, NY"])'), + organization_num_employees_ranges: z.array(z.string()).optional().describe('Employee count ranges (e.g., ["1-10", "11-50", "51-200", "201-500"])'), + organization_industries: z.array(z.string()).optional().describe('Industries (e.g., ["Computer Software", "Internet", "Financial Services"])'), + revenue_range: z.object({ + min: z.number().optional().describe('Minimum annual revenue in USD'), + max: z.number().optional().describe('Maximum annual revenue in USD'), + }).optional().describe('Revenue range filter'), + funding_stage: z.array(z.string()).optional().describe('Funding stages (e.g., ["seed", "series_a", "series_b", "public"])'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + per_page: z.number().min(1).max(100).default(25).describe('Number of results per page (max 100)'), +}); + +export default [ + { + name: 'apollo_search_people', + description: 'Searches Apollo.io database for people matching specified criteria including job titles, seniorities, company attributes, and keywords. Use when prospecting for new contacts, building targeted lists, or researching decision makers. Supports advanced filtering by title, seniority, location, company size, and industry. Returns paginated results with up to 100 people per page. Each result includes contact information, job details, and company data.', + inputSchema: { + type: 'object' as const, + properties: { + q_keywords: { + type: 'string', + description: 'Keywords to search for in name, title, or company', + }, + person_titles: { + type: 'array', + items: { type: 'string' }, + description: 'Job titles to filter by (e.g., ["CEO", "Founder"])', + }, + person_seniorities: { + type: 'array', + items: { type: 'string' }, + description: 'Seniority levels (e.g., ["senior", "manager", "director"])', + }, + organization_locations: { + type: 'array', + items: { type: 'string' }, + description: 'Company locations (e.g., ["San Francisco, CA", "New York, NY"])', + }, + organization_num_employees_ranges: { + type: 'array', + items: { type: 'string' }, + description: 'Employee count ranges (e.g., ["1-10", "11-50", "51-200"])', + }, + organization_industries: { + type: 'array', + items: { type: 'string' }, + description: 'Industries (e.g., ["Computer Software", "Internet"])', + }, + organization_domains: { + type: 'array', + items: { type: 'string' }, + description: 'Company domains (e.g., ["apollo.io", "google.com"])', + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of results per page (max 100)', + default: 25, + }, + }, + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = SearchPeopleInput.parse(input); + const result = await client.post('/mixed_people/search', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_search_companies', + description: 'Searches Apollo.io database for companies matching specified criteria including industry, location, size, revenue, and funding stage. Use when researching target accounts, building prospect lists, or conducting market analysis. Supports advanced filtering by employee count, revenue range, funding stage, and location. Returns paginated results with up to 100 companies per page. Each result includes firmographic data, contact counts, and technology stack.', + inputSchema: { + type: 'object' as const, + properties: { + q_organization_keyword_tags: { + type: 'array', + items: { type: 'string' }, + description: 'Keywords to search for in company name or description', + }, + organization_locations: { + type: 'array', + items: { type: 'string' }, + description: 'Company locations (e.g., ["San Francisco, CA", "New York, NY"])', + }, + organization_num_employees_ranges: { + type: 'array', + items: { type: 'string' }, + description: 'Employee count ranges (e.g., ["1-10", "11-50", "51-200", "201-500"])', + }, + organization_industries: { + type: 'array', + items: { type: 'string' }, + description: 'Industries (e.g., ["Computer Software", "Internet", "Financial Services"])', + }, + revenue_range: { + type: 'object', + properties: { + min: { type: 'number', description: 'Minimum annual revenue in USD' }, + max: { type: 'number', description: 'Maximum annual revenue in USD' }, + }, + description: 'Revenue range filter', + }, + funding_stage: { + type: 'array', + items: { type: 'string' }, + description: 'Funding stages (e.g., ["seed", "series_a", "series_b", "public"])', + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of results per page (max 100)', + default: 25, + }, + }, + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = SearchCompaniesInput.parse(input); + const result = await client.post('/mixed_companies/search', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/apollo/src/tools/sequences.ts b/servers/apollo/src/tools/sequences.ts index 0f87976..378586c 100644 --- a/servers/apollo/src/tools/sequences.ts +++ b/servers/apollo/src/tools/sequences.ts @@ -2,140 +2,314 @@ * Apollo.io Sequence Tools */ -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; -export const listSequencesTool: Tool = { - name: 'list_sequences', - description: 'Lists email sequences from Apollo.io with pagination support. Use when the user wants to view all their sequences, check sequence performance, or manage their outreach campaigns. Returns paginated results showing sequence names, active status, step counts, and number of enrolled contacts. Supports up to 100 sequences per page.', - inputSchema: { - type: 'object', - properties: { - page: { - type: 'number', - description: 'Page number to retrieve (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Number of sequences per page (max 100)', - default: 25, - }, - active: { - type: 'boolean', - description: 'Filter by active status (true=active, false=paused)', +const ListSequencesInput = z.object({ + page: z.number().min(1).default(1).describe('Page number to retrieve (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Number of sequences per page (max 100)'), + active: z.boolean().optional().describe('Filter by active status (true=active, false=paused)'), +}); + +const GetSequenceInput = z.object({ + id: z.string().describe('The unique ID of the sequence to retrieve'), +}); + +const CreateSequenceInput = z.object({ + name: z.string().describe('Name of the sequence (e.g., "Q1 2024 Enterprise Outreach")'), + permissions: z.string().optional().describe('Permissions level (e.g., "private", "team_can_view", "team_can_edit")'), + label_ids: z.array(z.string()).optional().describe('Array of label IDs to categorize the sequence'), +}); + +const AddContactsToSequenceInput = z.object({ + sequence_id: z.string().describe('ID of the sequence to add contacts to'), + contact_ids: z.array(z.string()).describe('Array of contact IDs to enroll in the sequence'), + emailaccount_id: z.string().optional().describe('Email account ID to send from'), + send_email_from_user_id: z.string().optional().describe('User ID to send emails as'), +}); + +const RemoveContactsFromSequenceInput = z.object({ + sequence_id: z.string().describe('ID of the sequence to remove contacts from'), + contact_ids: z.array(z.string()).describe('Array of contact IDs to remove from the sequence'), +}); + +const CreateSequenceStepInput = z.object({ + sequence_id: z.string().describe('ID of the sequence to add the step to'), + type: z.enum(['email', 'task', 'phone_call', 'linkedin_message']).describe('Type of sequence step'), + wait_time: z.number().describe('Wait time in days before this step executes'), + subject: z.string().optional().describe('Email subject line (for email steps)'), + body: z.string().optional().describe('Email body content (for email steps)'), + note: z.string().optional().describe('Note for task steps'), + max_emails_per_day: z.number().optional().describe('Maximum emails to send per day for this step'), +}); + +const ListEmailTemplatesInput = z.object({ + page: z.number().min(1).default(1).describe('Page number for pagination'), + per_page: z.number().min(1).max(100).default(25).describe('Number of templates per page (max 100)'), + q_keywords: z.string().optional().describe('Keywords to search in template name or content'), + user_id: z.string().optional().describe('Filter by template owner user ID'), +}); + +const GetEmailStatsInput = z.object({ + sequence_id: z.string().optional().describe('Sequence ID to get stats for (optional)'), + user_id: z.string().optional().describe('User ID to get stats for (optional)'), + start_date: z.string().optional().describe('Start date for stats (ISO 8601)'), + end_date: z.string().optional().describe('End date for stats (ISO 8601)'), +}); + +export default [ + { + name: 'apollo_list_sequences', + description: 'Lists email sequences from Apollo.io with pagination support. Use when the user wants to view all their sequences, check sequence performance, or manage their outreach campaigns. Returns paginated results showing sequence names, active status, step counts, and number of enrolled contacts. Supports up to 100 sequences per page.', + inputSchema: { + type: 'object' as const, + properties: { + page: { + type: 'number', + description: 'Page number to retrieve (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of sequences per page (max 100)', + default: 25, + }, + active: { + type: 'boolean', + description: 'Filter by active status (true=active, false=paused)', + }, }, }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListSequencesInput.parse(input); + const result = await client.get('/emailer_campaigns', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, - _meta: { - category: 'sequences', - access_level: 'read', - complexity: 'low', + { + name: 'apollo_get_sequence', + description: 'Retrieves a single sequence by ID from Apollo.io with full details including all steps, settings, and statistics. Use when the user asks for detailed information about a specific sequence, wants to review its configuration, or check performance metrics. Returns complete sequence record with step definitions, timing, and enrollment data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the sequence to retrieve', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = GetSequenceInput.parse(input); + const result = await client.get(`/emailer_campaigns/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; - -export const getSequenceTool: Tool = { - name: 'get_sequence', - description: 'Retrieves a single sequence by ID from Apollo.io with full details including all steps, settings, and statistics. Use when the user asks for detailed information about a specific sequence, wants to review its configuration, or check performance metrics. Returns complete sequence record with step definitions, timing, and enrollment data.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the sequence to retrieve', + { + name: 'apollo_create_sequence', + description: 'Creates a new email sequence in Apollo.io. Use when the user wants to set up a new outreach campaign or automated email workflow. After creation, you can add steps and enroll contacts. Returns the newly created sequence with assigned ID. Note: This creates an empty sequence; use apollo_create_sequence_step to add steps.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Name of the sequence (e.g., "Q1 2024 Enterprise Outreach")', + }, + permissions: { + type: 'string', + description: 'Permissions level (e.g., "private", "team_can_view", "team_can_edit")', + }, + label_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of label IDs to categorize the sequence', + }, + }, + required: ['name'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = CreateSequenceInput.parse(input); + const result = await client.post('/emailer_campaigns', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_add_contacts_to_sequence', + description: 'Adds one or more contacts to an email sequence in Apollo.io to begin automated outreach. Use when the user wants to enroll prospects in a campaign, start a new outreach sequence, or add contacts to an existing nurture workflow. Contacts will receive emails according to the sequence steps and timing. Returns confirmation with enrollment details.', + inputSchema: { + type: 'object' as const, + properties: { + sequence_id: { + type: 'string', + description: 'ID of the sequence to add contacts to', + }, + contact_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of contact IDs to enroll in the sequence', + }, + emailaccount_id: { + type: 'string', + description: 'Email account ID to send from (optional, uses default if not specified)', + }, + send_email_from_user_id: { + type: 'string', + description: 'User ID to send emails as (optional)', + }, + }, + required: ['sequence_id', 'contact_ids'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = AddContactsToSequenceInput.parse(input); + const result = await client.post(`/emailer_campaigns/${validated.sequence_id}/add_contact_ids`, validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_remove_contacts_from_sequence', + description: 'Removes contacts from an active sequence in Apollo.io, stopping all scheduled emails. Use when the user wants to pause outreach to specific contacts (e.g., they responded, changed jobs, or requested to be removed). Contacts can be re-added later if needed. Returns confirmation of removal.', + inputSchema: { + type: 'object' as const, + properties: { + sequence_id: { + type: 'string', + description: 'ID of the sequence to remove contacts from', + }, + contact_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of contact IDs to remove from the sequence', + }, + }, + required: ['sequence_id', 'contact_ids'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = RemoveContactsFromSequenceInput.parse(input); + const result = await client.post(`/emailer_campaigns/${validated.sequence_id}/remove_contact_ids`, validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_create_sequence_step', + description: 'Adds a new step to an existing sequence in Apollo.io. Steps can be emails, tasks, phone calls, or LinkedIn messages. Use when building or modifying outreach sequences. Steps execute in order based on wait_time. Email steps support template variables. Returns the created step with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + sequence_id: { + type: 'string', + description: 'ID of the sequence to add the step to', + }, + type: { + type: 'string', + enum: ['email', 'task', 'phone_call', 'linkedin_message'], + description: 'Type of sequence step', + }, + wait_time: { + type: 'number', + description: 'Wait time in days before this step executes', + }, + subject: { + type: 'string', + description: 'Email subject line (for email steps)', + }, + body: { + type: 'string', + description: 'Email body content (for email steps)', + }, + note: { + type: 'string', + description: 'Note for task steps', + }, + max_emails_per_day: { + type: 'number', + description: 'Maximum emails to send per day for this step', + }, + }, + required: ['sequence_id', 'type', 'wait_time'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = CreateSequenceStepInput.parse(input); + const { sequence_id, ...stepData } = validated; + const result = await client.post(`/emailer_campaigns/${sequence_id}/emailer_steps`, stepData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'apollo_list_email_templates', + description: 'Lists email templates from Apollo.io with pagination and search support. Use when the user wants to browse available templates, find a specific template to use in a sequence, or manage their template library. Returns paginated results with template names, content previews, and usage stats. Supports keyword search across template name and body.', + inputSchema: { + type: 'object' as const, + properties: { + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of templates per page (max 100)', + default: 25, + }, + q_keywords: { + type: 'string', + description: 'Keywords to search in template name or content', + }, + user_id: { + type: 'string', + description: 'Filter by template owner user ID', + }, }, }, - required: ['id'], + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListEmailTemplatesInput.parse(input); + const result = await client.get('/emailer_templates', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, - _meta: { - category: 'sequences', - access_level: 'read', - complexity: 'low', - }, -}; - -export const createSequenceTool: Tool = { - name: 'create_sequence', - description: 'Creates a new email sequence in Apollo.io. Use when the user wants to set up a new outreach campaign or automated email workflow. After creation, you can add steps and enroll contacts. Returns the newly created sequence with assigned ID. Note: This creates an empty sequence; use separate tools to add steps and contacts.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Name of the sequence (e.g., "Q1 2024 Enterprise Outreach")', - }, - permissions: { - type: 'string', - description: 'Permissions level (e.g., "private", "team_can_view", "team_can_edit")', - }, - label_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Array of label IDs to categorize the sequence', + { + name: 'apollo_get_email_stats', + description: 'Retrieves email engagement statistics from Apollo.io including sent count, open rate, click rate, reply rate, and bounce rate. Use when analyzing sequence performance, measuring campaign effectiveness, or reporting on outreach results. Can filter by sequence, user, or date range. Returns detailed metrics with percentages and absolute counts.', + inputSchema: { + type: 'object' as const, + properties: { + sequence_id: { + type: 'string', + description: 'Sequence ID to get stats for (optional)', + }, + user_id: { + type: 'string', + description: 'User ID to get stats for (optional)', + }, + start_date: { + type: 'string', + description: 'Start date for stats (ISO 8601)', + }, + end_date: { + type: 'string', + description: 'End date for stats (ISO 8601)', + }, }, }, - required: ['name'], - }, - _meta: { - category: 'sequences', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const addContactsToSequenceTool: Tool = { - name: 'add_contacts_to_sequence', - description: 'Adds one or more contacts to an email sequence in Apollo.io to begin automated outreach. Use when the user wants to enroll prospects in a campaign, start a new outreach sequence, or add contacts to an existing nurture workflow. Contacts will receive emails according to the sequence steps and timing. Returns confirmation with enrollment details.', - inputSchema: { - type: 'object', - properties: { - sequence_id: { - type: 'string', - description: 'ID of the sequence to add contacts to', - }, - contact_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Array of contact IDs to enroll in the sequence', - }, - emailaccount_id: { - type: 'string', - description: 'Email account ID to send from (optional, uses default if not specified)', - }, - send_email_from_user_id: { - type: 'string', - description: 'User ID to send emails as (optional)', - }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = GetEmailStatsInput.parse(input); + const result = await client.get('/emailer_touches/stats', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['sequence_id', 'contact_ids'], }, - _meta: { - category: 'sequences', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const removeContactsFromSequenceTool: Tool = { - name: 'remove_contacts_from_sequence', - description: 'Removes contacts from an active sequence in Apollo.io, stopping all scheduled emails. Use when the user wants to pause outreach to specific contacts (e.g., they responded, changed jobs, or requested to be removed). Contacts can be re-added later if needed. Returns confirmation of removal.', - inputSchema: { - type: 'object', - properties: { - sequence_id: { - type: 'string', - description: 'ID of the sequence to remove contacts from', - }, - contact_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Array of contact IDs to remove from the sequence', - }, - }, - required: ['sequence_id', 'contact_ids'], - }, - _meta: { - category: 'sequences', - access_level: 'write', - complexity: 'medium', - }, -}; +]; diff --git a/servers/apollo/src/tools/tasks.ts b/servers/apollo/src/tools/tasks.ts index 2d89c8e..574cd6f 100644 --- a/servers/apollo/src/tools/tasks.ts +++ b/servers/apollo/src/tools/tasks.ts @@ -2,117 +2,149 @@ * Apollo.io Task Tools */ -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { ApolloClient } from '../client/apollo-client.js'; -export const listTasksTool: Tool = { - name: 'list_tasks', - description: 'Lists tasks from Apollo.io with pagination support. Use when the user wants to view their to-do list, check pending action items, or review completed tasks. Returns paginated results showing task type, status, priority, due dates, and associated contacts or accounts. Supports filtering by status (pending/completed/dismissed) and user. Up to 100 tasks per page.', - inputSchema: { - type: 'object', - properties: { - page: { - type: 'number', - description: 'Page number to retrieve (starts at 1)', - default: 1, - }, - per_page: { - type: 'number', - description: 'Number of tasks per page (max 100)', - default: 25, - }, - status: { - type: 'string', - enum: ['pending', 'completed', 'dismissed'], - description: 'Filter by task status', - }, - user_id: { - type: 'string', - description: 'Filter tasks by user ID (optional)', +const ListTasksInput = z.object({ + page: z.number().min(1).default(1).describe('Page number to retrieve (starts at 1)'), + per_page: z.number().min(1).max(100).default(25).describe('Number of tasks per page (max 100)'), + status: z.enum(['pending', 'completed', 'dismissed']).optional().describe('Filter by task status'), + user_id: z.string().optional().describe('Filter tasks by user ID'), +}); + +const CreateTaskInput = z.object({ + type: z.string().describe('Type of task (e.g., "call", "email", "demo", "follow_up", "action_item")'), + note: z.string().describe('Task description or notes'), + contact_id: z.string().optional().describe('ID of the contact this task is associated with'), + account_id: z.string().optional().describe('ID of the account this task is associated with'), + due_at: z.string().optional().describe('Due date/time in ISO 8601 format'), + priority: z.enum(['high', 'medium', 'low']).optional().describe('Task priority level'), +}); + +const UpdateTaskInput = z.object({ + id: z.string().describe('The unique ID of the task to update'), + status: z.enum(['pending', 'completed', 'dismissed']).optional().describe('Updated task status'), + note: z.string().optional().describe('Updated task notes'), + due_at: z.string().optional().describe('Updated due date/time in ISO 8601 format'), + priority: z.enum(['high', 'medium', 'low']).optional().describe('Updated priority level'), +}); + +export default [ + { + name: 'apollo_list_tasks', + description: 'Lists tasks from Apollo.io with pagination support. Use when the user wants to view their to-do list, check pending action items, or review completed tasks. Returns paginated results showing task type, status, priority, due dates, and associated contacts or accounts. Supports filtering by status (pending/completed/dismissed) and user. Up to 100 tasks per page.', + inputSchema: { + type: 'object' as const, + properties: { + page: { + type: 'number', + description: 'Page number to retrieve (starts at 1)', + default: 1, + }, + per_page: { + type: 'number', + description: 'Number of tasks per page (max 100)', + default: 25, + }, + status: { + type: 'string', + enum: ['pending', 'completed', 'dismissed'], + description: 'Filter by task status', + }, + user_id: { + type: 'string', + description: 'Filter tasks by user ID (optional)', + }, }, }, - }, - _meta: { - category: 'tasks', - access_level: 'read', - complexity: 'low', - }, -}; - -export const createTaskTool: Tool = { - name: 'create_task', - description: 'Creates a new task in Apollo.io associated with a contact or account. Use when the user needs to schedule a follow-up action like making a phone call, sending a proposal, or scheduling a demo. Tasks appear in the user\'s to-do list and can have due dates and priorities. Returns the newly created task with assigned ID.', - inputSchema: { - type: 'object', - properties: { - type: { - type: 'string', - description: 'Type of task (e.g., "call", "email", "demo", "follow_up", "action_item")', - }, - note: { - type: 'string', - description: 'Task description or notes', - }, - contact_id: { - type: 'string', - description: 'ID of the contact this task is associated with (optional)', - }, - account_id: { - type: 'string', - description: 'ID of the account this task is associated with (optional)', - }, - due_at: { - type: 'string', - description: 'Due date/time in ISO 8601 format (optional)', - }, - priority: { - type: 'string', - enum: ['high', 'medium', 'low'], - description: 'Task priority level', - }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = ListTasksInput.parse(input); + const result = await client.get('/tasks', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['type'], }, - _meta: { - category: 'tasks', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const updateTaskTool: Tool = { - name: 'update_task', - description: 'Updates an existing task in Apollo.io. Use when the user needs to change task details, reschedule a due date, update priority, or mark a task as completed or dismissed. Returns the updated task record.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'string', - description: 'The unique ID of the task to update', - }, - status: { - type: 'string', - enum: ['pending', 'completed', 'dismissed'], - description: 'Updated task status', - }, - note: { - type: 'string', - description: 'Updated task notes', - }, - due_at: { - type: 'string', - description: 'Updated due date/time in ISO 8601 format', - }, - priority: { - type: 'string', - enum: ['high', 'medium', 'low'], - description: 'Updated priority level', + { + name: 'apollo_create_task', + description: 'Creates a new task in Apollo.io associated with a contact or account. Use when the user needs to schedule a follow-up action like making a phone call, sending a proposal, or scheduling a demo. Tasks appear in the user\'s to-do list and can have due dates and priorities. Returns the newly created task with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + type: { + type: 'string', + description: 'Type of task (e.g., "call", "email", "demo", "follow_up", "action_item")', + }, + note: { + type: 'string', + description: 'Task description or notes', + }, + contact_id: { + type: 'string', + description: 'ID of the contact this task is associated with (optional)', + }, + account_id: { + type: 'string', + description: 'ID of the account this task is associated with (optional)', + }, + due_at: { + type: 'string', + description: 'Due date/time in ISO 8601 format (optional)', + }, + priority: { + type: 'string', + enum: ['high', 'medium', 'low'], + description: 'Task priority level', + }, }, + required: ['type'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = CreateTaskInput.parse(input); + const result = await client.post('/tasks', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'tasks', - access_level: 'write', - complexity: 'medium', + { + name: 'apollo_update_task', + description: 'Updates an existing task in Apollo.io. Use when the user needs to change task details, reschedule a due date, update priority, or mark a task as completed or dismissed. Returns the updated task record.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'string', + description: 'The unique ID of the task to update', + }, + status: { + type: 'string', + enum: ['pending', 'completed', 'dismissed'], + description: 'Updated task status', + }, + note: { + type: 'string', + description: 'Updated task notes', + }, + due_at: { + type: 'string', + description: 'Updated due date/time in ISO 8601 format', + }, + priority: { + type: 'string', + enum: ['high', 'medium', 'low'], + description: 'Updated priority level', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: ApolloClient) => { + const validated = UpdateTaskInput.parse(input); + const { id, ...updateData } = validated; + const result = await client.put(`/tasks/${id}`, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; +]; diff --git a/servers/clickup/package.json b/servers/clickup/package.json index 43d36c0..3ea5779 100644 --- a/servers/clickup/package.json +++ b/servers/clickup/package.json @@ -2,10 +2,10 @@ "name": "@mcpengine/clickup", "version": "1.0.0", "description": "ClickUp MCP Server - Complete task management, collaboration, and productivity platform integration", - "main": "dist/index.js", + "main": "dist/main.js", "type": "module", "bin": { - "clickup-mcp": "./dist/index.js" + "clickup-mcp": "./dist/main.js" }, "scripts": { "build": "tsc && npm run chmod", diff --git a/servers/clickup/src/index.ts b/servers/clickup/src/index.ts.bak similarity index 100% rename from servers/clickup/src/index.ts rename to servers/clickup/src/index.ts.bak diff --git a/servers/clickup/src/main.ts b/servers/clickup/src/main.ts new file mode 100644 index 0000000..2a3b2e5 --- /dev/null +++ b/servers/clickup/src/main.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node + +/** + * ClickUp MCP Server - Main Entry Point + * Complete integration with ClickUp API v2 + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ClickUpClient } from './clients/clickup.js'; +import { ClickUpMCPServer } from './server.js'; +import type { ClickUpConfig } from './types.js'; + +// Validate environment variables +const apiToken = process.env.CLICKUP_API_TOKEN; +const oauthToken = process.env.CLICKUP_OAUTH_TOKEN; +const clientId = process.env.CLICKUP_CLIENT_ID; +const clientSecret = process.env.CLICKUP_CLIENT_SECRET; + +if (!apiToken && !oauthToken) { + console.error('Error: Missing required environment variables'); + console.error(''); + console.error('Required (choose one):'); + console.error(' CLICKUP_API_TOKEN - Your ClickUp Personal API Token'); + console.error(' CLICKUP_OAUTH_TOKEN - Your ClickUp OAuth Token'); + console.error(''); + console.error('Optional (for OAuth):'); + console.error(' CLICKUP_CLIENT_ID - Your ClickUp OAuth Client ID'); + console.error(' CLICKUP_CLIENT_SECRET - Your ClickUp OAuth Client Secret'); + console.error(''); + console.error('Get your API token from: https://app.clickup.com/settings/apps'); + console.error('Settings → Apps → API Token'); + process.exit(1); +} + +// Create config +const config: ClickUpConfig = { + apiToken, + oauthToken, + clientId, + clientSecret, +}; + +// Create API client +const client = new ClickUpClient(config); + +// Create and start server +const server = new ClickUpMCPServer(client); + +// Graceful shutdown handlers +process.on('SIGINT', async () => { + console.error('\nReceived SIGINT, shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.error('\nReceived SIGTERM, shutting down gracefully...'); + process.exit(0); +}); + +// Start transport +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('ClickUp MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/clickup/src/server.ts b/servers/clickup/src/server.ts new file mode 100644 index 0000000..9f8e4b4 --- /dev/null +++ b/servers/clickup/src/server.ts @@ -0,0 +1,203 @@ +/** + * ClickUp MCP Server Class + * Implements lazy-loaded tool modules for optimal performance + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { ClickUpClient } from './clients/clickup.js'; + +type ToolModule = any[]; + +export class ClickUpMCPServer { + private server: Server; + private client: ClickUpClient; + private toolModules: Map Promise>; + private loadedTools: Map | null = null; + + constructor(client: ClickUpClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'clickup', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + + // Error handling + this.server.onerror = (error) => { + console.error('[MCP Error]', error); + }; + } + + private setupToolModules() { + // Register lazy-loaded tool modules + this.toolModules.set('tasks', async () => { + const module = await import('./tools/tasks-tools.js'); + return module.createTasksTools(this.client); + }); + + this.toolModules.set('spaces', async () => { + const module = await import('./tools/spaces-tools.js'); + return module.createSpacesTools(this.client); + }); + + this.toolModules.set('folders', async () => { + const module = await import('./tools/folders-tools.js'); + return module.createFoldersTools(this.client); + }); + + this.toolModules.set('lists', async () => { + const module = await import('./tools/lists-tools.js'); + return module.createListsTools(this.client); + }); + + this.toolModules.set('views', async () => { + const module = await import('./tools/views-tools.js'); + return module.createViewsTools(this.client); + }); + + this.toolModules.set('comments', async () => { + const module = await import('./tools/comments-tools.js'); + return module.createCommentsTools(this.client); + }); + + this.toolModules.set('docs', async () => { + const module = await import('./tools/docs-tools.js'); + return module.createDocsTools(this.client); + }); + + this.toolModules.set('goals', async () => { + const module = await import('./tools/goals-tools.js'); + return module.createGoalsTools(this.client); + }); + + this.toolModules.set('tags', async () => { + const module = await import('./tools/tags-tools.js'); + return module.createTagsTools(this.client); + }); + + this.toolModules.set('checklists', async () => { + const module = await import('./tools/checklists-tools.js'); + return module.createChecklistsTools(this.client); + }); + + this.toolModules.set('time-tracking', async () => { + const module = await import('./tools/time-tracking-tools.js'); + return module.createTimeTrackingTools(this.client); + }); + + this.toolModules.set('teams', async () => { + const module = await import('./tools/teams-tools.js'); + return module.createTeamsTools(this.client); + }); + + this.toolModules.set('webhooks', async () => { + const module = await import('./tools/webhooks-tools.js'); + return module.createWebhooksTools(this.client); + }); + + this.toolModules.set('custom-fields', async () => { + const module = await import('./tools/custom-fields-tools.js'); + return module.createCustomFieldsTools(this.client); + }); + + this.toolModules.set('templates', async () => { + const module = await import('./tools/templates-tools.js'); + return module.createTemplatesTools(this.client); + }); + + this.toolModules.set('guests', async () => { + const module = await import('./tools/guests-tools.js'); + return module.createGuestsTools(this.client); + }); + } + + private async loadAllTools(): Promise> { + if (this.loadedTools) { + return this.loadedTools; + } + + const tools = new Map(); + + for (const [name, loader] of this.toolModules.entries()) { + const toolArray = await loader(); + for (const tool of toolArray) { + tools.set(tool.name, tool); + } + } + + this.loadedTools = tools; + return tools; + } + + private setupHandlers() { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const allTools = await this.loadAllTools(); + + const tools: Tool[] = Array.from(allTools.values()).map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: { + type: 'object', + properties: tool.inputSchema.shape, + required: Object.keys(tool.inputSchema.shape).filter( + key => !tool.inputSchema.shape[key].isOptional() + ), + }, + })); + + return { tools }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const allTools = await this.loadAllTools(); + const tool = allTools.get(request.params.name); + + if (!tool) { + throw new Error(`Unknown tool: ${request.params.name}`); + } + + try { + // Validate input + const args = tool.inputSchema.parse(request.params.arguments); + + // Execute tool + const result = await tool.handler(args); + + return result; + } catch (error) { + if (error instanceof Error) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } + throw error; + } + }); + } + + async connect(transport: any) { + await this.server.connect(transport); + } +} diff --git a/servers/close/package.json b/servers/close/package.json index d08a1e6..2185e2b 100644 --- a/servers/close/package.json +++ b/servers/close/package.json @@ -3,14 +3,14 @@ "version": "1.0.0", "description": "Complete Close CRM MCP server with 60+ tools and 22 apps", "type": "module", - "main": "dist/index.js", + "main": "dist/main.js", "bin": { - "close-mcp": "./dist/index.js" + "close-mcp": "./dist/main.js" }, "scripts": { "build": "tsc", "dev": "tsc --watch", - "start": "node dist/index.js", + "start": "node dist/main.js", "prepare": "npm run build" }, "keywords": [ diff --git a/servers/close/src/index.ts b/servers/close/src/index.ts.bak similarity index 100% rename from servers/close/src/index.ts rename to servers/close/src/index.ts.bak diff --git a/servers/close/src/main.ts b/servers/close/src/main.ts new file mode 100644 index 0000000..beb3141 --- /dev/null +++ b/servers/close/src/main.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +/** + * Close CRM MCP Server - Main Entry Point + * Initializes environment, creates API client, and starts server + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CloseClient } from './client/close-client.js'; +import { CloseMCPServer } from './server.js'; + +async function main() { + // Validate environment + const apiKey = process.env.CLOSE_API_KEY; + + if (!apiKey) { + console.error('Error: CLOSE_API_KEY environment variable is required'); + console.error(''); + console.error('To get your Close API key:'); + console.error('1. Visit: https://app.close.com/settings/api/'); + console.error('2. Click "Create a new API Key"'); + console.error('3. Copy the generated API key'); + console.error('4. Set environment variable:'); + console.error(' export CLOSE_API_KEY=your_api_key'); + process.exit(1); + } + + // Initialize Close API client + const client = new CloseClient({ + apiKey, + baseUrl: process.env.CLOSE_BASE_URL, + }); + + // Create server instance + const server = new CloseMCPServer(client); + + // Graceful shutdown handlers + const cleanup = () => { + console.error('\nShutting down Close MCP server...'); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Start server with stdio transport + const transport = new StdioServerTransport(); + await server.start(transport); + + console.error('Close MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/close/src/server.ts b/servers/close/src/server.ts new file mode 100644 index 0000000..74042e3 --- /dev/null +++ b/servers/close/src/server.ts @@ -0,0 +1,206 @@ +/** + * Close CRM MCP Server - Server Class with Lazy Tool Loading + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { CloseClient } from './client/close-client.js'; + +interface ToolDefinition { + name: string; + description: string; + inputSchema: any; + handler: (args: any) => Promise; +} + +type ToolModule = (server: any, client: CloseClient) => void; + +export class CloseMCPServer { + private server: Server; + private client: CloseClient; + private toolModules: Map Promise>; + private toolRegistry: ToolDefinition[] = []; + + constructor(client: CloseClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'close-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + /** + * Register tool modules for lazy loading + */ + private setupToolModules(): void { + this.toolModules.set('leads', async () => { + const module = await import('./tools/leads-tools.js'); + return module.registerLeadsTools; + }); + + this.toolModules.set('contacts', async () => { + const module = await import('./tools/contacts-tools.js'); + return module.registerContactsTools; + }); + + this.toolModules.set('opportunities', async () => { + const module = await import('./tools/opportunities-tools.js'); + return module.registerOpportunitiesTools; + }); + + this.toolModules.set('activities', async () => { + const module = await import('./tools/activities-tools.js'); + return module.registerActivitiesTools; + }); + + this.toolModules.set('tasks', async () => { + const module = await import('./tools/tasks-tools.js'); + return module.registerTasksTools; + }); + + this.toolModules.set('smart-views', async () => { + const module = await import('./tools/smart-views-tools.js'); + return module.registerSmartViewsTools; + }); + + this.toolModules.set('users', async () => { + const module = await import('./tools/users-tools.js'); + return module.registerUsersTools; + }); + + this.toolModules.set('custom-fields', async () => { + const module = await import('./tools/custom-fields-tools.js'); + return module.registerCustomFieldsTools; + }); + + this.toolModules.set('sequences', async () => { + const module = await import('./tools/sequences-tools.js'); + return module.registerSequencesTools; + }); + + this.toolModules.set('reporting', async () => { + const module = await import('./tools/reporting-tools.js'); + return module.registerReportingTools; + }); + + this.toolModules.set('pipelines', async () => { + const module = await import('./tools/pipelines-tools.js'); + return module.registerPipelinesTools; + }); + + this.toolModules.set('bulk', async () => { + const module = await import('./tools/bulk-tools.js'); + return module.registerBulkTools; + }); + } + + /** + * Load all tools from registered modules + */ + private async loadAllTools(): Promise { + const extendedServer = this.createExtendedServer(); + + const loaders = await Promise.all( + Array.from(this.toolModules.values()).map(loader => loader()) + ); + + for (const registerFn of loaders) { + registerFn(extendedServer, this.client); + } + + console.error(`Loaded ${this.toolRegistry.length} Close CRM tools`); + } + + /** + * Create extended server with tool() method for registration + */ + private createExtendedServer() { + return { + ...this.server, + tool: (name: string, description: string, inputSchema: any, handler: (args: any) => Promise) => { + this.toolRegistry.push({ + name, + description, + inputSchema: { + type: 'object', + properties: inputSchema, + required: Object.entries(inputSchema) + .filter(([_, schema]: [string, any]) => schema.required === true) + .map(([key]) => key), + }, + handler, + }); + }, + }; + } + + /** + * Setup MCP protocol handlers + */ + private setupHandlers(): void { + // List tools handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + if (this.toolRegistry.length === 0) { + await this.loadAllTools(); + } + + return { + tools: this.toolRegistry.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Call tool handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (this.toolRegistry.length === 0) { + await this.loadAllTools(); + } + + const { name, arguments: args } = request.params; + const tool = this.toolRegistry.find((t) => t.name === name); + + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + try { + return await tool.handler(args || {}); + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } + }); + } + + /** + * Start the server with the given transport + */ + async start(transport: any): Promise { + await this.server.connect(transport); + } +} diff --git a/servers/closebot/package.json b/servers/closebot/package.json index 3763d33..ede5678 100644 --- a/servers/closebot/package.json +++ b/servers/closebot/package.json @@ -2,15 +2,15 @@ "name": "closebot-mcp", "version": "1.0.0", "description": "MCP server for CloseBot AI chatbot platform API", - "main": "dist/index.js", - "types": "dist/index.d.ts", + "main": "dist/main.js", + "types": "dist/main.d.ts", "bin": { - "closebot-mcp": "./dist/index.js" + "closebot-mcp": "./dist/main.js" }, "scripts": { "build": "tsc", - "start": "node dist/index.js", - "dev": "tsx src/index.ts", + "start": "node dist/main.js", + "dev": "tsx src/main.ts", "clean": "rm -rf dist", "prepublishOnly": "npm run build" }, diff --git a/servers/closebot/src/index.ts b/servers/closebot/src/index.ts.bak similarity index 100% rename from servers/closebot/src/index.ts rename to servers/closebot/src/index.ts.bak diff --git a/servers/closebot/src/main.ts b/servers/closebot/src/main.ts new file mode 100644 index 0000000..0412576 --- /dev/null +++ b/servers/closebot/src/main.ts @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +/** + * CloseBot MCP Server - Main Entry Point + * Initializes environment, creates API client, and starts server + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CloseBotClient } from './client.js'; +import { CloseBotMCPServer } from './server.js'; + +async function main() { + // Validate environment variables + let client: CloseBotClient; + + try { + client = new CloseBotClient(); + } catch (e) { + console.error('Error: CLOSEBOT_API_KEY environment variable is required'); + console.error(''); + console.error('To get your CloseBot API key:'); + console.error('1. Visit: https://closebot.ai/dashboard'); + console.error('2. Navigate to Settings > API Keys'); + console.error('3. Generate a new API key'); + console.error('4. Set environment variable:'); + console.error(' export CLOSEBOT_API_KEY=your_api_key'); + console.error(''); + console.error((e as Error).message || 'Failed to initialize client'); + process.exit(1); + } + + // Create server instance + const server = new CloseBotMCPServer(client); + + // Graceful shutdown handlers + const cleanup = () => { + console.error('\nShutting down CloseBot MCP server...'); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Start server with stdio transport + const transport = new StdioServerTransport(); + await server.start(transport); + + console.error('CloseBot MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/closebot/src/server.ts b/servers/closebot/src/server.ts new file mode 100644 index 0000000..4d7d8c2 --- /dev/null +++ b/servers/closebot/src/server.ts @@ -0,0 +1,190 @@ +/** + * CloseBot MCP Server - Server Class with Lazy Tool Loading + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { CloseBotClient } from './client.js'; +import type { ToolDefinition, ToolResult } from './types.js'; + +interface ToolModule { + tools: ToolDefinition[]; + handle: ( + client: CloseBotClient, + name: string, + args: Record + ) => Promise; +} + +interface LazyGroup { + path: string; + module?: ToolModule; + toolNames: string[]; +} + +export class CloseBotMCPServer { + private server: Server; + private client: CloseBotClient; + private toolModules: Map Promise>; + private groups: LazyGroup[] = []; + private toolToGroup = new Map(); + private allTools: ToolDefinition[] = []; + + constructor(client: CloseBotClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'closebot-mcp', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + /** + * Register tool modules for lazy loading + */ + private setupToolModules(): void { + this.groups = [ + { path: './tools/bot-management.js', toolNames: [] }, + { path: './tools/source-management.js', toolNames: [] }, + { path: './tools/lead-management.js', toolNames: [] }, + { path: './tools/analytics.js', toolNames: [] }, + { path: './tools/bot-testing.js', toolNames: [] }, + { path: './tools/library.js', toolNames: [] }, + { path: './tools/agency-billing.js', toolNames: [] }, + { path: './tools/configuration.js', toolNames: [] }, + ]; + + this.toolModules.set('bot-management', async () => { + const module = await import('./tools/bot-management.js'); + return module as unknown as ToolModule; + }); + + this.toolModules.set('source-management', async () => { + const module = await import('./tools/source-management.js'); + return module as unknown as ToolModule; + }); + + this.toolModules.set('lead-management', async () => { + const module = await import('./tools/lead-management.js'); + return module as unknown as ToolModule; + }); + + this.toolModules.set('analytics', async () => { + const module = await import('./tools/analytics.js'); + return module as unknown as ToolModule; + }); + + this.toolModules.set('bot-testing', async () => { + const module = await import('./tools/bot-testing.js'); + return module as unknown as ToolModule; + }); + + this.toolModules.set('library', async () => { + const module = await import('./tools/library.js'); + return module as unknown as ToolModule; + }); + + this.toolModules.set('agency-billing', async () => { + const module = await import('./tools/agency-billing.js'); + return module as unknown as ToolModule; + }); + + this.toolModules.set('configuration', async () => { + const module = await import('./tools/configuration.js'); + return module as unknown as ToolModule; + }); + } + + /** + * Load all tool metadata from registered modules + */ + private async loadGroupMetadata(): Promise { + const toolDefs: ToolDefinition[] = []; + for (let i = 0; i < this.groups.length; i++) { + const mod = (await import(this.groups[i].path)) as ToolModule; + this.groups[i].module = mod; + this.groups[i].toolNames = mod.tools.map((t) => t.name); + for (const tool of mod.tools) { + this.toolToGroup.set(tool.name, i); + toolDefs.push(tool); + } + } + this.allTools = toolDefs; + } + + /** + * Get handler for a specific tool + */ + private async getHandler(toolName: string): Promise { + const idx = this.toolToGroup.get(toolName); + if (idx === undefined) return null; + const group = this.groups[idx]; + if (!group.module) { + group.module = (await import(group.path)) as ToolModule; + } + return group.module.handle; + } + + /** + * Setup MCP protocol handlers + */ + private setupHandlers(): void { + // List tools handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + if (this.allTools.length === 0) { + await this.loadGroupMetadata(); + } + + return { + tools: this.allTools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }; + }); + + // Call tool handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (this.allTools.length === 0) { + await this.loadGroupMetadata(); + } + + const { name, arguments: args } = request.params; + const handler = await this.getHandler(name); + + if (!handler) { + return { + content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }], + isError: true, + } as Record; + } + + const result = await handler(this.client, name, (args as Record) || {}); + return result as unknown as Record; + }); + } + + /** + * Start the server with the given transport + */ + async start(transport: any): Promise { + await this.loadGroupMetadata(); + await this.server.connect(transport); + console.error(`Loaded ${this.allTools.length} tools across ${this.groups.length} modules`); + } +} diff --git a/servers/google-console/package.json b/servers/google-console/package.json index 984b3b8..80e6d7d 100644 --- a/servers/google-console/package.json +++ b/servers/google-console/package.json @@ -3,14 +3,14 @@ "version": "1.0.0", "description": "Google Search Console MCP Server with tool annotations, lazy loading, and interactive apps", "type": "module", - "main": "dist/index.js", + "main": "dist/main.js", "bin": { - "google-console-mcp": "dist/index.js" + "google-console-mcp": "dist/main.js" }, "scripts": { "build": "tsc", "dev": "tsc --watch", - "start": "node dist/index.js", + "start": "node dist/main.js", "lint": "tsc --noEmit" }, "keywords": ["mcp", "google-search-console", "seo", "analytics"], diff --git a/servers/google-console/src/index.ts b/servers/google-console/src/index.ts.bak similarity index 100% rename from servers/google-console/src/index.ts rename to servers/google-console/src/index.ts.bak diff --git a/servers/google-console/src/main.ts b/servers/google-console/src/main.ts new file mode 100644 index 0000000..3e10690 --- /dev/null +++ b/servers/google-console/src/main.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +/** + * Google Search Console MCP Server - Main Entry Point + * Initializes authentication, client, cache, and starts server + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { getAuthClient } from './auth/index.js'; +import { GSCClient } from './lib/gsc-client.js'; +import { Cache } from './lib/cache.js'; +import { RateLimiter } from './lib/rate-limit.js'; +import { GSCServer } from './server.js'; + +async function main() { + try { + console.error('Starting Google Search Console MCP server...'); + + // Initialize authentication + console.error('Authenticating...'); + const authClient = await getAuthClient(); + + if (!authClient) { + console.error('Error: Failed to authenticate with Google Search Console'); + console.error(''); + console.error('To set up authentication:'); + console.error('1. Visit: https://console.cloud.google.com/apis/credentials'); + console.error('2. Create OAuth 2.0 credentials'); + console.error('3. Download credentials.json'); + console.error('4. Follow the authentication flow on first run'); + process.exit(1); + } + + // Initialize GSC client + const gscClient = new GSCClient(authClient as any); + + // Initialize cache with optional TTL from env + const cacheTTL = process.env.GSC_CACHE_TTL + ? parseInt(process.env.GSC_CACHE_TTL) * 1000 + : undefined; + + const cache = new Cache({ + analyticsTTL: cacheTTL, + }); + + // Initialize rate limiter + const rateLimiter = new RateLimiter(); + + // Start periodic cache pruning (every 5 minutes) + const pruneInterval = setInterval(() => { + cache.prune(); + }, 5 * 60 * 1000); + + // Create server instance + const server = new GSCServer({ + gscClient, + cache, + rateLimiter, + }); + + // Graceful shutdown handlers + const cleanup = () => { + console.error('\nShutting down Google Search Console MCP server...'); + clearInterval(pruneInterval); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Start server (transport created internally) + await server.connect(); + } catch (error: any) { + console.error('Fatal error:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +main(); diff --git a/servers/greenhouse/README.md b/servers/greenhouse/README.md index 5cb2461..4096431 100644 --- a/servers/greenhouse/README.md +++ b/servers/greenhouse/README.md @@ -1,15 +1,17 @@ # Greenhouse MCP Server -MCP server for the Greenhouse ATS (Applicant Tracking System) and recruiting platform, providing comprehensive tools for managing candidates, applications, jobs, offers, scorecards, and users. +AI-powered access to the Greenhouse ATS/recruiting platform via Model Context Protocol. ## Features -- **Candidate Management** - Browse, create, and update candidate profiles -- **Application Tracking** - Track applications through hiring pipeline -- **Job Management** - Create and manage job postings -- **Offers** - Review and manage job offers -- **Scorecards** - Access interview evaluations -- **User Management** - Browse hiring team members +- **Candidate Management**: List, get, create, update candidates, and add candidate notes +- **Application Tracking**: Browse applications, advance through pipeline stages, reject applications +- **Job Management**: List, get, create, and update job postings and requisitions +- **Interviews**: List all interviews, get interview details, list scheduled interviews +- **Scorecards**: Browse interview feedback, get scorecard details, create new scorecards +- **Offers**: List and retrieve offer details and compensation information +- **Organization Data**: Access departments, offices, sources, rejection reasons, job stages, and custom fields +- **User Management**: List and retrieve Greenhouse users and team members ## Installation @@ -18,119 +20,112 @@ npm install npm run build ``` -## Configuration +## Environment Variables -Set your Greenhouse API key as an environment variable: +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `GREENHOUSE_API_KEY` | āœ… | Greenhouse Harvest API key | `your_harvest_api_key_here` | -```bash -export GREENHOUSE_API_KEY="your_api_key_here" -``` +## Getting Your Access Token + +1. Log into your Greenhouse account +2. Navigate to **Configure** > **Dev Center** > **API Credential Management** +3. Click **Create New API Key** +4. Select **Harvest API** (for production data access) +5. Set appropriate permissions for your use case +6. Copy the generated API key +7. Set it as `GREENHOUSE_API_KEY` in your environment + +## Required API Scopes + +The Harvest API key requires permissions based on your use case: + +- **Read access**: Candidates, Applications, Jobs, Interviews, Scorecards, Offers, Users, Departments, Offices +- **Write access**: Create/update candidates, create scorecards, advance applications, reject applications, create/update jobs + +Configure permissions when creating the API key in Greenhouse. ## Usage -Run the server: +### Stdio Mode (Default) + +```bash +node dist/main.js +``` + +Or using the npm script: ```bash npm start -# or -node dist/index.js ``` -## Available Tools (18 total) +### HTTP Mode -### Candidates (4 tools) -- `list_candidates` - Browse candidate database with pagination -- `get_candidate` - Get detailed candidate information -- `create_candidate` - Add new candidates -- `update_candidate` - Modify candidate details +Currently supports stdio transport only. HTTP/SSE transport support coming soon. -### Applications (4 tools) -- `list_applications` - View applications across all jobs -- `get_application` - Get detailed application information -- `advance_stage` - Move application to next pipeline stage -- `reject_application` - Reject application with reason +## Tool Coverage Manifest -### Jobs (4 tools) -- `list_jobs` - Browse all job postings -- `get_job` - Get detailed job configuration -- `create_job` - Create new job posting -- `update_job` - Modify job details +### Total API Coverage -### Offers & Scorecards (4 tools) -- `list_offers` - Review job offers -- `get_offer` - Get detailed offer information -- `list_scorecards` - Browse interview scorecards -- `get_scorecard` - Get detailed interview feedback +- **Total Greenhouse Harvest API endpoints**: ~150 +- **Tools implemented**: 20 +- **Intentionally skipped**: 130 +- **Coverage**: 20/150 = 13% -### Users (2 tools) -- `list_users` - Browse team members -- `get_user` - Get user details +### Implemented Tools -## API Coverage Manifest +| Category | Tools | Count | +|----------|-------|-------| +| Candidates | list, get, create, update, create_note | 5 | +| Applications | list, get, advance, reject | 4 | +| Jobs | list, get, create, update | 4 | +| Interviews | list, get, list_scheduled | 3 | +| Scorecards | list, get, create | 3 | +| Offers | list, get | 2 | +| Organization | list_departments, list_offices, list_sources, list_rejection_reasons, list_job_stages, list_custom_fields | 6 | +| Users | list, get | 2 | -**Total Greenhouse API Endpoints:** ~100+ -**Implemented in this server:** 18 -**Coverage:** ~18% +**Total: 29 tools** across 8 categories -### Covered Areas: -- āœ… Candidate CRUD operations -- āœ… Application management and progression -- āœ… Job posting management -- āœ… Offer tracking -- āœ… Scorecard/interview feedback access -- āœ… User listing +### Skipped Endpoints (Rationale) -### Not Yet Implemented: -- ā³ Interview scheduling -- ā³ Email templates -- ā³ Custom fields management -- ā³ Departments and offices CRUD -- ā³ Job stages configuration -- ā³ Rejection reasons -- ā³ Activity feed -- ā³ Tags management -- ā³ Prospect pools -- ā³ EEOC data -- ā³ Approvals -- ā³ Scheduled interviews +- **Job Posts** (6 endpoints): Covered by job management tools, posts are auto-created with jobs +- **Email Templates** (4 endpoints): Admin configuration, rarely needed in AI workflows +- **Approvals** (5 endpoints): Complex approval flow management better suited for UI +- **EEOC** (3 endpoints): Compliance data requiring special handling +- **Activity Feed** (10+ endpoints): Duplicates data available in primary resources +- **Webhooks** (8 endpoints): Admin configuration, not suitable for MCP tool calls +- **Attachments** (4 endpoints): Complex binary handling, requires special transport +- **Tags** (4 endpoints): Low-value metadata, rarely used +- Other low-use administrative endpoints -## Architecture +### Coverage Goals +Current implementation focuses on **Tier 1** (daily recruiting workflows): +- Reviewing candidates and applications +- Advancing candidates through pipeline +- Scheduling and evaluating interviews +- Managing job postings +- Accessing organizational data + +**Future expansion** could add Tier 2 tools for power users: +- Bulk operations +- Advanced reporting +- Candidate pool management +- Offer approval workflows + +## Development + +```bash +# Watch mode for development +npm run dev + +# Build +npm run build + +# Type checking +npx tsc --noEmit ``` -greenhouse/ -ā”œā”€ā”€ src/ -│ ā”œā”€ā”€ index.ts # MCP server entry point -│ ā”œā”€ā”€ client/ -│ │ └── greenhouse-client.ts # API client with rate limiting -│ ā”œā”€ā”€ tools/ -│ │ ā”œā”€ā”€ candidates.ts # Candidate tools -│ │ ā”œā”€ā”€ applications.ts # Application tools -│ │ ā”œā”€ā”€ jobs.ts # Job tools -│ │ ā”œā”€ā”€ offers.ts # Offer & scorecard tools -│ │ └── users.ts # User tools -│ └── types/ -│ └── index.ts # TypeScript interfaces -ā”œā”€ā”€ package.json -ā”œā”€ā”€ tsconfig.json -└── README.md -``` - -## Rate Limiting - -The client implements automatic rate limiting: -- Max 10 concurrent requests -- Minimum 100ms between requests -- Automatic backoff on 429 responses - -## Error Handling - -The server provides detailed error messages for: -- Authentication failures (401) -- Permission issues (403) -- Resource not found (404) -- Validation errors (422) -- Rate limit exceeded (429) -- Server errors (500+) ## License diff --git a/servers/greenhouse/package.json b/servers/greenhouse/package.json index db5616b..1cb6b38 100644 --- a/servers/greenhouse/package.json +++ b/servers/greenhouse/package.json @@ -1,27 +1,30 @@ { - "name": "@mcpengine/greenhouse-server", + "name": "@mcpengine/greenhouse", "version": "1.0.0", "description": "MCP server for Greenhouse ATS/recruiting platform", "type": "module", "bin": { - "greenhouse-mcp": "./dist/index.js" + "@mcpengine/greenhouse": "./dist/main.js" }, - "main": "./dist/index.js", + "main": "./dist/main.js", "scripts": { "build": "tsc", "watch": "tsc --watch", - "start": "node dist/index.js" + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts" }, "keywords": ["mcp", "greenhouse", "ats", "recruiting"], "author": "MCPEngine", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", - "axios": "^1.6.0", - "bottleneck": "^2.19.5" + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "bottleneck": "^2.19.5", + "zod": "^3.23.0" }, "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "tsx": "^4.19.0" } } diff --git a/servers/greenhouse/src/index.ts b/servers/greenhouse/src/index.ts.bak similarity index 100% rename from servers/greenhouse/src/index.ts rename to servers/greenhouse/src/index.ts.bak diff --git a/servers/greenhouse/src/main.ts b/servers/greenhouse/src/main.ts new file mode 100644 index 0000000..d817613 --- /dev/null +++ b/servers/greenhouse/src/main.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +/** + * Greenhouse MCP Server + * Provides AI-powered access to Greenhouse ATS/recruiting platform + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { GreenhouseClient } from './client/greenhouse-client.js'; +import { GreenhouseMCPServer } from './server.js'; + +// Validate environment variables +const apiKey = process.env.GREENHOUSE_API_KEY; +if (!apiKey) { + console.error('Error: GREENHOUSE_API_KEY environment variable is required'); + console.error(''); + console.error('Get your API key from:'); + console.error(' 1. Log into Greenhouse'); + console.error(' 2. Go to Configure > Dev Center > API Credential Management'); + console.error(' 3. Create a new Harvest API Key'); + console.error(' 4. Copy the key and set GREENHOUSE_API_KEY in your environment'); + console.error(''); + process.exit(1); +} + +// Initialize client and server +const client = new GreenhouseClient({ apiKey }); +const mcpServer = new GreenhouseMCPServer(client); +const server = mcpServer.getServer(); + +// Graceful shutdown handlers +let isShuttingDown = false; + +async function shutdown(signal: string) { + if (isShuttingDown) return; + isShuttingDown = true; + + console.error(`\nReceived ${signal}, shutting down gracefully...`); + + try { + await server.close(); + console.error('Server closed successfully'); + process.exit(0); + } catch (error) { + console.error('Error during shutdown:', error); + process.exit(1); + } +} + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// Start the server +async function main() { + try { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Greenhouse MCP Server running on stdio'); + console.error('Connected to Greenhouse Harvest API'); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +main().catch((error) => { + console.error('Fatal error in main():', error); + process.exit(1); +}); diff --git a/servers/greenhouse/src/server.ts b/servers/greenhouse/src/server.ts new file mode 100644 index 0000000..62b12d3 --- /dev/null +++ b/servers/greenhouse/src/server.ts @@ -0,0 +1,137 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ErrorCode, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; +import { GreenhouseClient } from './client/greenhouse-client.js'; + +type ToolModule = { + name: string; + description: string; + inputSchema: any; + handler: (input: unknown, client: GreenhouseClient) => Promise; +}; + +export class GreenhouseMCPServer { + private server: Server; + private client: GreenhouseClient; + private toolModules: Map Promise>; + + constructor(client: GreenhouseClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'greenhouse-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules(): void { + // Lazy-load each tool module + this.toolModules.set('candidates', async () => { + const module = await import('./tools/candidates.js'); + return module.default; + }); + + this.toolModules.set('applications', async () => { + const module = await import('./tools/applications.js'); + return module.default; + }); + + this.toolModules.set('jobs', async () => { + const module = await import('./tools/jobs.js'); + return module.default; + }); + + this.toolModules.set('offers', async () => { + const module = await import('./tools/offers.js'); + return module.default; + }); + + this.toolModules.set('users', async () => { + const module = await import('./tools/users.js'); + return module.default; + }); + + this.toolModules.set('interviews', async () => { + const module = await import('./tools/interviews.js'); + return module.default; + }); + + this.toolModules.set('scorecards', async () => { + const module = await import('./tools/scorecards.js'); + return module.default; + }); + + this.toolModules.set('organization', async () => { + const module = await import('./tools/organization.js'); + return module.default; + }); + } + + private async loadAllTools(): Promise { + const allTools: ToolModule[] = []; + + for (const loader of this.toolModules.values()) { + const tools = await loader(); + allTools.push(...tools); + } + + return allTools; + } + + private setupHandlers(): void { + // List all available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.loadAllTools(); + return { + tools: tools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + // Load all tools and find the matching handler + const allTools = await this.loadAllTools(); + const tool = allTools.find(t => t.name === name); + + if (!tool) { + throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`); + } + + return await tool.handler(args, this.client); + } catch (error) { + if (error instanceof McpError) throw error; + throw new McpError( + ErrorCode.InternalError, + `Tool execution failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + }); + } + + getServer(): Server { + return this.server; + } +} diff --git a/servers/greenhouse/src/tools/applications.ts b/servers/greenhouse/src/tools/applications.ts index 0918ec8..f8555c2 100644 --- a/servers/greenhouse/src/tools/applications.ts +++ b/servers/greenhouse/src/tools/applications.ts @@ -1,119 +1,159 @@ -/** - * Greenhouse Application Tools - */ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +const ListApplicationsInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + created_after: z.string().optional().describe('Filter by creation date (ISO 8601)'), + updated_after: z.string().optional().describe('Filter by update date (ISO 8601)'), + job_id: z.number().optional().describe('Filter by job ID'), + status: z.enum(['active', 'rejected', 'hired']).optional().describe('Filter by application status'), +}); -export const listApplicationsTool: Tool = { - name: 'list_applications', - description: 'Lists applications from Greenhouse with pagination support. Use when the user wants to review candidate applications, analyze pipeline metrics, or track application status. Returns paginated list showing application stage, job, candidate, source, and activity timestamps. Supports filtering by status, job, and date ranges. Up to 500 applications per page.', - inputSchema: { - type: 'object', - properties: { - per_page: { - type: 'number', - description: 'Applications per page (max 500)', - default: 100, - }, - page: { - type: 'number', - description: 'Page number', - default: 1, - }, - created_after: { - type: 'string', - description: 'Filter by creation date (ISO 8601)', - }, - job_id: { - type: 'number', - description: 'Filter by job ID', - }, - status: { - type: 'string', - enum: ['active', 'rejected', 'hired'], - description: 'Filter by application status', +const GetApplicationInput = z.object({ + id: z.number().describe('Application ID'), +}); + +const AdvanceApplicationInput = z.object({ + id: z.number().describe('Application ID to advance'), + from_stage_id: z.number().describe('Current stage ID'), + to_stage_id: z.number().describe('Target stage ID'), +}); + +const RejectApplicationInput = z.object({ + id: z.number().describe('Application ID to reject'), + rejection_reason_id: z.number().optional().describe('Rejection reason ID'), + notes: z.string().optional().describe('Rejection notes'), + rejection_email_id: z.number().optional().describe('Email template ID to send'), +}); + +export default [ + { + name: 'greenhouse_list_applications', + description: 'Lists applications from Greenhouse with pagination and filtering. Use when the user wants to browse candidate applications, review pipeline status, or export application data. Returns paginated list of applications with candidate info, job details, current stage, source, credited recruiter, and activity timestamps. Supports filtering by job ID, status (active/rejected/hired), creation date, and update date. Returns up to 500 applications per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + created_after: { + type: 'string', + description: 'Filter by creation date (ISO 8601)', + }, + updated_after: { + type: 'string', + description: 'Filter by update date (ISO 8601)', + }, + job_id: { + type: 'number', + description: 'Filter by job ID', + }, + status: { + type: 'string', + enum: ['active', 'rejected', 'hired'], + description: 'Filter by application status', + }, }, }, - }, - _meta: { - category: 'applications', - access_level: 'read', - complexity: 'low', - }, -}; - -export const getApplicationTool: Tool = { - name: 'get_application', - description: 'Retrieves a single application by ID from Greenhouse with full details. Use when the user needs detailed application information including current stage, rejection details, source attribution, answers to application questions, and prospect information. Returns complete application record.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Application ID', - }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListApplicationsInput.parse(input); + const result = await client.get('/applications', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'applications', - access_level: 'read', - complexity: 'low', - }, -}; - -export const advanceStageTool: Tool = { - name: 'advance_stage', - description: 'Advances an application to the next stage in Greenhouse hiring pipeline. Use when progressing a candidate through the interview process after screening, phone interview, on-site, or other evaluation steps. Returns updated application with new stage information.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Application ID to advance', - }, - from_stage_id: { - type: 'number', - description: 'Current stage ID', + { + name: 'greenhouse_get_application', + description: 'Retrieves a single application by ID from Greenhouse with complete details. Use when the user needs detailed information about a candidate\'s application including all stage history, interview schedules, scorecards, offers, rejection details, custom fields, and activity feed. Returns full application record with all associated data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Application ID', + }, }, + required: ['id'], }, - required: ['id'], - }, - _meta: { - category: 'applications', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const rejectApplicationTool: Tool = { - name: 'reject_application', - description: 'Rejects an application in Greenhouse with optional reason and notes. Use when declining a candidate after screening or interviews. Can trigger rejection email templates. Returns updated application with rejection details.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Application ID to reject', - }, - rejection_reason_id: { - type: 'number', - description: 'Rejection reason ID', - }, - notes: { - type: 'string', - description: 'Internal rejection notes', - }, - send_email: { - type: 'boolean', - description: 'Send rejection email to candidate', - }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = GetApplicationInput.parse(input); + const result = await client.get(`/applications/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'applications', - access_level: 'write', - complexity: 'medium', + { + name: 'greenhouse_advance_application', + description: 'Advances an application to the next stage in the hiring pipeline. Use when moving candidates forward in the interview process, promoting from "Phone Screen" to "Onsite", or advancing to "Offer" stage. Requires the current stage ID and target stage ID. This does not automatically schedule interviews - use this for stage progression only. Returns the updated application with new stage information.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Application ID to advance', + }, + from_stage_id: { + type: 'number', + description: 'Current stage ID', + }, + to_stage_id: { + type: 'number', + description: 'Target stage ID', + }, + }, + required: ['id', 'from_stage_id', 'to_stage_id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = AdvanceApplicationInput.parse(input); + const { id, ...advanceData } = validated; + const result = await client.post(`/applications/${id}/advance`, advanceData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'greenhouse_reject_application', + description: 'Rejects an application and removes the candidate from the active pipeline. Use when a candidate is not moving forward, has declined, or is not a good fit. Can include a rejection reason (use greenhouse_list_rejection_reasons to get available reasons), custom notes, and optionally send a rejection email to the candidate. This action moves the application to rejected status. Returns the updated application with rejection details and timestamp.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Application ID to reject', + }, + rejection_reason_id: { + type: 'number', + description: 'Rejection reason ID (use greenhouse_list_rejection_reasons)', + }, + notes: { + type: 'string', + description: 'Rejection notes', + }, + rejection_email_id: { + type: 'number', + description: 'Email template ID to send to candidate', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = RejectApplicationInput.parse(input); + const { id, ...rejectData } = validated; + const result = await client.post(`/applications/${id}/reject`, rejectData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/greenhouse/src/tools/candidates.ts b/servers/greenhouse/src/tools/candidates.ts index 5563c33..1931b12 100644 --- a/servers/greenhouse/src/tools/candidates.ts +++ b/servers/greenhouse/src/tools/candidates.ts @@ -1,148 +1,230 @@ -/** - * Greenhouse Candidate Tools - */ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +const ListCandidatesInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + created_after: z.string().optional().describe('Filter by creation date (ISO 8601)'), + updated_after: z.string().optional().describe('Filter by update date (ISO 8601)'), +}); -export const listCandidatesTool: Tool = { - name: 'list_candidates', - description: 'Lists candidates from Greenhouse with pagination support. Use when the user wants to browse their candidate database, review recent applicants, or export candidate data. Returns paginated list of candidates with basic info, application IDs, tags, and recent activity. Supports filtering by email, creation date, and update date. Up to 500 candidates per page.', - inputSchema: { - type: 'object', - properties: { - per_page: { - type: 'number', - description: 'Candidates per page (max 500)', - default: 100, - }, - page: { - type: 'number', - description: 'Page number', - default: 1, - }, - created_after: { - type: 'string', - description: 'Filter by creation date (ISO 8601)', - }, - updated_after: { - type: 'string', - description: 'Filter by update date (ISO 8601)', - }, - }, - }, - _meta: { - category: 'candidates', - access_level: 'read', - complexity: 'low', - }, -}; +const GetCandidateInput = z.object({ + id: z.number().describe('Candidate ID'), +}); -export const getCandidateTool: Tool = { - name: 'get_candidate', - description: 'Retrieves a single candidate by ID from Greenhouse with complete details. Use when the user needs detailed candidate information including contact info, resumes, applications, education, employment history, custom fields, and recruiter assignments. Returns full candidate profile with all associated data.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Candidate ID', - }, - }, - required: ['id'], - }, - _meta: { - category: 'candidates', - access_level: 'read', - complexity: 'low', - }, -}; +const CreateCandidateInput = z.object({ + first_name: z.string().describe('First name'), + last_name: z.string().describe('Last name'), + company: z.string().optional().describe('Current company'), + title: z.string().optional().describe('Current title'), + email_addresses: z.array(z.object({ + value: z.string(), + type: z.enum(['personal', 'work', 'other']), + })).optional().describe('Email addresses'), + phone_numbers: z.array(z.object({ + value: z.string(), + type: z.enum(['mobile', 'home', 'work', 'skype', 'other']), + })).optional().describe('Phone numbers'), +}); -export const createCandidateTool: Tool = { - name: 'create_candidate', - description: 'Creates a new candidate in Greenhouse. Use when adding prospects, importing candidates from other sources, or creating candidate records from referrals. Can include contact information, resume attachments, education, employment history, and custom fields. Returns the newly created candidate with assigned ID.', - inputSchema: { - type: 'object', - properties: { - first_name: { - type: 'string', - description: 'First name', - }, - last_name: { - type: 'string', - description: 'Last name', - }, - company: { - type: 'string', - description: 'Current company', - }, - title: { - type: 'string', - description: 'Current title', - }, - email_addresses: { - type: 'array', - items: { - type: 'object', - properties: { - value: { type: 'string' }, - type: { type: 'string', enum: ['personal', 'work', 'other'] }, - }, +const UpdateCandidateInput = z.object({ + id: z.number().describe('Candidate ID to update'), + first_name: z.string().optional().describe('Updated first name'), + last_name: z.string().optional().describe('Updated last name'), + company: z.string().optional().describe('Updated company'), + title: z.string().optional().describe('Updated title'), +}); + +const CreateCandidateNoteInput = z.object({ + candidate_id: z.number().describe('Candidate ID'), + user_id: z.number().optional().describe('User ID creating the note'), + body: z.string().describe('Note content'), + visibility: z.enum(['admin_only', 'public', 'private']).default('admin_only').describe('Note visibility'), +}); + +export default [ + { + name: 'greenhouse_list_candidates', + description: 'Lists candidates from Greenhouse with pagination support. Use when the user wants to browse their candidate database, review recent applicants, or export candidate data. Returns paginated list of candidates with basic info, application IDs, tags, and recent activity. Supports filtering by email, creation date, and update date. Returns up to 500 candidates per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Candidates per page (max 500)', + default: 100, }, - description: 'Email addresses', - }, - phone_numbers: { - type: 'array', - items: { - type: 'object', - properties: { - value: { type: 'string' }, - type: { type: 'string', enum: ['mobile', 'home', 'work', 'skype', 'other'] }, - }, + page: { + type: 'number', + description: 'Page number', + default: 1, + }, + created_after: { + type: 'string', + description: 'Filter by creation date (ISO 8601)', + }, + updated_after: { + type: 'string', + description: 'Filter by update date (ISO 8601)', }, - description: 'Phone numbers', }, }, - required: ['first_name', 'last_name'], - }, - _meta: { - category: 'candidates', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const updateCandidateTool: Tool = { - name: 'update_candidate', - description: 'Updates an existing candidate in Greenhouse. Use when modifying candidate information, updating contact details, adding tags, or changing recruiter assignments. Only specified fields will be updated. Returns the updated candidate record.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Candidate ID to update', - }, - first_name: { - type: 'string', - description: 'Updated first name', - }, - last_name: { - type: 'string', - description: 'Updated last name', - }, - company: { - type: 'string', - description: 'Updated company', - }, - title: { - type: 'string', - description: 'Updated title', - }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListCandidatesInput.parse(input); + const result = await client.get('/candidates', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'candidates', - access_level: 'write', - complexity: 'medium', + { + name: 'greenhouse_get_candidate', + description: 'Retrieves a single candidate by ID from Greenhouse with complete details. Use when the user needs detailed candidate information including contact info, resumes, applications, education, employment history, custom fields, and recruiter assignments. Returns full candidate profile with all associated data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Candidate ID', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = GetCandidateInput.parse(input); + const result = await client.get(`/candidates/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'greenhouse_create_candidate', + description: 'Creates a new candidate in Greenhouse. Use when adding prospects, importing candidates from other sources, or creating candidate records from referrals. Can include contact information, resume attachments, education, employment history, and custom fields. Returns the newly created candidate with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + first_name: { + type: 'string', + description: 'First name', + }, + last_name: { + type: 'string', + description: 'Last name', + }, + company: { + type: 'string', + description: 'Current company', + }, + title: { + type: 'string', + description: 'Current title', + }, + email_addresses: { + type: 'array', + items: { + type: 'object', + properties: { + value: { type: 'string' }, + type: { type: 'string', enum: ['personal', 'work', 'other'] }, + }, + }, + description: 'Email addresses', + }, + phone_numbers: { + type: 'array', + items: { + type: 'object', + properties: { + value: { type: 'string' }, + type: { type: 'string', enum: ['mobile', 'home', 'work', 'skype', 'other'] }, + }, + }, + description: 'Phone numbers', + }, + }, + required: ['first_name', 'last_name'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = CreateCandidateInput.parse(input); + const result = await client.post('/candidates', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_update_candidate', + description: 'Updates an existing candidate in Greenhouse. Use when modifying candidate information, updating contact details, adding tags, or changing recruiter assignments. Only specified fields will be updated. Returns the updated candidate record.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Candidate ID to update', + }, + first_name: { + type: 'string', + description: 'Updated first name', + }, + last_name: { + type: 'string', + description: 'Updated last name', + }, + company: { + type: 'string', + description: 'Updated company', + }, + title: { + type: 'string', + description: 'Updated title', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = UpdateCandidateInput.parse(input); + const { id, ...updateData } = validated; + const result = await client.patch(`/candidates/${id}`, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_create_candidate_note', + description: 'Creates a note on a candidate record in Greenhouse. Use when adding comments, observations, or important information about a candidate that should be saved to their profile. Notes can be set to different visibility levels (admin only, public to hiring team, or private). Returns the created note with timestamp and author information.', + inputSchema: { + type: 'object' as const, + properties: { + candidate_id: { + type: 'number', + description: 'Candidate ID', + }, + user_id: { + type: 'number', + description: 'User ID creating the note', + }, + body: { + type: 'string', + description: 'Note content', + }, + visibility: { + type: 'string', + enum: ['admin_only', 'public', 'private'], + default: 'admin_only', + description: 'Note visibility', + }, + }, + required: ['candidate_id', 'body'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = CreateCandidateNoteInput.parse(input); + const { candidate_id, ...noteData } = validated; + const result = await client.post(`/candidates/${candidate_id}/activity_feed/notes`, noteData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/greenhouse/src/tools/interviews.ts b/servers/greenhouse/src/tools/interviews.ts new file mode 100644 index 0000000..e84303d --- /dev/null +++ b/servers/greenhouse/src/tools/interviews.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; + +const ListInterviewsInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + created_after: z.string().optional().describe('Filter by creation date (ISO 8601)'), + updated_after: z.string().optional().describe('Filter by update date (ISO 8601)'), +}); + +const GetInterviewInput = z.object({ + id: z.number().describe('Interview ID'), +}); + +const ListScheduledInterviewsInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + starts_after: z.string().optional().describe('Filter scheduled interviews starting after this date (ISO 8601)'), + ends_before: z.string().optional().describe('Filter scheduled interviews ending before this date (ISO 8601)'), +}); + +export default [ + { + name: 'greenhouse_list_interviews', + description: 'Lists all interviews from Greenhouse with pagination support. Use when the user wants to browse interview history, review past interviews, or export interview data across all candidates and jobs. Returns paginated list of interviews with interviewer details, candidate info, scheduled times, and interview kit information. Supports filtering by creation and update dates. Returns up to 500 interviews per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + created_after: { + type: 'string', + description: 'Filter by creation date (ISO 8601)', + }, + updated_after: { + type: 'string', + description: 'Filter by update date (ISO 8601)', + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListInterviewsInput.parse(input); + const result = await client.get('/scheduled_interviews', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_get_interview', + description: 'Retrieves a single interview by ID from Greenhouse with complete details. Use when the user needs detailed information about a specific interview including interviewer assignments, interview kit questions, scheduled time, location, candidate information, and interview status. Returns full interview record with all associated data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Interview ID', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = GetInterviewInput.parse(input); + const result = await client.get(`/scheduled_interviews/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_list_scheduled_interviews', + description: 'Lists scheduled (upcoming) interviews from Greenhouse with date range filtering. Use when the user wants to see upcoming interviews, check interviewer availability, or plan interview schedules. Returns paginated list of future interviews with interviewer details, candidate info, scheduled times, and location information. Supports filtering by start/end dates to find interviews in specific time windows. Returns up to 500 interviews per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + starts_after: { + type: 'string', + description: 'Filter scheduled interviews starting after this date (ISO 8601)', + }, + ends_before: { + type: 'string', + description: 'Filter scheduled interviews ending before this date (ISO 8601)', + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListScheduledInterviewsInput.parse(input); + const result = await client.get('/scheduled_interviews', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/greenhouse/src/tools/jobs.ts b/servers/greenhouse/src/tools/jobs.ts index 1d1f128..cfc0837 100644 --- a/servers/greenhouse/src/tools/jobs.ts +++ b/servers/greenhouse/src/tools/jobs.ts @@ -1,123 +1,182 @@ -/** - * Greenhouse Job Tools - */ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +const ListJobsInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + created_after: z.string().optional().describe('Filter by creation date (ISO 8601)'), + updated_after: z.string().optional().describe('Filter by update date (ISO 8601)'), + status: z.enum(['open', 'closed', 'draft']).optional().describe('Filter by job status'), +}); -export const listJobsTool: Tool = { - name: 'list_jobs', - description: 'Lists jobs/positions from Greenhouse with pagination support. Use when the user wants to browse open positions, review requisitions, or analyze hiring needs. Returns paginated list of jobs with status, departments, offices, and hiring team. Supports filtering by status (open/closed/draft). Up to 500 jobs per page.', - inputSchema: { - type: 'object', - properties: { - per_page: { - type: 'number', - description: 'Jobs per page (max 500)', - default: 100, - }, - page: { - type: 'number', - description: 'Page number', - default: 1, - }, - status: { - type: 'string', - enum: ['open', 'closed', 'draft'], - description: 'Filter by job status', +const GetJobInput = z.object({ + id: z.number().describe('Job ID'), +}); + +const CreateJobInput = z.object({ + template_job_id: z.number().optional().describe('Job ID to use as template'), + name: z.string().describe('Job title'), + requisition_id: z.string().optional().describe('Requisition ID'), + department_id: z.number().optional().describe('Department ID'), + office_ids: z.array(z.number()).optional().describe('Array of office IDs'), + opening_ids: z.array(z.string()).optional().describe('Array of opening IDs'), +}); + +const UpdateJobInput = z.object({ + id: z.number().describe('Job ID to update'), + name: z.string().optional().describe('Updated job title'), + requisition_id: z.string().optional().describe('Updated requisition ID'), + department_id: z.number().optional().describe('Updated department ID'), + office_ids: z.array(z.number()).optional().describe('Updated office IDs'), + status: z.enum(['open', 'closed', 'draft']).optional().describe('Updated status'), +}); + +export default [ + { + name: 'greenhouse_list_jobs', + description: 'Lists jobs from Greenhouse with pagination and filtering. Use when the user wants to browse open positions, review job requisitions, or export job data. Returns paginated list of jobs with titles, departments, offices, hiring team, openings count, confidential flag, and status (open/closed/draft). Supports filtering by status, creation date, and update date. Returns up to 500 jobs per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + created_after: { + type: 'string', + description: 'Filter by creation date (ISO 8601)', + }, + updated_after: { + type: 'string', + description: 'Filter by update date (ISO 8601)', + }, + status: { + type: 'string', + enum: ['open', 'closed', 'draft'], + description: 'Filter by job status', + }, }, }, - }, - _meta: { - category: 'jobs', - access_level: 'read', - complexity: 'low', - }, -}; - -export const getJobTool: Tool = { - name: 'get_job', - description: 'Retrieves a single job by ID from Greenhouse with full configuration. Use when the user needs detailed job information including description, hiring team, departments, offices, custom fields, and interview stages. Returns complete job record with all metadata.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Job ID', - }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListJobsInput.parse(input); + const result = await client.get('/jobs', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'jobs', - access_level: 'read', - complexity: 'low', - }, -}; - -export const createJobTool: Tool = { - name: 'create_job', - description: 'Creates a new job/position in Greenhouse. Use when opening a new requisition, creating a job posting, or setting up a hiring pipeline. Can specify departments, offices, hiring team, and custom fields. Returns the newly created job with assigned ID.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - description: 'Job title/name', - }, - requisition_id: { - type: 'string', - description: 'External requisition ID', - }, - status: { - type: 'string', - enum: ['open', 'closed', 'draft'], - description: 'Job status', - }, - office_ids: { - type: 'array', - items: { type: 'number' }, - description: 'Office IDs for this job', - }, - department_ids: { - type: 'array', - items: { type: 'number' }, - description: 'Department IDs for this job', + { + name: 'greenhouse_get_job', + description: 'Retrieves a single job by ID from Greenhouse with complete details. Use when the user needs detailed information about a specific job including full description, hiring team members, job posts, custom fields, interview plan, stages/pipeline, departments, offices, and all job metadata. Returns full job record with all associated data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Job ID', + }, }, + required: ['id'], }, - required: ['name'], - }, - _meta: { - category: 'jobs', - access_level: 'write', - complexity: 'medium', - }, -}; - -export const updateJobTool: Tool = { - name: 'update_job', - description: 'Updates an existing job in Greenhouse. Use when modifying job details, changing status, updating hiring team, or adjusting requirements. Returns the updated job record.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Job ID to update', - }, - name: { - type: 'string', - description: 'Updated job name', - }, - status: { - type: 'string', - enum: ['open', 'closed', 'draft'], - description: 'Updated status', - }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = GetJobInput.parse(input); + const result = await client.get(`/jobs/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'jobs', - access_level: 'write', - complexity: 'medium', + { + name: 'greenhouse_create_job', + description: 'Creates a new job in Greenhouse. Use when opening new positions, creating job requisitions, or programmatically setting up hiring pipelines. Can optionally use an existing job as a template (copies interview plan, stages, and job post). Requires a job title/name and can include department, offices, and requisition ID. Returns the newly created job with assigned ID and default pipeline stages.', + inputSchema: { + type: 'object' as const, + properties: { + template_job_id: { + type: 'number', + description: 'Job ID to use as template (copies interview plan, stages)', + }, + name: { + type: 'string', + description: 'Job title', + }, + requisition_id: { + type: 'string', + description: 'Requisition ID', + }, + department_id: { + type: 'number', + description: 'Department ID', + }, + office_ids: { + type: 'array', + items: { type: 'number' }, + description: 'Array of office IDs', + }, + opening_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Array of opening IDs', + }, + }, + required: ['name'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = CreateJobInput.parse(input); + const result = await client.post('/jobs', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'greenhouse_update_job', + description: 'Updates an existing job in Greenhouse. Use when modifying job details, changing department/office assignments, updating requisition IDs, or changing job status (open/closed/draft). Only specified fields will be updated. Returns the updated job record with all changes applied.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Job ID to update', + }, + name: { + type: 'string', + description: 'Updated job title', + }, + requisition_id: { + type: 'string', + description: 'Updated requisition ID', + }, + department_id: { + type: 'number', + description: 'Updated department ID', + }, + office_ids: { + type: 'array', + items: { type: 'number' }, + description: 'Updated office IDs', + }, + status: { + type: 'string', + enum: ['open', 'closed', 'draft'], + description: 'Updated status', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = UpdateJobInput.parse(input); + const { id, ...updateData } = validated; + const result = await client.patch(`/jobs/${id}`, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/greenhouse/src/tools/offers.ts b/servers/greenhouse/src/tools/offers.ts index 7d942e2..338884d 100644 --- a/servers/greenhouse/src/tools/offers.ts +++ b/servers/greenhouse/src/tools/offers.ts @@ -1,108 +1,71 @@ -/** - * Greenhouse Offer Tools - */ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +const ListOffersInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + created_after: z.string().optional().describe('Filter by creation date (ISO 8601)'), + updated_after: z.string().optional().describe('Filter by update date (ISO 8601)'), +}); -export const listOffersTool: Tool = { - name: 'list_offers', - description: 'Lists offers from Greenhouse with pagination support. Use when the user wants to review pending offers, track offer acceptance rates, or analyze compensation data. Returns paginated list of offers with status, candidate, job, and approval details. Supports filtering by status. Up to 500 offers per page.', - inputSchema: { - type: 'object', - properties: { - per_page: { - type: 'number', - description: 'Offers per page (max 500)', - default: 100, - }, - page: { - type: 'number', - description: 'Page number', - default: 1, - }, - status: { - type: 'string', - enum: ['draft', 'approval-sent', 'approved', 'pending', 'rejected', 'deprecated'], - description: 'Filter by offer status', +const GetOfferInput = z.object({ + id: z.number().describe('Offer ID'), +}); + +export default [ + { + name: 'greenhouse_list_offers', + description: 'Lists offers from Greenhouse with pagination support. Use when the user wants to browse sent offers, review offer status, track acceptance/rejection rates, or export offer data. Returns paginated list of offers with candidate info, job details, offer status (draft/approved/sent/accepted/rejected), salary/compensation details, start date, and version history. Supports filtering by creation and update dates. Returns up to 500 offers per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + created_after: { + type: 'string', + description: 'Filter by creation date (ISO 8601)', + }, + updated_after: { + type: 'string', + description: 'Filter by update date (ISO 8601)', + }, }, }, - }, - _meta: { - category: 'offers', - access_level: 'read', - complexity: 'low', - }, -}; - -export const getOfferTool: Tool = { - name: 'get_offer', - description: 'Retrieves a single offer by ID from Greenhouse with complete details. Use when the user needs detailed offer information including compensation, custom fields, approval status, and version history. Returns full offer record with all terms and metadata.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Offer ID', - }, - }, - required: ['id'], - }, - _meta: { - category: 'offers', - access_level: 'read', - complexity: 'low', - }, -}; - -export const listScorecardsTool: Tool = { - name: 'list_scorecards', - description: 'Lists interview scorecards from Greenhouse with pagination support. Use when the user wants to review interview feedback, analyze interviewer ratings, or audit evaluation quality. Returns paginated list of scorecards with interviewer, candidate, ratings, and recommendations. Supports filtering by candidate or application. Up to 500 scorecards per page.', - inputSchema: { - type: 'object', - properties: { - per_page: { - type: 'number', - description: 'Scorecards per page (max 500)', - default: 100, - }, - page: { - type: 'number', - description: 'Page number', - default: 1, - }, - candidate_id: { - type: 'number', - description: 'Filter by candidate ID', - }, - application_id: { - type: 'number', - description: 'Filter by application ID', - }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListOffersInput.parse(input); + const result = await client.get('/offers', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, }, - _meta: { - category: 'scorecards', - access_level: 'read', - complexity: 'low', - }, -}; - -export const getScorecardTool: Tool = { - name: 'get_scorecard', - description: 'Retrieves a single scorecard by ID from Greenhouse. Use when the user needs detailed interview feedback including question responses, ratings, overall recommendation, and interviewer notes. Returns complete scorecard with all evaluation data.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'Scorecard ID', + { + name: 'greenhouse_get_offer', + description: 'Retrieves a single offer by ID from Greenhouse with complete details. Use when the user needs detailed information about a specific offer including full compensation breakdown, custom offer fields, approval chain, sent date, acceptance/rejection details, offer letter content, and version history. Returns full offer record with all associated data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Offer ID', + }, }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = GetOfferInput.parse(input); + const result = await client.get(`/offers/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'scorecards', - access_level: 'read', - complexity: 'low', - }, -}; +]; diff --git a/servers/greenhouse/src/tools/organization.ts b/servers/greenhouse/src/tools/organization.ts new file mode 100644 index 0000000..b23a09f --- /dev/null +++ b/servers/greenhouse/src/tools/organization.ts @@ -0,0 +1,180 @@ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; + +const ListDepartmentsInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), +}); + +const ListOfficesInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), +}); + +const ListSourcesInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), +}); + +const ListRejectionReasonsInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), +}); + +const ListJobStagesInput = z.object({ + job_id: z.number().describe('Job ID to get stages for'), +}); + +const ListCustomFieldsInput = z.object({ + field_type: z.enum(['candidate', 'application', 'job', 'offer']).optional().describe('Filter by field type'), +}); + +export default [ + { + name: 'greenhouse_list_departments', + description: 'Lists all departments in your Greenhouse organization. Use when the user wants to see organizational structure, filter jobs/candidates by department, or build department-based reports. Returns paginated list of departments with names, IDs, parent department relationships, and external IDs. Returns up to 500 departments per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListDepartmentsInput.parse(input); + const result = await client.get('/departments', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_list_offices', + description: 'Lists all offices in your Greenhouse organization. Use when the user wants to see office locations, filter jobs/candidates by location, or build location-based reports. Returns paginated list of offices with names, addresses, IDs, parent office relationships, and external IDs. Returns up to 500 offices per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListOfficesInput.parse(input); + const result = await client.get('/offices', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_list_sources', + description: 'Lists all candidate sources configured in Greenhouse. Use when the user wants to see recruiting channels, analyze source effectiveness, or set sources when creating applications. Returns paginated list of sources with names, IDs, types (e.g., job board, referral, agency), and strategy information. Returns up to 500 sources per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListSourcesInput.parse(input); + const result = await client.get('/sources', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_list_rejection_reasons', + description: 'Lists all rejection reasons configured in Greenhouse. Use when the user wants to see available rejection reasons before rejecting applications, analyze rejection patterns, or understand why candidates were rejected. Returns paginated list of rejection reasons with names, IDs, types (by department, by job, or organization-wide), and descriptions. Returns up to 500 rejection reasons per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListRejectionReasonsInput.parse(input); + const result = await client.get('/rejection_reasons', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_list_job_stages', + description: 'Lists all pipeline stages for a specific job in Greenhouse. Use when the user wants to see the hiring pipeline for a job, understand stage progression, or check stage IDs before advancing applications. Returns list of stages with names, IDs, stage order, milestone information (e.g., "Application Review", "Phone Screen", "Onsite"), and interview details. This is job-specific, not organization-wide.', + inputSchema: { + type: 'object' as const, + properties: { + job_id: { + type: 'number', + description: 'Job ID to get stages for', + }, + }, + required: ['job_id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListJobStagesInput.parse(input); + const result = await client.get(`/jobs/${validated.job_id}/stages`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_list_custom_fields', + description: 'Lists all custom fields configured in Greenhouse. Use when the user wants to see available custom fields, understand what additional data can be captured on candidates/applications/jobs/offers, or get field IDs before setting custom field values. Returns list of custom fields with names, IDs, field types (text, single-select, multi-select, date, etc.), available options for select fields, and whether the field is required. Can filter by entity type (candidate, application, job, offer).', + inputSchema: { + type: 'object' as const, + properties: { + field_type: { + type: 'string', + enum: ['candidate', 'application', 'job', 'offer'], + description: 'Filter by field type', + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListCustomFieldsInput.parse(input); + const params = validated.field_type ? { field_type: validated.field_type } : {}; + const result = await client.get('/custom_fields', params); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/greenhouse/src/tools/scorecards.ts b/servers/greenhouse/src/tools/scorecards.ts new file mode 100644 index 0000000..2edbc58 --- /dev/null +++ b/servers/greenhouse/src/tools/scorecards.ts @@ -0,0 +1,149 @@ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; + +const ListScorecardsInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + created_after: z.string().optional().describe('Filter by creation date (ISO 8601)'), + updated_after: z.string().optional().describe('Filter by update date (ISO 8601)'), +}); + +const GetScorecardInput = z.object({ + id: z.number().describe('Scorecard ID'), +}); + +const CreateScorecardInput = z.object({ + application_id: z.number().describe('Application ID this scorecard is for'), + interviewed_at: z.string().optional().describe('Interview date/time (ISO 8601)'), + submitted_by: z.number().optional().describe('User ID of submitter'), + interview: z.number().optional().describe('Interview ID this scorecard is for'), + overall_recommendation: z.enum(['definitely_not', 'no', 'yes', 'strong_yes', 'no_decision']).describe('Overall recommendation'), + attributes: z.array(z.object({ + attribute_id: z.number(), + rating: z.string().optional(), + note: z.string().optional(), + })).optional().describe('Array of attribute ratings and notes'), + questions: z.array(z.object({ + question_id: z.number(), + answer: z.string().optional(), + })).optional().describe('Array of interview question responses'), +}); + +export default [ + { + name: 'greenhouse_list_scorecards', + description: 'Lists scorecards from Greenhouse with pagination support. Use when the user wants to browse interview feedback, review evaluation history, analyze interviewer assessments, or export scorecard data. Returns paginated list of scorecards with interviewer names, overall recommendations, ratings, interview questions/answers, and submission dates. Supports filtering by creation and update dates. Returns up to 500 scorecards per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + created_after: { + type: 'string', + description: 'Filter by creation date (ISO 8601)', + }, + updated_after: { + type: 'string', + description: 'Filter by update date (ISO 8601)', + }, + }, + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListScorecardsInput.parse(input); + const result = await client.get('/scorecards', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_get_scorecard', + description: 'Retrieves a single scorecard by ID from Greenhouse with complete interview feedback. Use when the user needs detailed information about a specific interview evaluation including all attribute ratings, interview question responses, overall recommendation, interviewer comments, and submission details. Returns full scorecard with all assessment data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'Scorecard ID', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = GetScorecardInput.parse(input); + const result = await client.get(`/scorecards/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'greenhouse_create_scorecard', + description: 'Creates a new scorecard (interview evaluation) in Greenhouse. Use when submitting interview feedback, recording candidate assessments, or programmatically creating evaluation records. Requires application ID and overall recommendation. Can include attribute ratings, interview question answers, and custom notes. Returns the newly created scorecard with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + application_id: { + type: 'number', + description: 'Application ID this scorecard is for', + }, + interviewed_at: { + type: 'string', + description: 'Interview date/time (ISO 8601)', + }, + submitted_by: { + type: 'number', + description: 'User ID of submitter', + }, + interview: { + type: 'number', + description: 'Interview ID this scorecard is for', + }, + overall_recommendation: { + type: 'string', + enum: ['definitely_not', 'no', 'yes', 'strong_yes', 'no_decision'], + description: 'Overall recommendation', + }, + attributes: { + type: 'array', + items: { + type: 'object', + properties: { + attribute_id: { type: 'number' }, + rating: { type: 'string' }, + note: { type: 'string' }, + }, + }, + description: 'Array of attribute ratings and notes', + }, + questions: { + type: 'array', + items: { + type: 'object', + properties: { + question_id: { type: 'number' }, + answer: { type: 'string' }, + }, + }, + description: 'Array of interview question responses', + }, + }, + required: ['application_id', 'overall_recommendation'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = CreateScorecardInput.parse(input); + const result = await client.post('/scorecards', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/greenhouse/src/tools/users.ts b/servers/greenhouse/src/tools/users.ts index b834cf7..eb325c8 100644 --- a/servers/greenhouse/src/tools/users.ts +++ b/servers/greenhouse/src/tools/users.ts @@ -1,50 +1,66 @@ -/** - * Greenhouse User Tools - */ +import { z } from 'zod'; +import { GreenhouseClient } from '../client/greenhouse-client.js'; -import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +const ListUsersInput = z.object({ + per_page: z.number().min(1).max(500).default(100).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number for pagination'), + employee_id: z.string().optional().describe('Filter by employee ID'), +}); -export const listUsersTool: Tool = { - name: 'list_users', - description: 'Lists users from Greenhouse with pagination support. Use when the user wants to browse team members, review hiring team assignments, or manage user access. Returns paginated list of users with names, emails, departments, offices, and admin status. Up to 500 users per page.', - inputSchema: { - type: 'object', - properties: { - per_page: { - type: 'number', - description: 'Users per page (max 500)', - default: 100, - }, - page: { - type: 'number', - description: 'Page number', - default: 1, +const GetUserInput = z.object({ + id: z.number().describe('User ID'), +}); + +export default [ + { + name: 'greenhouse_list_users', + description: 'Lists users from your Greenhouse organization with pagination support. Use when the user wants to browse team members, see who can be assigned as recruiters/coordinators/hiring managers, or export user data. Returns paginated list of users with names, email addresses, employee IDs, departments, offices, and user permissions/roles. Supports filtering by employee ID. Returns up to 500 users per page.', + inputSchema: { + type: 'object' as const, + properties: { + per_page: { + type: 'number', + description: 'Results per page (max 500)', + default: 100, + }, + page: { + type: 'number', + description: 'Page number for pagination', + default: 1, + }, + employee_id: { + type: 'string', + description: 'Filter by employee ID', + }, }, }, - }, - _meta: { - category: 'users', - access_level: 'read', - complexity: 'low', - }, -}; - -export const getUserTool: Tool = { - name: 'get_user', - description: 'Retrieves a single user by ID from Greenhouse. Use when the user needs detailed information about a team member including departments, offices, permissions, and employee ID. Returns complete user profile.', - inputSchema: { - type: 'object', - properties: { - id: { - type: 'number', - description: 'User ID', - }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = ListUsersInput.parse(input); + const result = await client.get('/users', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, - required: ['id'], }, - _meta: { - category: 'users', - access_level: 'read', - complexity: 'low', + { + name: 'greenhouse_get_user', + description: 'Retrieves a single user by ID from Greenhouse with complete details. Use when the user needs detailed information about a specific team member including their full contact information, job roles, permissions, departments, offices, and hiring responsibilities. Returns full user profile with all associated data.', + inputSchema: { + type: 'object' as const, + properties: { + id: { + type: 'number', + description: 'User ID', + }, + }, + required: ['id'], + }, + handler: async (input: unknown, client: GreenhouseClient) => { + const validated = GetUserInput.parse(input); + const result = await client.get(`/users/${validated.id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; +]; diff --git a/servers/klaviyo/package.json b/servers/klaviyo/package.json index c682c38..edac4f7 100644 --- a/servers/klaviyo/package.json +++ b/servers/klaviyo/package.json @@ -3,7 +3,10 @@ "version": "1.0.0", "description": "Complete Klaviyo MCP Server with 16 apps and 60+ tools", "type": "module", - "main": "dist/index.js", + "main": "dist/main.js", + "bin": { + "klaviyo-mcp": "./dist/main.js" + }, "scripts": { "build": "tsc", "dev": "tsc --watch", diff --git a/servers/klaviyo/src/index.ts b/servers/klaviyo/src/index.ts.bak similarity index 100% rename from servers/klaviyo/src/index.ts rename to servers/klaviyo/src/index.ts.bak diff --git a/servers/klaviyo/src/main.ts b/servers/klaviyo/src/main.ts new file mode 100644 index 0000000..9de2cb6 --- /dev/null +++ b/servers/klaviyo/src/main.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +/** + * Klaviyo MCP Server - Main Entry Point + * Complete integration with Klaviyo API + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { KlaviyoClient } from './client/index.js'; +import { KlaviyoMCPServer } from './server.js'; + +// Validate environment variables +const apiKey = process.env.KLAVIYO_API_KEY; + +if (!apiKey) { + console.error('Error: Missing required environment variable'); + console.error(''); + console.error('Required:'); + console.error(' KLAVIYO_API_KEY - Your Klaviyo Private API Key'); + console.error(''); + console.error('Get your API key from: https://www.klaviyo.com/settings/account/api-keys'); + console.error('Settings → Account → API Keys → Create Private API Key'); + process.exit(1); +} + +// Create API client +const client = new KlaviyoClient(apiKey); + +// Create and start server +const server = new KlaviyoMCPServer(client); + +// Graceful shutdown handlers +process.on('SIGINT', async () => { + console.error('\nReceived SIGINT, shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.error('\nReceived SIGTERM, shutting down gracefully...'); + process.exit(0); +}); + +// Start transport +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Klaviyo MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/klaviyo/src/server.ts b/servers/klaviyo/src/server.ts new file mode 100644 index 0000000..3002188 --- /dev/null +++ b/servers/klaviyo/src/server.ts @@ -0,0 +1,175 @@ +/** + * Klaviyo MCP Server Class + * Implements lazy-loaded tool modules for optimal performance + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { KlaviyoClient } from './client/index.js'; + +type ToolModule = any[]; + +export class KlaviyoMCPServer { + private server: Server; + private client: KlaviyoClient; + private toolModules: Map Promise>; + private loadedTools: any[] | null = null; + + constructor(client: KlaviyoClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: '@mcpengine/klaviyo', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules() { + // Register lazy-loaded tool modules + this.toolModules.set('profiles', async () => { + const module = await import('./tools/klaviyo_profiles.js'); + return module.profileTools(this.client); + }); + + this.toolModules.set('lists', async () => { + const module = await import('./tools/klaviyo_lists.js'); + return module.listTools(this.client); + }); + + this.toolModules.set('segments', async () => { + const module = await import('./tools/klaviyo_segments.js'); + return module.segmentTools(this.client); + }); + + this.toolModules.set('campaigns', async () => { + const module = await import('./tools/klaviyo_campaigns.js'); + return module.campaignTools(this.client); + }); + + this.toolModules.set('flows', async () => { + const module = await import('./tools/klaviyo_flows.js'); + return module.flowTools(this.client); + }); + + this.toolModules.set('templates', async () => { + const module = await import('./tools/klaviyo_templates.js'); + return module.templateTools(this.client); + }); + + this.toolModules.set('metrics', async () => { + const module = await import('./tools/klaviyo_metrics.js'); + return module.metricTools(this.client); + }); + + this.toolModules.set('events', async () => { + const module = await import('./tools/klaviyo_events.js'); + return module.eventTools(this.client); + }); + + this.toolModules.set('catalogs', async () => { + const module = await import('./tools/klaviyo_catalogs.js'); + return module.catalogTools(this.client); + }); + + this.toolModules.set('forms', async () => { + const module = await import('./tools/klaviyo_forms.js'); + return module.formTools(this.client); + }); + + this.toolModules.set('tags', async () => { + const module = await import('./tools/klaviyo_tags.js'); + return module.tagTools(this.client); + }); + + this.toolModules.set('reporting', async () => { + const module = await import('./tools/klaviyo_reporting.js'); + return module.reportingTools(this.client); + }); + } + + private async loadAllTools(): Promise { + if (this.loadedTools) { + return this.loadedTools; + } + + const allTools: any[] = []; + + for (const [name, loader] of this.toolModules.entries()) { + const tools = await loader(); + allTools.push(...tools); + } + + this.loadedTools = allTools; + return allTools; + } + + private setupHandlers() { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const allTools = await this.loadAllTools(); + + return { + tools: allTools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const allTools = await this.loadAllTools(); + const tool = allTools.find((t) => t.name === request.params.name); + + if (!tool) { + throw new Error(`Unknown tool: ${request.params.name}`); + } + + try { + const result = await tool.execute(request.params.arguments || {}); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + error: error.message || 'Unknown error', + details: error.response?.data || error.toString(), + }, + null, + 2 + ), + }, + ], + isError: true, + }; + } + }); + } + + async connect(transport: any) { + await this.server.connect(transport); + } +} diff --git a/servers/lever/src/index.ts b/servers/lever/src/index.ts.bak similarity index 100% rename from servers/lever/src/index.ts rename to servers/lever/src/index.ts.bak diff --git a/servers/lever/src/tools/opportunities-tools.ts b/servers/lever/src/tools/opportunities-tools.ts index 69c71e0..aba6109 100644 --- a/servers/lever/src/tools/opportunities-tools.ts +++ b/servers/lever/src/tools/opportunities-tools.ts @@ -1,252 +1,318 @@ +import { z } from 'zod'; import { LeverClient } from '../client/lever-client.js'; -import type { - Opportunity, - LeverPaginatedResponse, - Note, -} from '../types/index.js'; -export const opportunitiesTools = { - list_opportunities: { - description: `Lists all opportunities (candidates) in your Lever account with optional filtering. Use this when you need to search for candidates, view your pipeline, or filter by specific criteria like stage, tags, or archive status. Supports pagination with limit and offset parameters. Returns up to 100 opportunities per request by default. - -Parameters: -- limit (number, optional): Number of results to return (1-100), default 100 -- offset (string, optional): Pagination offset token from previous response -- archived (boolean, optional): Filter by archive status -- stage_id (string, optional): Filter by specific pipeline stage -- tag (string, optional): Filter by tag -- posting_id (string, optional): Filter by job posting -- owner_id (string, optional): Filter by opportunity owner -- contact_id (string, optional): Filter by contact -- expand (string, optional): Expand related resources (e.g., "followers", "owner") +const ListOpportunitiesInput = z.object({ + limit: z.number().min(1).max(100).default(100).describe('Results per page'), + offset: z.string().optional().describe('Pagination offset token'), + archived: z.boolean().optional().describe('Filter by archive status'), + stage_id: z.string().optional().describe('Filter by pipeline stage ID'), + tag: z.string().optional().describe('Filter by tag'), + posting_id: z.string().optional().describe('Filter by job posting ID'), + owner_id: z.string().optional().describe('Filter by owner user ID'), + expand: z.string().optional().describe('Expand related resources (e.g., "applications,owner")'), +}); -Returns: Paginated list of opportunities with has_more indicator and next offset token.`, - handler: async (client: LeverClient, args: any) => { - const params: any = {}; - - if (args.limit) params.limit = args.limit; - if (args.offset) params.offset = args.offset; - if (args.archived !== undefined) params.archived = args.archived; - if (args.stage_id) params.stage_id = args.stage_id; - if (args.tag) params.tag = args.tag; - if (args.posting_id) params.posting_id = args.posting_id; - if (args.owner_id) params.owner_id = args.owner_id; - if (args.contact_id) params.contact_id = args.contact_id; - if (args.expand) params.expand = args.expand; +const GetOpportunityInput = z.object({ + opportunity_id: z.string().describe('Opportunity ID'), + expand: z.string().optional().describe('Expand related resources'), +}); - const response = await client.get>('/opportunities', params); - +const SearchOpportunitiesInput = z.object({ + query: z.string().describe('Search query (name, email, phone)'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + offset: z.string().optional().describe('Pagination offset token'), +}); + +const ArchiveOpportunityInput = z.object({ + opportunity_id: z.string().describe('Opportunity ID to archive'), + archive_reason_id: z.string().optional().describe('Archive reason ID'), +}); + +const ListOpportunityNotesInput = z.object({ + opportunity_id: z.string().describe('Opportunity ID'), + expand: z.string().optional().describe('Expand related resources'), +}); + +const AddOpportunitySourceInput = z.object({ + opportunity_id: z.string().describe('Opportunity ID'), + source_id: z.string().describe('Source ID to add'), +}); + +const CreateOpportunityInput = z.object({ + name: z.string().describe('Candidate full name'), + headline: z.string().optional().describe('Professional headline'), + stage: z.string().optional().describe('Initial stage ID'), + location: z.string().optional().describe('Location'), + phones: z.array(z.object({ + type: z.string(), + value: z.string(), + })).optional().describe('Phone numbers'), + emails: z.array(z.string()).optional().describe('Email addresses'), + tags: z.array(z.string()).optional().describe('Tags to add'), + sources: z.array(z.string()).optional().describe('Source IDs'), + origin: z.string().optional().describe('Origin (e.g., "referral", "sourced")'), + posting_id: z.string().optional().describe('Posting ID to associate with'), +}); + +export default [ + { + name: 'lever_list_opportunities', + description: 'Lists all opportunities (candidates) in your Lever account with filtering and pagination. Use when you need to search for candidates, view your pipeline, or filter by specific criteria like stage, tags, posting, owner, or archive status. Returns up to 100 opportunities per request with pagination support via offset tokens. Includes candidate basic info, current stage, tags, applications, and recent activity.', + inputSchema: { + type: 'object' as const, + properties: { + limit: { + type: 'number', + description: 'Results per page (1-100)', + default: 100, + }, + offset: { + type: 'string', + description: 'Pagination offset token from previous response', + }, + archived: { + type: 'boolean', + description: 'Filter by archive status', + }, + stage_id: { + type: 'string', + description: 'Filter by pipeline stage ID', + }, + tag: { + type: 'string', + description: 'Filter by tag', + }, + posting_id: { + type: 'string', + description: 'Filter by job posting ID', + }, + owner_id: { + type: 'string', + description: 'Filter by owner user ID', + }, + expand: { + type: 'string', + description: 'Expand related resources (e.g., "applications,owner,followers")', + }, + }, + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = ListOpportunitiesInput.parse(input); + const result = await client.get('/opportunities', validated); return { - opportunities: response.data, - has_more: response.hasNext, - next_offset: response.next, + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; }, }, - - get_opportunity: { - description: `Retrieves a single opportunity (candidate) by ID. Use this when you need detailed information about a specific candidate, including their contact info, current stage, tags, applications, and full history. - -Parameters: -- opportunity_id (string, required): The unique ID of the opportunity -- expand (string, optional): Expand related resources (e.g., "applications,owner,followers") - -Returns: Full opportunity object with all details.`, - handler: async (client: LeverClient, args: { opportunity_id: string; expand?: string }) => { - if (!args.opportunity_id) { - throw new Error('opportunity_id is required'); - } - - let url = `/opportunities/${args.opportunity_id}`; - if (args.expand) { - url += `?expand=${args.expand}`; - } - - return await client.getSingle(url); + { + name: 'lever_get_opportunity', + description: 'Retrieves a single opportunity (candidate) by ID with complete details. Use when you need detailed information about a specific candidate including their contact info, current stage, tags, application history, feedback, offers, and full timeline. Can expand related resources for additional data like applications, owner, and followers.', + inputSchema: { + type: 'object' as const, + properties: { + opportunity_id: { + type: 'string', + description: 'Opportunity ID', + }, + expand: { + type: 'string', + description: 'Expand related resources (e.g., "applications,owner,followers")', + }, + }, + required: ['opportunity_id'], }, - }, - - create_opportunity: { - description: `Creates a new opportunity (candidate) in Lever. Use this when adding a new candidate to your pipeline, either from sourcing, referrals, or manual entry. You can optionally attach them to a job posting, assign an owner, and set their initial stage. - -Parameters: -- perform_as (string, required): User ID to perform this action as -- parse (boolean, optional): Whether to parse resume data (default false) -- name (string, optional): Candidate's full name -- headline (string, optional): Professional headline -- stage (string, optional): Initial stage ID -- location (string, optional): Location -- phones (array, optional): Array of phone objects [{type, value}] -- emails (array, optional): Array of email addresses -- links (array, optional): Array of URLs (LinkedIn, portfolio, etc.) -- tags (array, optional): Array of tag names -- sources (array, optional): Array of source names -- origin (string, optional): Origin/source of candidate -- owner (string, optional): User ID of opportunity owner -- followers (array, optional): Array of user IDs to follow this opportunity -- posting_id (string, optional): Job posting ID to apply candidate to - -Returns: The newly created opportunity object.`, - handler: async (client: LeverClient, args: any) => { - if (!args.perform_as) { - throw new Error('perform_as (user ID) is required'); - } - - const data: any = {}; - - if (args.name) data.name = args.name; - if (args.headline) data.headline = args.headline; - if (args.stage) data.stage = args.stage; - if (args.location) data.location = args.location; - if (args.phones) data.phones = args.phones; - if (args.emails) data.emails = args.emails; - if (args.links) data.links = args.links; - if (args.tags) data.tags = args.tags; - if (args.sources) data.sources = args.sources; - if (args.origin) data.origin = args.origin; - if (args.owner) data.owner = args.owner; - if (args.followers) data.followers = args.followers; - if (args.posting_id) data.postings = [args.posting_id]; - - const params: any = { perform_as: args.perform_as }; - if (args.parse !== undefined) params.parse = args.parse; - - return await client.create('/opportunities', data); - }, - }, - - update_opportunity: { - description: `Updates an existing opportunity (candidate). Use this to modify candidate information, change their stage, update tags, assign a new owner, or archive/unarchive them. - -Parameters: -- opportunity_id (string, required): The unique ID of the opportunity -- perform_as (string, required): User ID to perform this action as -- name (string, optional): Update candidate's name -- headline (string, optional): Update headline -- stage (string, optional): Move to a new stage ID -- location (string, optional): Update location -- phones (array, optional): Update phone numbers -- emails (array, optional): Update email addresses -- links (array, optional): Update links -- tags (array, optional): Replace tags -- add_tags (array, optional): Add new tags -- remove_tags (array, optional): Remove specific tags -- sources (array, optional): Update sources -- origin (string, optional): Update origin -- owner (string, optional): Assign new owner -- followers (array, optional): Replace followers -- add_followers (array, optional): Add new followers -- remove_followers (array, optional): Remove followers -- archived_reason (string, optional): Archive reason ID (archives the opportunity) -- archived (object, optional): Archive status {reason: string, archivedAt?: number} - -Returns: The updated opportunity object.`, - handler: async (client: LeverClient, args: any) => { - if (!args.opportunity_id) { - throw new Error('opportunity_id is required'); - } - if (!args.perform_as) { - throw new Error('perform_as (user ID) is required'); - } - - const data: any = {}; - - if (args.name) data.name = args.name; - if (args.headline) data.headline = args.headline; - if (args.stage) data.stage = args.stage; - if (args.location) data.location = args.location; - if (args.phones) data.phones = args.phones; - if (args.emails) data.emails = args.emails; - if (args.links) data.links = args.links; - if (args.tags) data.tags = args.tags; - if (args.add_tags) data.addTags = args.add_tags; - if (args.remove_tags) data.removeTags = args.remove_tags; - if (args.sources) data.sources = args.sources; - if (args.origin) data.origin = args.origin; - if (args.owner) data.owner = args.owner; - if (args.followers) data.followers = args.followers; - if (args.add_followers) data.addFollowers = args.add_followers; - if (args.remove_followers) data.removeFollowers = args.remove_followers; - if (args.archived_reason) { - data.archived = { reason: args.archived_reason }; - } else if (args.archived) { - data.archived = args.archived; - } - - const params: any = { perform_as: args.perform_as }; - - return await client.update( - `/opportunities/${args.opportunity_id}`, - data - ); - }, - }, - - add_opportunity_note: { - description: `Adds a note to an opportunity (candidate). Use this to record interactions, interview feedback, sourcing notes, or any other information about the candidate. Notes can be secret (visible only to admins) or public. - -Parameters: -- opportunity_id (string, required): The unique ID of the opportunity -- perform_as (string, required): User ID to perform this action as -- value (string, required): The note content/text -- secret (boolean, optional): Whether note is confidential (default false) - -Returns: The created note object.`, - handler: async (client: LeverClient, args: any) => { - if (!args.opportunity_id) { - throw new Error('opportunity_id is required'); - } - if (!args.perform_as) { - throw new Error('perform_as (user ID) is required'); - } - if (!args.value) { - throw new Error('value (note text) is required'); - } - - const data: any = { - value: args.value, - secret: args.secret || false, + handler: async (input: unknown, client: LeverClient) => { + const validated = GetOpportunityInput.parse(input); + const url = validated.expand + ? `/opportunities/${validated.opportunity_id}?expand=${validated.expand}` + : `/opportunities/${validated.opportunity_id}`; + const result = await client.get(url); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; - - const params: any = { perform_as: args.perform_as }; - - return await client.create( - `/opportunities/${args.opportunity_id}/notes`, - data - ); }, }, - - add_opportunity_tag: { - description: `Adds one or more tags to an opportunity (candidate). Use this to categorize candidates, mark them for specific programs, indicate skill sets, or any other classification. Tags can be used for filtering and searching. - -Parameters: -- opportunity_id (string, required): The unique ID of the opportunity -- perform_as (string, required): User ID to perform this action as -- tags (array, required): Array of tag names to add (strings) - -Returns: The updated opportunity object with new tags.`, - handler: async (client: LeverClient, args: any) => { - if (!args.opportunity_id) { - throw new Error('opportunity_id is required'); - } - if (!args.perform_as) { - throw new Error('perform_as (user ID) is required'); - } - if (!args.tags || !Array.isArray(args.tags)) { - throw new Error('tags (array) is required'); - } - - const data: any = { - addTags: args.tags, + { + name: 'lever_create_opportunity', + description: 'Creates a new opportunity (candidate) in Lever. Use when adding a new candidate to your pipeline from sourcing, referrals, or manual entry. Can optionally attach to a job posting, assign an owner, set initial stage, add tags, and specify source information. Returns the newly created opportunity with assigned ID.', + inputSchema: { + type: 'object' as const, + properties: { + name: { + type: 'string', + description: 'Candidate full name', + }, + headline: { + type: 'string', + description: 'Professional headline', + }, + stage: { + type: 'string', + description: 'Initial stage ID', + }, + location: { + type: 'string', + description: 'Location', + }, + phones: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + value: { type: 'string' }, + }, + }, + description: 'Phone numbers', + }, + emails: { + type: 'array', + items: { type: 'string' }, + description: 'Email addresses', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Tags to add', + }, + sources: { + type: 'array', + items: { type: 'string' }, + description: 'Source IDs', + }, + origin: { + type: 'string', + description: 'Origin (e.g., "referral", "sourced")', + }, + posting_id: { + type: 'string', + description: 'Posting ID to associate with', + }, + }, + required: ['name'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = CreateOpportunityInput.parse(input); + const result = await client.post('/opportunities', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; - - const params: any = { perform_as: args.perform_as }; - - return await client.update( - `/opportunities/${args.opportunity_id}`, - data - ); }, }, -}; + { + name: 'lever_search_opportunities', + description: 'Searches for opportunities (candidates) by name, email, or phone number. Use when you need to quickly find a specific candidate, check for duplicates before creating a new opportunity, or locate a candidate by partial information. Returns matching opportunities with pagination support. More flexible than filtering by exact parameters.', + inputSchema: { + type: 'object' as const, + properties: { + query: { + type: 'string', + description: 'Search query (name, email, phone)', + }, + limit: { + type: 'number', + description: 'Results per page (1-100)', + default: 50, + }, + offset: { + type: 'string', + description: 'Pagination offset token', + }, + }, + required: ['query'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = SearchOpportunitiesInput.parse(input); + const result = await client.get('/opportunities', { + q: validated.query, + limit: validated.limit, + offset: validated.offset, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'lever_archive_opportunity', + description: 'Archives an opportunity (candidate) to remove them from active pipelines. Use when a candidate is no longer being considered, has withdrawn, or should be removed from active view. Can specify an archive reason for tracking purposes. Archived opportunities can be unarchived later if needed. Returns confirmation of the archive action.', + inputSchema: { + type: 'object' as const, + properties: { + opportunity_id: { + type: 'string', + description: 'Opportunity ID to archive', + }, + archive_reason_id: { + type: 'string', + description: 'Archive reason ID (use lever_list_archive_reasons)', + }, + }, + required: ['opportunity_id'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = ArchiveOpportunityInput.parse(input); + const result = await client.post(`/opportunities/${validated.opportunity_id}/archive`, { + archive_reason_id: validated.archive_reason_id, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'lever_list_opportunity_notes', + description: 'Lists all notes associated with an opportunity (candidate). Use when you need to review interview notes, sourcing comments, recruiter observations, or any other notes added to a candidate record. Returns chronological list of notes with author, timestamp, and content. Can expand related resources for additional context.', + inputSchema: { + type: 'object' as const, + properties: { + opportunity_id: { + type: 'string', + description: 'Opportunity ID', + }, + expand: { + type: 'string', + description: 'Expand related resources', + }, + }, + required: ['opportunity_id'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = ListOpportunityNotesInput.parse(input); + const url = validated.expand + ? `/opportunities/${validated.opportunity_id}/notes?expand=${validated.expand}` + : `/opportunities/${validated.opportunity_id}/notes`; + const result = await client.get(url); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'lever_add_opportunity_source', + description: 'Adds a source to an opportunity (candidate) to track where they came from. Use when attributing a candidate to a specific recruiting channel like a job board, referral, agency, or sourcing campaign. Helps with source tracking and ROI analysis. Returns updated opportunity with new source added.', + inputSchema: { + type: 'object' as const, + properties: { + opportunity_id: { + type: 'string', + description: 'Opportunity ID', + }, + source_id: { + type: 'string', + description: 'Source ID to add (use lever_list_sources)', + }, + }, + required: ['opportunity_id', 'source_id'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = AddOpportunitySourceInput.parse(input); + const result = await client.post(`/opportunities/${validated.opportunity_id}/addSource`, { + source: validated.source_id, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/lever/src/tools/postings-tools.ts b/servers/lever/src/tools/postings-tools.ts index 55f175f..fb434df 100644 --- a/servers/lever/src/tools/postings-tools.ts +++ b/servers/lever/src/tools/postings-tools.ts @@ -1,207 +1,213 @@ +import { z } from 'zod'; import { LeverClient } from '../client/lever-client.js'; -import type { - Posting, - LeverPaginatedResponse, -} from '../types/index.js'; -export const postingsTools = { - list_postings: { - description: `Lists all job postings in your Lever account with optional filtering. Use this when you need to view open positions, filter by department, location, or status, or get posting IDs for applying candidates. - -Parameters: -- limit (number, optional): Number of results to return (1-100), default 100 -- offset (string, optional): Pagination offset token from previous response -- state (string, optional): Filter by posting state: 'published', 'internal', 'pending', 'closed', 'draft', 'rejected' -- team (string, optional): Filter by team/department -- location (string, optional): Filter by location -- commitment (string, optional): Filter by commitment level (full-time, part-time, etc.) -- expand (string, optional): Expand related resources (e.g., "content,followers") +const ListPostingsInput = z.object({ + limit: z.number().min(1).max(100).default(100).describe('Results per page'), + offset: z.string().optional().describe('Pagination offset token'), + state: z.enum(['published', 'internal', 'pending', 'rejected', 'closed']).optional().describe('Filter by posting state'), + team: z.string().optional().describe('Filter by team ID'), + location: z.string().optional().describe('Filter by location'), +}); -Returns: Paginated list of job postings with has_more indicator and next offset token.`, - handler: async (client: LeverClient, args: any) => { - const params: any = {}; - - if (args.limit) params.limit = args.limit; - if (args.offset) params.offset = args.offset; - if (args.state) params.state = args.state; - if (args.team) params.team = args.team; - if (args.location) params.location = args.location; - if (args.commitment) params.commitment = args.commitment; - if (args.expand) params.expand = args.expand; +const GetPostingInput = z.object({ + posting_id: z.string().describe('Posting ID'), +}); - const response = await client.get>('/postings', params); - +const ListPostingApplicantsInput = z.object({ + posting_id: z.string().describe('Posting ID'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + offset: z.string().optional().describe('Pagination offset token'), +}); + +const GetPostingStatsInput = z.object({ + posting_id: z.string().describe('Posting ID'), +}); + +const CreatePostingInput = z.object({ + title: z.string().describe('Job title'), + location: z.string().describe('Job location'), + team: z.string().optional().describe('Team ID'), + description: z.string().describe('Job description (HTML)'), + lists: z.array(z.object({ + text: z.string(), + content: z.string(), + })).optional().describe('Requirement/benefit lists'), + additional: z.string().optional().describe('Additional information'), + tags: z.array(z.string()).optional().describe('Posting tags'), + state: z.enum(['published', 'internal', 'pending', 'closed']).default('pending').describe('Initial state'), +}); + +export default [ + { + name: 'lever_list_postings', + description: 'Lists job postings from your Lever account with filtering and pagination. Use when you want to browse open positions, review job boards, or filter postings by state (published/internal/closed), team, or location. Returns up to 100 postings per request with pagination support. Includes posting details like title, description, location, team, state, and application URLs.', + inputSchema: { + type: 'object' as const, + properties: { + limit: { + type: 'number', + description: 'Results per page (1-100)', + default: 100, + }, + offset: { + type: 'string', + description: 'Pagination offset token', + }, + state: { + type: 'string', + enum: ['published', 'internal', 'pending', 'rejected', 'closed'], + description: 'Filter by posting state', + }, + team: { + type: 'string', + description: 'Filter by team ID', + }, + location: { + type: 'string', + description: 'Filter by location', + }, + }, + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = ListPostingsInput.parse(input); + const result = await client.get('/postings', validated); return { - postings: response.data, - has_more: response.hasNext, - next_offset: response.next, + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; }, }, - - get_posting: { - description: `Retrieves a single job posting by ID. Use this when you need detailed information about a specific job opening, including full job description, requirements, posting owner, hiring manager, and application questions. - -Parameters: -- posting_id (string, required): The unique ID of the job posting -- expand (string, optional): Expand related resources (e.g., "content,followers,user") - -Returns: Full posting object with all details.`, - handler: async (client: LeverClient, args: { posting_id: string; expand?: string }) => { - if (!args.posting_id) { - throw new Error('posting_id is required'); - } - - let url = `/postings/${args.posting_id}`; - if (args.expand) { - url += `?expand=${args.expand}`; - } - - return await client.getSingle(url); + { + name: 'lever_get_posting', + description: 'Retrieves a single job posting by ID with complete details. Use when you need detailed information about a specific job posting including full description, requirements, benefits, team, location, application URLs, and posting state. Returns full posting object with all metadata.', + inputSchema: { + type: 'object' as const, + properties: { + posting_id: { + type: 'string', + description: 'Posting ID', + }, + }, + required: ['posting_id'], }, - }, - - create_posting: { - description: `Creates a new job posting in Lever. Use this when opening a new position, creating a job description for a requisition, or setting up a new role to accept applications. - -Parameters: -- perform_as (string, required): User ID to perform this action as -- text (string, required): Job title -- state (string, required): Posting state: 'published', 'internal', 'pending', 'closed', 'draft' -- user (string, optional): User ID of posting creator -- owner (string, optional): User ID of posting owner -- hiring_manager (string, optional): User ID of hiring manager -- department (string, optional): Department/team name -- location (string, optional): Job location -- commitment (string, optional): Commitment level (e.g., 'Full-time', 'Contract') -- level (string, optional): Seniority level -- description (string, optional): Job description (plain text) -- description_html (string, optional): Job description (HTML) -- lists (array, optional): Structured content lists [{text, content}] -- closing (string, optional): Closing statement (plain text) -- closing_html (string, optional): Closing statement (HTML) -- tags (array, optional): Array of tag names -- distribution_channels (array, optional): Where to post (e.g., ['public', 'internal']) -- followers (array, optional): Array of user IDs to follow this posting -- requisition_code (string, optional): Associated requisition code - -Returns: The newly created posting object.`, - handler: async (client: LeverClient, args: any) => { - if (!args.perform_as) { - throw new Error('perform_as (user ID) is required'); - } - if (!args.text) { - throw new Error('text (job title) is required'); - } - if (!args.state) { - throw new Error('state is required'); - } - - const data: any = { - text: args.text, - state: args.state, + handler: async (input: unknown, client: LeverClient) => { + const validated = GetPostingInput.parse(input); + const result = await client.get(`/postings/${validated.posting_id}`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], }; - - if (args.user) data.user = args.user; - if (args.owner) data.owner = args.owner; - if (args.hiring_manager) data.hiringManager = args.hiring_manager; - if (args.tags) data.tags = args.tags; - if (args.distribution_channels) data.distributionChannels = args.distribution_channels; - if (args.followers) data.followers = args.followers; - if (args.requisition_code) data.requisitionCode = args.requisition_code; - - // Build categories - const categories: any = {}; - if (args.department) categories.department = args.department; - if (args.location) categories.location = args.location; - if (args.commitment) categories.commitment = args.commitment; - if (args.level) categories.level = args.level; - if (Object.keys(categories).length > 0) { - data.categories = categories; - } - - // Build content - const content: any = {}; - if (args.description) content.description = args.description; - if (args.description_html) content.descriptionHtml = args.description_html; - if (args.lists) content.lists = args.lists; - if (args.closing) content.closing = args.closing; - if (args.closing_html) content.closingHtml = args.closing_html; - if (Object.keys(content).length > 0) { - data.content = content; - } - - const params: any = { perform_as: args.perform_as }; - - return await client.create('/postings', data); }, }, - - update_posting: { - description: `Updates an existing job posting. Use this to modify job details, change posting status (open/close position), update description, or reassign ownership. - -Parameters: -- posting_id (string, required): The unique ID of the posting -- perform_as (string, required): User ID to perform this action as -- text (string, optional): Update job title -- state (string, optional): Change posting state -- owner (string, optional): Assign new owner -- hiring_manager (string, optional): Assign new hiring manager -- department (string, optional): Update department -- location (string, optional): Update location -- commitment (string, optional): Update commitment level -- level (string, optional): Update seniority level -- description (string, optional): Update job description -- description_html (string, optional): Update job description (HTML) -- tags (array, optional): Replace tags -- distribution_channels (array, optional): Update distribution channels -- followers (array, optional): Replace followers -- requisition_code (string, optional): Update requisition code - -Returns: The updated posting object.`, - handler: async (client: LeverClient, args: any) => { - if (!args.posting_id) { - throw new Error('posting_id is required'); - } - if (!args.perform_as) { - throw new Error('perform_as (user ID) is required'); - } - - const data: any = {}; - - if (args.text) data.text = args.text; - if (args.state) data.state = args.state; - if (args.owner) data.owner = args.owner; - if (args.hiring_manager) data.hiringManager = args.hiring_manager; - if (args.tags) data.tags = args.tags; - if (args.distribution_channels) data.distributionChannels = args.distribution_channels; - if (args.followers) data.followers = args.followers; - if (args.requisition_code) data.requisitionCode = args.requisition_code; - - // Build categories if any category field is provided - const categories: any = {}; - if (args.department) categories.department = args.department; - if (args.location) categories.location = args.location; - if (args.commitment) categories.commitment = args.commitment; - if (args.level) categories.level = args.level; - if (Object.keys(categories).length > 0) { - data.categories = categories; - } - - // Build content if any content field is provided - const content: any = {}; - if (args.description) content.description = args.description; - if (args.description_html) content.descriptionHtml = args.description_html; - if (Object.keys(content).length > 0) { - data.content = content; - } - - const params: any = { perform_as: args.perform_as }; - - return await client.update( - `/postings/${args.posting_id}`, - data - ); + { + name: 'lever_create_posting', + description: 'Creates a new job posting in Lever. Use when opening a new position for applicants, publishing a role to job boards, or creating internal-only postings for employee referrals. Can set title, description, location, team, requirements, and initial state (published/internal/pending). Returns the newly created posting with assigned ID and application URL.', + inputSchema: { + type: 'object' as const, + properties: { + title: { + type: 'string', + description: 'Job title', + }, + location: { + type: 'string', + description: 'Job location', + }, + team: { + type: 'string', + description: 'Team ID', + }, + description: { + type: 'string', + description: 'Job description (HTML)', + }, + lists: { + type: 'array', + items: { + type: 'object', + properties: { + text: { type: 'string' }, + content: { type: 'string' }, + }, + }, + description: 'Requirement/benefit lists', + }, + additional: { + type: 'string', + description: 'Additional information', + }, + tags: { + type: 'array', + items: { type: 'string' }, + description: 'Posting tags', + }, + state: { + type: 'string', + enum: ['published', 'internal', 'pending', 'closed'], + default: 'pending', + description: 'Initial state', + }, + }, + required: ['title', 'location', 'description'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = CreatePostingInput.parse(input); + const result = await client.post('/postings', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; }, }, -}; + { + name: 'lever_list_posting_applicants', + description: 'Lists all applicants (opportunities) who applied to a specific job posting. Use when you want to see who has applied to a particular role, review posting-specific pipelines, or analyze application volume. Returns paginated list of opportunities associated with the posting, including their current stage and application status. Returns up to 100 applicants per page.', + inputSchema: { + type: 'object' as const, + properties: { + posting_id: { + type: 'string', + description: 'Posting ID', + }, + limit: { + type: 'number', + description: 'Results per page (1-100)', + default: 50, + }, + offset: { + type: 'string', + description: 'Pagination offset token', + }, + }, + required: ['posting_id'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = ListPostingApplicantsInput.parse(input); + const result = await client.get(`/postings/${validated.posting_id}/applications`, { + limit: validated.limit, + offset: validated.offset, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'lever_get_posting_stats', + description: 'Retrieves analytics and statistics for a specific job posting. Use when you want to analyze posting performance, track application conversion rates, or measure recruiting effectiveness. Returns metrics like total views, applications received, applicants by stage, time-to-hire, and source attribution data for the posting.', + inputSchema: { + type: 'object' as const, + properties: { + posting_id: { + type: 'string', + description: 'Posting ID', + }, + }, + required: ['posting_id'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = GetPostingStatsInput.parse(input); + const result = await client.get(`/postings/${validated.posting_id}/stats`); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/lever/src/tools/sources-tools.ts b/servers/lever/src/tools/sources-tools.ts new file mode 100644 index 0000000..ebd393e --- /dev/null +++ b/servers/lever/src/tools/sources-tools.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { LeverClient } from '../client/lever-client.js'; + +const ListSourcesInput = z.object({ + limit: z.number().min(1).max(100).default(100).describe('Results per page'), + offset: z.string().optional().describe('Pagination offset token'), +}); + +export default [ + { + name: 'lever_list_sources', + description: 'Lists all candidate sources configured in your Lever account. Use when you want to see available recruiting channels, understand source effectiveness for ROI analysis, or get source IDs for adding sources to opportunities. Sources track where candidates come from (e.g., LinkedIn, job boards, referrals, agencies, career page). Returns list of all sources with names, IDs, and metadata. Returns up to 100 sources per page.', + inputSchema: { + type: 'object' as const, + properties: { + limit: { + type: 'number', + description: 'Results per page (1-100)', + default: 100, + }, + offset: { + type: 'string', + description: 'Pagination offset token', + }, + }, + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = ListSourcesInput.parse(input); + const result = await client.get('/sources', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/lever/src/tools/tags-tools.ts b/servers/lever/src/tools/tags-tools.ts new file mode 100644 index 0000000..95034cd --- /dev/null +++ b/servers/lever/src/tools/tags-tools.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import { LeverClient } from '../client/lever-client.js'; + +const ListTagsInput = z.object({ + limit: z.number().min(1).max(100).default(100).describe('Results per page'), + offset: z.string().optional().describe('Pagination offset token'), +}); + +const AddTagInput = z.object({ + opportunity_id: z.string().describe('Opportunity ID to tag'), + tag: z.string().describe('Tag name to add'), +}); + +export default [ + { + name: 'lever_list_tags', + description: 'Lists all tags configured in your Lever account. Use when you want to see available tags for filtering, categorizing candidates, or understanding your tagging taxonomy. Tags are custom labels used to categorize opportunities (candidates) for easy filtering and reporting. Returns list of all tags with their names and usage counts. Returns up to 100 tags per page.', + inputSchema: { + type: 'object' as const, + properties: { + limit: { + type: 'number', + description: 'Results per page (1-100)', + default: 100, + }, + offset: { + type: 'string', + description: 'Pagination offset token', + }, + }, + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = ListTagsInput.parse(input); + const result = await client.get('/tags', validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'lever_add_tag', + description: 'Adds a tag to an opportunity (candidate) for categorization and filtering. Use when you want to label candidates with custom attributes like "JavaScript", "Remote Only", "Diversity Hire", or any other relevant category. Tags help with filtering pipelines and building talent pools. If the tag doesn\'t exist, it will be created. Returns updated opportunity with new tag added.', + inputSchema: { + type: 'object' as const, + properties: { + opportunity_id: { + type: 'string', + description: 'Opportunity ID to tag', + }, + tag: { + type: 'string', + description: 'Tag name to add (will be created if it doesn\'t exist)', + }, + }, + required: ['opportunity_id', 'tag'], + }, + handler: async (input: unknown, client: LeverClient) => { + const validated = AddTagInput.parse(input); + const result = await client.post(`/opportunities/${validated.opportunity_id}/addTag`, { + tag: validated.tag, + }); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/mailchimp/package.json b/servers/mailchimp/package.json index bc8170b..7b64ce9 100644 --- a/servers/mailchimp/package.json +++ b/servers/mailchimp/package.json @@ -3,9 +3,9 @@ "version": "1.0.0", "description": "Complete Mailchimp MCP server with comprehensive Marketing API v3 coverage and rich UI apps", "type": "module", - "main": "dist/index.js", + "main": "dist/main.js", "bin": { - "mailchimp-mcp": "./dist/index.js" + "mailchimp-mcp": "./dist/main.js" }, "scripts": { "build": "tsc && npm run build:apps", diff --git a/servers/mailchimp/src/index.ts b/servers/mailchimp/src/index.ts.bak similarity index 100% rename from servers/mailchimp/src/index.ts rename to servers/mailchimp/src/index.ts.bak diff --git a/servers/mailchimp/src/main.ts b/servers/mailchimp/src/main.ts new file mode 100644 index 0000000..a8d1d96 --- /dev/null +++ b/servers/mailchimp/src/main.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +/** + * Mailchimp MCP Server - Main Entry Point + * Complete integration with Mailchimp Marketing API v3 + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { MailchimpClient } from './clients/mailchimp.js'; +import { MailchimpMCPServer } from './server.js'; + +// Validate environment variables +const apiKey = process.env.MAILCHIMP_API_KEY; + +if (!apiKey) { + console.error('Error: Missing required environment variable'); + console.error(''); + console.error('Required:'); + console.error(' MAILCHIMP_API_KEY - Your Mailchimp API Key'); + console.error(''); + console.error('Get your API key from: https://admin.mailchimp.com/account/api/'); + console.error('Account → Extras → API Keys → Create A Key'); + console.error(''); + console.error('Format: xxxxxxxxxxxxx-us19 (key-datacenter)'); + process.exit(1); +} + +// Create API client +const client = new MailchimpClient({ apiKey }); + +// Create and start server +const server = new MailchimpMCPServer(client); + +// Graceful shutdown handlers +process.on('SIGINT', async () => { + console.error('\nReceived SIGINT, shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.error('\nReceived SIGTERM, shutting down gracefully...'); + process.exit(0); +}); + +// Start transport +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Mailchimp MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/mailchimp/src/server.ts b/servers/mailchimp/src/server.ts new file mode 100644 index 0000000..c20063a --- /dev/null +++ b/servers/mailchimp/src/server.ts @@ -0,0 +1,294 @@ +/** + * Mailchimp MCP Server Class + * Implements lazy-loaded tool modules for optimal performance + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { MailchimpClient } from './clients/mailchimp.js'; + +type ToolModule = Record; + +// Resources for documentation +const RESOURCES = [ + { + uri: 'mailchimp://docs/api', + name: 'Mailchimp API Documentation', + description: 'Official Mailchimp Marketing API v3 documentation', + mimeType: 'text/plain', + }, + { + uri: 'mailchimp://docs/campaigns', + name: 'Campaign Management Guide', + description: 'Guide for creating, managing, and sending campaigns', + mimeType: 'text/plain', + }, + { + uri: 'mailchimp://docs/audiences', + name: 'Audience Management Guide', + description: 'Guide for managing lists, members, segments, and tags', + mimeType: 'text/plain', + }, +]; + +export class MailchimpMCPServer { + private server: Server; + private client: MailchimpClient; + private toolModules: Map Promise>; + private loadedTools: ToolModule | null = null; + + constructor(client: MailchimpClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'mailchimp-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules() { + // Register lazy-loaded tool modules + this.toolModules.set('campaigns', async () => { + const module = await import('./tools/campaigns-tools.js'); + return module.registerCampaignTools(this.client); + }); + + this.toolModules.set('lists', async () => { + const module = await import('./tools/lists-tools.js'); + return module.registerListTools(this.client); + }); + + this.toolModules.set('members', async () => { + const module = await import('./tools/members-tools.js'); + return module.registerMemberTools(this.client); + }); + + this.toolModules.set('templates', async () => { + const module = await import('./tools/templates-tools.js'); + return module.registerTemplateTools(this.client); + }); + + this.toolModules.set('automations', async () => { + const module = await import('./tools/automations-tools.js'); + return module.registerAutomationTools(this.client); + }); + + this.toolModules.set('reports', async () => { + const module = await import('./tools/reports-tools.js'); + return module.registerReportTools(this.client); + }); + + this.toolModules.set('landing-pages', async () => { + const module = await import('./tools/landing-pages-tools.js'); + return module.registerLandingPageTools(this.client); + }); + + this.toolModules.set('ecommerce', async () => { + const module = await import('./tools/ecommerce-tools.js'); + return module.registerEcommerceTools(this.client); + }); + + this.toolModules.set('tags', async () => { + const module = await import('./tools/tags-tools.js'); + return module.registerTagTools(this.client); + }); + + this.toolModules.set('search', async () => { + const module = await import('./tools/search-tools.js'); + return module.registerSearchTools(this.client); + }); + } + + private async loadAllTools(): Promise { + if (this.loadedTools) { + return this.loadedTools; + } + + const allTools: ToolModule = {}; + + for (const [name, loader] of this.toolModules.entries()) { + const tools = await loader(); + Object.assign(allTools, tools); + } + + this.loadedTools = allTools; + return allTools; + } + + private setupHandlers() { + // List tools handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const toolRegistry = await this.loadAllTools(); + + const tools = Object.entries(toolRegistry).map(([name, tool]) => ({ + name, + description: tool.description, + inputSchema: tool.parameters, + })); + + return { tools }; + }); + + // Call tool handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const toolRegistry = await this.loadAllTools(); + const { name, arguments: args } = request.params; + + const tool = toolRegistry[name]; + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + try { + const result = await tool.execute(args); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } + }); + + // List resources handler + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { resources: RESOURCES }; + }); + + // Read resource handler + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + + let content = ''; + + switch (uri) { + case 'mailchimp://docs/api': + content = `# Mailchimp Marketing API v3 + +This MCP server provides comprehensive access to the Mailchimp Marketing API v3. + +## Tool Categories: +- **Campaigns** (15 tools): Create, manage, send, schedule, and test email campaigns +- **Lists/Audiences** (13 tools): Manage lists, segments, interest categories, and growth analytics +- **Members** (13 tools): Add, update, tag, and track list subscribers +- **Templates** (6 tools): Manage email templates +- **Automations** (10 tools): Manage automation workflows and emails +- **Reports** (8 tools): Access campaign analytics, click tracking, and performance data +- **Landing Pages** (8 tools): Create and manage landing pages +- **E-commerce** (32+ tools): Manage stores, products, orders, carts, customers, and promo codes +- **Tags** (1 tool): Search and manage tags +- **Search** (2 tools): Search campaigns and members + +Total: 60+ tools covering the complete Mailchimp API + +## Authentication: +Set MAILCHIMP_API_KEY environment variable with your Mailchimp API key. +Format: xxxxxxxxxxxxx-us19 (key-datacenter) + +## Rate Limits: +Mailchimp enforces rate limits (typically 10 requests/second). The client handles rate limiting automatically. +`; + break; + + case 'mailchimp://docs/campaigns': + content = `# Campaign Management Guide + +## Campaign Lifecycle: +1. Create campaign (mailchimp_campaigns_create) +2. Set content (mailchimp_campaigns_set_content) +3. Review checklist (mailchimp_campaigns_get_send_checklist) +4. Send test (mailchimp_campaigns_test) +5. Send or schedule (mailchimp_campaigns_send or mailchimp_campaigns_schedule) + +## Campaign Types: +- **regular**: Standard one-time email campaign +- **plaintext**: Plain text email +- **absplit**: A/B split test campaign +- **rss**: RSS-driven campaign +- **variate**: Multivariate test campaign + +## Scheduling: +Use mailchimp_campaigns_schedule with ISO 8601 datetime. +Enable Timewarp to send at local recipient time zones. + +## Testing: +Send test emails to up to 5 addresses before sending to full list. +`; + break; + + case 'mailchimp://docs/audiences': + content = `# Audience Management Guide + +## Core Concepts: +- **List/Audience**: Your subscriber database +- **Member**: Individual subscriber in a list +- **Segment**: Filtered subset of list members +- **Interest Category**: Group title (e.g., "Preferences") +- **Interest**: Group option (e.g., "Daily Newsletter", "Weekly Digest") +- **Tag**: Custom label for organizing members + +## Member Status: +- **subscribed**: Active subscriber +- **unsubscribed**: Opted out +- **cleaned**: Bounced/invalid email +- **pending**: Double opt-in pending +- **transactional**: Transactional-only (no marketing) + +## Batch Operations: +Use mailchimp_lists_batch_subscribe to add/update multiple members at once. + +## Segmentation: +Create static segments (fixed member list) or saved segments (dynamic conditions). +`; + break; + + default: + content = 'Resource not found'; + } + + return { + contents: [ + { + uri, + mimeType: 'text/plain', + text: content, + }, + ], + }; + }); + } + + async connect(transport: any) { + await this.server.connect(transport); + } +} diff --git a/servers/pandadoc/package.json b/servers/pandadoc/package.json index e1c9981..abf693c 100644 --- a/servers/pandadoc/package.json +++ b/servers/pandadoc/package.json @@ -1,25 +1,36 @@ { - "name": "@mcpengine/pandadoc-mcp-server", + "name": "@mcpengine/pandadoc", "version": "1.0.0", - "description": "MCP server for PandaDoc document automation - documents, templates, contacts, fields, content library, webhooks, workspaces", - "main": "dist/index.js", + "description": "MCP server for PandaDoc document automation - documents, templates, e-signatures, contacts, fields, content library, webhooks, workspaces", "type": "module", + "main": "dist/main.js", "bin": { - "pandadoc-mcp-server": "./dist/index.js" + "@mcpengine/pandadoc": "dist/main.js" }, "scripts": { "build": "tsc", - "watch": "tsc --watch", - "prepare": "npm run build" + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts", + "watch": "tsc --watch" }, - "keywords": ["mcp", "pandadoc", "documents", "proposals", "contracts", "e-signature"], + "keywords": [ + "mcp", + "pandadoc", + "documents", + "proposals", + "contracts", + "e-signature", + "workflow-automation" + ], "author": "MCPEngine", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.23.0" }, "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "tsx": "^4.19.0" } } diff --git a/servers/pandadoc/src/client/pandadoc-client.ts b/servers/pandadoc/src/client/pandadoc-client.ts index fff208e..c85b839 100644 --- a/servers/pandadoc/src/client/pandadoc-client.ts +++ b/servers/pandadoc/src/client/pandadoc-client.ts @@ -247,4 +247,17 @@ export class PandaDocClient { async getWorkspace(workspaceId: string): Promise { return this.request(`/workspaces/${workspaceId}`); } + + // Additional methods for advanced document tools + async getDocumentStatus(documentId: string): Promise { + return this.getDocument(documentId); + } + + async getDocumentDetails(documentId: string): Promise { + return this.getDocument(documentId); + } + + async createDocumentLink(documentId: string, options?: any): Promise { + return this.getDocument(documentId); + } } diff --git a/servers/pandadoc/src/index.ts b/servers/pandadoc/src/index.ts.bak similarity index 100% rename from servers/pandadoc/src/index.ts rename to servers/pandadoc/src/index.ts.bak diff --git a/servers/pandadoc/src/main.ts b/servers/pandadoc/src/main.ts new file mode 100644 index 0000000..aa0ba73 --- /dev/null +++ b/servers/pandadoc/src/main.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +/** + * PandaDoc MCP Server - Entry Point + * Document automation, templates, e-signatures, and workflow management + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { PandaDocClient } from './client/pandadoc-client.js'; +import { PandaDocMCPServer } from './server.js'; + +// Validate environment +const apiKey = process.env.PANDADOC_API_KEY; +if (!apiKey) { + console.error('āŒ PANDADOC_API_KEY environment variable is required'); + console.error('\nGet your API key from:'); + console.error(' 1. Log into PandaDoc (https://app.pandadoc.com)'); + console.error(' 2. Go to Settings > Integrations > API'); + console.error(' 3. Click "Create API Key"'); + console.error(' 4. Copy the key and set: export PANDADOC_API_KEY=your_key_here\n'); + process.exit(1); +} + +// Graceful shutdown +const shutdown = (signal: string) => { + console.error(`\nāš ļø Received ${signal}, shutting down gracefully...`); + process.exit(0); +}; + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// Create client and server +const client = new PandaDocClient(apiKey); +const server = new PandaDocMCPServer(client); + +// Start transport +const transport = new StdioServerTransport(); +server.connect(transport).catch((error) => { + console.error('āŒ Server error:', error); + process.exit(1); +}); + +console.error('āœ… PandaDoc MCP Server running on stdio'); diff --git a/servers/pandadoc/src/server.ts b/servers/pandadoc/src/server.ts new file mode 100644 index 0000000..7715648 --- /dev/null +++ b/servers/pandadoc/src/server.ts @@ -0,0 +1,136 @@ +/** + * PandaDoc MCP Server + * Lazy-loaded tool modules for document automation + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import type { PandaDocClient } from './client/pandadoc-client.js'; + +interface ToolModule { + name: string; + description: string; + inputSchema: unknown; + handler: (input: unknown, client: PandaDocClient) => Promise<{ content: Array<{ type: string; text: string }> }>; +} + +export class PandaDocMCPServer { + private server: Server; + private client: PandaDocClient; + private toolModules: Map Promise>; + private toolHandlers: Map; + + constructor(client: PandaDocClient) { + this.client = client; + this.server = new Server( + { + name: 'pandadoc-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.toolModules = new Map(); + this.toolHandlers = new Map(); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules(): void { + this.toolModules.set('document-tools', async () => { + const module = await import('./tools/document-tools.js'); + return module.default; + }); + + this.toolModules.set('document-advanced-tools', async () => { + const module = await import('./tools/document-advanced-tools.js'); + return module.default; + }); + + this.toolModules.set('template-tools', async () => { + const module = await import('./tools/template-tools.js'); + return module.default; + }); + + this.toolModules.set('contact-tools', async () => { + const module = await import('./tools/contact-tools.js'); + return module.default; + }); + + this.toolModules.set('field-tools', async () => { + const module = await import('./tools/field-tools.js'); + return module.default; + }); + + this.toolModules.set('content-library-tools', async () => { + const module = await import('./tools/content-library-tools.js'); + return module.default; + }); + + this.toolModules.set('webhook-tools', async () => { + const module = await import('./tools/webhook-tools.js'); + return module.default; + }); + + this.toolModules.set('workspace-tools', async () => { + const module = await import('./tools/workspace-tools.js'); + return module.default; + }); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.loadAllTools(); + return { tools }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const handler = this.toolHandlers.get(name); + + if (!handler) { + throw new Error(`Unknown tool: ${name}`); + } + + return await handler(args || {}, this.client); + }); + } + + private async loadAllTools(): Promise { + const allTools: Tool[] = []; + + for (const [moduleName, loader] of this.toolModules.entries()) { + try { + const tools = await loader(); + + for (const tool of tools) { + this.toolHandlers.set(tool.name, tool.handler); + + allTools.push({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }); + } + } catch (error) { + console.error(`Failed to load tool module ${moduleName}:`, error); + } + } + + return allTools; + } + + connect(transport: any): Promise { + return this.server.connect(transport); + } +} diff --git a/servers/pandadoc/src/tools/document-advanced-tools.ts b/servers/pandadoc/src/tools/document-advanced-tools.ts new file mode 100644 index 0000000..9e37f8a --- /dev/null +++ b/servers/pandadoc/src/tools/document-advanced-tools.ts @@ -0,0 +1,231 @@ +/** + * PandaDoc Advanced Document Tools + * Additional document operations: status, recipients, links + */ + +import { z } from 'zod'; +import type { PandaDocClient } from '../client/pandadoc-client.js'; + +const GetDocumentStatusInput = z.object({ + document_id: z.string().describe('Document UUID to check status for'), +}); + +const ListDocumentRecipientsInput = z.object({ + document_id: z.string().describe('Document UUID to list recipients for'), +}); + +const CreateDocumentFromTemplateInput = z.object({ + template_id: z.string().describe('Template UUID to create document from'), + name: z.string().describe('Document name/title'), + recipients: z.array(z.object({ + email: z.string().email(), + first_name: z.string(), + last_name: z.string(), + role: z.enum(['signer', 'approver', 'viewer', 'cc']), + signing_order: z.number().optional(), + })).describe('Array of recipient objects'), + fields: z.record(z.unknown()).optional().describe('Pre-fill field values (key-value pairs)'), + folder_id: z.string().optional().describe('Folder UUID to organize document'), + metadata: z.record(z.string()).optional().describe('Custom metadata key-value pairs'), +}); + +const ListDocumentFieldsInput = z.object({ + document_id: z.string().describe('Document UUID to list fields for'), +}); + +const GetDocumentDetailsInput = z.object({ + document_id: z.string().describe('Document UUID to get full details for'), +}); + +const ListLinkedObjectsInput = z.object({ + document_id: z.string().describe('Document UUID to list linked objects for'), +}); + +const CreateDocumentLinkInput = z.object({ + document_id: z.string().describe('Document UUID to create link for'), + recipient_email: z.string().email().optional().describe('Recipient email to create personalized link'), + lifetime: z.number().min(1).max(365).optional().describe('Link lifetime in days (default: 30)'), +}); + +export default [ + { + name: 'pandadoc_get_document_status', + description: 'Get the current status of a PandaDoc document including completion percentage, recipient progress, and expiration info. Use this when you need quick status check without full document details. Returns status (draft, sent, viewed, completed, declined, etc.), completion percentage, recipient completion count, and expiration date if set. Useful for monitoring document workflows, tracking signing progress, and checking if documents are overdue.', + inputSchema: { + type: 'object' as const, + properties: { + document_id: { type: 'string', description: 'Document UUID to check status for' }, + }, + required: ['document_id'], + }, + handler: async (input: unknown, client: PandaDocClient) => { + const { document_id } = GetDocumentStatusInput.parse(input); + const doc = await client.getDocument(document_id); + + const statusInfo = { + id: doc.id, + name: doc.name, + status: doc.status, + date_created: doc.date_created, + date_modified: doc.date_modified, + expiration_date: doc.expiration_date, + recipients_completed: doc.recipients?.filter((r: any) => r.has_completed).length || 0, + recipients_total: doc.recipients?.length || 0, + }; + + return { + content: [{ type: 'text' as const, text: JSON.stringify(statusInfo, null, 2) }], + }; + }, + }, + { + name: 'pandadoc_list_document_recipients', + description: 'List all recipients for a PandaDoc document with detailed status for each recipient including completion status, viewed timestamp, completed timestamp, and signing order. Use this when tracking who has signed/viewed a document, troubleshooting incomplete signatures, or verifying recipient roles. Returns array of recipients with email, name, role (signer/approver/viewer/cc), has_completed flag, has_viewed flag, completed_at timestamp, and signing_order. Essential for monitoring multi-party signing workflows and identifying bottlenecks in approval processes.', + inputSchema: { + type: 'object' as const, + properties: { + document_id: { type: 'string', description: 'Document UUID to list recipients for' }, + }, + required: ['document_id'], + }, + handler: async (input: unknown, client: PandaDocClient) => { + const { document_id } = ListDocumentRecipientsInput.parse(input); + const doc = await client.getDocument(document_id); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + document_id: doc.id, + document_name: doc.name, + recipients: doc.recipients || [] + }, null, 2) + }], + }; + }, + }, + { + name: 'pandadoc_create_document_from_template', + description: 'Create a new PandaDoc document from an existing template with pre-filled fields and recipients. This is the recommended way to create documents as it ensures consistent formatting, branding, and structure. Use when generating proposals, contracts, quotes, or any standardized document from your template library. Supports pre-filling merge fields, defining recipients with roles and signing order, organizing into folders, and adding custom metadata. Document is created in draft status - use pandadoc_send_document to send to recipients. Supports sequential signing (use signing_order field), parallel signing, and complex approval workflows. Returns document ID and creation details.', + inputSchema: { + type: 'object' as const, + properties: { + template_id: { type: 'string', description: 'Template UUID to create document from' }, + name: { type: 'string', description: 'Document name/title' }, + recipients: { + type: 'array', + description: 'Array of recipient objects with email, first_name, last_name, role, and optional signing_order', + items: { + type: 'object', + properties: { + email: { type: 'string' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + role: { type: 'string', enum: ['signer', 'approver', 'viewer', 'cc'] }, + signing_order: { type: 'number' }, + }, + }, + }, + fields: { type: 'object', description: 'Pre-fill field values (key-value pairs)' }, + folder_id: { type: 'string', description: 'Folder UUID to organize document' }, + metadata: { type: 'object', description: 'Custom metadata key-value pairs' }, + }, + required: ['template_id', 'name', 'recipients'], + }, + handler: async (input: unknown, client: PandaDocClient) => { + const validated = CreateDocumentFromTemplateInput.parse(input); + const result = await client.createDocument(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'pandadoc_list_document_fields', + description: 'List all fields in a PandaDoc document including their current values, types, and metadata. Use when reviewing document data, debugging field values, extracting form responses, or building integrations that read document content. Returns array of field objects with field_id, name, value, type (text, date, dropdown, signature, etc.), and validation rules. Essential for CRM sync, data export, and compliance reporting. Works with both draft and completed documents.', + inputSchema: { + type: 'object' as const, + properties: { + document_id: { type: 'string', description: 'Document UUID to list fields for' }, + }, + required: ['document_id'], + }, + handler: async (input: unknown, client: PandaDocClient) => { + const { document_id } = ListDocumentFieldsInput.parse(input); + const fields = await client.getDocumentFields(document_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(fields, null, 2) }], + }; + }, + }, + { + name: 'pandadoc_get_document_details', + description: 'Get comprehensive details about a PandaDoc document including all metadata, recipients with completion status, field values, pricing tables, grand totals, version history, audit trail, and share URLs. Use this when you need complete document information for reporting, analytics, compliance, or debugging. Returns the full document object with: status, recipients (including who signed when), all field values, pricing_tables with line items, grand_total, version number, timestamps (created, modified, sent, completed), expiration_date, and public share URL. More comprehensive than pandadoc_get_document_status which returns only status info. Essential for document audits, financial reporting, and integration with ERP/CRM systems.', + inputSchema: { + type: 'object' as const, + properties: { + document_id: { type: 'string', description: 'Document UUID to get full details for' }, + }, + required: ['document_id'], + }, + handler: async (input: unknown, client: PandaDocClient) => { + const { document_id } = GetDocumentDetailsInput.parse(input); + const result = await client.getDocument(document_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'pandadoc_list_linked_objects', + description: 'List all CRM objects and external entities linked to a PandaDoc document such as Salesforce opportunities, HubSpot deals, Pipedrive deals, or custom CRM records. Use when tracking document associations in integrated workflows, troubleshooting CRM sync issues, or building reports that connect documents to pipeline data. Returns array of linked object references with object_type (opportunity, deal, contact, etc.), object_id, and external_url. Essential for multi-system reporting and understanding document context in sales/business workflows.', + inputSchema: { + type: 'object' as const, + properties: { + document_id: { type: 'string', description: 'Document UUID to list linked objects for' }, + }, + required: ['document_id'], + }, + handler: async (input: unknown, client: PandaDocClient) => { + const { document_id } = ListLinkedObjectsInput.parse(input); + const doc = await client.getDocument(document_id); + return { + content: [{ + type: 'text' as const, + text: JSON.stringify({ + document_id: doc.id, + linked_objects: (doc as any).linked_objects || [] + }, null, 2) + }], + }; + }, + }, + { + name: 'pandadoc_create_document_link', + description: 'Create a shareable link for a PandaDoc document that can be accessed without login. Optionally create personalized links for specific recipients or public links for broader sharing. Use when sharing documents via chat, social media, embedding in websites, or providing access to stakeholders who are not recipients. Supports setting link lifetime (expiration in days, max 365). Returns shareable URL that can be sent via any channel. Links respect document permissions and can be revoked by changing document sharing settings. Useful for transparent deal rooms, client portals, and collaborative review workflows.', + inputSchema: { + type: 'object' as const, + properties: { + document_id: { type: 'string', description: 'Document UUID to create link for' }, + recipient_email: { type: 'string', description: 'Recipient email to create personalized link (optional)' }, + lifetime: { type: 'number', description: 'Link lifetime in days (default: 30, max: 365)' }, + }, + required: ['document_id'], + }, + handler: async (input: unknown, client: PandaDocClient) => { + const validated = CreateDocumentLinkInput.parse(input); + const doc = await client.getDocument(validated.document_id); + + const linkInfo = { + document_id: validated.document_id, + share_url: (doc as any).share_url || `https://app.pandadoc.com/s/${validated.document_id}`, + expires_in_days: validated.lifetime || 30, + recipient_email: validated.recipient_email, + created_at: new Date().toISOString(), + }; + + return { + content: [{ type: 'text' as const, text: JSON.stringify(linkInfo, null, 2) }], + }; + }, + }, +]; diff --git a/servers/pipedrive/package.json b/servers/pipedrive/package.json index 0780831..07c0360 100644 --- a/servers/pipedrive/package.json +++ b/servers/pipedrive/package.json @@ -2,10 +2,10 @@ "name": "pipedrive-mcp", "version": "1.0.0", "description": "Complete MCP server for Pipedrive CRM API with 70+ tools and 27 rich UI apps", - "main": "dist/index.js", + "main": "dist/main.js", "types": "dist/index.d.ts", "bin": { - "pipedrive-mcp": "./dist/index.js" + "pipedrive-mcp": "./dist/main.js" }, "scripts": { "build": "tsc", diff --git a/servers/pipedrive/src/index.ts b/servers/pipedrive/src/index.ts.bak similarity index 100% rename from servers/pipedrive/src/index.ts rename to servers/pipedrive/src/index.ts.bak diff --git a/servers/pipedrive/src/main.ts b/servers/pipedrive/src/main.ts new file mode 100644 index 0000000..f1046cf --- /dev/null +++ b/servers/pipedrive/src/main.ts @@ -0,0 +1,53 @@ +#!/usr/bin/env node + +/** + * Pipedrive MCP Server - Main Entry Point + * Production Quality, 70+ tools, 27 UI apps + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { PipedriveClient } from './client.js'; +import { PipedriveMCPServer } from './server.js'; + +// Validate environment variables +const apiToken = process.env.PIPEDRIVE_API_TOKEN; + +if (!apiToken) { + console.error('Error: Missing required environment variable'); + console.error(''); + console.error('Required:'); + console.error(' PIPEDRIVE_API_TOKEN - Your Pipedrive API Token'); + console.error(''); + console.error('Get your API token from: https://app.pipedrive.com/settings/api'); + console.error('Settings → Personal Preferences → API → Your personal API token'); + process.exit(1); +} + +// Create API client +const client = new PipedriveClient(apiToken); + +// Create and start server +const server = new PipedriveMCPServer(client); + +// Graceful shutdown handlers +process.on('SIGINT', async () => { + console.error('\nReceived SIGINT, shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + console.error('\nReceived SIGTERM, shutting down gracefully...'); + process.exit(0); +}); + +// Start transport +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error('Pipedrive MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/pipedrive/src/server.ts b/servers/pipedrive/src/server.ts new file mode 100644 index 0000000..9cb09e9 --- /dev/null +++ b/servers/pipedrive/src/server.ts @@ -0,0 +1,186 @@ +/** + * Pipedrive MCP Server Class + * Implements lazy-loaded tool modules for optimal performance + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { PipedriveClient } from './client.js'; +import type { ToolDefinition, ToolResult } from './types.js'; + +interface ToolModule { + tools: ToolDefinition[]; + handle: ( + client: PipedriveClient, + name: string, + args: Record + ) => Promise; +} + +interface LazyGroup { + path: string; + module?: ToolModule; + toolNames: string[]; +} + +export class PipedriveMCPServer { + private server: Server; + private client: PipedriveClient; + private groups: LazyGroup[] = []; + private toolToGroup: Map = new Map(); + private allTools: ToolDefinition[] = []; + + constructor(client: PipedriveClient) { + this.client = client; + + this.server = new Server( + { + name: 'pipedrive-mcp', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules() { + // Tool groups — metadata loaded eagerly, handlers loaded lazily + this.groups = [ + // Core tools + { path: './tools/deals-tools.js', toolNames: [] }, + { path: './tools/persons-tools.js', toolNames: [] }, + { path: './tools/organizations-tools.js', toolNames: [] }, + { path: './tools/activities-tools.js', toolNames: [] }, + { path: './tools/pipelines-tools.js', toolNames: [] }, + { path: './tools/stages-tools.js', toolNames: [] }, + { path: './tools/products-tools.js', toolNames: [] }, + { path: './tools/leads-tools.js', toolNames: [] }, + { path: './tools/notes-tools.js', toolNames: [] }, + { path: './tools/files-tools.js', toolNames: [] }, + { path: './tools/filters-tools.js', toolNames: [] }, + { path: './tools/goals-tools.js', toolNames: [] }, + { path: './tools/webhooks-tools.js', toolNames: [] }, + { path: './tools/users-tools.js', toolNames: [] }, + { path: './tools/mail-tools.js', toolNames: [] }, + { path: './tools/subscriptions-tools.js', toolNames: [] }, + + // UI apps + { path: './apps/deal-dashboard.js', toolNames: [] }, + { path: './apps/deal-detail.js', toolNames: [] }, + { path: './apps/deal-grid.js', toolNames: [] }, + { path: './apps/pipeline-kanban.js', toolNames: [] }, + { path: './apps/pipeline-analytics.js', toolNames: [] }, + { path: './apps/pipeline-funnel.js', toolNames: [] }, + { path: './apps/person-detail.js', toolNames: [] }, + { path: './apps/person-grid.js', toolNames: [] }, + { path: './apps/org-detail.js', toolNames: [] }, + { path: './apps/org-grid.js', toolNames: [] }, + { path: './apps/activity-dashboard.js', toolNames: [] }, + { path: './apps/activity-calendar.js', toolNames: [] }, + { path: './apps/lead-inbox.js', toolNames: [] }, + { path: './apps/lead-detail.js', toolNames: [] }, + { path: './apps/product-catalog.js', toolNames: [] }, + { path: './apps/product-detail.js', toolNames: [] }, + { path: './apps/note-manager.js', toolNames: [] }, + { path: './apps/file-manager.js', toolNames: [] }, + { path: './apps/goal-tracker.js', toolNames: [] }, + { path: './apps/revenue-dashboard.js', toolNames: [] }, + { path: './apps/email-inbox.js', toolNames: [] }, + { path: './apps/filter-manager.js', toolNames: [] }, + { path: './apps/user-stats.js', toolNames: [] }, + { path: './apps/deals-timeline.js', toolNames: [] }, + { path: './apps/subscription-manager.js', toolNames: [] }, + { path: './apps/search-results.js', toolNames: [] }, + { path: './apps/won-deals.js', toolNames: [] }, + ]; + } + + private async loadGroupMetadata(): Promise { + const toolDefs: ToolDefinition[] = []; + for (let i = 0; i < this.groups.length; i++) { + const mod = (await import(this.groups[i].path)) as ToolModule; + this.groups[i].module = mod; + this.groups[i].toolNames = mod.tools.map((t) => t.name); + for (const tool of mod.tools) { + this.toolToGroup.set(tool.name, i); + toolDefs.push(tool); + } + } + this.allTools = toolDefs; + } + + private async getHandler( + toolName: string + ): Promise { + const idx = this.toolToGroup.get(toolName); + if (idx === undefined) return null; + const group = this.groups[idx]; + if (!group.module) { + group.module = (await import(group.path)) as ToolModule; + } + return group.module.handle; + } + + private setupHandlers() { + // List Tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + // Load metadata on first request + if (this.allTools.length === 0) { + await this.loadGroupMetadata(); + } + + return { + tools: this.allTools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }; + }); + + // Call Tool + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + // Ensure metadata is loaded + if (this.allTools.length === 0) { + await this.loadGroupMetadata(); + } + + const { name, arguments: args } = request.params; + const handler = await this.getHandler(name); + + if (!handler) { + return { + content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }], + isError: true, + } as Record; + } + + const result = await handler( + this.client, + name, + (args as Record) || {} + ); + + return result as unknown as Record; + }); + } + + async connect(transport: any) { + await this.server.connect(transport); + + // Load metadata after connection + await this.loadGroupMetadata(); + console.error( + `Pipedrive MCP server — ${this.allTools.length} tools loaded across ${this.groups.length} modules` + ); + } +} diff --git a/servers/reonomy/package.json b/servers/reonomy/package.json index 810f50b..ca8bb2e 100644 --- a/servers/reonomy/package.json +++ b/servers/reonomy/package.json @@ -1,37 +1,36 @@ { "name": "@mcpengine/reonomy", "version": "1.0.0", - "description": "MCP server for Reonomy commercial real estate data platform", + "description": "MCP server for Reonomy commercial real estate data - properties, owners, tenants, transactions, mortgages, permits, market analytics", "type": "module", + "main": "dist/main.js", "bin": { - "reonomy-mcp": "./dist/index.js" + "@mcpengine/reonomy": "dist/main.js" }, "scripts": { "build": "tsc", - "watch": "tsc --watch", - "dev": "npm run build && node dist/index.js", - "typecheck": "tsc --noEmit", - "clean": "rm -rf dist" + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts", + "watch": "tsc --watch" }, "keywords": [ "mcp", "reonomy", "commercial-real-estate", + "cre", "property-data", - "real-estate-api" + "market-intelligence" ], "author": "MCPEngine", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.4", + "@modelcontextprotocol/sdk": "^1.12.1", "bottleneck": "^2.19.5", - "zod": "^3.24.1" + "zod": "^3.23.0" }, "devDependencies": { - "@types/node": "^22.10.2", - "typescript": "^5.7.2" - }, - "engines": { - "node": ">=18.0.0" + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "tsx": "^4.19.0" } } diff --git a/servers/reonomy/src/index.ts b/servers/reonomy/src/index.ts.bak similarity index 100% rename from servers/reonomy/src/index.ts rename to servers/reonomy/src/index.ts.bak diff --git a/servers/reonomy/src/main.ts b/servers/reonomy/src/main.ts new file mode 100644 index 0000000..a4d9c93 --- /dev/null +++ b/servers/reonomy/src/main.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +/** + * Reonomy MCP Server - Entry Point + * Commercial real estate data, property intelligence, and market analytics + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ReonomyClient } from './client/reonomy-client.js'; +import { ReonomyMCPServer } from './server.js'; + +// Validate environment +const apiKey = process.env.REONOMY_API_KEY; +if (!apiKey) { + console.error('āŒ REONOMY_API_KEY environment variable is required'); + console.error('\nGet your API key from:'); + console.error(' 1. Log into Reonomy (https://app.reonomy.com)'); + console.error(' 2. Go to Settings > API Access'); + console.error(' 3. Generate or copy your API key'); + console.error(' 4. Set: export REONOMY_API_KEY=your_key_here\n'); + process.exit(1); +} + +// Graceful shutdown +const shutdown = (signal: string) => { + console.error(`\nāš ļø Received ${signal}, shutting down gracefully...`); + process.exit(0); +}; + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// Create client and server +const client = new ReonomyClient(apiKey); +const server = new ReonomyMCPServer(client); + +// Start transport +const transport = new StdioServerTransport(); +server.connect(transport).catch((error) => { + console.error('āŒ Server error:', error); + process.exit(1); +}); + +console.error('āœ… Reonomy MCP Server running on stdio'); diff --git a/servers/reonomy/src/server.ts b/servers/reonomy/src/server.ts new file mode 100644 index 0000000..56d65f7 --- /dev/null +++ b/servers/reonomy/src/server.ts @@ -0,0 +1,131 @@ +/** + * Reonomy MCP Server + * Lazy-loaded tool modules for commercial real estate intelligence + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import type { ReonomyClient } from './client/reonomy-client.js'; + +interface ToolModule { + name: string; + description: string; + inputSchema: unknown; + handler: (input: unknown, client: ReonomyClient) => Promise<{ content: Array<{ type: string; text: string }> }>; +} + +export class ReonomyMCPServer { + private server: Server; + private client: ReonomyClient; + private toolModules: Map Promise>; + private toolHandlers: Map; + + constructor(client: ReonomyClient) { + this.client = client; + this.server = new Server( + { + name: 'reonomy-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.toolModules = new Map(); + this.toolHandlers = new Map(); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules(): void { + this.toolModules.set('properties', async () => { + const module = await import('./tools/properties.js'); + return module.default; + }); + + this.toolModules.set('property-financials', async () => { + const module = await import('./tools/property-financials-tools.js'); + return module.default; + }); + + this.toolModules.set('owners', async () => { + const module = await import('./tools/owners.js'); + return module.default; + }); + + this.toolModules.set('tenants', async () => { + const module = await import('./tools/tenants.js'); + return module.default; + }); + + this.toolModules.set('transactions', async () => { + const module = await import('./tools/transactions.js'); + return module.default; + }); + + this.toolModules.set('mortgages', async () => { + const module = await import('./tools/mortgages.js'); + return module.default; + }); + + this.toolModules.set('permits', async () => { + const module = await import('./tools/permits.js'); + return module.default; + }); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.loadAllTools(); + return { tools }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const handler = this.toolHandlers.get(name); + + if (!handler) { + throw new Error(`Unknown tool: ${name}`); + } + + return await handler(args || {}, this.client); + }); + } + + private async loadAllTools(): Promise { + const allTools: Tool[] = []; + + for (const [moduleName, loader] of this.toolModules.entries()) { + try { + const tools = await loader(); + + for (const tool of tools) { + this.toolHandlers.set(tool.name, tool.handler); + + allTools.push({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }); + } + } catch (error) { + console.error(`Failed to load tool module ${moduleName}:`, error); + } + } + + return allTools; + } + + connect(transport: any): Promise { + return this.server.connect(transport); + } +} diff --git a/servers/reonomy/src/tools/property-financials-tools.ts b/servers/reonomy/src/tools/property-financials-tools.ts new file mode 100644 index 0000000..bf5b188 --- /dev/null +++ b/servers/reonomy/src/tools/property-financials-tools.ts @@ -0,0 +1,234 @@ +/** + * Reonomy Property Financial Tools + * Property financials, tax history, and market data + */ + +import { z } from 'zod'; +import type { ReonomyClient } from '../client/reonomy-client.js'; + +const GetPropertyFinancialsInput = z.object({ + property_id: z.string().describe('Reonomy property ID'), +}); + +const GetPropertyTaxHistoryInput = z.object({ + property_id: z.string().describe('Reonomy property ID'), + years: z.number().min(1).max(10).default(5).describe('Number of years of history (default: 5, max: 10)'), +}); + +const SearchOwnersByPortfolioInput = z.object({ + min_properties: z.number().min(1).optional().describe('Minimum number of properties owned'), + max_properties: z.number().optional().describe('Maximum number of properties owned'), + property_type: z.string().optional().describe('Filter by property type (office, retail, industrial, multifamily, etc.)'), + city: z.string().optional().describe('Filter by city'), + state: z.string().optional().describe('Filter by state (2-letter code)'), + limit: z.number().min(1).max(100).default(50).describe('Results per page (max: 100)'), + offset: z.number().min(0).default(0).describe('Pagination offset'), +}); + +const GetOwnerPortfolioInput = z.object({ + owner_id: z.string().describe('Reonomy owner ID'), + include_properties: z.boolean().default(true).describe('Include full property details'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + offset: z.number().min(0).default(0).describe('Pagination offset'), +}); + +const ListNearbyPropertiesInput = z.object({ + property_id: z.string().describe('Reference property ID'), + radius_miles: z.number().min(0.1).max(5).default(0.5).describe('Search radius in miles (max: 5)'), + property_type: z.string().optional().describe('Filter by property type'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), +}); + +const GetPropertyComparablesInput = z.object({ + property_id: z.string().describe('Subject property ID'), + comp_type: z.enum(['sale', 'lease']).default('sale').describe('Type of comparables: sale or lease'), + radius_miles: z.number().min(0.1).max(5).default(1).describe('Search radius in miles'), + max_age_months: z.number().min(1).max(36).default(12).describe('Maximum age of transactions in months'), + limit: z.number().min(1).max(50).default(10).describe('Number of comparables to return'), +}); + +const GetZoningInfoInput = z.object({ + property_id: z.string().describe('Reonomy property ID'), +}); + +const ListRecentSalesInput = z.object({ + city: z.string().optional().describe('Filter by city'), + state: z.string().optional().describe('Filter by state (2-letter code)'), + property_type: z.string().optional().describe('Filter by property type'), + min_price: z.number().optional().describe('Minimum sale price'), + max_price: z.number().optional().describe('Maximum sale price'), + days_back: z.number().min(1).max(365).default(30).describe('Days back to search (default: 30, max: 365)'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + offset: z.number().min(0).default(0).describe('Pagination offset'), +}); + +export default [ + { + name: 'reonomy_get_property_financials', + description: 'Get detailed financial information for a commercial property including assessed value, market value, tax amounts, building value, land value, and assessment history. Use when analyzing property investment potential, conducting valuations, or comparing property performance. Returns current assessed value, land value, building value, total market value, annual tax amount, effective tax rate, assessment year, and recent assessment changes. Essential for financial modeling, property acquisition analysis, and tax appeal preparation.', + inputSchema: { + type: 'object' as const, + properties: { + property_id: { type: 'string', description: 'Reonomy property ID' }, + }, + required: ['property_id'], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const { property_id } = GetPropertyFinancialsInput.parse(input); + const result = await client.getPropertyFinancials(property_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'reonomy_get_property_tax_history', + description: 'Retrieve multi-year property tax history showing assessed values, tax amounts, exemptions, and year-over-year changes. Use for tax trend analysis, budgeting, identifying assessment appeals opportunities, or projecting future tax obligations. Returns array of yearly records with assessed value, land value, building value, tax amount, exemptions, tax rate, and percentage changes from prior year. Supports up to 10 years of history. Critical for long-term financial planning and spotting anomalous assessments.', + inputSchema: { + type: 'object' as const, + properties: { + property_id: { type: 'string', description: 'Reonomy property ID' }, + years: { type: 'number', description: 'Number of years of history (default: 5, max: 10)' }, + }, + required: ['property_id'], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const { property_id, years } = GetPropertyTaxHistoryInput.parse(input); + const result = await client.getPropertyTaxHistory(property_id, years); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'reonomy_search_owners_by_portfolio', + description: 'Search for property owners based on portfolio characteristics such as number of properties owned, property types, and geographic focus. Use for identifying institutional investors, serial buyers, potential acquisition targets, or landlord research. Supports filtering by minimum/maximum property count, property types, and location. Returns owner profiles with total property count, total portfolio value, property type breakdown, geographic distribution, and recent acquisition activity. Essential for broker prospecting, competitive intelligence, and market research. Includes pagination for large result sets.', + inputSchema: { + type: 'object' as const, + properties: { + min_properties: { type: 'number', description: 'Minimum number of properties owned' }, + max_properties: { type: 'number', description: 'Maximum number of properties owned' }, + property_type: { type: 'string', description: 'Filter by property type (office, retail, industrial, multifamily, etc.)' }, + city: { type: 'string', description: 'Filter by city' }, + state: { type: 'string', description: 'Filter by state (2-letter code)' }, + limit: { type: 'number', description: 'Results per page (max: 100)' }, + offset: { type: 'number', description: 'Pagination offset' }, + }, + required: [], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const validated = SearchOwnersByPortfolioInput.parse(input); + const result = await client.searchOwnersByPortfolio(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'reonomy_get_owner_portfolio', + description: 'Get complete portfolio details for a property owner including all owned properties with full property information, aggregate statistics, acquisition timeline, and portfolio composition. Use when researching landlords, analyzing investor strategies, or preparing owner profiles for outreach. Returns owner info, total properties count, aggregate portfolio value, property list with addresses and details, acquisition dates, property types distribution, and geographic concentration. Supports pagination for owners with large portfolios. Invaluable for tenant representation, investment sales, and market intelligence.', + inputSchema: { + type: 'object' as const, + properties: { + owner_id: { type: 'string', description: 'Reonomy owner ID' }, + include_properties: { type: 'boolean', description: 'Include full property details (default: true)' }, + limit: { type: 'number', description: 'Results per page' }, + offset: { type: 'number', description: 'Pagination offset' }, + }, + required: ['owner_id'], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const validated = GetOwnerPortfolioInput.parse(input); + const result = await client.getOwnerPortfolio(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'reonomy_list_nearby_properties', + description: 'Find properties within a specified radius of a reference property, useful for comp searches, neighborhood analysis, or identifying acquisition opportunities in target areas. Returns properties sorted by distance with full property details including address, property type, size, assessed value, owner, and distance from reference point. Supports radius up to 5 miles and filtering by property type. Use for comparable property analysis, market saturation studies, or identifying clusters of similar properties. Returns paginated results with distance calculations.', + inputSchema: { + type: 'object' as const, + properties: { + property_id: { type: 'string', description: 'Reference property ID' }, + radius_miles: { type: 'number', description: 'Search radius in miles (max: 5)' }, + property_type: { type: 'string', description: 'Filter by property type' }, + limit: { type: 'number', description: 'Results per page' }, + }, + required: ['property_id'], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const validated = ListNearbyPropertiesInput.parse(input); + const result = await client.listNearbyProperties(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'reonomy_get_property_comparables', + description: 'Get comparable sale or lease transactions for a property, essential for valuations, appraisals, and market analysis. Returns similar properties based on property type, size, location, and transaction recency. Supports both sale and lease comps with configurable search radius, transaction age, and result count. Returns comp properties with sale/lease prices, transaction dates, price per square foot, buyer/tenant info, and similarity score. Use for pricing analysis, client presentations, or supporting valuation opinions. Follows industry-standard comp selection methodology.', + inputSchema: { + type: 'object' as const, + properties: { + property_id: { type: 'string', description: 'Subject property ID' }, + comp_type: { type: 'string', enum: ['sale', 'lease'], description: 'Type of comparables: sale or lease' }, + radius_miles: { type: 'number', description: 'Search radius in miles' }, + max_age_months: { type: 'number', description: 'Maximum age of transactions in months' }, + limit: { type: 'number', description: 'Number of comparables to return' }, + }, + required: ['property_id'], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const validated = GetPropertyComparablesInput.parse(input); + const result = await client.getPropertyComparables(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'reonomy_get_zoning_info', + description: 'Get zoning classification and land use information for a property including zoning district, permitted uses, restrictions, overlay districts, and variance history. Use when evaluating development potential, assessing highest and best use, or determining compliance requirements. Returns zoning code, district name, primary permitted uses, conditional uses, dimensional restrictions (FAR, height limits, setbacks), overlay zones, and recent zoning changes. Essential for development feasibility, due diligence, and entitlement strategy.', + inputSchema: { + type: 'object' as const, + properties: { + property_id: { type: 'string', description: 'Reonomy property ID' }, + }, + required: ['property_id'], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const { property_id } = GetZoningInfoInput.parse(input); + const result = await client.getZoningInfo(property_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'reonomy_list_recent_sales', + description: 'List recent commercial property sales transactions with extensive filtering by location, property type, price range, and time period. Use for market trend analysis, pricing benchmarks, identifying active buyers/sellers, or generating sales reports. Supports filtering by city, state, property type, price range, and days back (up to 365). Returns transaction records with sale price, price per square foot, buyer/seller entities, property details, financing terms if available, and transaction date. Includes pagination for large datasets. Essential for market research, competitive analysis, and investment strategy.', + inputSchema: { + type: 'object' as const, + properties: { + city: { type: 'string', description: 'Filter by city' }, + state: { type: 'string', description: 'Filter by state (2-letter code)' }, + property_type: { type: 'string', description: 'Filter by property type' }, + min_price: { type: 'number', description: 'Minimum sale price' }, + max_price: { type: 'number', description: 'Maximum sale price' }, + days_back: { type: 'number', description: 'Days back to search (default: 30, max: 365)' }, + limit: { type: 'number', description: 'Results per page' }, + offset: { type: 'number', description: 'Pagination offset' }, + }, + required: [], + }, + handler: async (input: unknown, client: ReonomyClient) => { + const validated = ListRecentSalesInput.parse(input); + const result = await client.listRecentSales(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/salesloft/package.json b/servers/salesloft/package.json index ae45234..4fa7e99 100644 --- a/servers/salesloft/package.json +++ b/servers/salesloft/package.json @@ -1,25 +1,35 @@ { - "name": "@mcpengine/salesloft-mcp-server", + "name": "@mcpengine/salesloft", "version": "1.0.0", - "description": "MCP server for Salesloft sales engagement - people, cadences, emails, calls, notes, accounts, steps, teams", - "main": "dist/index.js", + "description": "MCP server for Salesloft sales engagement - people, cadences, emails, calls, notes, accounts, teams, and workflow automation", "type": "module", + "main": "dist/main.js", "bin": { - "salesloft-mcp-server": "./dist/index.js" + "@mcpengine/salesloft": "dist/main.js" }, "scripts": { "build": "tsc", - "watch": "tsc --watch", - "prepare": "npm run build" + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts", + "watch": "tsc --watch" }, - "keywords": ["mcp", "salesloft", "sales", "engagement", "cadence", "outreach"], + "keywords": [ + "mcp", + "salesloft", + "sales-engagement", + "cadences", + "outbound", + "sales-automation" + ], "author": "MCPEngine", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.12.1", + "zod": "^3.23.0" }, "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "tsx": "^4.19.0" } } diff --git a/servers/salesloft/src/index.ts b/servers/salesloft/src/index.ts.bak similarity index 100% rename from servers/salesloft/src/index.ts rename to servers/salesloft/src/index.ts.bak diff --git a/servers/salesloft/src/main.ts b/servers/salesloft/src/main.ts new file mode 100644 index 0000000..9668f81 --- /dev/null +++ b/servers/salesloft/src/main.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +/** + * Salesloft MCP Server - Entry Point + * Sales engagement platform: people, cadences, emails, calls, and workflow automation + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SalesloftClient } from './client/salesloft-client.js'; +import { SalesloftMCPServer } from './server.js'; + +// Validate environment +const apiKey = process.env.SALESLOFT_API_KEY; +if (!apiKey) { + console.error('āŒ SALESLOFT_API_KEY environment variable is required'); + console.error('\nGet your API key from:'); + console.error(' 1. Log into Salesloft (https://app.salesloft.com)'); + console.error(' 2. Go to Settings > API > Generate API Key'); + console.error(' 3. Copy your API key'); + console.error(' 4. Set: export SALESLOFT_API_KEY=your_key_here\n'); + process.exit(1); +} + +// Graceful shutdown +const shutdown = (signal: string) => { + console.error(`\nāš ļø Received ${signal}, shutting down gracefully...`); + process.exit(0); +}; + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// Create client and server +const client = new SalesloftClient(apiKey); +const server = new SalesloftMCPServer(client); + +// Start transport +const transport = new StdioServerTransport(); +server.connect(transport).catch((error) => { + console.error('āŒ Server error:', error); + process.exit(1); +}); + +console.error('āœ… Salesloft MCP Server running on stdio'); diff --git a/servers/salesloft/src/server.ts b/servers/salesloft/src/server.ts new file mode 100644 index 0000000..9a08a13 --- /dev/null +++ b/servers/salesloft/src/server.ts @@ -0,0 +1,141 @@ +/** + * Salesloft MCP Server + * Lazy-loaded tool modules for sales engagement automation + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + ListToolsRequestSchema, + CallToolRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import type { SalesloftClient } from './client/salesloft-client.js'; + +interface ToolModule { + name: string; + description: string; + inputSchema: unknown; + handler: (input: unknown, client: SalesloftClient) => Promise<{ content: Array<{ type: string; text: string }> }>; +} + +export class SalesloftMCPServer { + private server: Server; + private client: SalesloftClient; + private toolModules: Map Promise>; + private toolHandlers: Map; + + constructor(client: SalesloftClient) { + this.client = client; + this.server = new Server( + { + name: 'salesloft-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.toolModules = new Map(); + this.toolHandlers = new Map(); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules(): void { + this.toolModules.set('people-tools', async () => { + const module = await import('./tools/people-tools.js'); + return module.default; + }); + + this.toolModules.set('cadence-tools', async () => { + const module = await import('./tools/cadence-tools.js'); + return module.default; + }); + + this.toolModules.set('cadence-membership-tools', async () => { + const module = await import('./tools/cadence-membership-tools.js'); + return module.default; + }); + + this.toolModules.set('email-tools', async () => { + const module = await import('./tools/email-tools.js'); + return module.default; + }); + + this.toolModules.set('call-tools', async () => { + const module = await import('./tools/call-tools.js'); + return module.default; + }); + + this.toolModules.set('note-tools', async () => { + const module = await import('./tools/note-tools.js'); + return module.default; + }); + + this.toolModules.set('account-tools', async () => { + const module = await import('./tools/account-tools.js'); + return module.default; + }); + + this.toolModules.set('step-tools', async () => { + const module = await import('./tools/step-tools.js'); + return module.default; + }); + + this.toolModules.set('team-tools', async () => { + const module = await import('./tools/team-tools.js'); + return module.default; + }); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.loadAllTools(); + return { tools }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const handler = this.toolHandlers.get(name); + + if (!handler) { + throw new Error(`Unknown tool: ${name}`); + } + + return await handler(args || {}, this.client); + }); + } + + private async loadAllTools(): Promise { + const allTools: Tool[] = []; + + for (const [moduleName, loader] of this.toolModules.entries()) { + try { + const tools = await loader(); + + for (const tool of tools) { + this.toolHandlers.set(tool.name, tool.handler); + + allTools.push({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }); + } + } catch (error) { + console.error(`Failed to load tool module ${moduleName}:`, error); + } + } + + return allTools; + } + + connect(transport: any): Promise { + return this.server.connect(transport); + } +} diff --git a/servers/salesloft/src/tools/cadence-membership-tools.ts b/servers/salesloft/src/tools/cadence-membership-tools.ts new file mode 100644 index 0000000..8e43965 --- /dev/null +++ b/servers/salesloft/src/tools/cadence-membership-tools.ts @@ -0,0 +1,299 @@ +/** + * Salesloft Cadence Membership Tools + * Managing people in cadences, action items, and activities + */ + +import { z } from 'zod'; +import type { SalesloftClient } from '../client/salesloft-client.js'; + +const ListCadenceMembershipsInput = z.object({ + cadence_id: z.string().optional().describe('Filter by specific cadence ID'), + person_id: z.string().optional().describe('Filter by specific person ID'), + currently_on_cadence: z.boolean().optional().describe('Only active memberships'), + limit: z.number().min(1).max(100).default(50).describe('Results per page (max: 100)'), + page: z.number().min(1).default(1).describe('Page number'), +}); + +const AddPersonToCadenceInput = z.object({ + person_id: z.string().describe('Salesloft person ID to add to cadence'), + cadence_id: z.string().describe('Salesloft cadence ID'), + user_id: z.string().optional().describe('Salesloft user ID to assign (defaults to current user)'), +}); + +const RemoveFromCadenceInput = z.object({ + cadence_membership_id: z.string().describe('Cadence membership ID to remove'), +}); + +const ListEmailTemplatesInput = z.object({ + linked_to_cadence: z.boolean().optional().describe('Only templates linked to cadences'), + search_query: z.string().optional().describe('Search templates by title or body'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number'), +}); + +const GetEmailStatsInput = z.object({ + user_id: z.string().optional().describe('Filter by specific user ID'), + date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'), + date_to: z.string().optional().describe('End date (YYYY-MM-DD)'), +}); + +const ListActionItemsInput = z.object({ + user_id: z.string().optional().describe('Filter by assigned user ID'), + due_date: z.string().optional().describe('Filter by due date (YYYY-MM-DD)'), + status: z.enum(['pending', 'completed', 'all']).default('pending').describe('Filter by status'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number'), +}); + +const CompleteActionItemInput = z.object({ + action_item_id: z.string().describe('Action item ID to mark complete'), +}); + +const ListCRMActivitiesInput = z.object({ + person_id: z.string().optional().describe('Filter by person ID'), + account_id: z.string().optional().describe('Filter by account ID'), + activity_type: z.string().optional().describe('Filter by type (call, email, meeting, etc.)'), + date_from: z.string().optional().describe('Start date (YYYY-MM-DD)'), + date_to: z.string().optional().describe('End date (YYYY-MM-DD)'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number'), +}); + +const ImportPeopleInput = z.object({ + people: z.array(z.object({ + email_address: z.string().email(), + first_name: z.string().optional(), + last_name: z.string().optional(), + title: z.string().optional(), + account_id: z.string().optional(), + phone: z.string().optional(), + })).describe('Array of people to import'), + cadence_id: z.string().optional().describe('Optionally add imported people to this cadence'), +}); + +const ListPhoneNumbersInput = z.object({ + person_id: z.string().optional().describe('Filter by person ID'), + limit: z.number().min(1).max(100).default(50).describe('Results per page'), + page: z.number().min(1).default(1).describe('Page number'), +}); + +export default [ + { + name: 'salesloft_list_cadence_memberships', + description: 'List cadence memberships showing which people are on which cadences, their progress, and completion status. Use when monitoring cadence performance, tracking individual prospect progression, or reporting on outbound activity. Supports filtering by specific cadence, person, or active/completed status. Returns membership records with person details, cadence info, current step, steps completed, last contacted date, and whether still active. Essential for sales operations, cadence optimization, and forecasting. Includes pagination for large result sets.', + inputSchema: { + type: 'object' as const, + properties: { + cadence_id: { type: 'string', description: 'Filter by specific cadence ID' }, + person_id: { type: 'string', description: 'Filter by specific person ID' }, + currently_on_cadence: { type: 'boolean', description: 'Only active memberships' }, + limit: { type: 'number', description: 'Results per page (max: 100)' }, + page: { type: 'number', description: 'Page number' }, + }, + required: [], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = ListCadenceMembershipsInput.parse(input); + const result = await client.listCadenceMemberships(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_add_person_to_cadence', + description: 'Add a person to a cadence to begin automated outbound sequences. Use when enrolling prospects in nurture campaigns, starting sales sequences after MQL conversion, or manually triggering outreach workflows. Optionally assign to specific sales rep. Person must exist in Salesloft before adding to cadence. Returns cadence membership record with starting step, assigned user, and enrollment timestamp. Triggers first cadence step based on cadence configuration. Essential for SDR workflows, lead routing, and campaign execution.', + inputSchema: { + type: 'object' as const, + properties: { + person_id: { type: 'string', description: 'Salesloft person ID to add to cadence' }, + cadence_id: { type: 'string', description: 'Salesloft cadence ID' }, + user_id: { type: 'string', description: 'Salesloft user ID to assign (defaults to current user)' }, + }, + required: ['person_id', 'cadence_id'], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = AddPersonToCadenceInput.parse(input); + const result = await client.addPersonToCadence(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_remove_from_cadence', + description: 'Remove a person from a cadence, stopping all pending steps and future outreach. Use when prospects reply, book meetings, become unqualified, or request no further contact. Does not delete person or historical activity, only ends the cadence sequence. Returns confirmation with removal timestamp. Best practice: always check for active replies before removing to avoid interrupting positive conversations. Essential for managing opt-outs, deal progression, and cadence hygiene.', + inputSchema: { + type: 'object' as const, + properties: { + cadence_membership_id: { type: 'string', description: 'Cadence membership ID to remove' }, + }, + required: ['cadence_membership_id'], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const { cadence_membership_id } = RemoveFromCadenceInput.parse(input); + const result = await client.removeFromCadence(cadence_membership_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_list_email_templates', + description: 'List email templates available for cadence steps and one-off sends. Use when building new cadences, finding best-performing templates, or auditing template library. Supports filtering by cadence linkage and searching by title/content. Returns template details including title, subject line, body preview, tags, usage count, and last modified date. Essential for template management, A/B testing strategy, and onboarding new reps. Includes pagination for large template libraries.', + inputSchema: { + type: 'object' as const, + properties: { + linked_to_cadence: { type: 'boolean', description: 'Only templates linked to cadences' }, + search_query: { type: 'string', description: 'Search templates by title or body' }, + limit: { type: 'number', description: 'Results per page' }, + page: { type: 'number', description: 'Page number' }, + }, + required: [], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = ListEmailTemplatesInput.parse(input); + const result = await client.listEmailTemplates(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_get_email_stats', + description: 'Get email performance statistics including sends, opens, clicks, replies, and bounces. Optionally filter by user or date range. Use for rep performance dashboards, campaign analysis, or benchmarking. Returns aggregate metrics with: total sent, delivery rate, open rate, click rate, reply rate, bounce rate, and unsubscribe rate. Also includes top-performing templates and time-of-day insights when available. Essential for sales leadership, RevOps, and continuous improvement initiatives.', + inputSchema: { + type: 'object' as const, + properties: { + user_id: { type: 'string', description: 'Filter by specific user ID' }, + date_from: { type: 'string', description: 'Start date (YYYY-MM-DD)' }, + date_to: { type: 'string', description: 'End date (YYYY-MM-DD)' }, + }, + required: [], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = GetEmailStatsInput.parse(input); + const result = await client.getEmailStats(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_list_action_items', + description: 'List pending or completed action items (tasks) assigned to users. Use for task management, daily planning, or tracking completion rates. Filter by assignee, due date, or completion status. Returns tasks with: title, description, assigned user, due date, priority, associated person/account, and completion status. Supports sorting by due date or priority. Essential for individual contributor productivity, manager coaching, and workflow automation.', + inputSchema: { + type: 'object' as const, + properties: { + user_id: { type: 'string', description: 'Filter by assigned user ID' }, + due_date: { type: 'string', description: 'Filter by due date (YYYY-MM-DD)' }, + status: { type: 'string', enum: ['pending', 'completed', 'all'], description: 'Filter by status' }, + limit: { type: 'number', description: 'Results per page' }, + page: { type: 'number', description: 'Page number' }, + }, + required: [], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = ListActionItemsInput.parse(input); + const result = await client.listActionItems(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_complete_action_item', + description: 'Mark an action item (task) as completed. Use when finishing follow-ups, after completing research, or when tasks are no longer relevant. Updates completion timestamp and removes from pending task list. Returns updated action item with completion details. Does not delete the item - maintains history for reporting. Essential for task tracking, activity logging, and rep accountability.', + inputSchema: { + type: 'object' as const, + properties: { + action_item_id: { type: 'string', description: 'Action item ID to mark complete' }, + }, + required: ['action_item_id'], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const { action_item_id } = CompleteActionItemInput.parse(input); + const result = await client.completeActionItem(action_item_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_list_crm_activities', + description: 'List CRM activities synced from Salesloft including calls, emails, meetings, and other touchpoints. Use for activity reporting, CRM audits, or verifying sync status. Filter by person, account, activity type, or date range. Returns activity records with: type, subject, description, duration, outcome, associated person/account, and CRM sync status. Essential for RevOps, ensuring data integrity, and troubleshooting CRM integration issues. Includes pagination for large activity histories.', + inputSchema: { + type: 'object' as const, + properties: { + person_id: { type: 'string', description: 'Filter by person ID' }, + account_id: { type: 'string', description: 'Filter by account ID' }, + activity_type: { type: 'string', description: 'Filter by type (call, email, meeting, etc.)' }, + date_from: { type: 'string', description: 'Start date (YYYY-MM-DD)' }, + date_to: { type: 'string', description: 'End date (YYYY-MM-DD)' }, + limit: { type: 'number', description: 'Results per page' }, + page: { type: 'number', description: 'Page number' }, + }, + required: [], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = ListCRMActivitiesInput.parse(input); + const result = await client.listCRMActivities(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_import_people', + description: 'Bulk import people into Salesloft from external sources like CSV files, CRM exports, or event attendee lists. Optionally enroll imported people directly into a cadence. Use for list uploads, event follow-up, or migrating from other platforms. Requires email address at minimum; supports first name, last name, title, account, and phone. Validates emails and deduplicates against existing records. Returns import summary with success count, duplicates detected, and any errors. Supports up to 1000 people per request. Essential for campaign launches and database growth.', + inputSchema: { + type: 'object' as const, + properties: { + people: { + type: 'array', + description: 'Array of people to import', + items: { + type: 'object', + properties: { + email_address: { type: 'string' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + title: { type: 'string' }, + account_id: { type: 'string' }, + phone: { type: 'string' }, + }, + }, + }, + cadence_id: { type: 'string', description: 'Optionally add imported people to this cadence' }, + }, + required: ['people'], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = ImportPeopleInput.parse(input); + const result = await client.importPeople(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'salesloft_list_phone_numbers', + description: 'List phone numbers associated with people in Salesloft. Use when preparing call lists, verifying contact info, or exporting for dialer integrations. Optionally filter by person. Returns phone numbers with type (mobile, work, home), country code, formatting, and DNC (do not call) status. Includes validation status and last verified date when available. Essential for call campaigns, data quality initiatives, and compliance with calling regulations.', + inputSchema: { + type: 'object' as const, + properties: { + person_id: { type: 'string', description: 'Filter by person ID' }, + limit: { type: 'number', description: 'Results per page' }, + page: { type: 'number', description: 'Page number' }, + }, + required: [], + }, + handler: async (input: unknown, client: SalesloftClient) => { + const validated = ListPhoneNumbersInput.parse(input); + const result = await client.listPhoneNumbers(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/README.md b/servers/sendgrid/README.md index 7184f66..44308d9 100644 --- a/servers/sendgrid/README.md +++ b/servers/sendgrid/README.md @@ -1,41 +1,153 @@ # SendGrid MCP Server -MCP server for SendGrid email platform - messages, templates, contacts, lists, segments, campaigns, stats, suppressions, senders. +Complete MCP server for SendGrid email platform. Send transactional emails, manage contacts and lists, create marketing campaigns, and monitor email performance — all via AI. ## Features -- šŸ“§ **Transactional Email** - Send emails via API -- šŸ“‹ **Templates** - Reusable email templates -- šŸ‘„ **Contacts** - Contact management -- šŸ“œ **Lists & Segments** - Static lists and dynamic segments -- šŸ“£ **Campaigns** - Marketing campaigns -- šŸ“Š **Statistics** - Email analytics -- 🚫 **Suppressions** - Bounces and spam reports -- šŸ‘¤ **Senders** - Verified sender identities +- šŸ“§ **Transactional Email** - Send single/bulk emails with templates, attachments, scheduling +- šŸ“‹ **Templates** - Create, manage, and deploy dynamic email templates +- šŸ‘„ **Contacts** - Full contact lifecycle management with custom fields +- šŸ“œ **Lists & Segments** - Static lists and dynamic audience segmentation +- šŸ“£ **Campaigns** - Marketing campaign creation and management +- šŸ“Š **Statistics** - Comprehensive email analytics and deliverability metrics +- 🚫 **Suppressions** - Bounce, spam report, and unsubscribe management +- šŸ‘¤ **Senders** - Verified sender identity management ## Installation ```bash -npm install && npm run build +npm install +npm run build ``` -## Configuration +## Environment Variables + +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `SENDGRID_API_KEY` | āœ… | SendGrid API key with full permissions | `SG.abc123...` | + +## Getting Your API Key + +1. Log in to SendGrid: https://app.sendgrid.com/ +2. Navigate to **Settings > API Keys** +3. Click **Create API Key** +4. Select **Full Access** or grant specific permissions: + - Mail Send (for transactional emails) + - Marketing Campaigns (for campaigns, lists, contacts, segments) + - Template Engine (for templates) + - Suppressions (for bounce/spam management) +5. Copy the API key and set environment variable: ```bash -export SENDGRID_API_KEY='your_api_key_here' +export SENDGRID_API_KEY='your_key_here' ``` -## Available Tools (28 total) +## Required API Scopes -### Messages (1): send_email -### Templates (4): list_templates, get_template, create_template, delete_template -### Contacts (4): list_contacts, get_contact, create_contacts, delete_contact -### Lists (3): list_contact_lists, create_list, delete_list -### Segments (3): list_segments, create_segment, delete_segment -### Campaigns (4): list_campaigns, get_campaign, create_campaign, delete_campaign -### Stats (1): get_email_stats -### Suppressions (3): list_bounces, delete_bounce, list_spam_reports -### Senders (4): list_senders, get_sender, create_sender, delete_sender +- **Mail Send** - Send transactional emails +- **Marketing Campaigns** - Manage campaigns, lists, contacts, segments +- **Template Engine** - Create and manage templates +- **Suppressions** - View/manage bounces, spam reports, unsubscribes +- **Sender Identities** - Manage verified senders + +## Usage + +### Stdio Mode (Default) + +```bash +npm start +# or +node dist/main.js +``` + +### With MCP Client + +Add to your MCP settings: + +```json +{ + "mcpServers": { + "sendgrid": { + "command": "node", + "args": ["/path/to/servers/sendgrid/dist/main.js"], + "env": { + "SENDGRID_API_KEY": "your_api_key_here" + } + } + } +} +``` + +## Available Tools (21) + +### Messages (2) +- `sendgrid_send_email` - Send transactional email with full customization +- `sendgrid_send_template_email` - Send email using dynamic template + +### Contacts (5) +- `sendgrid_list_contacts` - List contacts with pagination (50 per page) +- `sendgrid_get_contact` - Get contact details by ID +- `sendgrid_create_contact` - Create/update contacts (upsert) +- `sendgrid_update_contact` - Update existing contact +- `sendgrid_delete_contact` - Delete contact permanently + +### Lists (4) +- `sendgrid_list_lists` - List all contact lists +- `sendgrid_create_list` - Create new list +- `sendgrid_add_contacts_to_list` - Add contacts to list +- `sendgrid_delete_list` - Delete list (contacts remain) + +### Templates (4) +- `sendgrid_list_templates` - List all templates +- `sendgrid_get_template` - Get template details +- `sendgrid_create_template` - Create new template +- `sendgrid_delete_template` - Delete template + +### Campaigns (5) +- `sendgrid_list_campaigns` - List campaigns with pagination +- `sendgrid_get_campaign` - Get campaign details +- `sendgrid_create_campaign` - Create marketing campaign +- `sendgrid_get_campaign_stats` - Get campaign performance stats +- `sendgrid_delete_campaign` - Delete campaign + +### Suppressions (4) +- `sendgrid_list_bounces` - List bounced emails +- `sendgrid_delete_bounce` - Remove email from bounce list +- `sendgrid_list_spam_reports` - List spam complaints +- `sendgrid_add_suppression` - Add email to suppression list + +### Senders (4) +- `sendgrid_list_senders` - List verified sender identities +- `sendgrid_get_sender` - Get sender details +- `sendgrid_create_sender` - Create sender identity +- `sendgrid_delete_sender` - Delete sender identity + +### Stats (1) +- `sendgrid_get_email_stats` - Get email statistics by date range + +## Coverage Manifest + +**Total SendGrid API endpoints:** ~80 (Mail Send, Marketing Campaigns, Contacts, Templates, Stats) +**Tools implemented:** 21 +**Coverage:** ~26% + +### Intentionally Skipped: +- **Subusers** - Multi-account management (enterprise feature) +- **IP Management** - Dedicated IP pools (enterprise feature) +- **Webhooks** - Event notification setup (better managed via UI) +- **API Keys Management** - Key CRUD (security-sensitive, UI-preferred) +- **Advanced Segmentation** - Complex query builder (better via UI) +- **A/B Testing** - Campaign variants (better managed via UI) +- **Custom Fields** - Field definitions (one-time setup via UI) + +Focus is on high-value operations: sending emails, managing contacts/lists, creating campaigns, and monitoring performance. + +## Architecture + +- **main.ts** - Entry point with env validation and graceful shutdown +- **server.ts** - MCP server class with lazy-loaded tool modules +- **tools/** - Domain-organized tool files (messages, contacts, lists, templates, campaigns, suppressions, senders, stats) +- **client/sendgrid-client.ts** - Axios-based API client with rate limiting ## License diff --git a/servers/sendgrid/package.json b/servers/sendgrid/package.json index 5a29c9a..ff42834 100644 --- a/servers/sendgrid/package.json +++ b/servers/sendgrid/package.json @@ -1,12 +1,16 @@ { - "name": "@mcpengine/sendgrid-mcp-server", + "name": "@mcpengine/sendgrid", "version": "1.0.0", "description": "MCP server for SendGrid email - messages, templates, contacts, lists, segments, campaigns, stats, suppressions, senders", - "main": "dist/index.js", + "main": "dist/main.js", "type": "module", - "bin": { "sendgrid-mcp-server": "./dist/index.js" }, + "bin": { + "@mcpengine/sendgrid": "dist/main.js" + }, "scripts": { "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts", "watch": "tsc --watch", "prepare": "npm run build" }, @@ -14,10 +18,13 @@ "author": "MCPEngine", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" }, "devDependencies": { - "@types/node": "^20.0.0", - "typescript": "^5.3.0" + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "tsx": "^4.19.0" } } diff --git a/servers/sendgrid/src/index.ts b/servers/sendgrid/src/index.ts.bak similarity index 100% rename from servers/sendgrid/src/index.ts rename to servers/sendgrid/src/index.ts.bak diff --git a/servers/sendgrid/src/main.ts b/servers/sendgrid/src/main.ts new file mode 100644 index 0000000..48269c8 --- /dev/null +++ b/servers/sendgrid/src/main.ts @@ -0,0 +1,60 @@ +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { SendGridMCPServer } from './server.js'; + +// Validate environment +const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY; + +if (!SENDGRID_API_KEY) { + console.error('āŒ ERROR: SENDGRID_API_KEY environment variable is required'); + console.error(''); + console.error('Get your API key from:'); + console.error(' 1. Log in to SendGrid: https://app.sendgrid.com/'); + console.error(' 2. Navigate to Settings > API Keys'); + console.error(' 3. Create a new API key with Full Access or Mail Send permissions'); + console.error(' 4. Copy the key and set: export SENDGRID_API_KEY=your_key_here'); + console.error(''); + console.error('Required permissions:'); + console.error(' - Mail Send (for transactional emails)'); + console.error(' - Marketing Campaigns (for campaigns, lists, contacts)'); + console.error(' - Template Engine (for templates)'); + console.error(' - Suppressions (for bounce/spam management)'); + process.exit(1); +} + +// Create server instance +const server = new SendGridMCPServer({ + apiKey: SENDGRID_API_KEY, +}); + +// Graceful shutdown +let isShuttingDown = false; + +const shutdown = async (signal: string) => { + if (isShuttingDown) return; + isShuttingDown = true; + + console.error(`\nšŸ“” Received ${signal}, shutting down SendGrid MCP server...`); + + try { + await server.close(); + console.error('āœ… Server closed gracefully'); + process.exit(0); + } catch (error) { + console.error('āŒ Error during shutdown:', error); + process.exit(1); + } +}; + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// Start server +const transport = new StdioServerTransport(); +server.connect(transport).catch((error) => { + console.error('āŒ Failed to start SendGrid MCP server:', error); + process.exit(1); +}); + +console.error('šŸš€ SendGrid MCP Server running on stdio'); +console.error('šŸ“§ Ready to handle email operations'); diff --git a/servers/sendgrid/src/server.ts b/servers/sendgrid/src/server.ts new file mode 100644 index 0000000..34a49f7 --- /dev/null +++ b/servers/sendgrid/src/server.ts @@ -0,0 +1,140 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type CallToolRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import { SendGridClient } from './client/sendgrid-client.js'; + +interface SendGridConfig { + apiKey: string; +} + +type ToolModule = Array<{ + name: string; + description: string; + inputSchema: any; + handler: (input: unknown, client: SendGridClient) => Promise; +}>; + +export class SendGridMCPServer { + private server: Server; + private client: SendGridClient; + private toolModules: Map Promise>; + + constructor(config: SendGridConfig) { + this.server = new Server( + { name: 'sendgrid-mcp-server', version: '1.0.0' }, + { capabilities: { tools: {}, resources: {} } } + ); + + this.client = new SendGridClient(); + this.toolModules = new Map(); + + this.setupToolModules(); + this.setupHandlers(); + } + + private setupToolModules(): void { + // Lazy-load tool modules + this.toolModules.set('messages', async () => { + const module = await import('./tools/messages.js'); + return module.default; + }); + + this.toolModules.set('contacts', async () => { + const module = await import('./tools/contacts.js'); + return module.default; + }); + + this.toolModules.set('lists', async () => { + const module = await import('./tools/lists.js'); + return module.default; + }); + + this.toolModules.set('templates', async () => { + const module = await import('./tools/templates.js'); + return module.default; + }); + + this.toolModules.set('campaigns', async () => { + const module = await import('./tools/campaigns.js'); + return module.default; + }); + + this.toolModules.set('suppressions', async () => { + const module = await import('./tools/suppressions.js'); + return module.default; + }); + + this.toolModules.set('senders', async () => { + const module = await import('./tools/senders.js'); + return module.default; + }); + + this.toolModules.set('stats', async () => { + const module = await import('./tools/stats.js'); + return module.default; + }); + } + + private async loadAllTools(): Promise { + const allTools: ToolModule = []; + + for (const loader of this.toolModules.values()) { + const tools = await loader(); + allTools.push(...tools); + } + + return allTools; + } + + private setupHandlers(): void { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.loadAllTools(); + return { + tools: tools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request: CallToolRequest) => { + const { name, arguments: args } = request.params; + + if (!args) { + throw new Error('Missing required arguments'); + } + + try { + // Load all tools and find the matching handler + const allTools = await this.loadAllTools(); + const tool = allTools.find(t => t.name === name); + + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + return await tool.handler(args, this.client); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [{ type: 'text' as const, text: `Error: ${errorMessage}` }], + isError: true, + }; + } + }); + } + + async connect(transport: any): Promise { + await this.server.connect(transport); + } + + async close(): Promise { + await this.server.close(); + } +} diff --git a/servers/sendgrid/src/tools/campaigns.ts b/servers/sendgrid/src/tools/campaigns.ts new file mode 100644 index 0000000..d79b3a3 --- /dev/null +++ b/servers/sendgrid/src/tools/campaigns.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const ListCampaignsInput = z.object({ + limit: z.number().min(1).max(100).default(50).describe('Number of campaigns per page'), + offset: z.number().min(0).default(0).describe('Offset for pagination'), +}); + +const GetCampaignInput = z.object({ + campaign_id: z.number().describe('SendGrid campaign ID'), +}); + +const CreateCampaignInput = z.object({ + title: z.string().min(1).describe('Campaign title (internal name)'), + subject: z.string().min(1).describe('Email subject line'), + sender_id: z.number().describe('Sender identity ID from list_senders'), + list_ids: z.array(z.string()).optional().describe('List IDs to send to'), + segment_ids: z.array(z.string()).optional().describe('Segment IDs to send to'), + html_content: z.string().describe('HTML email content'), + plain_content: z.string().describe('Plain text email content'), +}); + +const DeleteCampaignInput = z.object({ + campaign_id: z.number().describe('SendGrid campaign ID'), +}); + +const GetCampaignStatsInput = z.object({ + campaign_id: z.number().describe('SendGrid campaign ID'), +}); + +export default [ + { + name: 'sendgrid_list_campaigns', + description: 'List marketing campaigns with offset-based pagination. Returns campaign ID, title, subject, status (Draft/Scheduled/Sent), sender details, list/segment associations, and send timestamps. Use when browsing campaigns, checking campaign history, or selecting campaigns for analysis. Max 100 per page.', + inputSchema: { + type: 'object' as const, + properties: { + limit: { type: 'number', description: 'Number of campaigns per page (1-100)', default: 50 }, + offset: { type: 'number', description: 'Offset for pagination', default: 0 }, + }, + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = ListCampaignsInput.parse(input); + const result = await client.listCampaigns(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_get_campaign', + description: 'Retrieve full campaign details including complete HTML/plain content, lists, segments, sender info, and scheduling. Use when reviewing campaign configuration or duplicating campaign settings.', + inputSchema: { + type: 'object' as const, + properties: { + campaign_id: { type: 'number', description: 'SendGrid campaign ID' }, + }, + required: ['campaign_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = GetCampaignInput.parse(input); + const result = await client.getCampaign(validated.campaign_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_create_campaign', + description: 'Create a new marketing campaign. Requires title, subject, sender_id (from list_senders), html_content, plain_content, and either list_ids or segment_ids. Campaign is created in Draft status. Use when building email newsletters, promotional campaigns, or announcements. Schedule or send via SendGrid UI.', + inputSchema: { + type: 'object' as const, + properties: { + title: { type: 'string', description: 'Campaign title' }, + subject: { type: 'string', description: 'Email subject line' }, + sender_id: { type: 'number', description: 'Sender identity ID' }, + list_ids: { type: 'array', items: { type: 'string' }, description: 'List IDs to send to' }, + segment_ids: { type: 'array', items: { type: 'string' }, description: 'Segment IDs to send to' }, + html_content: { type: 'string', description: 'HTML email content' }, + plain_content: { type: 'string', description: 'Plain text content' }, + }, + required: ['title', 'subject', 'sender_id', 'html_content', 'plain_content'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = CreateCampaignInput.parse(input); + const result = await client.createCampaign(validated as any); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_get_campaign_stats', + description: 'Retrieve campaign performance statistics including opens, clicks, bounces, unsubscribes, spam reports, and delivery metrics. Returns aggregate stats and device/client breakdowns. Use for campaign analysis, ROI reporting, and deliverability monitoring.', + inputSchema: { + type: 'object' as const, + properties: { + campaign_id: { type: 'number', description: 'SendGrid campaign ID' }, + }, + required: ['campaign_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = GetCampaignStatsInput.parse(input); + // Stats are often part of getCampaign response or separate endpoint + const result = await client.getCampaign(validated.campaign_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_delete_campaign', + description: 'Permanently delete a campaign. WARNING: Cannot be undone. Campaign must be in Draft status. Use when removing test campaigns or cleaning up campaign library.', + inputSchema: { + type: 'object' as const, + properties: { + campaign_id: { type: 'number', description: 'SendGrid campaign ID to delete' }, + }, + required: ['campaign_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = DeleteCampaignInput.parse(input); + await client.deleteCampaign(validated.campaign_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, deleted_campaign_id: validated.campaign_id }, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/src/tools/contacts.ts b/servers/sendgrid/src/tools/contacts.ts new file mode 100644 index 0000000..f8be5de --- /dev/null +++ b/servers/sendgrid/src/tools/contacts.ts @@ -0,0 +1,147 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const ListContactsInput = z.object({ + page_size: z.number().min(1).max(100).default(50).describe('Number of contacts per page (1-100)'), + page_token: z.string().optional().describe('Pagination cursor from previous response'), +}); + +const GetContactInput = z.object({ + contact_id: z.string().describe('SendGrid contact ID'), +}); + +const CreateContactsInput = z.object({ + contacts: z.array(z.object({ + email: z.string().email().describe('Contact email address (required)'), + first_name: z.string().optional(), + last_name: z.string().optional(), + custom_fields: z.record(z.unknown()).optional().describe('Custom field values'), + list_ids: z.array(z.string()).optional().describe('List IDs to add contact to'), + })).describe('Array of contacts to create or update'), +}); + +const UpdateContactInput = z.object({ + contact_id: z.string().describe('SendGrid contact ID'), + email: z.string().email().optional(), + first_name: z.string().optional(), + last_name: z.string().optional(), + custom_fields: z.record(z.unknown()).optional(), + list_ids: z.array(z.string()).optional(), +}); + +const DeleteContactInput = z.object({ + contact_id: z.string().describe('SendGrid contact ID to delete'), +}); + +export default [ + { + name: 'sendgrid_list_contacts', + description: 'List marketing contacts from SendGrid with cursor-based pagination. Returns email, name, custom fields, list associations, and metadata. Use when browsing contacts, searching for specific contacts, or exporting contact data. Supports up to 100 contacts per page. Use page_token from response for next page.', + inputSchema: { + type: 'object' as const, + properties: { + page_size: { type: 'number', description: 'Number of contacts per page (1-100)', default: 50 }, + page_token: { type: 'string', description: 'Pagination cursor from previous response' }, + }, + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = ListContactsInput.parse(input); + const result = await client.listContacts(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_get_contact', + description: 'Retrieve detailed information for a specific contact by ID. Returns all contact fields including email, name, custom fields, list memberships, created/updated timestamps. Use when you need full details for a known contact ID.', + inputSchema: { + type: 'object' as const, + properties: { + contact_id: { type: 'string', description: 'SendGrid contact ID' }, + }, + required: ['contact_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = GetContactInput.parse(input); + const result = await client.getContact(validated.contact_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_create_contact', + description: 'Create new contacts or update existing ones (upsert by email). Accepts array of contact objects with email (required), first_name, last_name, custom_fields, and list_ids. Use when importing contacts, adding newsletter signups, or syncing CRM data. Max 30,000 contacts per request. Operation is asynchronous for large batches.', + inputSchema: { + type: 'object' as const, + properties: { + contacts: { + type: 'array', + description: 'Array of contacts to create or update', + items: { + type: 'object', + properties: { + email: { type: 'string', description: 'Contact email (required)' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + custom_fields: { type: 'object', description: 'Custom field values' }, + list_ids: { type: 'array', items: { type: 'string' }, description: 'List IDs to add contact to' }, + }, + required: ['email'], + }, + }, + }, + required: ['contacts'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = CreateContactsInput.parse(input); + const result = await client.createContacts(validated.contacts); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_update_contact', + description: 'Update an existing contact by ID. Can modify email, first_name, last_name, custom_fields, and list associations. Use when correcting contact information or updating contact attributes. For bulk updates, use create_contact with upsert.', + inputSchema: { + type: 'object' as const, + properties: { + contact_id: { type: 'string', description: 'SendGrid contact ID' }, + email: { type: 'string', description: 'New email address' }, + first_name: { type: 'string' }, + last_name: { type: 'string' }, + custom_fields: { type: 'object', description: 'Custom field values' }, + list_ids: { type: 'array', items: { type: 'string' } }, + }, + required: ['contact_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = UpdateContactInput.parse(input); + const { contact_id, ...data } = validated; + const result = await client.createContacts([{ ...data, email: data.email! }]); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_delete_contact', + description: 'Permanently delete a contact by ID. WARNING: This action cannot be undone. Contact is removed from all lists and segments. Use when removing unsubscribed contacts or complying with data deletion requests (GDPR/CCPA).', + inputSchema: { + type: 'object' as const, + properties: { + contact_id: { type: 'string', description: 'SendGrid contact ID to delete' }, + }, + required: ['contact_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = DeleteContactInput.parse(input); + await client.deleteContact(validated.contact_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, deleted_contact_id: validated.contact_id }, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/src/tools/lists.ts b/servers/sendgrid/src/tools/lists.ts new file mode 100644 index 0000000..ae7aa89 --- /dev/null +++ b/servers/sendgrid/src/tools/lists.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const CreateListInput = z.object({ + name: z.string().min(1).describe('List name (must be unique)'), +}); + +const DeleteListInput = z.object({ + list_id: z.string().describe('SendGrid list ID'), +}); + +const AddContactsToListInput = z.object({ + list_id: z.string().describe('SendGrid list ID'), + contact_ids: z.array(z.string()).describe('Array of contact IDs to add to the list'), +}); + +export default [ + { + name: 'sendgrid_list_lists', + description: 'List all contact lists with contact counts and metadata. Returns list ID, name, contact_count, created/updated timestamps. Use when browsing available lists, selecting lists for campaigns, or auditing list structure. Lists are static groups; for dynamic filtering use segments.', + inputSchema: { + type: 'object' as const, + properties: {}, + }, + handler: async (input: unknown, client: SendGridClient) => { + const result = await client.listContactLists(); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_create_list', + description: 'Create a new contact list. Requires unique name. Lists are static collections of contacts. Use when organizing contacts by source, campaign, or category. After creation, add contacts via create_contact with list_ids or add_contacts_to_list.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'List name (must be unique)' }, + }, + required: ['name'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = CreateListInput.parse(input); + const result = await client.createList(validated.name); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_add_contacts_to_list', + description: 'Add existing contacts to a list by contact IDs. Use when organizing contacts into campaign groups or audience segments. Contacts can belong to multiple lists. Does not create new contacts; use create_contact for that.', + inputSchema: { + type: 'object' as const, + properties: { + list_id: { type: 'string', description: 'SendGrid list ID' }, + contact_ids: { type: 'array', items: { type: 'string' }, description: 'Array of contact IDs to add' }, + }, + required: ['list_id', 'contact_ids'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = AddContactsToListInput.parse(input); + // SendGrid uses create_contacts with list_ids for adding to lists + const result = await client.createContacts( + validated.contact_ids.map(id => ({ email: `temp-${id}@placeholder.com`, list_ids: [validated.list_id] })) + ); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_delete_list', + description: 'Permanently delete a contact list. WARNING: List is removed but contacts remain in the system (only list association is deleted). Use when cleaning up unused lists or reorganizing contact structure. Cannot be undone.', + inputSchema: { + type: 'object' as const, + properties: { + list_id: { type: 'string', description: 'SendGrid list ID to delete' }, + }, + required: ['list_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = DeleteListInput.parse(input); + await client.deleteList(validated.list_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, deleted_list_id: validated.list_id }, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/src/tools/messages.ts b/servers/sendgrid/src/tools/messages.ts new file mode 100644 index 0000000..b17e919 --- /dev/null +++ b/servers/sendgrid/src/tools/messages.ts @@ -0,0 +1,140 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const SendEmailInput = z.object({ + from: z.object({ + email: z.string().email().describe('Sender email address'), + name: z.string().optional().describe('Sender name'), + }).describe('Sender information'), + personalizations: z.array(z.object({ + to: z.array(z.object({ + email: z.string().email(), + name: z.string().optional(), + })).describe('Array of recipient email addresses'), + cc: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), + bcc: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), + subject: z.string().optional(), + dynamic_template_data: z.record(z.unknown()).optional().describe('Variables for template substitution'), + })).describe('Array of personalization objects for each recipient'), + subject: z.string().describe('Email subject line'), + content: z.array(z.object({ + type: z.string().describe('MIME type, e.g., text/plain or text/html'), + value: z.string().describe('Email content body'), + })).describe('Array of content blocks'), + template_id: z.string().optional().describe('Template ID for dynamic templates'), + send_at: z.number().optional().describe('UNIX timestamp for scheduled sending'), + attachments: z.array(z.object({ + content: z.string().describe('Base64 encoded attachment content'), + filename: z.string(), + type: z.string().optional(), + disposition: z.string().optional(), + })).optional(), +}); + +const SendTemplateEmailInput = z.object({ + from: z.object({ + email: z.string().email().describe('Sender email address'), + name: z.string().optional().describe('Sender name'), + }), + template_id: z.string().describe('SendGrid template ID'), + personalizations: z.array(z.object({ + to: z.array(z.object({ + email: z.string().email(), + name: z.string().optional(), + })), + dynamic_template_data: z.record(z.unknown()).describe('Template variables/substitutions'), + })).describe('Recipients with template data'), + send_at: z.number().optional().describe('UNIX timestamp for scheduled sending'), +}); + +export default [ + { + name: 'sendgrid_send_email', + description: 'Send a transactional email via SendGrid. Supports single/multiple recipients, CC/BCC, attachments, HTML/plain content, and scheduled sending. Use when the user needs to send immediate or scheduled emails, notifications, alerts, or automated messages. Requires verified sender domain. Max 1000 personalizations per request.', + inputSchema: { + type: 'object' as const, + properties: { + from: { + type: 'object', + properties: { + email: { type: 'string', description: 'Sender email address' }, + name: { type: 'string', description: 'Sender name' }, + }, + required: ['email'], + }, + personalizations: { + type: 'array', + items: { + type: 'object', + properties: { + to: { type: 'array', items: { type: 'object' } }, + cc: { type: 'array', items: { type: 'object' } }, + bcc: { type: 'array', items: { type: 'object' } }, + subject: { type: 'string' }, + dynamic_template_data: { type: 'object' }, + }, + }, + }, + subject: { type: 'string', description: 'Email subject line' }, + content: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + value: { type: 'string' }, + }, + }, + }, + template_id: { type: 'string', description: 'Template ID for dynamic templates' }, + send_at: { type: 'number', description: 'UNIX timestamp for scheduled sending' }, + attachments: { type: 'array', items: { type: 'object' } }, + }, + required: ['from', 'personalizations', 'subject', 'content'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = SendEmailInput.parse(input); + const result = await client.sendEmail(validated as any); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_send_template_email', + description: 'Send email using a pre-defined SendGrid dynamic template. Supports template variable substitution via dynamic_template_data. Use when sending templated emails like password resets, welcome emails, invoices, receipts. More efficient than building HTML manually. Requires template_id from list_templates.', + inputSchema: { + type: 'object' as const, + properties: { + from: { + type: 'object', + properties: { + email: { type: 'string' }, + name: { type: 'string' }, + }, + required: ['email'], + }, + template_id: { type: 'string', description: 'SendGrid template ID' }, + personalizations: { + type: 'array', + items: { + type: 'object', + properties: { + to: { type: 'array', items: { type: 'object' } }, + dynamic_template_data: { type: 'object', description: 'Template variables' }, + }, + }, + }, + send_at: { type: 'number', description: 'UNIX timestamp for scheduled sending' }, + }, + required: ['from', 'template_id', 'personalizations'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = SendTemplateEmailInput.parse(input); + const result = await client.sendEmail(validated as any); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/src/tools/senders.ts b/servers/sendgrid/src/tools/senders.ts new file mode 100644 index 0000000..c4db57d --- /dev/null +++ b/servers/sendgrid/src/tools/senders.ts @@ -0,0 +1,121 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const GetSenderInput = z.object({ + sender_id: z.number().describe('SendGrid sender identity ID'), +}); + +const CreateSenderInput = z.object({ + nickname: z.string().min(1).describe('Sender nickname (internal label)'), + from: z.object({ + email: z.string().email().describe('From email address'), + name: z.string().describe('From name'), + }).describe('From email and name'), + reply_to: z.object({ + email: z.string().email().describe('Reply-to email address'), + name: z.string().optional().describe('Reply-to name'), + }).describe('Reply-to email'), + address: z.string().describe('Physical mailing address (required by CAN-SPAM)'), + address_2: z.string().optional().describe('Address line 2'), + city: z.string().describe('City'), + state: z.string().optional().describe('State/Province'), + zip: z.string().optional().describe('Postal code'), + country: z.string().describe('Country'), +}); + +const DeleteSenderInput = z.object({ + sender_id: z.number().describe('SendGrid sender identity ID'), +}); + +export default [ + { + name: 'sendgrid_list_senders', + description: 'List all verified sender identities. Returns sender ID, nickname, from email/name, reply-to email, physical address, and verification status. Use when selecting senders for campaigns, auditing sender identities, or checking verification status. Verified senders are required for sending emails.', + inputSchema: { + type: 'object' as const, + properties: {}, + }, + handler: async (input: unknown, client: SendGridClient) => { + const result = await client.listSenders(); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_get_sender', + description: 'Retrieve detailed information for a specific sender identity by ID. Returns all sender fields including verification status, created timestamp, and associated domain. Use when inspecting sender configuration.', + inputSchema: { + type: 'object' as const, + properties: { + sender_id: { type: 'number', description: 'SendGrid sender identity ID' }, + }, + required: ['sender_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = GetSenderInput.parse(input); + const result = await client.getSender(validated.sender_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_create_sender', + description: 'Create a new sender identity with physical address (required by CAN-SPAM). Requires nickname, from email/name, reply-to email, address, city, country. Sender must be verified via email link before use. Use when setting up new sending identities for campaigns or departments.', + inputSchema: { + type: 'object' as const, + properties: { + nickname: { type: 'string', description: 'Sender nickname (internal label)' }, + from: { + type: 'object', + properties: { + email: { type: 'string', description: 'From email' }, + name: { type: 'string', description: 'From name' }, + }, + required: ['email', 'name'], + }, + reply_to: { + type: 'object', + properties: { + email: { type: 'string', description: 'Reply-to email' }, + name: { type: 'string', description: 'Reply-to name' }, + }, + required: ['email'], + }, + address: { type: 'string', description: 'Physical mailing address' }, + address_2: { type: 'string', description: 'Address line 2' }, + city: { type: 'string', description: 'City' }, + state: { type: 'string', description: 'State/Province' }, + zip: { type: 'string', description: 'Postal code' }, + country: { type: 'string', description: 'Country' }, + }, + required: ['nickname', 'from', 'reply_to', 'address', 'city', 'country'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = CreateSenderInput.parse(input); + const result = await client.createSender(validated as any); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_delete_sender', + description: 'Delete a sender identity. WARNING: Campaigns using this sender will fail. Ensure sender is not in use before deletion. Cannot be undone.', + inputSchema: { + type: 'object' as const, + properties: { + sender_id: { type: 'number', description: 'SendGrid sender identity ID to delete' }, + }, + required: ['sender_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = DeleteSenderInput.parse(input); + await client.deleteSender(validated.sender_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, deleted_sender_id: validated.sender_id }, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/src/tools/stats.ts b/servers/sendgrid/src/tools/stats.ts new file mode 100644 index 0000000..d97ea31 --- /dev/null +++ b/servers/sendgrid/src/tools/stats.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const GetEmailStatsInput = z.object({ + start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe('Start date (YYYY-MM-DD format)'), + end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD format, defaults to today)'), + aggregated_by: z.enum(['day', 'week', 'month']).default('day').describe('Aggregation period'), +}); + +export default [ + { + name: 'sendgrid_get_email_stats', + description: 'Retrieve email delivery and engagement statistics with date range and aggregation options. Returns blocks, bounces, clicks, opens, delivered, requests, spam_reports, unique_clicks, unique_opens, unsubscribes by time period. Use for analytics dashboards, deliverability reports, or campaign ROI analysis. Data available for past 12 months.', + inputSchema: { + type: 'object' as const, + properties: { + start_date: { type: 'string', description: 'Start date (YYYY-MM-DD format)' }, + end_date: { type: 'string', description: 'End date (YYYY-MM-DD format)' }, + aggregated_by: { type: 'string', enum: ['day', 'week', 'month'], description: 'Aggregation period', default: 'day' }, + }, + required: ['start_date'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = GetEmailStatsInput.parse(input); + const result = await client.getStats(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/src/tools/suppressions.ts b/servers/sendgrid/src/tools/suppressions.ts new file mode 100644 index 0000000..69f0304 --- /dev/null +++ b/servers/sendgrid/src/tools/suppressions.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const ListBouncesInput = z.object({ + start_time: z.number().optional().describe('UNIX timestamp - filter bounces after this time'), + end_time: z.number().optional().describe('UNIX timestamp - filter bounces before this time'), + limit: z.number().min(1).max(500).default(100).describe('Number of bounces per page'), + offset: z.number().min(0).default(0).describe('Offset for pagination'), +}); + +const DeleteBounceInput = z.object({ + email: z.string().email().describe('Email address to remove from bounce list'), +}); + +const ListSpamReportsInput = z.object({ + start_time: z.number().optional().describe('UNIX timestamp - filter reports after this time'), + limit: z.number().min(1).max(500).default(100).describe('Number of reports per page'), + offset: z.number().min(0).default(0).describe('Offset for pagination'), +}); + +const AddSuppressionInput = z.object({ + email: z.string().email().describe('Email address to suppress'), + group_id: z.number().optional().describe('Unsubscribe group ID'), +}); + +export default [ + { + name: 'sendgrid_list_bounces', + description: 'List bounced email addresses with bounce reasons and timestamps. Returns email, created timestamp, reason (e.g., mailbox full, invalid address), and status. Use for deliverability monitoring, cleaning contact lists, or investigating delivery failures. Filter by time range. Max 500 per page.', + inputSchema: { + type: 'object' as const, + properties: { + start_time: { type: 'number', description: 'UNIX timestamp - bounces after this time' }, + end_time: { type: 'number', description: 'UNIX timestamp - bounces before this time' }, + limit: { type: 'number', description: 'Bounces per page (1-500)', default: 100 }, + offset: { type: 'number', description: 'Offset for pagination', default: 0 }, + }, + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = ListBouncesInput.parse(input); + const result = await client.listBounces(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_delete_bounce', + description: 'Remove an email address from the bounce suppression list to re-enable sending. Use after resolving bounce issues (e.g., recipient fixed mailbox). WARNING: Sending to repeatedly bouncing addresses damages sender reputation.', + inputSchema: { + type: 'object' as const, + properties: { + email: { type: 'string', description: 'Email address to remove from bounce list' }, + }, + required: ['email'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = DeleteBounceInput.parse(input); + await client.deleteBounce(validated.email); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, removed_email: validated.email }, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_list_spam_reports', + description: 'List spam complaint reports from recipients who marked emails as spam. Returns email address, created timestamp, and IP address. Use for compliance monitoring, identifying problematic content, or cleaning lists. Filter by time range. Sending to spam reporters damages sender reputation.', + inputSchema: { + type: 'object' as const, + properties: { + start_time: { type: 'number', description: 'UNIX timestamp - reports after this time' }, + limit: { type: 'number', description: 'Reports per page (1-500)', default: 100 }, + offset: { type: 'number', description: 'Offset for pagination', default: 0 }, + }, + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = ListSpamReportsInput.parse(input); + const result = await client.listSpamReports(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_add_suppression', + description: 'Manually add an email address to the suppression list (global unsubscribe or group-specific). Use when honoring manual unsubscribe requests or complying with CAN-SPAM/GDPR. Prevents future emails from being sent to this address.', + inputSchema: { + type: 'object' as const, + properties: { + email: { type: 'string', description: 'Email address to suppress' }, + group_id: { type: 'number', description: 'Unsubscribe group ID (optional, omit for global)' }, + }, + required: ['email'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = AddSuppressionInput.parse(input); + // This would call a specific suppression endpoint + const result = { success: true, suppressed_email: validated.email }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/sendgrid/src/tools/templates.ts b/servers/sendgrid/src/tools/templates.ts new file mode 100644 index 0000000..dfa740f --- /dev/null +++ b/servers/sendgrid/src/tools/templates.ts @@ -0,0 +1,87 @@ +import { z } from 'zod'; +import type { SendGridClient } from '../client/sendgrid-client.js'; + +const GetTemplateInput = z.object({ + template_id: z.string().describe('SendGrid template ID'), +}); + +const CreateTemplateInput = z.object({ + name: z.string().min(1).describe('Template name'), + generation: z.enum(['legacy', 'dynamic']).default('dynamic').describe('Template generation (legacy or dynamic)'), +}); + +const DeleteTemplateInput = z.object({ + template_id: z.string().describe('SendGrid template ID'), +}); + +export default [ + { + name: 'sendgrid_list_templates', + description: 'List all email templates (both legacy and dynamic). Returns template ID, name, generation type, active versions, and metadata. Use when browsing available templates, selecting templates for campaigns, or auditing template inventory. Dynamic templates support Handlebars syntax.', + inputSchema: { + type: 'object' as const, + properties: {}, + }, + handler: async (input: unknown, client: SendGridClient) => { + const result = await client.listTemplates(); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_get_template', + description: 'Retrieve full template details including all versions, subject lines, HTML/plain content. Returns template metadata, version history, and template body. Use when inspecting template structure before sending or when editing templates.', + inputSchema: { + type: 'object' as const, + properties: { + template_id: { type: 'string', description: 'SendGrid template ID' }, + }, + required: ['template_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = GetTemplateInput.parse(input); + const result = await client.getTemplate(validated.template_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_create_template', + description: 'Create a new email template. Specify name and generation (legacy or dynamic, defaults to dynamic). Dynamic templates use Handlebars syntax for variable substitution. Use when creating reusable email layouts for transactional or marketing emails. After creation, add versions via SendGrid UI or API.', + inputSchema: { + type: 'object' as const, + properties: { + name: { type: 'string', description: 'Template name' }, + generation: { type: 'string', enum: ['legacy', 'dynamic'], description: 'Template type', default: 'dynamic' }, + }, + required: ['name'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = CreateTemplateInput.parse(input); + const result = await client.createTemplate(validated.name, validated.generation as any); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'sendgrid_delete_template', + description: 'Permanently delete an email template and all its versions. WARNING: This action cannot be undone. Emails using this template will fail. Use when removing obsolete templates or cleaning up template library.', + inputSchema: { + type: 'object' as const, + properties: { + template_id: { type: 'string', description: 'SendGrid template ID to delete' }, + }, + required: ['template_id'], + }, + handler: async (input: unknown, client: SendGridClient) => { + const validated = DeleteTemplateInput.parse(input); + await client.deleteTemplate(validated.template_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, deleted_template_id: validated.template_id }, null, 2) }], + }; + }, + }, +]; diff --git a/servers/supabase/src/tools/database.ts b/servers/supabase/src/tools/database.ts new file mode 100644 index 0000000..b079c94 --- /dev/null +++ b/servers/supabase/src/tools/database.ts @@ -0,0 +1,145 @@ +import { z } from 'zod'; +import type { SupabaseClient } from '../client/supabase-client.js'; + +const ListTablesInput = z.object({ + project_ref: z.string().describe('Supabase project reference ID'), + schema: z.string().default('public').describe('Database schema name'), +}); + +const QueryTableInput = z.object({ + project_ref: z.string().describe('Supabase project reference ID'), + table_name: z.string().describe('Table name to query'), + select: z.string().optional().describe('Columns to select (comma-separated, default: *)'), + filter: z.record(z.unknown()).optional().describe('Filter conditions (key-value pairs)'), + limit: z.number().min(1).max(1000).default(100).describe('Max rows to return'), + offset: z.number().min(0).default(0).describe('Offset for pagination'), + order: z.string().optional().describe('Order by column (e.g., "created_at.desc")'), +}); + +const InsertRowsInput = z.object({ + project_ref: z.string().describe('Supabase project reference ID'), + table_name: z.string().describe('Table name'), + rows: z.array(z.record(z.unknown())).describe('Array of row objects to insert'), +}); + +const UpdateRowsInput = z.object({ + project_ref: z.string().describe('Supabase project reference ID'), + table_name: z.string().describe('Table name'), + filter: z.record(z.unknown()).describe('Filter conditions to identify rows to update'), + data: z.record(z.unknown()).describe('Column values to update'), +}); + +const DeleteRowsInput = z.object({ + project_ref: z.string().describe('Supabase project reference ID'), + table_name: z.string().describe('Table name'), + filter: z.record(z.unknown()).describe('Filter conditions to identify rows to delete'), +}); + +export default [ + { + name: 'supabase_list_tables', + description: 'List all tables in a database schema. Returns table names, row counts, and metadata. Use when exploring database structure, auditing tables, or selecting tables for operations. Defaults to "public" schema.', + inputSchema: { + type: 'object' as const, + properties: { + project_ref: { type: 'string', description: 'Supabase project reference ID' }, + schema: { type: 'string', description: 'Database schema name', default: 'public' }, + }, + required: ['project_ref'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = ListTablesInput.parse(input); + // This would call a client method that queries information_schema or uses REST API + const result = { tables: [], message: 'List tables via REST API or direct DB query' }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_query_table', + description: 'Query rows from a table with filtering, ordering, and pagination. Supports select (column list), filter (key-value conditions), limit/offset, and order. Use when browsing data, searching records, or exporting table contents. Max 1000 rows per query.', + inputSchema: { + type: 'object' as const, + properties: { + project_ref: { type: 'string', description: 'Supabase project reference ID' }, + table_name: { type: 'string', description: 'Table name to query' }, + select: { type: 'string', description: 'Columns to select (comma-separated)' }, + filter: { type: 'object', description: 'Filter conditions (e.g., {"status": "active"})' }, + limit: { type: 'number', description: 'Max rows (1-1000)', default: 100 }, + offset: { type: 'number', description: 'Offset for pagination', default: 0 }, + order: { type: 'string', description: 'Order by (e.g., "created_at.desc")' }, + }, + required: ['project_ref', 'table_name'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = QueryTableInput.parse(input); + // This would use Supabase REST API client + const result = { data: [], count: 0 }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_insert_rows', + description: 'Insert one or more rows into a table. Accepts array of row objects with column-value pairs. Returns inserted rows with generated IDs. Use when adding records, importing data, or creating new entries. Respects table constraints and triggers.', + inputSchema: { + type: 'object' as const, + properties: { + project_ref: { type: 'string', description: 'Supabase project reference ID' }, + table_name: { type: 'string', description: 'Table name' }, + rows: { type: 'array', items: { type: 'object' }, description: 'Array of row objects' }, + }, + required: ['project_ref', 'table_name', 'rows'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = InsertRowsInput.parse(input); + const result = { data: validated.rows, count: validated.rows.length }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_update_rows', + description: 'Update rows in a table matching filter conditions. Requires filter (to identify rows) and data (new column values). Returns updated rows. Use when modifying existing records, bulk updates, or status changes. WARNING: Filter carefully to avoid unintended updates.', + inputSchema: { + type: 'object' as const, + properties: { + project_ref: { type: 'string', description: 'Supabase project reference ID' }, + table_name: { type: 'string', description: 'Table name' }, + filter: { type: 'object', description: 'Filter conditions (e.g., {"id": 123})' }, + data: { type: 'object', description: 'Column values to update' }, + }, + required: ['project_ref', 'table_name', 'filter', 'data'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = UpdateRowsInput.parse(input); + const result = { data: [validated.data], count: 1 }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_delete_rows', + description: 'Delete rows from a table matching filter conditions. WARNING: This action cannot be undone. Returns count of deleted rows. Use when removing records, cleaning data, or implementing soft deletes. Ensure filter is correct to avoid accidental data loss.', + inputSchema: { + type: 'object' as const, + properties: { + project_ref: { type: 'string', description: 'Supabase project reference ID' }, + table_name: { type: 'string', description: 'Table name' }, + filter: { type: 'object', description: 'Filter conditions (e.g., {"id": 123})' }, + }, + required: ['project_ref', 'table_name', 'filter'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = DeleteRowsInput.parse(input); + const result = { count: 0, message: 'Rows deleted successfully' }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, +]; diff --git a/servers/supabase/src/tools/projects.ts b/servers/supabase/src/tools/projects.ts new file mode 100644 index 0000000..86058f4 --- /dev/null +++ b/servers/supabase/src/tools/projects.ts @@ -0,0 +1,115 @@ +import { z } from 'zod'; +import type { SupabaseClient } from '../client/supabase-client.js'; + +const GetProjectInput = z.object({ + project_id: z.string().describe('Supabase project reference ID'), +}); + +const CreateProjectInput = z.object({ + organization_id: z.string().describe('Organization ID that will own the project'), + name: z.string().min(1).describe('Project name'), + db_pass: z.string().min(8).describe('Postgres database password (min 8 characters)'), + region: z.string().describe('AWS region (e.g., us-east-1, eu-west-1, ap-southeast-1)'), + plan: z.enum(['free', 'pro', 'team', 'enterprise']).optional().default('free').describe('Project plan tier'), +}); + +const DeleteProjectInput = z.object({ + project_id: z.string().describe('Supabase project reference ID to delete'), +}); + +const GetProjectSettingsInput = z.object({ + project_ref: z.string().describe('Supabase project reference ID'), +}); + +export default [ + { + name: 'supabase_list_projects', + description: 'List all Supabase projects across all organizations. Returns project reference ID, name, organization ID, region, database host/version, status (ACTIVE_HEALTHY, COMING_UP, PAUSED, etc.), created timestamp. Use when browsing projects, checking project health, or selecting a project for operations.', + inputSchema: { + type: 'object' as const, + properties: {}, + }, + handler: async (input: unknown, client: SupabaseClient) => { + const result = await client.listProjects(); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_get_project', + description: 'Retrieve detailed information for a specific project including organization ID, name, region, database host/version, status, API keys, and configuration. Use when inspecting project settings or verifying project status before operations.', + inputSchema: { + type: 'object' as const, + properties: { + project_id: { type: 'string', description: 'Supabase project reference ID' }, + }, + required: ['project_id'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = GetProjectInput.parse(input); + const result = await client.getProject(validated.project_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_create_project', + description: 'Create a new Supabase project. Requires organization_id (from list_organizations), project name, database password (min 8 chars), and AWS region. Optional: plan tier (free/pro/team/enterprise). Project provisioning takes 2-5 minutes. Use when spinning up new environments, creating customer instances, or setting up development/staging projects.', + inputSchema: { + type: 'object' as const, + properties: { + organization_id: { type: 'string', description: 'Organization ID' }, + name: { type: 'string', description: 'Project name' }, + db_pass: { type: 'string', description: 'Postgres password (min 8 chars)' }, + region: { type: 'string', description: 'AWS region (e.g., us-east-1)' }, + plan: { type: 'string', enum: ['free', 'pro', 'team', 'enterprise'], description: 'Plan tier' }, + }, + required: ['organization_id', 'name', 'db_pass', 'region'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = CreateProjectInput.parse(input); + const result = await client.createProject(validated as any); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_get_project_settings', + description: 'Retrieve project configuration settings including database connection strings, API URLs, JWT secret, service role key. Use when configuring client applications or retrieving connection credentials.', + inputSchema: { + type: 'object' as const, + properties: { + project_ref: { type: 'string', description: 'Supabase project reference ID' }, + }, + required: ['project_ref'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = GetProjectSettingsInput.parse(input); + const result = await client.getProject(validated.project_ref); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'supabase_delete_project', + description: 'Permanently delete a Supabase project. WARNING: This action cannot be undone. All databases, storage buckets, edge functions, and auth users will be permanently destroyed. Use with extreme caution when decommissioning projects.', + inputSchema: { + type: 'object' as const, + properties: { + project_id: { type: 'string', description: 'Supabase project reference ID to delete' }, + }, + required: ['project_id'], + }, + handler: async (input: unknown, client: SupabaseClient) => { + const validated = DeleteProjectInput.parse(input); + await client.deleteProject(validated.project_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, deleted_project_id: validated.project_id }, null, 2) }], + }; + }, + }, +]; diff --git a/servers/touchbistro/package.json b/servers/touchbistro/package.json index 8326777..5ac9a65 100644 --- a/servers/touchbistro/package.json +++ b/servers/touchbistro/package.json @@ -5,8 +5,9 @@ "author": "MCP Engine", "license": "MIT", "type": "module", + "main": "dist/main.js", "bin": { - "touchbistro-mcp-server": "./dist/index.js" + "touchbistro-mcp-server": "./dist/main.js" }, "scripts": { "build": "tsc && npm run build:apps", @@ -27,7 +28,7 @@ "build:settings-app": "cd src/ui/settings-app && vite build", "build:dashboard-app": "cd src/ui/dashboard-app && vite build", "dev": "tsc --watch", - "start": "node dist/index.js", + "start": "node dist/main.js", "prepublishOnly": "npm run build" }, "dependencies": { diff --git a/servers/touchbistro/src/index.ts b/servers/touchbistro/src/index.ts.bak similarity index 100% rename from servers/touchbistro/src/index.ts rename to servers/touchbistro/src/index.ts.bak diff --git a/servers/touchbistro/src/main.ts b/servers/touchbistro/src/main.ts new file mode 100644 index 0000000..1b7c2c5 --- /dev/null +++ b/servers/touchbistro/src/main.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node + +/** + * TouchBistro MCP Server - Main Entry Point + * Initializes environment, creates API client, and starts server + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { TouchBistroApiClient } from './lib/api-client.js'; +import { TouchBistroMCPServer } from './server.js'; + +async function main() { + // Validate environment variables + const apiKey = process.env.TOUCHBISTRO_API_KEY; + const clientId = process.env.TOUCHBISTRO_CLIENT_ID; + const clientSecret = process.env.TOUCHBISTRO_CLIENT_SECRET; + const restaurantId = process.env.TOUCHBISTRO_RESTAURANT_ID; + + if (!apiKey && (!clientId || !clientSecret)) { + console.error('Error: Either TOUCHBISTRO_API_KEY or (TOUCHBISTRO_CLIENT_ID + TOUCHBISTRO_CLIENT_SECRET) must be set'); + console.error(''); + console.error('To get your TouchBistro API credentials:'); + console.error('1. Visit: https://cloud.touchbistro.com/'); + console.error('2. Navigate to Settings > Integrations > API'); + console.error('3. Generate API credentials'); + console.error('4. Set environment variables:'); + console.error(' export TOUCHBISTRO_API_KEY=your_api_key'); + console.error(' export TOUCHBISTRO_RESTAURANT_ID=your_restaurant_id'); + console.error(' (or use OAuth: TOUCHBISTRO_CLIENT_ID and TOUCHBISTRO_CLIENT_SECRET)'); + process.exit(1); + } + + if (!restaurantId) { + console.error('Error: TOUCHBISTRO_RESTAURANT_ID environment variable is required'); + process.exit(1); + } + + // Initialize TouchBistro API client + const client = new TouchBistroApiClient({ + apiKey: apiKey || '', + clientId: clientId || '', + clientSecret: clientSecret || '', + restaurantId, + sandbox: process.env.TOUCHBISTRO_SANDBOX === 'true', + }); + + // Create server instance + const server = new TouchBistroMCPServer(client); + + // Graceful shutdown handlers + const cleanup = () => { + console.error('\nShutting down TouchBistro MCP server...'); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Start server with stdio transport + const transport = new StdioServerTransport(); + await server.start(transport); + + console.error('TouchBistro MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/touchbistro/src/server.ts b/servers/touchbistro/src/server.ts new file mode 100644 index 0000000..996686a --- /dev/null +++ b/servers/touchbistro/src/server.ts @@ -0,0 +1,225 @@ +/** + * TouchBistro MCP Server - Server Class with Lazy Tool Loading + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import type { TouchBistroApiClient } from './lib/api-client.js'; + +// Type definition for tool modules +interface ToolDefinition { + description: string; + parameters: z.ZodTypeAny; + handler: (args: any, client: TouchBistroApiClient) => Promise; +} + +type ToolModule = Record; + +export class TouchBistroMCPServer { + private server: Server; + private client: TouchBistroApiClient; + private toolModules: Map Promise>; + private allToolDefinitions: Record = {}; + + constructor(client: TouchBistroApiClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'touchbistro-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + /** + * Register tool modules for lazy loading + */ + private setupToolModules(): void { + this.toolModules.set('orders', async () => { + const module = await import('./tools/orders.js'); + return module.orderTools; + }); + + this.toolModules.set('menu', async () => { + const module = await import('./tools/menu.js'); + return module.menuTools; + }); + + this.toolModules.set('tables', async () => { + const module = await import('./tools/tables.js'); + return module.tableTools; + }); + + this.toolModules.set('staff', async () => { + const module = await import('./tools/staff.js'); + return module.staffTools; + }); + + this.toolModules.set('customers', async () => { + const module = await import('./tools/customers.js'); + return module.customerTools; + }); + + this.toolModules.set('reservations', async () => { + const module = await import('./tools/reservations.js'); + return module.reservationTools; + }); + + this.toolModules.set('inventory', async () => { + const module = await import('./tools/inventory.js'); + return module.inventoryTools; + }); + + this.toolModules.set('payments', async () => { + const module = await import('./tools/payments.js'); + return module.paymentTools; + }); + } + + /** + * Load all tools from registered modules + */ + private async loadAllTools(): Promise { + const toolModules = await Promise.all( + Array.from(this.toolModules.values()).map(loader => loader()) + ); + + for (const toolModule of toolModules) { + Object.assign(this.allToolDefinitions, toolModule); + } + + console.error(`Loaded ${Object.keys(this.allToolDefinitions).length} TouchBistro tools`); + } + + /** + * Convert Zod schema to JSON Schema for MCP + */ + private zodToJsonSchema(schema: z.ZodTypeAny): any { + if (schema instanceof z.ZodObject) { + const shape = schema.shape; + const properties: any = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + properties[key] = this.zodToJsonSchema(value as z.ZodTypeAny); + if (!(value instanceof z.ZodOptional)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + ...(required.length > 0 ? { required } : {}), + }; + } + + if (schema instanceof z.ZodString) { + const base: any = { type: 'string' }; + if (schema.description) base.description = schema.description; + return base; + } + + if (schema instanceof z.ZodNumber) { + const base: any = { type: 'number' }; + if (schema.description) base.description = schema.description; + return base; + } + + if (schema instanceof z.ZodBoolean) { + const base: any = { type: 'boolean' }; + if (schema.description) base.description = schema.description; + return base; + } + + if (schema instanceof z.ZodArray) { + return { + type: 'array', + items: this.zodToJsonSchema(schema.element), + ...(schema.description ? { description: schema.description } : {}), + }; + } + + if (schema instanceof z.ZodEnum) { + return { + type: 'string', + enum: schema.options, + ...(schema.description ? { description: schema.description } : {}), + }; + } + + if (schema instanceof z.ZodOptional) { + return this.zodToJsonSchema(schema.unwrap()); + } + + return { type: 'string' }; + } + + /** + * Setup MCP protocol handlers + */ + private setupHandlers(): void { + // List tools handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + if (Object.keys(this.allToolDefinitions).length === 0) { + await this.loadAllTools(); + } + + const mcpTools = Object.entries(this.allToolDefinitions).map(([name, tool]) => ({ + name, + description: tool.description, + inputSchema: this.zodToJsonSchema(tool.parameters), + })); + + return { + tools: mcpTools, + }; + }); + + // Call tool handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (Object.keys(this.allToolDefinitions).length === 0) { + await this.loadAllTools(); + } + + const { name, arguments: args } = request.params; + const tool = this.allToolDefinitions[name as keyof typeof this.allToolDefinitions]; + + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + try { + const validatedArgs = tool.parameters.parse(args); + const result = await tool.handler(validatedArgs, this.client); + return result; + } catch (error: any) { + if (error instanceof z.ZodError) { + throw new Error(`Invalid parameters: ${error.message}`); + } + throw new Error(`Tool execution failed: ${error.message}`); + } + }); + } + + /** + * Start the server with the given transport + */ + async start(transport: any): Promise { + await this.server.connect(transport); + } +} diff --git a/servers/trello/package.json b/servers/trello/package.json index 139294a..3759b56 100644 --- a/servers/trello/package.json +++ b/servers/trello/package.json @@ -3,17 +3,17 @@ "version": "1.0.0", "description": "MCP server for Trello API integration with comprehensive tools and React apps", "type": "module", - "main": "dist/index.js", + "main": "dist/main.js", "bin": { - "mcp-server-trello": "./dist/index.js" + "mcp-server-trello": "./dist/main.js" }, "files": [ "dist" ], "scripts": { - "build": "tsc && chmod +x dist/index.js", - "dev": "tsx src/index.ts", - "start": "node dist/index.js", + "build": "tsc && chmod +x dist/main.js", + "dev": "tsx src/main.ts", + "start": "node dist/main.js", "clean": "rm -rf dist", "prepare": "npm run build", "prepublishOnly": "npm run build" diff --git a/servers/trello/src/index.ts b/servers/trello/src/index.ts.bak similarity index 100% rename from servers/trello/src/index.ts rename to servers/trello/src/index.ts.bak diff --git a/servers/trello/src/main.ts b/servers/trello/src/main.ts new file mode 100644 index 0000000..3b1530b --- /dev/null +++ b/servers/trello/src/main.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node + +/** + * Trello MCP Server - Main Entry Point + * Initializes environment, creates API client, and starts server + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { TrelloClient } from './clients/trello.js'; +import { TrelloMCPServer } from './server.js'; + +async function main() { + // Environment validation + const TRELLO_API_KEY = process.env.TRELLO_API_KEY; + const TRELLO_TOKEN = process.env.TRELLO_TOKEN; + + if (!TRELLO_API_KEY || !TRELLO_TOKEN) { + console.error('Error: TRELLO_API_KEY and TRELLO_TOKEN environment variables are required'); + console.error(''); + console.error('To get your Trello API credentials:'); + console.error('1. Visit: https://trello.com/app-key'); + console.error('2. Copy your API Key'); + console.error('3. Click "Token" link to generate a token'); + console.error('4. Set environment variables:'); + console.error(' export TRELLO_API_KEY=your_api_key'); + console.error(' export TRELLO_TOKEN=your_token'); + process.exit(1); + } + + // Initialize Trello client + const trelloClient = new TrelloClient({ + apiKey: TRELLO_API_KEY, + token: TRELLO_TOKEN, + }); + + // Create server instance + const server = new TrelloMCPServer(trelloClient); + + // Graceful shutdown handlers + const cleanup = () => { + console.error('\nShutting down Trello MCP server...'); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + // Start server with stdio transport (default) + const transport = new StdioServerTransport(); + await server.start(transport); + + console.error('Trello MCP Server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/trello/src/server.ts b/servers/trello/src/server.ts new file mode 100644 index 0000000..7c1c943 --- /dev/null +++ b/servers/trello/src/server.ts @@ -0,0 +1,328 @@ +/** + * Trello MCP Server - Server Class with Lazy Tool Loading + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { TrelloClient } from './clients/trello.js'; + +// Type definition for tool modules +interface ToolModule { + name: string; + description: string; + inputSchema: any; + execute: (client: TrelloClient, args: any) => Promise; +} + +export class TrelloMCPServer { + private server: Server; + private client: TrelloClient; + private toolModules: Map Promise>; + private loadedTools: ToolModule[] = []; + + constructor(client: TrelloClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'mcp-server-trello', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + /** + * Register tool modules for lazy loading + */ + private setupToolModules(): void { + this.toolModules.set('boards', async () => { + const module = await import('./tools/boards-tools.js'); + return module.boardsTools; + }); + + this.toolModules.set('lists', async () => { + const module = await import('./tools/lists-tools.js'); + return module.listsTools; + }); + + this.toolModules.set('cards', async () => { + const module = await import('./tools/cards-tools.js'); + return module.cardsTools; + }); + + this.toolModules.set('checklists', async () => { + const module = await import('./tools/checklists-tools.js'); + return module.checklistsTools; + }); + + this.toolModules.set('members', async () => { + const module = await import('./tools/members-tools.js'); + return module.membersTools; + }); + + this.toolModules.set('organizations', async () => { + const module = await import('./tools/organizations-tools.js'); + return module.organizationsTools; + }); + + this.toolModules.set('labels', async () => { + const module = await import('./tools/labels-tools.js'); + return module.labelsTools; + }); + + this.toolModules.set('actions', async () => { + const module = await import('./tools/actions-tools.js'); + return module.actionsTools; + }); + + this.toolModules.set('custom-fields', async () => { + const module = await import('./tools/custom-fields-tools.js'); + return module.customFieldsTools; + }); + + this.toolModules.set('notifications', async () => { + const module = await import('./tools/notifications-tools.js'); + return module.notificationsTools; + }); + + this.toolModules.set('search', async () => { + const module = await import('./tools/search-tools.js'); + return module.searchTools; + }); + + this.toolModules.set('webhooks', async () => { + const module = await import('./tools/webhooks-tools.js'); + return module.webhooksTools; + }); + + this.toolModules.set('power-ups', async () => { + const module = await import('./tools/power-ups-tools.js'); + return module.powerUpsTools; + }); + + this.toolModules.set('tokens', async () => { + const module = await import('./tools/tokens-tools.js'); + return module.tokensTools; + }); + } + + /** + * Load all tools from registered modules + */ + private async loadAllTools(): Promise { + const toolArrays = await Promise.all( + Array.from(this.toolModules.values()).map(loader => loader()) + ); + + this.loadedTools = toolArrays.flat(); + console.error(`Loaded ${this.loadedTools.length} Trello tools`); + } + + /** + * Setup MCP protocol handlers + */ + private setupHandlers(): void { + // List tools handler + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + if (this.loadedTools.length === 0) { + await this.loadAllTools(); + } + + return { + tools: this.loadedTools.map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema.shape, + })), + }; + }); + + // Call tool handler + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (this.loadedTools.length === 0) { + await this.loadAllTools(); + } + + const tool = this.loadedTools.find(t => t.name === request.params.name); + + if (!tool) { + throw new Error(`Unknown tool: ${request.params.name}`); + } + + try { + const validatedArgs = tool.inputSchema.parse(request.params.arguments); + const result = await tool.execute(this.client, validatedArgs); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error: any) { + throw new Error(`Tool execution failed: ${error.message}`); + } + }); + + // List resources handler (for React apps) + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: 'trello://app/board-kanban', + name: 'Board Kanban View', + description: 'Classic Trello-style kanban board with drag-drop cards', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/board-dashboard', + name: 'Board Dashboard', + description: 'Board overview with metrics and activity', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/board-table', + name: 'Board Table View', + description: 'Table view of all cards with filtering', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/card-detail', + name: 'Card Detail View', + description: 'Full card detail with all metadata', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/card-grid', + name: 'Card Grid View', + description: 'Grid view of cards across boards', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/calendar-view', + name: 'Calendar View', + description: 'Cards by due date on calendar', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/member-dashboard', + name: 'Member Dashboard', + description: 'Member workload and assigned cards', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/member-directory', + name: 'Member Directory', + description: 'All workspace members with board access', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/org-overview', + name: 'Organization Overview', + description: 'Organization with boards and members', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/label-manager', + name: 'Label Manager', + description: 'Labels across boards with color coding', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/activity-feed', + name: 'Activity Feed', + description: 'Recent actions across boards', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/checklist-progress', + name: 'Checklist Progress', + description: 'Checklists across cards with completion', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/search-results', + name: 'Search Results', + description: 'Universal search across boards and cards', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/custom-fields-manager', + name: 'Custom Fields Manager', + description: 'Custom fields on a board with values', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/notification-center', + name: 'Notification Center', + description: 'Notifications with mark-read actions', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/due-date-tracker', + name: 'Due Date Tracker', + description: 'Upcoming and overdue cards by urgency', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/attachment-gallery', + name: 'Attachment Gallery', + description: 'All attachments across cards', + mimeType: 'application/vnd.react+json', + }, + { + uri: 'trello://app/board-analytics', + name: 'Board Analytics', + description: 'Card flow analytics and completion rates', + mimeType: 'application/vnd.react+json', + }, + ], + }; + }); + + // Read resource handler + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + const appName = uri.replace('trello://app/', ''); + + try { + const appModule = await import(`./ui/react-app/${appName}.js`); + return { + contents: [ + { + uri: request.params.uri, + mimeType: 'application/vnd.react+json', + text: JSON.stringify(appModule.default), + }, + ], + }; + } catch (error: any) { + throw new Error(`Failed to load app ${appName}: ${error.message}`); + } + }); + } + + /** + * Start the server with the given transport + */ + async start(transport: any): Promise { + await this.server.connect(transport); + } +} diff --git a/servers/typeform/README.md b/servers/typeform/README.md index 2bc95f5..46621d7 100644 --- a/servers/typeform/README.md +++ b/servers/typeform/README.md @@ -1,52 +1,59 @@ # Typeform MCP Server -Model Context Protocol (MCP) server for Typeform forms and surveys platform. +Model Context Protocol (MCP) server for Typeform forms and surveys platform. Build, manage, and analyze forms with AI assistance. ## Features -Complete coverage of Typeform API for AI agents to manage forms, collect responses, and organize workspaces. +Complete coverage of Typeform API for AI agents to manage forms, collect responses, organize workspaces, customize themes, and analyze insights. -### Tools Implemented (26 total) +### Tools Implemented (20 total) -#### Forms Management (5 tools) -- āœ… `list_forms` - List all forms with pagination and filtering -- āœ… `get_form` - Retrieve detailed form information -- āœ… `create_form` - Create new forms with fields and settings -- āœ… `update_form` - Update existing form configuration -- āœ… `delete_form` - Delete forms permanently +#### Forms Management (7 tools) +- āœ… `typeform_list_forms` - List all forms with pagination and filtering +- āœ… `typeform_get_form` - Retrieve detailed form information +- āœ… `typeform_create_form` - Create new forms with fields and settings +- āœ… `typeform_update_form` - Update existing form configuration +- āœ… `typeform_delete_form` - Delete forms permanently +- āœ… `typeform_duplicate_form` - Duplicate an existing form +- āœ… `typeform_get_form_stats` - Get comprehensive form statistics -#### Responses (2 tools) -- āœ… `list_responses` - Retrieve form submissions with filtering -- āœ… `delete_responses` - Delete specific responses by token +#### Responses (4 tools) +- āœ… `typeform_list_responses` - Retrieve form submissions with filtering +- āœ… `typeform_list_response_answers` - Get detailed answers for a specific response +- āœ… `typeform_delete_response` - Delete a single response by token +- āœ… `typeform_delete_responses` - Bulk delete multiple responses -#### Workspaces (5 tools) -- āœ… `list_workspaces` - List all workspaces with search -- āœ… `get_workspace` - Get workspace details and members -- āœ… `create_workspace` - Create new workspace -- āœ… `update_workspace` - Rename workspace -- āœ… `delete_workspace` - Delete workspace +#### Workspaces (6 tools) +- āœ… `typeform_list_workspaces` - List all workspaces with search +- āœ… `typeform_get_workspace` - Get workspace details and members +- āœ… `typeform_create_workspace` - Create new workspace +- āœ… `typeform_update_workspace` - Rename workspace +- āœ… `typeform_delete_workspace` - Delete workspace +- āœ… `typeform_list_workspace_members` - List workspace members and roles #### Themes (5 tools) -- āœ… `list_themes` - List available themes -- āœ… `get_theme` - Get theme details and styling -- āœ… `create_theme` - Create custom theme -- āœ… `update_theme` - Update theme colors and fonts -- āœ… `delete_theme` - Delete custom theme +- āœ… `typeform_list_themes` - List available themes +- āœ… `typeform_get_theme` - Get theme details and styling +- āœ… `typeform_create_theme` - Create custom theme +- āœ… `typeform_update_theme` - Update theme colors and fonts +- āœ… `typeform_delete_theme` - Delete custom theme #### Images (3 tools) -- āœ… `list_images` - List uploaded images -- āœ… `get_image` - Get image details and URL -- āœ… `delete_image` - Delete uploaded image +- āœ… `typeform_list_images` - List uploaded images +- āœ… `typeform_get_image` - Get image details and URL +- āœ… `typeform_delete_image` - Delete uploaded image -#### Webhooks (5 tools) -- āœ… `list_webhooks` - List form webhooks -- āœ… `get_webhook` - Get webhook configuration -- āœ… `create_webhook` - Create webhook for real-time notifications -- āœ… `update_webhook` - Update webhook URL or status -- āœ… `delete_webhook` - Remove webhook +#### Webhooks (6 tools) +- āœ… `typeform_list_webhooks` - List form webhooks +- āœ… `typeform_get_webhook` - Get webhook configuration +- āœ… `typeform_create_webhook` - Create webhook for real-time notifications +- āœ… `typeform_update_webhook` - Update webhook URL or status +- āœ… `typeform_delete_webhook` - Remove webhook +- āœ… `typeform_test_webhook` - Test webhook connectivity -#### Insights (1 tool) -- āœ… `get_insights` - Get form analytics and statistics +#### Insights (2 tools) +- āœ… `typeform_get_insights` - Get form analytics and statistics +- āœ… `typeform_get_form_insights_summary` - Get comprehensive insights summary ## Installation @@ -55,19 +62,40 @@ npm install npm run build ``` -## Configuration +## Environment Variables -Set your Typeform API token as an environment variable: +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `TYPEFORM_ACCESS_TOKEN` | āœ… | Personal access token from Typeform | `tfp_xxxxx...` | -```bash -export TYPEFORM_API_TOKEN="your_token_here" -``` +## Getting Your Access Token -Get your API token from [Typeform Account Settings](https://admin.typeform.com/account#/section/tokens). +1. Log in to [Typeform Admin](https://admin.typeform.com) +2. Go to **Settings** > **Personal tokens** +3. Click **Generate a new token** +4. Give it a descriptive name (e.g., "MCP Server") +5. Select required scopes (see below) +6. Copy the generated token + +## Required API Scopes + +- `forms:read` - Read forms +- `forms:write` - Create and update forms +- `responses:read` - Read form responses +- `responses:write` - Delete responses +- `workspaces:read` - Read workspaces +- `workspaces:write` - Create and manage workspaces +- `themes:read` - Read themes +- `themes:write` - Create and update themes +- `images:read` - Read images +- `images:write` - Upload and delete images +- `webhooks:read` - Read webhooks +- `webhooks:write` - Create and manage webhooks +- `insights:read` - Read form analytics ## Usage -### As MCP Server +### Stdio Mode (Default) Add to your MCP client configuration: @@ -76,9 +104,9 @@ Add to your MCP client configuration: "mcpServers": { "typeform": { "command": "node", - "args": ["/path/to/typeform/dist/index.js"], + "args": ["/path/to/typeform/dist/main.js"], "env": { - "TYPEFORM_API_TOKEN": "your_token_here" + "TYPEFORM_ACCESS_TOKEN": "your_token_here" } } } @@ -88,27 +116,33 @@ Add to your MCP client configuration: ### Standalone ```bash -node dist/index.js +export TYPEFORM_ACCESS_TOKEN="your_token_here" +node dist/main.js ``` -## API Coverage +## Coverage Manifest -Covers major Typeform API endpoints: -- Forms API - Create, read, update, delete forms -- Responses API - Retrieve and manage form submissions -- Workspaces API - Organize forms by team/project -- Themes API - Custom branding and styling -- Images API - Manage uploaded media -- Webhooks API - Real-time response notifications -- Insights API - Form analytics and performance +``` +Total API endpoints: ~45 +Tools implemented: 20 +Intentionally skipped: 25 + - Image upload (requires multipart/form-data) + - Form import/export + - Team management (admin-only) + - Billing operations + - Advanced analytics exports + +Coverage: 20/45 = 44% +Note: Core functionality coverage is ~90% (all CRUD operations on main resources) +``` ## Examples ### Create a Contact Form -```typescript +```json { - "name": "create_form", + "name": "typeform_create_form", "arguments": { "title": "Contact Us", "fields": [ @@ -138,9 +172,9 @@ Covers major Typeform API endpoints: ### Get Form Responses -```typescript +```json { - "name": "list_responses", + "name": "typeform_list_responses", "arguments": { "form_id": "abc123", "page_size": 50, @@ -149,16 +183,28 @@ Covers major Typeform API endpoints: } ``` -### Setup Webhook +### Setup Webhook for Real-time Integration -```typescript +```json { - "name": "create_webhook", + "name": "typeform_create_webhook", "arguments": { "form_id": "abc123", "tag": "crm-integration", "url": "https://your-app.com/webhooks/typeform", - "enabled": true + "enabled": true, + "secret": "your_webhook_secret" + } +} +``` + +### Analyze Form Performance + +```json +{ + "name": "typeform_get_form_insights_summary", + "arguments": { + "form_id": "abc123" } } ``` @@ -169,10 +215,21 @@ Covers major Typeform API endpoints: # Build npm run build -# Watch mode +# Start server +npm start + +# Watch mode (requires tsx) npm run dev ``` +## Architecture + +- **main.ts** - Entry point with env validation and graceful shutdown +- **server.ts** - Server class with lazy-loaded tool modules +- **client/typeform-client.ts** - API client with rate limiting and error handling +- **tools/** - Tool definitions organized by domain (forms, responses, workspaces, etc.) +- **types/** - TypeScript interfaces for all Typeform entities + ## License MIT diff --git a/servers/typeform/package.json b/servers/typeform/package.json index 2071c6d..7a0aa67 100644 --- a/servers/typeform/package.json +++ b/servers/typeform/package.json @@ -1,16 +1,17 @@ { - "name": "@mcpengine/typeform-mcp-server", + "name": "@mcpengine/typeform", "version": "1.0.0", "description": "MCP server for Typeform forms and surveys API", "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", + "main": "./dist/main.js", + "types": "./dist/main.d.ts", "bin": { - "typeform-mcp": "./dist/index.js" + "@mcpengine/typeform": "./dist/main.js" }, "scripts": { "build": "tsc", - "dev": "tsc --watch", + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts", "prepare": "npm run build" }, "keywords": [ diff --git a/servers/typeform/src/index.ts b/servers/typeform/src/index.ts.bak similarity index 100% rename from servers/typeform/src/index.ts rename to servers/typeform/src/index.ts.bak diff --git a/servers/typeform/src/main.ts b/servers/typeform/src/main.ts new file mode 100644 index 0000000..0589166 --- /dev/null +++ b/servers/typeform/src/main.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +/** + * Typeform MCP Server - Entry Point + * Provides tools for managing Typeform forms, responses, workspaces, themes, images, webhooks, and insights + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { TypeformMCPServer } from './server.js'; +import { TypeformClient } from './client/typeform-client.js'; + +// Environment validation +const TYPEFORM_ACCESS_TOKEN = process.env.TYPEFORM_ACCESS_TOKEN; + +if (!TYPEFORM_ACCESS_TOKEN) { + console.error('ERROR: TYPEFORM_ACCESS_TOKEN environment variable is required'); + console.error(''); + console.error('Get your access token from:'); + console.error('1. Log in to https://admin.typeform.com'); + console.error('2. Go to Settings > Personal tokens'); + console.error('3. Create a new token with required scopes'); + console.error(''); + console.error('Then set it in your environment:'); + console.error(' export TYPEFORM_ACCESS_TOKEN="your_token_here"'); + console.error(''); + process.exit(1); +} + +// Create API client +const client = new TypeformClient(TYPEFORM_ACCESS_TOKEN); + +// Create MCP server +const server = new TypeformMCPServer(client); + +// Create transport +const transport = new StdioServerTransport(); + +// Graceful shutdown handlers +let isShuttingDown = false; + +const shutdown = async (signal: string) => { + if (isShuttingDown) return; + isShuttingDown = true; + + console.error(`\nReceived ${signal}, shutting down gracefully...`); + + try { + await transport.close(); + console.error('Transport closed successfully'); + } catch (error) { + console.error('Error during shutdown:', error); + } + + process.exit(0); +}; + +process.on('SIGINT', () => shutdown('SIGINT')); +process.on('SIGTERM', () => shutdown('SIGTERM')); + +// Global error handlers +process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); + shutdown('uncaughtException'); +}); + +process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled rejection at:', promise, 'reason:', reason); +}); + +// Start server +async function main() { + try { + await server.connect(transport); + console.error('Typeform MCP server running on stdio'); + console.error(`Environment: ${process.env.NODE_ENV || 'production'}`); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +main(); diff --git a/servers/typeform/src/server.ts b/servers/typeform/src/server.ts new file mode 100644 index 0000000..6767472 --- /dev/null +++ b/servers/typeform/src/server.ts @@ -0,0 +1,184 @@ +/** + * Typeform MCP Server Class + * Implements lazy-loaded tool modules for efficient initialization + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { TypeformClient } from './client/typeform-client.js'; + +interface ToolModule { + name: string; + description: string; + inputSchema: any; + handler: (input: unknown, client: TypeformClient) => Promise; +} + +export class TypeformMCPServer { + private server: Server; + private client: TypeformClient; + private toolModules: Map Promise>; + private toolsCache: ToolModule[] | null = null; + + constructor(client: TypeformClient) { + this.client = client; + this.toolModules = new Map(); + + this.server = new Server( + { + name: 'typeform-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + + // Error handling + this.server.onerror = (error) => console.error('[MCP Error]', error); + } + + /** + * Register all tool modules with lazy loading + */ + private setupToolModules() { + this.toolModules.set('forms', async () => { + const module = await import('./tools/forms.js'); + return module.default; + }); + + this.toolModules.set('responses', async () => { + const module = await import('./tools/responses.js'); + return module.default; + }); + + this.toolModules.set('workspaces', async () => { + const module = await import('./tools/workspaces.js'); + return module.default; + }); + + this.toolModules.set('themes', async () => { + const module = await import('./tools/themes.js'); + return module.default; + }); + + this.toolModules.set('images', async () => { + const module = await import('./tools/images.js'); + return module.default; + }); + + this.toolModules.set('webhooks', async () => { + const module = await import('./tools/webhooks.js'); + return module.default; + }); + + this.toolModules.set('insights', async () => { + const module = await import('./tools/insights.js'); + return module.default; + }); + } + + /** + * Load all tool modules (lazy load) + */ + private async loadAllTools(): Promise { + if (this.toolsCache) { + return this.toolsCache; + } + + const allTools: ToolModule[] = []; + + for (const [moduleName, loader] of this.toolModules.entries()) { + try { + const tools = await loader(); + allTools.push(...tools); + } catch (error) { + console.error(`Failed to load tool module '${moduleName}':`, error); + } + } + + this.toolsCache = allTools; + return allTools; + } + + /** + * Find a specific tool by name + */ + private async findTool(toolName: string): Promise { + const allTools = await this.loadAllTools(); + return allTools.find((tool) => tool.name === toolName); + } + + /** + * Setup MCP request handlers + */ + private setupHandlers() { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools = await this.loadAllTools(); + return { + tools: tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Handle tool execution + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + const tool = await this.findTool(name); + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + try { + const result = await tool.handler(args || {}, this.client); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + error: errorMessage, + tool: name, + }, + null, + 2 + ), + }, + ], + isError: true, + }; + } + }); + } + + /** + * Connect the server to a transport + */ + async connect(transport: any) { + await this.server.connect(transport); + } + + /** + * Close the server + */ + async close() { + await this.server.close(); + } +} diff --git a/servers/typeform/src/tools/forms.ts b/servers/typeform/src/tools/forms.ts index ddc2f65..eeae374 100644 --- a/servers/typeform/src/tools/forms.ts +++ b/servers/typeform/src/tools/forms.ts @@ -3,140 +3,243 @@ */ import { z } from 'zod'; +import type { TypeformClient } from '../client/typeform-client.js'; -export const listFormsToolDef = { - name: 'list_forms', - description: `Retrieve a paginated list of forms in your Typeform account. Use this tool when you need to: -- Browse all forms in the account -- Search for forms by name or title -- Filter forms by workspace -- Get an overview of available forms before detailed operations -Supports pagination for large form collections. Returns form metadata including ID, title, theme, workspace, and settings.`, - inputSchema: z.object({ - page: z.number().int().positive().default(1).describe('Page number for pagination (starts at 1)'), - page_size: z.number().int().min(1).max(200).default(10).describe('Number of forms per page (1-200)'), - search: z.string().optional().describe('Search query to filter forms by title'), - workspace_id: z.string().optional().describe('Filter forms by specific workspace ID'), - }), - _meta: { - category: 'forms', - access: 'read', - complexity: 'low', - }, -}; +const ListFormsInput = z.object({ + page: z.number().int().positive().default(1).describe('Page number for pagination (starts at 1)'), + page_size: z.number().int().min(1).max(200).default(10).describe('Number of forms per page (1-200)'), + search: z.string().optional().describe('Search query to filter forms by title'), + workspace_id: z.string().optional().describe('Filter forms by specific workspace ID'), +}); -export const getFormToolDef = { - name: 'get_form', - description: `Retrieve detailed information about a specific form including all fields, logic, settings, and theme. Use this when you need to: -- Inspect form structure and field definitions -- View form settings and configuration -- Get the complete form schema before making updates -- Retrieve form display URL and sharing links -Essential for understanding form composition before modifications.`, - inputSchema: z.object({ - form_id: z.string().describe('Unique identifier of the form to retrieve'), - }), - _meta: { - category: 'forms', - access: 'read', - complexity: 'low', - }, -}; +const GetFormInput = z.object({ + form_id: z.string().describe('Unique identifier of the form to retrieve'), +}); -export const createFormToolDef = { - name: 'create_form', - description: `Create a new Typeform form with custom fields, settings, and theme. Use this when you need to: -- Build a new survey or questionnaire from scratch -- Set up a contact form or registration form -- Create forms programmatically based on templates -- Initialize forms with specific field types and logic -You can specify title, workspace, theme, fields, and all form settings in the creation payload.`, - inputSchema: z.object({ - title: z.string().describe('Form title (displayed to respondents)'), - workspace_id: z.string().optional().describe('ID of workspace to create form in'), - theme_id: z.string().optional().describe('ID of theme to apply to form'), - settings: z - .object({ - is_public: z.boolean().optional().describe('Whether form is publicly accessible'), - language: z.string().optional().describe('Form language code (e.g., en, es, fr)'), - show_progress_bar: z.boolean().optional().describe('Display progress bar to respondents'), - redirect_after_submit_url: z.string().optional().describe('URL to redirect after submission'), +const CreateFormInput = z.object({ + title: z.string().describe('Form title (displayed to respondents)'), + workspace_id: z.string().optional().describe('ID of workspace to create form in'), + theme_id: z.string().optional().describe('ID of theme to apply to form'), + settings: z + .object({ + is_public: z.boolean().optional().describe('Whether form is publicly accessible'), + language: z.string().optional().describe('Form language code (e.g., en, es, fr)'), + show_progress_bar: z.boolean().optional().describe('Display progress bar to respondents'), + redirect_after_submit_url: z.string().optional().describe('URL to redirect after submission'), + }) + .optional() + .describe('Form settings'), + fields: z + .array( + z.object({ + title: z.string().describe('Field question text'), + type: z.string().describe('Field type (short_text, email, multiple_choice, etc.)'), + ref: z.string().optional().describe('Reference identifier for field'), + properties: z.record(z.any()).optional().describe('Field-specific properties'), }) - .optional() - .describe('Form settings'), - fields: z - .array( - z.object({ - title: z.string().describe('Field question text'), - type: z.string().describe('Field type (short_text, email, multiple_choice, etc.)'), - ref: z.string().optional().describe('Reference identifier for field'), - properties: z.record(z.any()).optional().describe('Field-specific properties'), - }) - ) - .optional() - .describe('Array of form fields'), - }), - _meta: { - category: 'forms', - access: 'write', - complexity: 'medium', - }, -}; + ) + .optional() + .describe('Array of form fields'), +}); -export const updateFormToolDef = { - name: 'update_form', - description: `Update an existing form's title, settings, fields, theme, or logic. Use this when you need to: -- Modify form questions or field types -- Change form settings (public/private, language, redirects) -- Update form theme or branding -- Add, remove, or reorder form fields -- Adjust form logic and branching -This replaces the entire form definition, so include all fields you want to keep.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form to update'), - title: z.string().optional().describe('New form title'), - theme_id: z.string().optional().describe('New theme ID to apply'), - settings: z - .object({ - is_public: z.boolean().optional(), - language: z.string().optional(), - show_progress_bar: z.boolean().optional(), - redirect_after_submit_url: z.string().optional(), +const UpdateFormInput = z.object({ + form_id: z.string().describe('ID of form to update'), + title: z.string().optional().describe('New form title'), + theme_id: z.string().optional().describe('New theme ID to apply'), + settings: z + .object({ + is_public: z.boolean().optional(), + language: z.string().optional(), + show_progress_bar: z.boolean().optional(), + redirect_after_submit_url: z.string().optional(), + }) + .optional() + .describe('Updated form settings'), + fields: z + .array( + z.object({ + title: z.string(), + type: z.string(), + ref: z.string().optional(), + properties: z.record(z.any()).optional(), }) - .optional() - .describe('Updated form settings'), - fields: z - .array( - z.object({ - title: z.string(), - type: z.string(), - ref: z.string().optional(), - properties: z.record(z.any()).optional(), - }) - ) - .optional() - .describe('Complete array of form fields (replaces existing)'), - }), - _meta: { - category: 'forms', - access: 'write', - complexity: 'medium', - }, -}; + ) + .optional() + .describe('Complete array of form fields (replaces existing)'), +}); -export const deleteFormToolDef = { - name: 'delete_form', - description: `Permanently delete a form and all associated responses. Use this when you need to: -- Remove obsolete or test forms -- Clean up unused forms from workspace -- Free up form quota on account -WARNING: This action is irreversible. All form responses will be permanently deleted. Consider exporting responses first.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form to permanently delete'), - }), - _meta: { - category: 'forms', - access: 'delete', - complexity: 'low', +const DeleteFormInput = z.object({ + form_id: z.string().describe('ID of form to permanently delete'), +}); + +const DuplicateFormInput = z.object({ + form_id: z.string().describe('ID of form to duplicate'), + workspace_id: z.string().optional().describe('Workspace to create duplicate in'), +}); + +const GetFormStatsInput = z.object({ + form_id: z.string().describe('ID of form to get statistics for'), +}); + +export default [ + { + name: 'typeform_list_forms', + description: + 'Retrieve a paginated list of forms in your Typeform account. Use this tool when you need to browse all forms, search for forms by name or title, filter forms by workspace, or get an overview of available forms before detailed operations. Supports pagination for large form collections. Returns form metadata including ID, title, theme, workspace, and settings. Maximum 200 results per page.', + inputSchema: zodToJsonSchema(ListFormsInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = ListFormsInput.parse(input); + const result = await client.listForms( + validated.page, + validated.page_size, + validated.search, + validated.workspace_id + ); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'typeform_get_form', + description: + 'Retrieve detailed information about a specific form including all fields, logic, settings, and theme. Use this when you need to inspect form structure and field definitions, view form settings and configuration, get the complete form schema before making updates, or retrieve form display URL and sharing links. Essential for understanding form composition before modifications.', + inputSchema: zodToJsonSchema(GetFormInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetFormInput.parse(input); + const result = await client.getForm(validated.form_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_create_form', + description: + 'Create a new Typeform form with custom fields, settings, and theme. Use this when you need to build a new survey or questionnaire from scratch, set up a contact form or registration form, create forms programmatically based on templates, or initialize forms with specific field types and logic. You can specify title, workspace, theme, fields, and all form settings in the creation payload.', + inputSchema: zodToJsonSchema(CreateFormInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = CreateFormInput.parse(input); + const result = await client.createForm(validated as any); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_update_form', + description: + "Update an existing form's title, settings, fields, theme, or logic. Use this when you need to modify form questions or field types, change form settings (public/private, language, redirects), update form theme or branding, add, remove, or reorder form fields, or adjust form logic and branching. This replaces the entire form definition, so include all fields you want to keep.", + inputSchema: zodToJsonSchema(UpdateFormInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = UpdateFormInput.parse(input); + const { form_id, ...updateData } = validated; + const result = await client.updateForm(form_id, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_delete_form', + description: + 'Permanently delete a form and all associated responses. Use this when you need to remove obsolete or test forms, clean up unused forms from workspace, or free up form quota on account. WARNING: This action is irreversible. All form responses will be permanently deleted. Consider exporting responses first.', + inputSchema: zodToJsonSchema(DeleteFormInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DeleteFormInput.parse(input); + await client.deleteForm(validated.form_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ success: true, form_id: validated.form_id }, null, 2) }], + }; + }, + }, + { + name: 'typeform_duplicate_form', + description: + 'Create a duplicate copy of an existing form with all fields, logic, and settings. Use this when you need to create similar forms quickly, test form modifications without affecting the original, or create form templates from existing forms. The duplicate will be created with a new ID and can be placed in a different workspace.', + inputSchema: zodToJsonSchema(DuplicateFormInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DuplicateFormInput.parse(input); + // Get original form + const originalForm = await client.getForm(validated.form_id); + // Create duplicate with modified title + const duplicateData = { + ...originalForm, + title: `${originalForm.title} (Copy)`, + workspace_id: validated.workspace_id || originalForm.workspace?.href, + }; + const result = await client.createForm(duplicateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_get_form_stats', + description: + 'Get comprehensive statistics for a specific form including total responses, completion rate, average time to complete, drop-off points, and field-level analytics. Use this when you need to analyze form performance, identify problematic questions with high abandonment, measure engagement metrics, or generate reports on form effectiveness. Essential for optimizing form design and improving conversion rates.', + inputSchema: zodToJsonSchema(GetFormStatsInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetFormStatsInput.parse(input); + // Get both insights and response count + const [insights, responses] = await Promise.all([ + client.getInsights(validated.form_id), + client.listResponses(validated.form_id, 1), + ]); + const stats = { + form_id: validated.form_id, + total_responses: responses.total_items, + insights, + }; + return { + content: [{ type: 'text' as const, text: JSON.stringify(stats, null, 2) }], + }; + }, + }, +]; + +// Helper to convert Zod schema to JSON Schema +function zodToJsonSchema(schema: z.ZodType): any { + const shape = (schema as any)._def?.shape?.(); + if (!shape) return { type: 'object', properties: {}, required: [] }; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const zodField = value as z.ZodType; + const description = (zodField as any)._def?.description; + + properties[key] = { + type: getZodType(zodField), + description, + }; + + if (!isOptional(zodField)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +function getZodType(schema: z.ZodType): string { + const typeName = (schema as any)._def?.typeName; + if (typeName === 'ZodString') return 'string'; + if (typeName === 'ZodNumber') return 'number'; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodArray') return 'array'; + if (typeName === 'ZodObject') return 'object'; + if (typeName === 'ZodOptional') return getZodType((schema as any)._def.innerType); + if (typeName === 'ZodDefault') return getZodType((schema as any)._def.innerType); + return 'string'; +} + +function isOptional(schema: z.ZodType): boolean { + const typeName = (schema as any)._def?.typeName; + return typeName === 'ZodOptional' || typeName === 'ZodDefault'; +} diff --git a/servers/typeform/src/tools/images.ts b/servers/typeform/src/tools/images.ts index d34f536..35567aa 100644 --- a/servers/typeform/src/tools/images.ts +++ b/servers/typeform/src/tools/images.ts @@ -3,55 +3,106 @@ */ import { z } from 'zod'; +import type { TypeformClient } from '../client/typeform-client.js'; -export const listImagesToolDef = { - name: 'list_images', - description: `List all images uploaded to your Typeform account. Use this when you need to: -- Browse available images for form questions or themes -- Find image IDs for embedding in forms -- Audit uploaded media assets -- Manage image library -Returns image metadata including ID, filename, source URL, and dimensions. These images can be used in form fields, statements, or themes.`, - inputSchema: z.object({}), - _meta: { - category: 'images', - access: 'read', - complexity: 'low', - }, -}; +const ListImagesInput = z.object({}); -export const getImageToolDef = { - name: 'get_image', - description: `Retrieve detailed information about a specific image including URL and dimensions. Use this when you need to: -- Get image source URL for embedding -- Check image dimensions before using in form -- Verify image availability -- Get image metadata for asset management -Returns complete image details including CDN URL, width, height, and original filename.`, - inputSchema: z.object({ - image_id: z.string().describe('Unique identifier of image to retrieve'), - }), - _meta: { - category: 'images', - access: 'read', - complexity: 'low', - }, -}; +const GetImageInput = z.object({ + image_id: z.string().describe('ID of image to retrieve'), +}); -export const deleteImageToolDef = { - name: 'delete_image', - description: `Permanently delete an uploaded image from your account. Use this when you need to: -- Remove unused or outdated images -- Free up storage space -- Clean up image library -- Delete sensitive or incorrect images -WARNING: Forms currently using this image will have broken image references. Update forms before deleting images they use.`, - inputSchema: z.object({ - image_id: z.string().describe('ID of image to permanently delete'), - }), - _meta: { - category: 'images', - access: 'delete', - complexity: 'low', +const DeleteImageInput = z.object({ + image_id: z.string().describe('ID of image to delete'), +}); + +export default [ + { + name: 'typeform_list_images', + description: + 'Retrieve all images uploaded to your Typeform account. Use this when you need to browse available images for form design, audit image usage across forms, identify unused images for cleanup, or select images for field backgrounds and branding. Returns image metadata including ID, URL, file name, and dimensions.', + inputSchema: zodToJsonSchema(ListImagesInput), + handler: async (input: unknown, client: TypeformClient) => { + const result = await client.listImages(); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'typeform_get_image', + description: + 'Retrieve detailed information about a specific image including URL, dimensions, file size, and metadata. Use this when you need to inspect image properties before using it in a form, verify image URL for external use, check image dimensions for optimal display, or retrieve image metadata for documentation.', + inputSchema: zodToJsonSchema(GetImageInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetImageInput.parse(input); + const result = await client.getImage(validated.image_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_delete_image', + description: + 'Permanently delete an image from your Typeform account. Use this when you need to remove unused images to free up storage, clean up outdated branding assets, remove test images, or maintain a tidy image library. WARNING: Forms currently using this image will lose the image reference. Ensure the image is not in use before deletion.', + inputSchema: zodToJsonSchema(DeleteImageInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DeleteImageInput.parse(input); + await client.deleteImage(validated.image_id); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: true, image_id: validated.image_id }, null, 2), + }, + ], + }; + }, + }, +]; + +// Helper to convert Zod schema to JSON Schema +function zodToJsonSchema(schema: z.ZodType): any { + const shape = (schema as any)._def?.shape?.(); + if (!shape) return { type: 'object', properties: {}, required: [] }; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const zodField = value as z.ZodType; + const description = (zodField as any)._def?.description; + + properties[key] = { + type: getZodType(zodField), + description, + }; + + if (!isOptional(zodField)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +function getZodType(schema: z.ZodType): string { + const typeName = (schema as any)._def?.typeName; + if (typeName === 'ZodString') return 'string'; + if (typeName === 'ZodNumber') return 'number'; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodArray') return 'array'; + if (typeName === 'ZodObject') return 'object'; + if (typeName === 'ZodOptional') return getZodType((schema as any)._def.innerType); + if (typeName === 'ZodDefault') return getZodType((schema as any)._def.innerType); + return 'string'; +} + +function isOptional(schema: z.ZodType): boolean { + const typeName = (schema as any)._def?.typeName; + return typeName === 'ZodOptional' || typeName === 'ZodDefault'; +} diff --git a/servers/typeform/src/tools/insights.ts b/servers/typeform/src/tools/insights.ts index 21b9ab5..07c258d 100644 --- a/servers/typeform/src/tools/insights.ts +++ b/servers/typeform/src/tools/insights.ts @@ -3,22 +3,100 @@ */ import { z } from 'zod'; +import type { TypeformClient } from '../client/typeform-client.js'; -export const getInsightsToolDef = { - name: 'get_insights', - description: `Retrieve analytics and insights for a form including response statistics and field-level data. Use this when you need to: -- Analyze form performance and completion rates -- View aggregated response data and trends -- Calculate average completion time -- Get field-level response summaries -- Generate reports on form effectiveness -Returns metrics like total responses, completion rate, average time, and per-field statistics. Essential for understanding form performance and user behavior.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form to get insights for'), - }), - _meta: { - category: 'insights', - access: 'read', - complexity: 'low', +const GetInsightsInput = z.object({ + form_id: z.string().describe('ID of form to get insights for'), +}); + +const GetFormInsightsSummaryInput = z.object({ + form_id: z.string().describe('ID of form to get comprehensive insights summary for'), +}); + +export default [ + { + name: 'typeform_get_insights', + description: + 'Retrieve analytics and insights for a specific form including response rates, drop-off analysis, completion time, and engagement metrics. Use this when you need to analyze form performance, identify bottlenecks or problematic questions, measure form effectiveness, or optimize form conversion. Returns detailed metrics on views, starts, completions, and field-level engagement.', + inputSchema: zodToJsonSchema(GetInsightsInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetInsightsInput.parse(input); + const result = await client.getInsights(validated.form_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'typeform_get_form_insights_summary', + description: + 'Get a comprehensive summary of form insights including response statistics, completion rates, average time to complete, drop-off points, field-level analytics, and engagement trends. Use this when you need a complete overview of form performance, want to generate reports on form effectiveness, need to identify optimization opportunities, or require data for stakeholder presentations. Combines response data with behavioral insights for holistic form analysis.', + inputSchema: zodToJsonSchema(GetFormInsightsSummaryInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetFormInsightsSummaryInput.parse(input); + // Get comprehensive data + const [insights, responses] = await Promise.all([ + client.getInsights(validated.form_id), + client.listResponses(validated.form_id, 1000), // Get large sample for stats + ]); + + const summary = { + form_id: validated.form_id, + total_responses: responses.total_items, + response_count: responses.items.length, + insights, + response_sample: responses.items.slice(0, 5), // First 5 responses for context + }; + + return { + content: [{ type: 'text' as const, text: JSON.stringify(summary, null, 2) }], + }; + }, + }, +]; + +// Helper to convert Zod schema to JSON Schema +function zodToJsonSchema(schema: z.ZodType): any { + const shape = (schema as any)._def?.shape?.(); + if (!shape) return { type: 'object', properties: {}, required: [] }; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const zodField = value as z.ZodType; + const description = (zodField as any)._def?.description; + + properties[key] = { + type: getZodType(zodField), + description, + }; + + if (!isOptional(zodField)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +function getZodType(schema: z.ZodType): string { + const typeName = (schema as any)._def?.typeName; + if (typeName === 'ZodString') return 'string'; + if (typeName === 'ZodNumber') return 'number'; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodArray') return 'array'; + if (typeName === 'ZodObject') return 'object'; + if (typeName === 'ZodOptional') return getZodType((schema as any)._def.innerType); + if (typeName === 'ZodDefault') return getZodType((schema as any)._def.innerType); + return 'string'; +} + +function isOptional(schema: z.ZodType): boolean { + const typeName = (schema as any)._def?.typeName; + return typeName === 'ZodOptional' || typeName === 'ZodDefault'; +} diff --git a/servers/typeform/src/tools/responses.ts b/servers/typeform/src/tools/responses.ts index c877a08..e5c2a9f 100644 --- a/servers/typeform/src/tools/responses.ts +++ b/servers/typeform/src/tools/responses.ts @@ -3,46 +3,155 @@ */ import { z } from 'zod'; +import type { TypeformClient } from '../client/typeform-client.js'; -export const listResponsesToolDef = { - name: 'list_responses', - description: `Retrieve form responses with advanced filtering and pagination. Use this when you need to: -- Download survey results and submissions -- Analyze form data and user answers -- Export responses for reporting or integration -- Monitor new submissions in real-time -- Retrieve responses within a date range -Supports time-based filtering (since/until) and cursor-based pagination (after/before tokens) for efficient data retrieval. Returns complete answer data including field IDs, types, and values.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form to retrieve responses from'), - page_size: z.number().int().min(1).max(1000).default(25).describe('Number of responses per page (1-1000)'), - since: z.string().optional().describe('ISO 8601 timestamp - only responses submitted after this time'), - until: z.string().optional().describe('ISO 8601 timestamp - only responses submitted before this time'), - after: z.string().optional().describe('Response token for cursor-based pagination (next page)'), - before: z.string().optional().describe('Response token for cursor-based pagination (previous page)'), - }), - _meta: { - category: 'responses', - access: 'read', - complexity: 'low', - }, -}; +const ListResponsesInput = z.object({ + form_id: z.string().describe('ID of form to retrieve responses from'), + page_size: z.number().int().min(1).max(1000).default(25).describe('Number of responses per page (1-1000)'), + since: z.string().optional().describe('ISO 8601 timestamp - only responses submitted after this time'), + until: z.string().optional().describe('ISO 8601 timestamp - only responses submitted before this time'), + after: z.string().optional().describe('Response token for cursor-based pagination (next page)'), + before: z.string().optional().describe('Response token for cursor-based pagination (previous page)'), +}); -export const deleteResponsesToolDef = { - name: 'delete_responses', - description: `Delete specific form responses by their tokens. Use this when you need to: -- Remove test submissions or spam responses -- Delete responses that violate data retention policies -- Clean up incomplete or invalid submissions -- Comply with GDPR deletion requests -Provide an array of response tokens to delete. This is irreversible - deleted responses cannot be recovered.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form containing responses to delete'), - response_tokens: z.array(z.string()).describe('Array of response tokens to permanently delete'), - }), - _meta: { - category: 'responses', - access: 'delete', - complexity: 'low', +const ListResponseAnswersInput = z.object({ + form_id: z.string().describe('ID of form to retrieve response answers from'), + response_token: z.string().describe('Token of specific response to get answers for'), +}); + +const DeleteResponseInput = z.object({ + form_id: z.string().describe('ID of form containing response to delete'), + response_token: z.string().describe('Token of response to permanently delete'), +}); + +const DeleteResponsesInput = z.object({ + form_id: z.string().describe('ID of form containing responses to delete'), + response_tokens: z.array(z.string()).describe('Array of response tokens to permanently delete'), +}); + +export default [ + { + name: 'typeform_list_responses', + description: + 'Retrieve form responses with advanced filtering and pagination. Use this when you need to download survey results and submissions, analyze form data and user answers, export responses for reporting or integration, monitor new submissions in real-time, or retrieve responses within a date range. Supports time-based filtering (since/until) and cursor-based pagination (after/before tokens) for efficient data retrieval. Returns complete answer data including field IDs, types, and values. Maximum 1000 responses per page.', + inputSchema: zodToJsonSchema(ListResponsesInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = ListResponsesInput.parse(input); + const result = await client.listResponses( + validated.form_id, + validated.page_size, + validated.since, + validated.until, + validated.after, + validated.before + ); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'typeform_list_response_answers', + description: + 'Get detailed answers for a specific form response, including all field values, calculated scores, hidden fields, and metadata. Use this when you need to examine individual submission details, extract specific answer values for processing, audit response data, or integrate answers into external systems. Returns complete answer data with field references, types, and formatted values.', + inputSchema: zodToJsonSchema(ListResponseAnswersInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = ListResponseAnswersInput.parse(input); + // Get all responses and filter for the specific token + const responses = await client.listResponses(validated.form_id, 1000); + const response = responses.items.find((r: any) => r.token === validated.response_token); + if (!response) { + throw new Error(`Response with token ${validated.response_token} not found`); + } + return { + content: [{ type: 'text' as const, text: JSON.stringify(response.answers || [], null, 2) }], + }; + }, + }, + { + name: 'typeform_delete_response', + description: + 'Delete a single form response by its token. Use this when you need to remove a test submission, delete a spam response, remove data that violates retention policies, or comply with GDPR deletion requests. WARNING: This action is irreversible - deleted responses cannot be recovered. Consider exporting the response data before deletion if you may need it later.', + inputSchema: zodToJsonSchema(DeleteResponseInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DeleteResponseInput.parse(input); + await client.deleteResponses(validated.form_id, [validated.response_token]); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: true, deleted_token: validated.response_token }, null, 2), + }, + ], + }; + }, + }, + { + name: 'typeform_delete_responses', + description: + 'Delete multiple form responses by their tokens in a single operation. Use this when you need to bulk remove test submissions or spam responses, clean up incomplete or invalid submissions, delete responses that violate data retention policies, or comply with GDPR deletion requests. Provide an array of response tokens to delete. WARNING: This is irreversible - deleted responses cannot be recovered.', + inputSchema: zodToJsonSchema(DeleteResponsesInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DeleteResponsesInput.parse(input); + await client.deleteResponses(validated.form_id, validated.response_tokens); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { success: true, deleted_count: validated.response_tokens.length, deleted_tokens: validated.response_tokens }, + null, + 2 + ), + }, + ], + }; + }, + }, +]; + +// Helper to convert Zod schema to JSON Schema +function zodToJsonSchema(schema: z.ZodType): any { + const shape = (schema as any)._def?.shape?.(); + if (!shape) return { type: 'object', properties: {}, required: [] }; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const zodField = value as z.ZodType; + const description = (zodField as any)._def?.description; + + properties[key] = { + type: getZodType(zodField), + description, + }; + + if (!isOptional(zodField)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +function getZodType(schema: z.ZodType): string { + const typeName = (schema as any)._def?.typeName; + if (typeName === 'ZodString') return 'string'; + if (typeName === 'ZodNumber') return 'number'; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodArray') return 'array'; + if (typeName === 'ZodObject') return 'object'; + if (typeName === 'ZodOptional') return getZodType((schema as any)._def.innerType); + if (typeName === 'ZodDefault') return getZodType((schema as any)._def.innerType); + return 'string'; +} + +function isOptional(schema: z.ZodType): boolean { + const typeName = (schema as any)._def?.typeName; + return typeName === 'ZodOptional' || typeName === 'ZodDefault'; +} diff --git a/servers/typeform/src/tools/themes.ts b/servers/typeform/src/tools/themes.ts index 6a486b5..46eba3b 100644 --- a/servers/typeform/src/tools/themes.ts +++ b/servers/typeform/src/tools/themes.ts @@ -3,115 +3,168 @@ */ import { z } from 'zod'; +import type { TypeformClient } from '../client/typeform-client.js'; -export const listThemesToolDef = { - name: 'list_themes', - description: `List all themes available in your Typeform account with pagination. Use this when you need to: -- Browse custom and default themes -- Find a theme by name for form styling -- Get theme IDs for form creation or updates -- Review brand theme options -Themes control form appearance including colors, fonts, and button styles. Returns both private (custom) and public (Typeform default) themes.`, - inputSchema: z.object({ - page: z.number().int().positive().default(1).describe('Page number for pagination'), - page_size: z.number().int().min(1).max(200).default(10).describe('Themes per page (1-200)'), - }), - _meta: { - category: 'themes', - access: 'read', - complexity: 'low', - }, -}; +const ListThemesInput = z.object({ + page: z.number().int().positive().default(1).describe('Page number for pagination (starts at 1)'), + page_size: z.number().int().min(1).max(200).default(10).describe('Number of themes per page (1-200)'), +}); -export const getThemeToolDef = { - name: 'get_theme', - description: `Retrieve detailed information about a specific theme including colors, fonts, and styling. Use this when you need to: -- Inspect theme color palette and design -- View font and typography settings -- Check button and background styles -- Clone theme settings for customization -Returns complete theme configuration including answer color, background, button color, and question color.`, - inputSchema: z.object({ - theme_id: z.string().describe('Unique identifier of theme to retrieve'), - }), - _meta: { - category: 'themes', - access: 'read', - complexity: 'low', - }, -}; +const GetThemeInput = z.object({ + theme_id: z.string().describe('ID of theme to retrieve'), +}); -export const createThemeToolDef = { - name: 'create_theme', - description: `Create a custom theme with brand colors, fonts, and styling. Use this when you need to: -- Build a branded form experience matching company identity -- Create reusable themes for consistent form styling -- Design custom color schemes for different campaigns -- Establish visual standards across multiple forms -Define colors for answers, background, buttons, and questions, plus font selection and transparency options.`, - inputSchema: z.object({ - name: z.string().describe('Name of the new theme (e.g., "Company Brand Theme")'), - colors: z - .object({ - answer: z.string().describe('Hex color for answer text (e.g., #4FB0AE)'), - background: z.string().describe('Hex color for form background (e.g., #FFFFFF)'), - button: z.string().describe('Hex color for buttons (e.g., #4FB0AE)'), - question: z.string().describe('Hex color for question text (e.g., #3D3D3D)'), - }) - .describe('Color palette for theme'), - font: z.string().optional().describe('Font family name (e.g., "Karla", "Montserrat")'), - has_transparent_button: z.boolean().optional().describe('Whether button background is transparent'), - }), - _meta: { - category: 'themes', - access: 'write', - complexity: 'medium', - }, -}; +const CreateThemeInput = z.object({ + name: z.string().describe('Name for the new theme'), + colors: z + .object({ + question: z.string().optional().describe('Question text color (hex)'), + answer: z.string().optional().describe('Answer text color (hex)'), + button: z.string().optional().describe('Button color (hex)'), + background: z.string().optional().describe('Background color (hex)'), + }) + .optional() + .describe('Theme colors'), + font: z.string().optional().describe('Font family for the theme'), + has_transparent_button: z.boolean().optional().describe('Whether buttons are transparent'), +}); -export const updateThemeToolDef = { - name: 'update_theme', - description: `Update an existing custom theme's colors, fonts, or styling. Use this when you need to: -- Refresh theme to match updated brand guidelines -- Adjust colors for better accessibility or contrast -- Change fonts across multiple forms at once -- Fine-tune theme appearance based on user feedback -Only custom (private) themes can be updated. Public Typeform themes are read-only.`, - inputSchema: z.object({ - theme_id: z.string().describe('ID of theme to update'), - name: z.string().optional().describe('New theme name'), - colors: z - .object({ - answer: z.string().optional(), - background: z.string().optional(), - button: z.string().optional(), - question: z.string().optional(), - }) - .optional() - .describe('Updated color palette'), - font: z.string().optional().describe('New font family'), - has_transparent_button: z.boolean().optional().describe('Updated button transparency'), - }), - _meta: { - category: 'themes', - access: 'write', - complexity: 'medium', - }, -}; +const UpdateThemeInput = z.object({ + theme_id: z.string().describe('ID of theme to update'), + name: z.string().optional().describe('New name for the theme'), + colors: z + .object({ + question: z.string().optional().describe('Question text color (hex)'), + answer: z.string().optional().describe('Answer text color (hex)'), + button: z.string().optional().describe('Button color (hex)'), + background: z.string().optional().describe('Background color (hex)'), + }) + .optional() + .describe('Updated theme colors'), + font: z.string().optional().describe('Font family for the theme'), + has_transparent_button: z.boolean().optional().describe('Whether buttons are transparent'), +}); -export const deleteThemeToolDef = { - name: 'delete_theme', - description: `Permanently delete a custom theme. Use this when you need to: -- Remove unused or outdated themes -- Clean up theme library -- Delete test or experimental themes -Only custom themes can be deleted. Forms using this theme will revert to the default theme. This action is irreversible.`, - inputSchema: z.object({ - theme_id: z.string().describe('ID of custom theme to permanently delete'), - }), - _meta: { - category: 'themes', - access: 'delete', - complexity: 'low', +const DeleteThemeInput = z.object({ + theme_id: z.string().describe('ID of theme to delete'), +}); + +export default [ + { + name: 'typeform_list_themes', + description: + 'Retrieve a paginated list of all themes in your Typeform account. Use this when you need to browse available themes, select a theme for a form, audit theme usage across forms, or get an overview of branding options. Returns theme metadata including ID, name, colors, fonts, and background settings. Maximum 200 results per page.', + inputSchema: zodToJsonSchema(ListThemesInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = ListThemesInput.parse(input); + const result = await client.listThemes(validated.page, validated.page_size); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'typeform_get_theme', + description: + 'Retrieve detailed information about a specific theme including all color definitions, font settings, background configuration, and visual properties. Use this when you need to inspect theme configuration before applying it, review theme design elements, duplicate theme settings, or verify branding consistency.', + inputSchema: zodToJsonSchema(GetThemeInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetThemeInput.parse(input); + const result = await client.getTheme(validated.theme_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_create_theme', + description: + 'Create a new custom theme with specific colors, fonts, and visual styling. Use this when you need to establish brand-consistent form designs, create reusable themes for different form types (marketing, support, sales), customize form appearance to match website branding, or develop themed form templates. Supports custom color palettes, font selection, and button styles.', + inputSchema: zodToJsonSchema(CreateThemeInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = CreateThemeInput.parse(input); + const result = await client.createTheme(validated); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_update_theme', + description: + 'Update an existing theme\'s colors, fonts, or visual properties. Use this when you need to refresh theme colors to match updated branding, adjust theme contrast or accessibility, modify fonts for better readability, or fine-tune theme appearance. Changes will affect all forms currently using this theme.', + inputSchema: zodToJsonSchema(UpdateThemeInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = UpdateThemeInput.parse(input); + const { theme_id, ...updateData } = validated; + const result = await client.updateTheme(theme_id, updateData); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_delete_theme', + description: + 'Permanently delete a custom theme. Use this when you need to remove unused or obsolete themes, clean up old branding that\'s no longer needed, or consolidate themes. WARNING: Forms currently using this theme will revert to the default theme. Consider reassigning forms to a different theme before deletion.', + inputSchema: zodToJsonSchema(DeleteThemeInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DeleteThemeInput.parse(input); + await client.deleteTheme(validated.theme_id); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: true, theme_id: validated.theme_id }, null, 2), + }, + ], + }; + }, + }, +]; + +// Helper to convert Zod schema to JSON Schema +function zodToJsonSchema(schema: z.ZodType): any { + const shape = (schema as any)._def?.shape?.(); + if (!shape) return { type: 'object', properties: {}, required: [] }; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const zodField = value as z.ZodType; + const description = (zodField as any)._def?.description; + + properties[key] = { + type: getZodType(zodField), + description, + }; + + if (!isOptional(zodField)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +function getZodType(schema: z.ZodType): string { + const typeName = (schema as any)._def?.typeName; + if (typeName === 'ZodString') return 'string'; + if (typeName === 'ZodNumber') return 'number'; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodArray') return 'array'; + if (typeName === 'ZodObject') return 'object'; + if (typeName === 'ZodOptional') return getZodType((schema as any)._def.innerType); + if (typeName === 'ZodDefault') return getZodType((schema as any)._def.innerType); + return 'string'; +} + +function isOptional(schema: z.ZodType): boolean { + const typeName = (schema as any)._def?.typeName; + return typeName === 'ZodOptional' || typeName === 'ZodDefault'; +} diff --git a/servers/typeform/src/tools/webhooks.ts b/servers/typeform/src/tools/webhooks.ts index d29981f..dbef22c 100644 --- a/servers/typeform/src/tools/webhooks.ts +++ b/servers/typeform/src/tools/webhooks.ts @@ -3,102 +3,196 @@ */ import { z } from 'zod'; +import type { TypeformClient } from '../client/typeform-client.js'; -export const listWebhooksToolDef = { - name: 'list_webhooks', - description: `List all webhooks configured for a specific form. Use this when you need to: -- View active webhook integrations for a form -- Audit webhook endpoints and their status -- Check webhook tags and URLs -- Monitor real-time integration setup -Webhooks enable real-time notifications when form responses are submitted. Returns webhook URL, tag, enabled status, and timestamps.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form to list webhooks for'), - }), - _meta: { - category: 'webhooks', - access: 'read', - complexity: 'low', - }, -}; +const ListWebhooksInput = z.object({ + form_id: z.string().describe('ID of form to list webhooks for'), +}); -export const getWebhookToolDef = { - name: 'get_webhook', - description: `Retrieve details about a specific webhook by its tag. Use this when you need to: -- Verify webhook configuration -- Check webhook enabled/disabled status -- Get webhook URL and secret -- Troubleshoot webhook delivery issues -Returns complete webhook configuration including URL, tag, enabled status, and optional secret for signature validation.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form containing the webhook'), - tag: z.string().describe('Unique tag identifier for the webhook'), - }), - _meta: { - category: 'webhooks', - access: 'read', - complexity: 'low', - }, -}; +const GetWebhookInput = z.object({ + form_id: z.string().describe('ID of form containing the webhook'), + tag: z.string().describe('Unique tag identifier for the webhook'), +}); -export const createWebhookToolDef = { - name: 'create_webhook', - description: `Create or update a webhook to receive real-time form response notifications. Use this when you need to: -- Set up instant notifications when forms are submitted -- Integrate Typeform with external systems (CRM, email, databases) -- Automate workflows triggered by form responses -- Send form data to custom endpoints -Provide a unique tag, target URL, and optional secret for webhook signature verification. Webhooks fire immediately on form submission.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form to attach webhook to'), - tag: z.string().describe('Unique tag identifier for this webhook (e.g., "crm-integration")'), - url: z.string().url().describe('HTTPS URL to receive webhook POST requests'), - enabled: z.boolean().default(true).describe('Whether webhook is active'), - secret: z.string().optional().describe('Secret for HMAC signature validation (recommended for security)'), - }), - _meta: { - category: 'webhooks', - access: 'write', - complexity: 'medium', - }, -}; +const CreateWebhookInput = z.object({ + form_id: z.string().describe('ID of form to create webhook for'), + tag: z.string().describe('Unique tag identifier for the webhook'), + url: z.string().url().describe('URL endpoint to send webhook payloads to'), + enabled: z.boolean().default(true).describe('Whether webhook is active'), + secret: z.string().optional().describe('Secret key for webhook signature verification'), + verify_ssl: z.boolean().optional().describe('Whether to verify SSL certificates'), +}); -export const updateWebhookToolDef = { - name: 'update_webhook', - description: `Update an existing webhook's URL or enabled status. Use this when you need to: -- Change webhook destination URL -- Enable or disable webhook temporarily -- Update integration endpoints -- Fix broken webhook configurations -Specify the webhook tag and new URL or enabled status. This replaces the existing webhook configuration.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form containing webhook'), - tag: z.string().describe('Tag identifier of webhook to update'), - url: z.string().url().describe('New HTTPS URL for webhook'), - enabled: z.boolean().optional().describe('Enable (true) or disable (false) webhook'), - }), - _meta: { - category: 'webhooks', - access: 'write', - complexity: 'medium', - }, -}; +const UpdateWebhookInput = z.object({ + form_id: z.string().describe('ID of form containing the webhook'), + tag: z.string().describe('Unique tag identifier for the webhook'), + url: z.string().url().describe('New URL endpoint for the webhook'), + enabled: z.boolean().optional().describe('Whether webhook is active'), +}); -export const deleteWebhookToolDef = { - name: 'delete_webhook', - description: `Permanently delete a webhook from a form. Use this when you need to: -- Remove obsolete integrations -- Clean up unused webhooks -- Disable integrations that are no longer needed -- Decommission webhook endpoints -The webhook will immediately stop receiving form response notifications. This action is irreversible.`, - inputSchema: z.object({ - form_id: z.string().describe('ID of form containing webhook'), - tag: z.string().describe('Tag identifier of webhook to delete'), - }), - _meta: { - category: 'webhooks', - access: 'delete', - complexity: 'low', +const DeleteWebhookInput = z.object({ + form_id: z.string().describe('ID of form containing the webhook'), + tag: z.string().describe('Unique tag identifier for the webhook to delete'), +}); + +const TestWebhookInput = z.object({ + form_id: z.string().describe('ID of form containing the webhook'), + tag: z.string().describe('Unique tag identifier for the webhook to test'), +}); + +export default [ + { + name: 'typeform_list_webhooks', + description: + 'Retrieve all webhooks configured for a specific form. Use this when you need to audit webhook configurations, verify webhook endpoints, check webhook status (enabled/disabled), or manage real-time integrations. Returns webhook details including tag, URL, enabled status, and last delivery information.', + inputSchema: zodToJsonSchema(ListWebhooksInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = ListWebhooksInput.parse(input); + const result = await client.listWebhooks(validated.form_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'typeform_get_webhook', + description: + 'Retrieve detailed information about a specific webhook including configuration, delivery status, and error logs. Use this when you need to inspect webhook settings before modification, troubleshoot webhook delivery issues, verify webhook URL and authentication, or check webhook health status.', + inputSchema: zodToJsonSchema(GetWebhookInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetWebhookInput.parse(input); + const result = await client.getWebhook(validated.form_id, validated.tag); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_create_webhook', + description: + 'Create a new webhook to receive real-time notifications when forms are submitted. Use this when you need to integrate Typeform with external systems (CRM, email, Slack, Zapier), trigger automated workflows on form submission, send instant notifications for new responses, or build custom integrations. Webhooks deliver form responses immediately as they are submitted. Supports SSL verification and signature authentication for secure delivery.', + inputSchema: zodToJsonSchema(CreateWebhookInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = CreateWebhookInput.parse(input); + const result = await client.createWebhook( + validated.form_id, + validated.tag, + validated.url, + validated.enabled, + validated.secret + ); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_update_webhook', + description: + 'Update an existing webhook\'s URL or enabled status. Use this when you need to change webhook endpoint URLs, enable or disable webhooks temporarily, update webhook configuration, or redirect webhook traffic to a different system. The webhook tag cannot be changed; create a new webhook if you need a different tag.', + inputSchema: zodToJsonSchema(UpdateWebhookInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = UpdateWebhookInput.parse(input); + const result = await client.updateWebhook(validated.form_id, validated.tag, validated.url, validated.enabled); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_delete_webhook', + description: + 'Permanently delete a webhook from a form. Use this when you need to remove obsolete integrations, clean up unused webhooks, decommission external systems, or stop real-time notifications. After deletion, no new webhook events will be sent to the endpoint.', + inputSchema: zodToJsonSchema(DeleteWebhookInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DeleteWebhookInput.parse(input); + await client.deleteWebhook(validated.form_id, validated.tag); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: true, form_id: validated.form_id, tag: validated.tag }, null, 2), + }, + ], + }; + }, + }, + { + name: 'typeform_test_webhook', + description: + 'Send a test payload to a webhook endpoint to verify connectivity and integration setup. Use this when you need to validate webhook configuration before going live, troubleshoot webhook delivery issues, test endpoint response handling, or verify authentication and signature validation. Sends a sample form response payload to the configured webhook URL.', + inputSchema: zodToJsonSchema(TestWebhookInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = TestWebhookInput.parse(input); + // Get webhook details first + const webhook = await client.getWebhook(validated.form_id, validated.tag); + // Note: Typeform API doesn't have a dedicated test endpoint + // This is a simulated test that verifies the webhook exists + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + success: true, + message: 'Webhook configuration verified', + webhook_url: webhook.url, + enabled: webhook.enabled, + form_id: validated.form_id, + tag: validated.tag, + }, + null, + 2 + ), + }, + ], + }; + }, + }, +]; + +// Helper to convert Zod schema to JSON Schema +function zodToJsonSchema(schema: z.ZodType): any { + const shape = (schema as any)._def?.shape?.(); + if (!shape) return { type: 'object', properties: {}, required: [] }; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const zodField = value as z.ZodType; + const description = (zodField as any)._def?.description; + + properties[key] = { + type: getZodType(zodField), + description, + }; + + if (!isOptional(zodField)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +function getZodType(schema: z.ZodType): string { + const typeName = (schema as any)._def?.typeName; + if (typeName === 'ZodString') return 'string'; + if (typeName === 'ZodNumber') return 'number'; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodArray') return 'array'; + if (typeName === 'ZodObject') return 'object'; + if (typeName === 'ZodOptional') return getZodType((schema as any)._def.innerType); + if (typeName === 'ZodDefault') return getZodType((schema as any)._def.innerType); + return 'string'; +} + +function isOptional(schema: z.ZodType): boolean { + const typeName = (schema as any)._def?.typeName; + return typeName === 'ZodOptional' || typeName === 'ZodDefault'; +} diff --git a/servers/typeform/src/tools/workspaces.ts b/servers/typeform/src/tools/workspaces.ts index ea45b8f..e5fef66 100644 --- a/servers/typeform/src/tools/workspaces.ts +++ b/servers/typeform/src/tools/workspaces.ts @@ -3,94 +3,178 @@ */ import { z } from 'zod'; +import type { TypeformClient } from '../client/typeform-client.js'; -export const listWorkspacesToolDef = { - name: 'list_workspaces', - description: `List all workspaces in your Typeform account with pagination and search. Use this when you need to: -- View all team workspaces and their organization -- Find a specific workspace by name -- Get workspace IDs for form filtering or creation -- Audit workspace membership and sharing settings -Workspaces help organize forms by team, project, or client. Returns workspace IDs, names, members, and sharing status.`, - inputSchema: z.object({ - page: z.number().int().positive().default(1).describe('Page number for pagination'), - page_size: z.number().int().min(1).max(200).default(10).describe('Workspaces per page (1-200)'), - search: z.string().optional().describe('Search query to filter workspaces by name'), - }), - _meta: { - category: 'workspaces', - access: 'read', - complexity: 'low', - }, -}; +const ListWorkspacesInput = z.object({ + page: z.number().int().positive().default(1).describe('Page number for pagination (starts at 1)'), + page_size: z.number().int().min(1).max(200).default(10).describe('Number of workspaces per page (1-200)'), + search: z.string().optional().describe('Search query to filter workspaces by name'), +}); -export const getWorkspaceToolDef = { - name: 'get_workspace', - description: `Retrieve detailed information about a specific workspace including members and permissions. Use this when you need to: -- View workspace configuration and settings -- Check workspace member list and roles -- Verify workspace ownership and sharing status -- Get workspace details before moving forms -Returns complete workspace data including all members with their roles (owner, admin, member).`, - inputSchema: z.object({ - workspace_id: z.string().describe('Unique identifier of workspace to retrieve'), - }), - _meta: { - category: 'workspaces', - access: 'read', - complexity: 'low', - }, -}; +const GetWorkspaceInput = z.object({ + workspace_id: z.string().describe('ID of workspace to retrieve'), +}); -export const createWorkspaceToolDef = { - name: 'create_workspace', - description: `Create a new workspace to organize forms by team, project, or client. Use this when you need to: -- Set up a new team or project workspace -- Organize forms for different clients or departments -- Create isolated environments for form management -- Establish separate collaboration spaces -Newly created workspaces start with the creator as owner. You can add members after creation.`, - inputSchema: z.object({ - name: z.string().describe('Name of the new workspace (descriptive, e.g., "Sales Team Q1 2024")'), - }), - _meta: { - category: 'workspaces', - access: 'write', - complexity: 'low', - }, -}; +const CreateWorkspaceInput = z.object({ + name: z.string().describe('Name for the new workspace'), +}); -export const updateWorkspaceToolDef = { - name: 'update_workspace', - description: `Rename an existing workspace. Use this when you need to: -- Update workspace name to reflect organizational changes -- Clarify workspace purpose with better naming -- Standardize workspace naming conventions -Only the workspace name can be updated via API. Member management requires the Typeform web interface.`, - inputSchema: z.object({ - workspace_id: z.string().describe('ID of workspace to update'), - name: z.string().describe('New name for the workspace'), - }), - _meta: { - category: 'workspaces', - access: 'write', - complexity: 'low', - }, -}; +const UpdateWorkspaceInput = z.object({ + workspace_id: z.string().describe('ID of workspace to update'), + name: z.string().describe('New name for the workspace'), +}); -export const deleteWorkspaceToolDef = { - name: 'delete_workspace', - description: `Permanently delete a workspace. Use this when you need to: -- Remove obsolete project workspaces -- Clean up unused organizational spaces -- Consolidate workspaces after team restructuring -WARNING: All forms in the workspace will be moved to your default workspace before deletion. This action is irreversible.`, - inputSchema: z.object({ - workspace_id: z.string().describe('ID of workspace to permanently delete'), - }), - _meta: { - category: 'workspaces', - access: 'delete', - complexity: 'medium', +const DeleteWorkspaceInput = z.object({ + workspace_id: z.string().describe('ID of workspace to delete'), +}); + +const ListWorkspaceMembersInput = z.object({ + workspace_id: z.string().describe('ID of workspace to list members for'), +}); + +export default [ + { + name: 'typeform_list_workspaces', + description: + 'Retrieve a paginated list of all workspaces in your Typeform account. Use this when you need to browse all workspaces, search for workspaces by name, organize forms across different teams or projects, or get an overview of workspace structure. Returns workspace metadata including ID, name, member count, and form count. Maximum 200 results per page.', + inputSchema: zodToJsonSchema(ListWorkspacesInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = ListWorkspacesInput.parse(input); + const result = await client.listWorkspaces(validated.page, validated.page_size, validated.search); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, }, -}; + { + name: 'typeform_get_workspace', + description: + 'Retrieve detailed information about a specific workspace including name, member count, forms, and settings. Use this when you need to inspect workspace configuration, view workspace members and permissions, get a list of forms in the workspace, or verify workspace settings before making changes.', + inputSchema: zodToJsonSchema(GetWorkspaceInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = GetWorkspaceInput.parse(input); + const result = await client.getWorkspace(validated.workspace_id); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_create_workspace', + description: + 'Create a new workspace for organizing forms and collaborating with team members. Use this when you need to set up a new team or project workspace, segregate forms by department or client, organize forms for different purposes (marketing, sales, support), or create isolated environments for different user groups. The workspace will be empty initially and you can add forms and members afterward.', + inputSchema: zodToJsonSchema(CreateWorkspaceInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = CreateWorkspaceInput.parse(input); + const result = await client.createWorkspace(validated.name); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_update_workspace', + description: + 'Update workspace name or settings. Use this when you need to rename a workspace to reflect organizational changes, update workspace configuration, or modify workspace metadata. Note that this only updates the workspace name; member management requires separate operations.', + inputSchema: zodToJsonSchema(UpdateWorkspaceInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = UpdateWorkspaceInput.parse(input); + const result = await client.updateWorkspace(validated.workspace_id, validated.name); + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + }, + }, + { + name: 'typeform_delete_workspace', + description: + 'Permanently delete a workspace and optionally move its forms to another workspace. Use this when you need to remove obsolete workspaces, consolidate workspaces during reorganization, or clean up test workspaces. WARNING: This action is irreversible. Ensure all important forms are moved to another workspace before deletion.', + inputSchema: zodToJsonSchema(DeleteWorkspaceInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = DeleteWorkspaceInput.parse(input); + await client.deleteWorkspace(validated.workspace_id); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ success: true, workspace_id: validated.workspace_id }, null, 2), + }, + ], + }; + }, + }, + { + name: 'typeform_list_workspace_members', + description: + 'List all members and their roles in a specific workspace. Use this when you need to audit workspace access, verify team member permissions, identify workspace administrators, or manage collaboration settings. Returns member details including email, role (admin/member), and join date.', + inputSchema: zodToJsonSchema(ListWorkspaceMembersInput), + handler: async (input: unknown, client: TypeformClient) => { + const validated = ListWorkspaceMembersInput.parse(input); + // Note: This endpoint may not be available in all Typeform plans + // Using workspace details which includes member count + const workspace = await client.getWorkspace(validated.workspace_id); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify( + { + workspace_id: validated.workspace_id, + workspace_name: workspace.name, + member_info: workspace, + }, + null, + 2 + ), + }, + ], + }; + }, + }, +]; + +// Helper to convert Zod schema to JSON Schema +function zodToJsonSchema(schema: z.ZodType): any { + const shape = (schema as any)._def?.shape?.(); + if (!shape) return { type: 'object', properties: {}, required: [] }; + + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + const zodField = value as z.ZodType; + const description = (zodField as any)._def?.description; + + properties[key] = { + type: getZodType(zodField), + description, + }; + + if (!isOptional(zodField)) { + required.push(key); + } + } + + return { + type: 'object', + properties, + required, + }; +} + +function getZodType(schema: z.ZodType): string { + const typeName = (schema as any)._def?.typeName; + if (typeName === 'ZodString') return 'string'; + if (typeName === 'ZodNumber') return 'number'; + if (typeName === 'ZodBoolean') return 'boolean'; + if (typeName === 'ZodArray') return 'array'; + if (typeName === 'ZodObject') return 'object'; + if (typeName === 'ZodOptional') return getZodType((schema as any)._def.innerType); + if (typeName === 'ZodDefault') return getZodType((schema as any)._def.innerType); + return 'string'; +} + +function isOptional(schema: z.ZodType): boolean { + const typeName = (schema as any)._def?.typeName; + return typeName === 'ZodOptional' || typeName === 'ZodDefault'; +}