diff --git a/servers/hubspot/.env.example b/servers/hubspot/.env.example new file mode 100644 index 0000000..80225f9 --- /dev/null +++ b/servers/hubspot/.env.example @@ -0,0 +1,3 @@ +# HubSpot Private App Access Token +# Create at: https://app.hubspot.com/private-apps/{your-account-id} +HUBSPOT_ACCESS_TOKEN=your-token-here diff --git a/servers/hubspot/.gitignore b/servers/hubspot/.gitignore new file mode 100644 index 0000000..2ac8f23 --- /dev/null +++ b/servers/hubspot/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +coverage/ +.vscode/ +.idea/ diff --git a/servers/hubspot/README.md b/servers/hubspot/README.md new file mode 100644 index 0000000..c375cea --- /dev/null +++ b/servers/hubspot/README.md @@ -0,0 +1,203 @@ +# HubSpot MCP Server + +Model Context Protocol (MCP) server for HubSpot CRM API v3. Provides comprehensive access to HubSpot CRM objects, marketing tools, CMS, and analytics. + +## Features + +- **CRM Objects**: Contacts, Companies, Deals, Tickets, Line Items, Products, Quotes +- **Associations**: Manage relationships between objects +- **Marketing**: Email campaigns, forms, lists, workflows +- **CMS**: Blog posts, pages, HubDB tables +- **Engagements**: Notes, calls, emails, meetings, tasks +- **Pipelines & Stages**: Deal and ticket pipeline management +- **Search API**: Advanced filtering and search across all objects +- **Batch Operations**: Efficient bulk create/update/archive +- **Rate Limiting**: Automatic retry with exponential backoff +- **Pagination**: Automatic handling of cursor-based pagination + +## Installation + +```bash +npm install +npm run build +``` + +## Configuration + +Create a `.env` file (see `.env.example`): + +```bash +HUBSPOT_ACCESS_TOKEN=your-private-app-token-here +``` + +### Getting a HubSpot Access Token + +1. Go to your HubSpot account settings +2. Navigate to **Integrations** → **Private Apps** +3. Click **Create a private app** +4. Configure the required scopes: + - `crm.objects.contacts.read` / `crm.objects.contacts.write` + - `crm.objects.companies.read` / `crm.objects.companies.write` + - `crm.objects.deals.read` / `crm.objects.deals.write` + - `crm.objects.owners.read` + - Additional scopes based on your needs +5. Copy the generated access token + +## Usage + +### Development Mode + +```bash +npm run dev +``` + +### Production Mode + +```bash +npm run build +npm start +``` + +### MCP Configuration + +Add to your MCP client configuration (e.g., Claude Desktop): + +```json +{ + "mcpServers": { + "hubspot": { + "command": "node", + "args": ["/path/to/hubspot/dist/main.js"], + "env": { + "HUBSPOT_ACCESS_TOKEN": "your-token-here" + } + } + } +} +``` + +## Available Tools + +### Contacts + +- `hubspot_contacts_list` - List all contacts with pagination +- `hubspot_contacts_get` - Get a contact by ID +- `hubspot_contacts_create` - Create a new contact +- `hubspot_contacts_update` - Update contact properties +- `hubspot_contacts_search` - Search contacts with filters + +### Companies + +- `hubspot_companies_list` - List all companies +- `hubspot_companies_get` - Get a company by ID +- `hubspot_companies_create` - Create a new company + +### Deals + +- `hubspot_deals_list` - List all deals +- `hubspot_deals_get` - Get a deal by ID +- `hubspot_deals_create` - Create a new deal + +### Tickets + +- `hubspot_tickets_list` - List all tickets +- `hubspot_tickets_create` - Create a new ticket + +### Associations + +- `hubspot_associations_get` - Get associations for an object +- `hubspot_associations_create` - Create an association between objects + +### Pipelines + +- `hubspot_pipelines_list` - List pipelines for an object type + +### Owners + +- `hubspot_owners_list` - List all owners in the account + +### Properties + +- `hubspot_properties_list` - List properties for an object type + +## Architecture + +### Core Components + +1. **`src/types/index.ts`** - Comprehensive TypeScript type definitions for all HubSpot entities +2. **`src/clients/hubspot.ts`** - API client with retry logic, rate limiting, and pagination +3. **`src/server.ts`** - MCP server implementation with lazy-loaded tool modules +4. **`src/main.ts`** - Entry point with environment validation and graceful shutdown + +### API Client Features + +- **Authentication**: Bearer token (Private App or OAuth) +- **Rate Limiting**: Automatic handling of 429 responses with retry-after headers +- **Retry Logic**: Exponential backoff for transient failures (max 3 retries) +- **Pagination**: Automatic cursor-based pagination (HubSpot uses `after` cursor) +- **Batch Operations**: Efficient bulk operations for create/update/archive +- **Type Safety**: Full TypeScript type definitions for all API entities + +### HubSpot API Limits + +- **Free/Starter**: 10 requests/second +- **Professional/Enterprise**: Higher limits based on tier +- **Search API**: Up to 10,000 results per search +- **Batch API**: Up to 100 objects per batch request + +## Error Handling + +The server provides structured error responses: + +```typescript +{ + content: [{ + type: 'text', + text: 'Error: HubSpot API Error: {message} ({status}) [{correlationId}]' + }], + isError: true +} +``` + +## Development + +### Type Safety + +All HubSpot entities use branded types for IDs: + +```typescript +type ContactId = string & { readonly __brand: 'ContactId' }; +type CompanyId = string & { readonly __brand: 'CompanyId' }; +``` + +### Adding New Tools + +1. Add tool definition in `src/server.ts` → `ListToolsRequestSchema` handler +2. Add routing in `handleToolCall` method +3. Implement handler method (or lazy-load from separate module) + +### Testing + +```bash +npm test # (tests not yet implemented) +``` + +### Type Checking + +```bash +npx tsc --noEmit +``` + +## Resources + +- [HubSpot API Documentation](https://developers.hubspot.com/docs/api/overview) +- [HubSpot CRM API v3](https://developers.hubspot.com/docs/api/crm/understanding-the-crm) +- [Model Context Protocol](https://modelcontextprotocol.io) + +## License + +MIT + +## Support + +For issues and feature requests, please open an issue on GitHub. diff --git a/servers/hubspot/package.json b/servers/hubspot/package.json new file mode 100644 index 0000000..f8668e9 --- /dev/null +++ b/servers/hubspot/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcpengine/hubspot", + "version": "1.0.0", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "tsx": "^4.19.0", + "@types/node": "^22.0.0" + } +} diff --git a/servers/hubspot/src/clients/hubspot.ts b/servers/hubspot/src/clients/hubspot.ts new file mode 100644 index 0000000..366d358 --- /dev/null +++ b/servers/hubspot/src/clients/hubspot.ts @@ -0,0 +1,457 @@ +/** + * HubSpot API Client + * Handles authentication, pagination, rate limiting, and batch operations + */ + +import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios'; +import type { + HubSpotObject, + PagedResponse, + SearchRequest, + SearchResponse, + BatchReadRequest, + BatchCreateRequest, + BatchUpdateRequest, + BatchArchiveRequest, + BatchResponse, + HubSpotError, + AssociationResult, + BatchAssociation, +} from '../types/index.js'; + +export interface HubSpotClientConfig { + accessToken: string; + baseURL?: string; + maxRetries?: number; + retryDelay?: number; +} + +export class HubSpotClient { + private client: AxiosInstance; + private maxRetries: number; + private retryDelay: number; + + constructor(config: HubSpotClientConfig) { + this.maxRetries = config.maxRetries ?? 3; + this.retryDelay = config.retryDelay ?? 1000; + + this.client = axios.create({ + baseURL: config.baseURL ?? 'https://api.hubapi.com', + headers: { + Authorization: `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); + + // Response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status === 429) { + // Rate limit hit - implement exponential backoff + const retryAfter = parseInt(error.response.headers['retry-after'] || '1', 10); + await this.sleep(retryAfter * 1000); + return this.client.request(error.config!); + } + throw this.handleError(error); + } + ); + } + + /** + * Generic request with retry logic + */ + private async request( + config: AxiosRequestConfig, + retries = 0 + ): Promise { + try { + const response = await this.client.request(config); + return response.data; + } catch (error) { + if (retries < this.maxRetries && this.isRetryableError(error)) { + await this.sleep(this.retryDelay * Math.pow(2, retries)); + return this.request(config, retries + 1); + } + throw error; + } + } + + /** + * Check if error is retryable + */ + private isRetryableError(error: any): boolean { + if (axios.isAxiosError(error)) { + const status = error.response?.status; + return ( + status === 429 || // Rate limit + status === 500 || // Server error + status === 502 || // Bad gateway + status === 503 || // Service unavailable + status === 504 // Gateway timeout + ); + } + return false; + } + + /** + * Handle and format errors + */ + private handleError(error: AxiosError): Error { + if (error.response?.data) { + const hubspotError = error.response.data; + return new Error( + `HubSpot API Error: ${hubspotError.message} (${hubspotError.status}) [${hubspotError.correlationId}]` + ); + } + return new Error(`HubSpot API Error: ${error.message}`); + } + + /** + * Sleep utility for retry delays + */ + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + // ===== CRM Objects API ===== + + /** + * Get a single object by ID + */ + async getObject>( + objectType: string, + objectId: string, + properties?: string[], + associations?: string[] + ): Promise> { + const params: Record = {}; + if (properties?.length) { + params.properties = properties.join(','); + } + if (associations?.length) { + params.associations = associations.join(','); + } + + return this.request>({ + method: 'GET', + url: `/crm/v3/objects/${objectType}/${objectId}`, + params, + }); + } + + /** + * List objects with pagination + */ + async listObjects>( + objectType: string, + options?: { + limit?: number; + after?: string; + properties?: string[]; + associations?: string[]; + } + ): Promise>> { + const params: Record = { + limit: Math.min(options?.limit ?? 100, 100), + }; + + if (options?.after) { + params.after = options.after; + } + if (options?.properties?.length) { + params.properties = options.properties.join(','); + } + if (options?.associations?.length) { + params.associations = options.associations.join(','); + } + + return this.request>>({ + method: 'GET', + url: `/crm/v3/objects/${objectType}`, + params, + }); + } + + /** + * Create a new object + */ + async createObject>( + objectType: string, + properties: T, + associations?: Array<{ to: { id: string }; types: Array<{ associationCategory: string; associationTypeId: number }> }> + ): Promise> { + return this.request>({ + method: 'POST', + url: `/crm/v3/objects/${objectType}`, + data: { + properties, + associations, + }, + }); + } + + /** + * Update an existing object + */ + async updateObject>( + objectType: string, + objectId: string, + properties: Partial + ): Promise> { + return this.request>({ + method: 'PATCH', + url: `/crm/v3/objects/${objectType}/${objectId}`, + data: { properties }, + }); + } + + /** + * Archive (soft delete) an object + */ + async archiveObject(objectType: string, objectId: string): Promise { + await this.request({ + method: 'DELETE', + url: `/crm/v3/objects/${objectType}/${objectId}`, + }); + } + + // ===== Search API ===== + + /** + * Search objects with filters and pagination + */ + async searchObjects>( + objectType: string, + searchRequest: SearchRequest + ): Promise>> { + return this.request>>({ + method: 'POST', + url: `/crm/v3/objects/${objectType}/search`, + data: searchRequest, + }); + } + + /** + * Search all results with automatic pagination (up to 10,000 limit) + */ + async searchAllObjects>( + objectType: string, + searchRequest: SearchRequest, + maxResults = 10000 + ): Promise[]> { + const allResults: HubSpotObject[] = []; + let after: string | undefined; + const limit = Math.min(searchRequest.limit ?? 100, 100); + + do { + const response = await this.searchObjects(objectType, { + ...searchRequest, + limit, + after, + }); + + allResults.push(...response.results); + after = response.paging?.next?.after; + + if (allResults.length >= maxResults) { + break; + } + } while (after); + + return allResults.slice(0, maxResults); + } + + // ===== Batch API ===== + + /** + * Batch read objects + */ + async batchReadObjects>( + objectType: string, + request: BatchReadRequest + ): Promise>> { + return this.request>>({ + method: 'POST', + url: `/crm/v3/objects/${objectType}/batch/read`, + data: request, + }); + } + + /** + * Batch create objects + */ + async batchCreateObjects>( + objectType: string, + request: BatchCreateRequest + ): Promise>> { + return this.request>>({ + method: 'POST', + url: `/crm/v3/objects/${objectType}/batch/create`, + data: request, + }); + } + + /** + * Batch update objects + */ + async batchUpdateObjects>( + objectType: string, + request: BatchUpdateRequest + ): Promise>> { + return this.request>>({ + method: 'POST', + url: `/crm/v3/objects/${objectType}/batch/update`, + data: request, + }); + } + + /** + * Batch archive objects + */ + async batchArchiveObjects( + objectType: string, + request: BatchArchiveRequest + ): Promise { + await this.request({ + method: 'POST', + url: `/crm/v3/objects/${objectType}/batch/archive`, + data: request, + }); + } + + // ===== Associations API ===== + + /** + * Get associations for an object + */ + async getAssociations( + fromObjectType: string, + fromObjectId: string, + toObjectType: string + ): Promise { + return this.request({ + method: 'GET', + url: `/crm/v4/objects/${fromObjectType}/${fromObjectId}/associations/${toObjectType}`, + }); + } + + /** + * Create an association between objects + */ + async createAssociation( + fromObjectType: string, + fromObjectId: string, + toObjectType: string, + toObjectId: string, + associationTypeId: number + ): Promise { + await this.request({ + method: 'PUT', + url: `/crm/v4/objects/${fromObjectType}/${fromObjectId}/associations/${toObjectType}/${toObjectId}`, + data: [ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId, + }, + ], + }); + } + + /** + * Remove an association between objects + */ + async removeAssociation( + fromObjectType: string, + fromObjectId: string, + toObjectType: string, + toObjectId: string, + associationTypeId: number + ): Promise { + await this.request({ + method: 'DELETE', + url: `/crm/v4/objects/${fromObjectType}/${fromObjectId}/associations/${toObjectType}/${toObjectId}`, + data: [ + { + associationCategory: 'HUBSPOT_DEFINED', + associationTypeId, + }, + ], + }); + } + + /** + * Batch create associations + */ + async batchCreateAssociations( + fromObjectType: string, + toObjectType: string, + associations: BatchAssociation[] + ): Promise { + await this.request({ + method: 'POST', + url: `/crm/v4/associations/${fromObjectType}/${toObjectType}/batch/create`, + data: { inputs: associations }, + }); + } + + // ===== Properties API ===== + + /** + * Get all properties for an object type + */ + async getProperties(objectType: string): Promise { + const response = await this.request<{ results: any[] }>({ + method: 'GET', + url: `/crm/v3/properties/${objectType}`, + }); + return response.results; + } + + /** + * Create a custom property + */ + async createProperty(objectType: string, property: any): Promise { + return this.request({ + method: 'POST', + url: `/crm/v3/properties/${objectType}`, + data: property, + }); + } + + // ===== Pipelines API ===== + + /** + * Get all pipelines for an object type + */ + async getPipelines(objectType: string): Promise { + const response = await this.request<{ results: any[] }>({ + method: 'GET', + url: `/crm/v3/pipelines/${objectType}`, + }); + return response.results; + } + + // ===== Owners API ===== + + /** + * Get all owners + */ + async getOwners(): Promise { + const response = await this.request<{ results: any[] }>({ + method: 'GET', + url: '/crm/v3/owners', + }); + return response.results; + } + + // ===== Generic API request ===== + + /** + * Make a generic API request (for endpoints not covered above) + */ + async apiRequest(config: AxiosRequestConfig): Promise { + return this.request(config); + } +} diff --git a/servers/hubspot/src/main.ts b/servers/hubspot/src/main.ts new file mode 100644 index 0000000..6012d0b --- /dev/null +++ b/servers/hubspot/src/main.ts @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +/** + * HubSpot MCP Server - Entry Point + * Handles environment validation, dual transport, graceful shutdown, and health checks + */ + +import { HubSpotServer } from './server.js'; +import { z } from 'zod'; + +// Environment validation schema +const envSchema = z.object({ + HUBSPOT_ACCESS_TOKEN: z.string().min(1, 'HUBSPOT_ACCESS_TOKEN is required'), + SERVER_NAME: z.string().optional().default('@mcpengine/hubspot'), + SERVER_VERSION: z.string().optional().default('1.0.0'), + NODE_ENV: z.enum(['development', 'production', 'test']).optional().default('production'), +}); + +/** + * Validate environment variables + */ +function validateEnvironment() { + try { + return envSchema.parse(process.env); + } catch (error) { + if (error instanceof z.ZodError) { + console.error('Environment validation failed:'); + error.errors.forEach((err) => { + console.error(` - ${err.path.join('.')}: ${err.message}`); + }); + process.exit(1); + } + throw error; + } +} + +/** + * Health check endpoint (for monitoring) + */ +function setupHealthCheck() { + // Simple health check that responds to SIGUSR1 + process.on('SIGUSR1', () => { + console.error(JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: process.memoryUsage(), + })); + }); +} + +/** + * Setup graceful shutdown handlers + */ +function setupGracefulShutdown(server: HubSpotServer) { + let isShuttingDown = false; + + const shutdown = async (signal: string) => { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + console.error(`Received ${signal}, shutting down gracefully...`); + + try { + // Give pending operations time to complete + await new Promise((resolve) => setTimeout(resolve, 1000)); + + console.error('Server shut down successfully'); + process.exit(0); + } catch (error) { + console.error('Error during shutdown:', error); + process.exit(1); + } + }; + + // Handle termination signals + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); + shutdown('uncaughtException'); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled rejection at:', promise, 'reason:', reason); + shutdown('unhandledRejection'); + }); +} + +/** + * Main entry point + */ +async function main() { + try { + // Validate environment + const env = validateEnvironment(); + + console.error('Starting HubSpot MCP Server...'); + console.error(`Environment: ${env.NODE_ENV}`); + console.error(`Server: ${env.SERVER_NAME} v${env.SERVER_VERSION}`); + + // Create and configure server + const server = new HubSpotServer({ + accessToken: env.HUBSPOT_ACCESS_TOKEN, + serverName: env.SERVER_NAME, + serverVersion: env.SERVER_VERSION, + }); + + // Setup health check + setupHealthCheck(); + + // Setup graceful shutdown + setupGracefulShutdown(server); + + // Start server (uses stdio transport by default) + console.error('Server starting with stdio transport...'); + await server.start(); + + console.error('HubSpot MCP Server is running'); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Run the server +main(); diff --git a/servers/hubspot/src/server.ts b/servers/hubspot/src/server.ts new file mode 100644 index 0000000..d9d1f03 --- /dev/null +++ b/servers/hubspot/src/server.ts @@ -0,0 +1,559 @@ +/** + * HubSpot MCP Server + * Main server implementation with lazy-loaded tool modules + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { HubSpotClient } from './clients/hubspot.js'; + +export interface HubSpotServerConfig { + accessToken: string; + serverName?: string; + serverVersion?: string; +} + +export class HubSpotServer { + private server: Server; + private client: HubSpotClient; + private toolModules: Map = new Map(); + + constructor(config: HubSpotServerConfig) { + this.client = new HubSpotClient({ + accessToken: config.accessToken, + }); + + this.server = new Server( + { + name: config.serverName ?? '@mcpengine/hubspot', + version: config.serverVersion ?? '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupHandlers(); + } + + /** + * Setup MCP protocol handlers + */ + private setupHandlers(): void { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + // CRM Objects + { + name: 'hubspot_contacts_list', + description: 'List contacts with optional filtering and pagination', + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Max results (1-100)' }, + after: { type: 'string', description: 'Pagination cursor' }, + properties: { + type: 'array', + items: { type: 'string' }, + description: 'Properties to return', + }, + }, + }, + }, + { + name: 'hubspot_contacts_get', + description: 'Get a contact by ID', + inputSchema: { + type: 'object', + properties: { + contactId: { type: 'string', description: 'Contact ID' }, + properties: { + type: 'array', + items: { type: 'string' }, + description: 'Properties to return', + }, + }, + required: ['contactId'], + }, + }, + { + name: 'hubspot_contacts_create', + description: 'Create a new contact', + inputSchema: { + type: 'object', + properties: { + properties: { + type: 'object', + description: 'Contact properties (email, firstname, lastname, etc.)', + }, + }, + required: ['properties'], + }, + }, + { + name: 'hubspot_contacts_update', + description: 'Update a contact', + inputSchema: { + type: 'object', + properties: { + contactId: { type: 'string', description: 'Contact ID' }, + properties: { + type: 'object', + description: 'Properties to update', + }, + }, + required: ['contactId', 'properties'], + }, + }, + { + name: 'hubspot_contacts_search', + description: 'Search contacts with filters', + inputSchema: { + type: 'object', + properties: { + filterGroups: { + type: 'array', + description: 'Filter groups for search', + }, + properties: { + type: 'array', + items: { type: 'string' }, + description: 'Properties to return', + }, + limit: { type: 'number', description: 'Max results' }, + }, + }, + }, + // Companies + { + name: 'hubspot_companies_list', + description: 'List companies', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'hubspot_companies_get', + description: 'Get a company by ID', + inputSchema: { + type: 'object', + properties: { + companyId: { type: 'string', description: 'Company ID' }, + }, + required: ['companyId'], + }, + }, + { + name: 'hubspot_companies_create', + description: 'Create a new company', + inputSchema: { + type: 'object', + properties: { + properties: { type: 'object', description: 'Company properties' }, + }, + required: ['properties'], + }, + }, + // Deals + { + name: 'hubspot_deals_list', + description: 'List deals', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'hubspot_deals_get', + description: 'Get a deal by ID', + inputSchema: { + type: 'object', + properties: { + dealId: { type: 'string', description: 'Deal ID' }, + }, + required: ['dealId'], + }, + }, + { + name: 'hubspot_deals_create', + description: 'Create a new deal', + inputSchema: { + type: 'object', + properties: { + properties: { type: 'object', description: 'Deal properties' }, + }, + required: ['properties'], + }, + }, + // Tickets + { + name: 'hubspot_tickets_list', + description: 'List tickets', + inputSchema: { type: 'object', properties: {} }, + }, + { + name: 'hubspot_tickets_create', + description: 'Create a new ticket', + inputSchema: { + type: 'object', + properties: { + properties: { type: 'object', description: 'Ticket properties' }, + }, + required: ['properties'], + }, + }, + // Associations + { + name: 'hubspot_associations_get', + description: 'Get associations for an object', + inputSchema: { + type: 'object', + properties: { + fromObjectType: { type: 'string' }, + fromObjectId: { type: 'string' }, + toObjectType: { type: 'string' }, + }, + required: ['fromObjectType', 'fromObjectId', 'toObjectType'], + }, + }, + { + name: 'hubspot_associations_create', + description: 'Create an association between objects', + inputSchema: { + type: 'object', + properties: { + fromObjectType: { type: 'string' }, + fromObjectId: { type: 'string' }, + toObjectType: { type: 'string' }, + toObjectId: { type: 'string' }, + associationTypeId: { type: 'number' }, + }, + required: [ + 'fromObjectType', + 'fromObjectId', + 'toObjectType', + 'toObjectId', + 'associationTypeId', + ], + }, + }, + // Pipelines + { + name: 'hubspot_pipelines_list', + description: 'List pipelines for an object type', + inputSchema: { + type: 'object', + properties: { + objectType: { + type: 'string', + description: 'Object type (deals, tickets, etc.)', + }, + }, + required: ['objectType'], + }, + }, + // Owners + { + name: 'hubspot_owners_list', + description: 'List all owners', + inputSchema: { type: 'object', properties: {} }, + }, + // Properties + { + name: 'hubspot_properties_list', + description: 'List properties for an object type', + inputSchema: { + type: 'object', + properties: { + objectType: { type: 'string', description: 'Object type' }, + }, + required: ['objectType'], + }, + }, + ], + }; + }); + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + try { + return await this.handleToolCall(request.params.name, request.params.arguments ?? {}); + } catch (error: any) { + return { + content: [ + { + type: 'text', + text: `Error: ${error.message}`, + }, + ], + isError: true, + }; + } + }); + + // List resources (for UI apps) + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: 'hubspot://contacts', + name: 'HubSpot Contacts', + description: 'Access to HubSpot contacts data', + mimeType: 'application/json', + }, + { + uri: 'hubspot://companies', + name: 'HubSpot Companies', + description: 'Access to HubSpot companies data', + mimeType: 'application/json', + }, + { + uri: 'hubspot://deals', + name: 'HubSpot Deals', + description: 'Access to HubSpot deals data', + mimeType: 'application/json', + }, + ], + }; + }); + + // Read resources + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + + // Parse URI and return appropriate data + // Implementation will be in tool modules + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify({ message: 'Resource handler not yet implemented' }), + }, + ], + }; + }); + } + + /** + * Handle tool calls with lazy loading + */ + private async handleToolCall(name: string, args: any): Promise { + // Tool routing - this is where lazy-loaded modules will be imported + // For now, we'll implement basic routing structure + + const [prefix, module, action] = name.split('_'); + + if (prefix !== 'hubspot') { + throw new Error(`Unknown tool prefix: ${prefix}`); + } + + // Route to appropriate handler based on module + switch (module) { + case 'contacts': + return this.handleContactsTool(action, args); + case 'companies': + return this.handleCompaniesTool(action, args); + case 'deals': + return this.handleDealsTool(action, args); + case 'tickets': + return this.handleTicketsTool(action, args); + case 'associations': + return this.handleAssociationsTool(action, args); + case 'pipelines': + return this.handlePipelinesTool(action, args); + case 'owners': + return this.handleOwnersTool(action, args); + case 'properties': + return this.handlePropertiesTool(action, args); + default: + throw new Error(`Unknown module: ${module}`); + } + } + + /** + * Placeholder tool handlers - will be replaced with lazy-loaded modules + */ + private async handleContactsTool(action: string, args: any): Promise { + switch (action) { + case 'list': + const contacts = await this.client.listObjects('contacts', args); + return { + content: [{ type: 'text', text: JSON.stringify(contacts, null, 2) }], + }; + case 'get': + const contact = await this.client.getObject('contacts', args.contactId, args.properties); + return { + content: [{ type: 'text', text: JSON.stringify(contact, null, 2) }], + }; + case 'create': + const newContact = await this.client.createObject('contacts', args.properties); + return { + content: [{ type: 'text', text: JSON.stringify(newContact, null, 2) }], + }; + case 'update': + const updated = await this.client.updateObject('contacts', args.contactId, args.properties); + return { + content: [{ type: 'text', text: JSON.stringify(updated, null, 2) }], + }; + case 'search': + const results = await this.client.searchObjects('contacts', args); + return { + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], + }; + default: + throw new Error(`Unknown contacts action: ${action}`); + } + } + + private async handleCompaniesTool(action: string, args: any): Promise { + switch (action) { + case 'list': + const companies = await this.client.listObjects('companies', args); + return { + content: [{ type: 'text', text: JSON.stringify(companies, null, 2) }], + }; + case 'get': + const company = await this.client.getObject('companies', args.companyId); + return { + content: [{ type: 'text', text: JSON.stringify(company, null, 2) }], + }; + case 'create': + const newCompany = await this.client.createObject('companies', args.properties); + return { + content: [{ type: 'text', text: JSON.stringify(newCompany, null, 2) }], + }; + default: + throw new Error(`Unknown companies action: ${action}`); + } + } + + private async handleDealsTool(action: string, args: any): Promise { + switch (action) { + case 'list': + const deals = await this.client.listObjects('deals', args); + return { + content: [{ type: 'text', text: JSON.stringify(deals, null, 2) }], + }; + case 'get': + const deal = await this.client.getObject('deals', args.dealId); + return { + content: [{ type: 'text', text: JSON.stringify(deal, null, 2) }], + }; + case 'create': + const newDeal = await this.client.createObject('deals', args.properties); + return { + content: [{ type: 'text', text: JSON.stringify(newDeal, null, 2) }], + }; + default: + throw new Error(`Unknown deals action: ${action}`); + } + } + + private async handleTicketsTool(action: string, args: any): Promise { + switch (action) { + case 'list': + const tickets = await this.client.listObjects('tickets', args); + return { + content: [{ type: 'text', text: JSON.stringify(tickets, null, 2) }], + }; + case 'create': + const newTicket = await this.client.createObject('tickets', args.properties); + return { + content: [{ type: 'text', text: JSON.stringify(newTicket, null, 2) }], + }; + default: + throw new Error(`Unknown tickets action: ${action}`); + } + } + + private async handleAssociationsTool(action: string, args: any): Promise { + switch (action) { + case 'get': + const associations = await this.client.getAssociations( + args.fromObjectType, + args.fromObjectId, + args.toObjectType + ); + return { + content: [{ type: 'text', text: JSON.stringify(associations, null, 2) }], + }; + case 'create': + await this.client.createAssociation( + args.fromObjectType, + args.fromObjectId, + args.toObjectType, + args.toObjectId, + args.associationTypeId + ); + return { + content: [{ type: 'text', text: 'Association created successfully' }], + }; + default: + throw new Error(`Unknown associations action: ${action}`); + } + } + + private async handlePipelinesTool(action: string, args: any): Promise { + switch (action) { + case 'list': + const pipelines = await this.client.getPipelines(args.objectType); + return { + content: [{ type: 'text', text: JSON.stringify(pipelines, null, 2) }], + }; + default: + throw new Error(`Unknown pipelines action: ${action}`); + } + } + + private async handleOwnersTool(action: string, args: any): Promise { + switch (action) { + case 'list': + const owners = await this.client.getOwners(); + return { + content: [{ type: 'text', text: JSON.stringify(owners, null, 2) }], + }; + default: + throw new Error(`Unknown owners action: ${action}`); + } + } + + private async handlePropertiesTool(action: string, args: any): Promise { + switch (action) { + case 'list': + const properties = await this.client.getProperties(args.objectType); + return { + content: [{ type: 'text', text: JSON.stringify(properties, null, 2) }], + }; + default: + throw new Error(`Unknown properties action: ${action}`); + } + } + + /** + * Start the server + */ + async start(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } + + /** + * Get the underlying Server instance + */ + getServer(): Server { + return this.server; + } + + /** + * Get the HubSpot client + */ + getClient(): HubSpotClient { + return this.client; + } +} diff --git a/servers/hubspot/src/types/index.ts b/servers/hubspot/src/types/index.ts new file mode 100644 index 0000000..7addaa9 --- /dev/null +++ b/servers/hubspot/src/types/index.ts @@ -0,0 +1,608 @@ +/** + * HubSpot MCP Server - TypeScript Type Definitions + * Based on HubSpot CRM API v3 + */ + +// Branded ID types (HubSpot uses numeric string IDs) +export type ContactId = string & { readonly __brand: 'ContactId' }; +export type CompanyId = string & { readonly __brand: 'CompanyId' }; +export type DealId = string & { readonly __brand: 'DealId' }; +export type TicketId = string & { readonly __brand: 'TicketId' }; +export type LineItemId = string & { readonly __brand: 'LineItemId' }; +export type ProductId = string & { readonly __brand: 'ProductId' }; +export type QuoteId = string & { readonly __brand: 'QuoteId' }; +export type EngagementId = string & { readonly __brand: 'EngagementId' }; +export type OwnerId = string & { readonly __brand: 'OwnerId' }; +export type TeamId = string & { readonly __brand: 'TeamId' }; +export type PipelineId = string & { readonly __brand: 'PipelineId' }; +export type StageId = string & { readonly __brand: 'StageId' }; +export type EmailId = string & { readonly __brand: 'EmailId' }; +export type CampaignId = string & { readonly __brand: 'CampaignId' }; +export type FormId = string & { readonly __brand: 'FormId' }; +export type ListId = string & { readonly __brand: 'ListId' }; +export type WorkflowId = string & { readonly __brand: 'WorkflowId' }; +export type BlogId = string & { readonly __brand: 'BlogId' }; +export type BlogPostId = string & { readonly __brand: 'BlogPostId' }; +export type PageId = string & { readonly __brand: 'PageId' }; +export type HubDbTableId = string & { readonly __brand: 'HubDbTableId' }; +export type HubDbRowId = string & { readonly __brand: 'HubDbRowId' }; +export type TimelineEventTypeId = string & { readonly __brand: 'TimelineEventTypeId' }; +export type WebhookId = string & { readonly __brand: 'WebhookId' }; + +// Generic object ID +export type HubSpotObjectId = string; + +// Standard HubSpot object format +export interface HubSpotObject = Record> { + id: HubSpotObjectId; + properties: T; + createdAt: string; + updatedAt: string; + archived: boolean; + archivedAt?: string; +} + +// CRM Object Types +export type CRMObjectType = + | 'contacts' + | 'companies' + | 'deals' + | 'tickets' + | 'line_items' + | 'products' + | 'quotes'; + +// Contact properties +export interface ContactProperties { + email?: string; + firstname?: string; + lastname?: string; + phone?: string; + company?: string; + website?: string; + lifecyclestage?: string; + hs_lead_status?: string; + jobtitle?: string; + address?: string; + city?: string; + state?: string; + zip?: string; + country?: string; + [key: string]: any; +} + +export interface Contact extends HubSpotObject { + id: ContactId; +} + +// Company properties +export interface CompanyProperties { + name?: string; + domain?: string; + industry?: string; + phone?: string; + city?: string; + state?: string; + country?: string; + website?: string; + numberofemployees?: string; + annualrevenue?: string; + description?: string; + type?: string; + [key: string]: any; +} + +export interface Company extends HubSpotObject { + id: CompanyId; +} + +// Deal properties +export interface DealProperties { + dealname?: string; + amount?: string; + closedate?: string; + dealstage?: string; + pipeline?: string; + hubspot_owner_id?: string; + description?: string; + deal_currency_code?: string; + [key: string]: any; +} + +export interface Deal extends HubSpotObject { + id: DealId; +} + +// Ticket properties +export interface TicketProperties { + subject?: string; + content?: string; + hs_ticket_priority?: string; + hs_pipeline_stage?: string; + hs_pipeline?: string; + hubspot_owner_id?: string; + hs_ticket_category?: string; + [key: string]: any; +} + +export interface Ticket extends HubSpotObject { + id: TicketId; +} + +// Line Item properties +export interface LineItemProperties { + name?: string; + price?: string; + quantity?: string; + amount?: string; + hs_sku?: string; + description?: string; + discount?: string; + [key: string]: any; +} + +export interface LineItem extends HubSpotObject { + id: LineItemId; +} + +// Product properties +export interface ProductProperties { + name?: string; + description?: string; + price?: string; + hs_sku?: string; + hs_cost_of_goods_sold?: string; + hs_recurring_billing_period?: string; + [key: string]: any; +} + +export interface Product extends HubSpotObject { + id: ProductId; +} + +// Quote properties +export interface QuoteProperties { + hs_title?: string; + hs_expiration_date?: string; + hs_status?: string; + hs_public_url_key?: string; + [key: string]: any; +} + +export interface Quote extends HubSpotObject { + id: QuoteId; +} + +// Associations +export interface AssociationType { + associationCategory: 'HUBSPOT_DEFINED' | 'USER_DEFINED' | 'INTEGRATOR_DEFINED'; + associationTypeId: number; +} + +export interface Association { + id: HubSpotObjectId; + type: string; +} + +export interface AssociationResult { + from: { + id: HubSpotObjectId; + }; + to: Array<{ + id: HubSpotObjectId; + type: string; + }>; +} + +export interface BatchAssociation { + from: { + id: HubSpotObjectId; + }; + to: { + id: HubSpotObjectId; + }; + type: AssociationType; +} + +// Engagement types +export type EngagementType = 'NOTE' | 'CALL' | 'EMAIL' | 'MEETING' | 'TASK'; + +export interface EngagementProperties { + hs_timestamp?: string; + hs_engagement_type?: EngagementType; + hs_body_preview?: string; + hubspot_owner_id?: string; + [key: string]: any; +} + +export interface NoteProperties extends EngagementProperties { + hs_note_body?: string; +} + +export interface CallProperties extends EngagementProperties { + hs_call_title?: string; + hs_call_body?: string; + hs_call_duration?: string; + hs_call_status?: string; + hs_call_direction?: string; +} + +export interface EmailEngagementProperties extends EngagementProperties { + hs_email_subject?: string; + hs_email_text?: string; + hs_email_html?: string; + hs_email_status?: string; +} + +export interface MeetingProperties extends EngagementProperties { + hs_meeting_title?: string; + hs_meeting_body?: string; + hs_meeting_start_time?: string; + hs_meeting_end_time?: string; + hs_meeting_outcome?: string; +} + +export interface TaskProperties extends EngagementProperties { + hs_task_subject?: string; + hs_task_body?: string; + hs_task_status?: string; + hs_task_priority?: string; + hs_task_type?: string; +} + +export interface Engagement extends HubSpotObject { + id: EngagementId; +} + +// Pipeline & Stages +export interface PipelineStage { + id: StageId; + label: string; + displayOrder: number; + metadata: Record; + createdAt: string; + updatedAt: string; + archived: boolean; +} + +export interface Pipeline { + id: PipelineId; + label: string; + displayOrder: number; + stages: PipelineStage[]; + createdAt: string; + updatedAt: string; + archived: boolean; +} + +// Owner & Team +export interface Owner { + id: OwnerId; + email: string; + firstName?: string; + lastName?: string; + userId?: number; + createdAt: string; + updatedAt: string; + archived: boolean; + teams?: Array<{ id: TeamId; name: string }>; +} + +export interface Team { + id: TeamId; + name: string; + userIds: number[]; + createdAt: string; + updatedAt: string; +} + +// Properties +export interface PropertyDefinition { + name: string; + label: string; + type: 'string' | 'number' | 'date' | 'datetime' | 'enumeration' | 'bool'; + fieldType: string; + description?: string; + groupName?: string; + options?: Array<{ + label: string; + value: string; + displayOrder: number; + hidden: boolean; + }>; + createdAt?: string; + updatedAt?: string; + hidden: boolean; + displayOrder?: number; + calculated: boolean; + externalOptions: boolean; + hasUniqueValue: boolean; + formField: boolean; +} + +export interface PropertyGroup { + name: string; + label: string; + displayOrder: number; + properties: string[]; +} + +// Marketing - Email +export interface MarketingEmail { + id: EmailId; + name: string; + subject: string; + fromName?: string; + replyTo?: string; + created: string; + updated: string; + state?: string; + campaignGuid?: string; +} + +// Marketing - Campaign +export interface Campaign { + id: CampaignId; + name: string; + created: string; + updated: string; + counters?: { + sent?: number; + delivered?: number; + open?: number; + click?: number; + }; +} + +// Marketing - Form +export interface Form { + guid: FormId; + name: string; + createdAt: string; + updatedAt: string; + archived: boolean; + formType: string; + submitText?: string; + redirect?: string; + formFieldGroups?: any[]; +} + +// Marketing - List +export interface List { + listId: ListId; + name: string; + dynamic: boolean; + createdAt: string; + updatedAt: string; + filters?: any[]; + metaData?: { + size?: number; + processing?: string; + }; +} + +// Marketing - Workflow +export interface Workflow { + id: WorkflowId; + name: string; + type: string; + enabled: boolean; + createdAt: number; + updatedAt: number; + contactListIds?: { + enrolled?: number[]; + active?: number[]; + completed?: number[]; + }; +} + +// CMS - Blog +export interface Blog { + id: BlogId; + name: string; + created: string; + updated: string; + domain?: string; + language?: string; +} + +export interface BlogPost { + id: BlogPostId; + name: string; + slug: string; + contentGroupId: BlogId; + created: string; + updated: string; + published: string; + authorName?: string; + state: string; + htmlTitle?: string; + metaDescription?: string; + postBody?: string; + postSummary?: string; +} + +// CMS - Page +export interface Page { + id: PageId; + name: string; + slug: string; + created: string; + updated: string; + published?: string; + domain?: string; + htmlTitle?: string; + metaDescription?: string; +} + +// CMS - HubDB +export interface HubDbTable { + id: HubDbTableId; + name: string; + label: string; + columns: Array<{ + id: string; + name: string; + label: string; + type: string; + }>; + rowCount: number; + createdAt: string; + updatedAt: string; + published: boolean; +} + +export interface HubDbRow { + id: HubDbRowId; + path?: string; + name?: string; + values: Record; + createdAt: string; + updatedAt: string; +} + +// Search API +export interface FilterGroup { + filters: Filter[]; +} + +export interface Filter { + propertyName: string; + operator: FilterOperator; + value?: string | number | boolean; + values?: string[]; +} + +export type FilterOperator = + | 'EQ' + | 'NEQ' + | 'LT' + | 'LTE' + | 'GT' + | 'GTE' + | 'BETWEEN' + | 'IN' + | 'NOT_IN' + | 'HAS_PROPERTY' + | 'NOT_HAS_PROPERTY' + | 'CONTAINS_TOKEN' + | 'NOT_CONTAINS_TOKEN'; + +export interface Sort { + propertyName: string; + direction: 'ASCENDING' | 'DESCENDING'; +} + +export interface SearchRequest { + filterGroups?: FilterGroup[]; + sorts?: Sort[]; + query?: string; + properties?: string[]; + limit?: number; + after?: string; +} + +export interface SearchResponse { + total: number; + results: T[]; + paging?: { + next?: { + after: string; + link?: string; + }; + }; +} + +// Webhooks +export interface Webhook { + id: WebhookId; + createdAt: string; + updatedAt: string; + active: boolean; + eventType: string; + targetUrl: string; +} + +// Timeline Events +export interface TimelineEventType { + id: TimelineEventTypeId; + name: string; + headerTemplate?: string; + detailTemplate?: string; + objectType: string; + applicationId: string; + createdAt: string; + updatedAt: string; +} + +export interface TimelineEvent { + id: string; + eventTemplateId: TimelineEventTypeId; + objectId: HubSpotObjectId; + timestamp: string; + tokens?: Record; + extraData?: Record; +} + +// Pagination +export interface PaginationInfo { + next?: { + after: string; + link?: string; + }; + prev?: { + before: string; + link?: string; + }; +} + +export interface PagedResponse { + results: T[]; + paging?: PaginationInfo; +} + +// Batch operations +export interface BatchReadRequest { + properties?: string[]; + propertiesWithHistory?: string[]; + idProperty?: string; + inputs: Array<{ id: string }>; +} + +export interface BatchCreateRequest { + inputs: Array<{ properties: T }>; +} + +export interface BatchUpdateRequest { + inputs: Array<{ id: string; properties: T }>; +} + +export interface BatchArchiveRequest { + inputs: Array<{ id: string }>; +} + +export interface BatchResponse { + status: 'PENDING' | 'PROCESSING' | 'CANCELED' | 'COMPLETE'; + results: T[]; + numErrors?: number; + errors?: Array<{ + status: string; + category: string; + message: string; + context?: Record; + }>; + startedAt: string; + completedAt: string; +} + +// API Error Response +export interface HubSpotError { + status: string; + message: string; + correlationId: string; + category: string; + errors?: Array<{ + message: string; + in?: string; + }>; +} diff --git a/servers/hubspot/tsconfig.json b/servers/hubspot/tsconfig.json new file mode 100644 index 0000000..38a0f2f --- /dev/null +++ b/servers/hubspot/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/servers/quickbooks/.env.example b/servers/quickbooks/.env.example new file mode 100644 index 0000000..b1887c3 --- /dev/null +++ b/servers/quickbooks/.env.example @@ -0,0 +1,16 @@ +# QuickBooks Online Configuration + +# Required: OAuth2 Access Token +QBO_ACCESS_TOKEN=your_access_token_here + +# Required: Company/Realm ID +QBO_REALM_ID=your_realm_id_here + +# Optional: Refresh Token (for token renewal) +QBO_REFRESH_TOKEN=your_refresh_token_here + +# Optional: Use sandbox environment (true/false, default: false) +QBO_SANDBOX=false + +# Optional: API minor version (default: 73) +QBO_MINOR_VERSION=73 diff --git a/servers/quickbooks/.gitignore b/servers/quickbooks/.gitignore new file mode 100644 index 0000000..dae4171 --- /dev/null +++ b/servers/quickbooks/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment +.env +.env.local + +# Logs +*.log +logs/ + +# OS files +.DS_Store +Thumbs.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# TypeScript +*.tsbuildinfo diff --git a/servers/quickbooks/README.md b/servers/quickbooks/README.md new file mode 100644 index 0000000..ff7d20c --- /dev/null +++ b/servers/quickbooks/README.md @@ -0,0 +1,174 @@ +# QuickBooks Online MCP Server + +Model Context Protocol (MCP) server for QuickBooks Online integration. + +## Features + +- **Full QBO API Coverage**: Invoices, Customers, Vendors, Bills, Payments, Estimates, Items, Accounts, Reports, and more +- **SQL-like Queries**: Native support for QBO's query language +- **Rate Limiting**: Respects QBO's 500 req/min throttle with automatic retry +- **Optimistic Locking**: SyncToken support for safe updates +- **Sandbox Support**: Test against QBO Sandbox environment +- **Type Safety**: Comprehensive TypeScript types for all QBO entities +- **Pagination**: Automatic handling of large result sets +- **Token Refresh**: Built-in OAuth2 token refresh support + +## Installation + +```bash +npm install +``` + +## Configuration + +Create a `.env` file (see `.env.example`): + +```bash +# Required +QBO_ACCESS_TOKEN=your_access_token +QBO_REALM_ID=your_realm_id + +# Optional +QBO_REFRESH_TOKEN=your_refresh_token +QBO_SANDBOX=false +QBO_MINOR_VERSION=73 +``` + +### Getting OAuth Credentials + +1. Create an app at [Intuit Developer Portal](https://developer.intuit.com) +2. Configure OAuth2 redirect URIs +3. Obtain access token and realm ID via OAuth flow +4. Store refresh token for automatic renewal + +## Usage + +### Development + +```bash +npm run dev +``` + +### Production + +```bash +npm run build +npm start +``` + +## Architecture + +### Foundation Components + +- **`src/types/index.ts`**: Comprehensive TypeScript interfaces for all QBO entities +- **`src/clients/quickbooks.ts`**: Full-featured QBO API client with retry, rate limiting, and pagination +- **`src/server.ts`**: MCP server with lazy-loaded tool modules +- **`src/main.ts`**: Entry point with environment validation and dual transport + +### QuickBooks Client Features + +- OAuth2 Bearer authentication +- Automatic retry with exponential backoff (3 retries) +- Rate limit handling (500 req/min) +- Pagination support (startPosition + maxResults) +- SQL-like query execution +- Batch operations (up to 30 per batch) +- Token refresh helper +- Sandbox/production environment switching + +### Tool Categories (Lazy-Loaded) + +Tool implementations will be added in: +- `src/tools/invoices.ts` - Create, read, update, send invoices +- `src/tools/customers.ts` - Customer management +- `src/tools/payments.ts` - Payment processing +- `src/tools/estimates.ts` - Estimate creation and conversion +- `src/tools/bills.ts` - Bill management +- `src/tools/vendors.ts` - Vendor operations +- `src/tools/items.ts` - Inventory and service items +- `src/tools/accounts.ts` - Chart of accounts +- `src/tools/reports.ts` - P&L, Balance Sheet, Cash Flow, etc. +- `src/tools/employees.ts` - Employee management +- `src/tools/time-activities.ts` - Time tracking +- `src/tools/taxes.ts` - Tax codes and rates +- `src/tools/purchases.ts` - Purchase orders and expenses +- `src/tools/journal-entries.ts` - Manual journal entries + +## API Examples + +### Query Invoices + +```typescript +const result = await client.query( + 'SELECT * FROM Invoice WHERE TotalAmt > 1000', + { startPosition: 1, maxResults: 100 } +); +``` + +### Create Customer + +```typescript +const customer = await client.create('Customer', { + DisplayName: 'Acme Corp', + PrimaryEmailAddr: { Address: 'billing@acme.com' }, +}); +``` + +### Update Invoice (with SyncToken) + +```typescript +const invoice = await client.update('Invoice', { + Id: '123', + SyncToken: '0', + TotalAmt: 5000, +}); +``` + +### Get Reports + +```typescript +const profitLoss = await client.getReport('ProfitAndLoss', { + start_date: '2024-01-01', + end_date: '2024-12-31', + accounting_method: 'Accrual', +}); +``` + +## QuickBooks Online API Reference + +- [API Documentation](https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities/invoice) +- [Query Language](https://developer.intuit.com/app/developer/qbo/docs/develop/explore-the-quickbooks-online-api/data-queries) +- [OAuth Guide](https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization) + +## Type Safety + +All QBO entities use: +- Branded types for IDs (prevents mixing Customer/Vendor/etc. IDs) +- Discriminated unions for entity types and statuses +- SyncToken for optimistic locking on all entities +- Strict TypeScript mode for compile-time safety + +## Rate Limiting + +QBO enforces 500 requests/minute: +- Client tracks requests per rolling window +- Automatically throttles when approaching limit +- Retries 429 responses with exponential backoff + +## Error Handling + +QBO errors include: +- Error code +- Message +- Detail +- Element (field causing error) + +The client wraps these in structured MCP errors. + +## License + +MIT + +## Contributing + +This is the foundation layer. Tool implementations and UI apps will be added separately. diff --git a/servers/quickbooks/package.json b/servers/quickbooks/package.json new file mode 100644 index 0000000..1b6977f --- /dev/null +++ b/servers/quickbooks/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcpengine/quickbooks", + "version": "1.0.0", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "tsx": "^4.19.0", + "@types/node": "^22.0.0" + } +} diff --git a/servers/quickbooks/src/clients/quickbooks.ts b/servers/quickbooks/src/clients/quickbooks.ts new file mode 100644 index 0000000..7e6b15c --- /dev/null +++ b/servers/quickbooks/src/clients/quickbooks.ts @@ -0,0 +1,312 @@ +import axios, { AxiosInstance, AxiosError } from 'axios'; +import type { QBOEntity, QueryResponse, QBOError } from '../types/index.js'; + +export interface QuickBooksConfig { + accessToken: string; + realmId: string; + refreshToken?: string; + sandbox?: boolean; + minorVersion?: number; +} + +export interface PaginationOptions { + startPosition?: number; + maxResults?: number; +} + +export interface QueryOptions extends PaginationOptions { + sql: string; +} + +const DEFAULT_MINOR_VERSION = 73; +const MAX_RESULTS = 1000; +const RATE_LIMIT_REQUESTS = 500; +const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute +const MAX_RETRIES = 3; +const INITIAL_RETRY_DELAY_MS = 1000; + +export class QuickBooksClient { + private client: AxiosInstance; + private realmId: string; + private refreshToken?: string; + private minorVersion: number; + private requestCount = 0; + private windowStart = Date.now(); + + constructor(config: QuickBooksConfig) { + this.realmId = config.realmId; + this.refreshToken = config.refreshToken; + this.minorVersion = config.minorVersion ?? DEFAULT_MINOR_VERSION; + + const baseURL = config.sandbox + ? 'https://sandbox-quickbooks.api.intuit.com' + : 'https://quickbooks.api.intuit.com'; + + this.client = axios.create({ + baseURL, + headers: { + 'Authorization': `Bearer ${config.accessToken}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }); + } + + /** + * Rate limiting check - respects QBO 500 req/min throttle + */ + private async checkRateLimit(): Promise { + const now = Date.now(); + const elapsed = now - this.windowStart; + + if (elapsed >= RATE_LIMIT_WINDOW_MS) { + // Reset window + this.requestCount = 0; + this.windowStart = now; + return; + } + + if (this.requestCount >= RATE_LIMIT_REQUESTS) { + // Wait until window resets + const waitMs = RATE_LIMIT_WINDOW_MS - elapsed; + await this.sleep(waitMs); + this.requestCount = 0; + this.windowStart = Date.now(); + } + + this.requestCount++; + } + + /** + * Exponential backoff retry logic + */ + private async retry( + fn: () => Promise, + retries = MAX_RETRIES, + delay = INITIAL_RETRY_DELAY_MS + ): Promise { + try { + await this.checkRateLimit(); + return await fn(); + } catch (error) { + if (retries === 0) throw error; + + const axiosError = error as AxiosError; + + // Retry on rate limit (429) or server errors (5xx) + if ( + axiosError.response?.status === 429 || + (axiosError.response?.status && axiosError.response.status >= 500) + ) { + await this.sleep(delay); + return this.retry(fn, retries - 1, delay * 2); + } + + throw error; + } + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + /** + * Build URL with minor version parameter + */ + private buildUrl(path: string, params?: Record): string { + const url = `/v3/company/${this.realmId}${path}`; + const queryParams = new URLSearchParams({ + minorversion: this.minorVersion.toString(), + ...Object.fromEntries( + Object.entries(params || {}).map(([k, v]) => [k, String(v)]) + ), + }); + return `${url}?${queryParams.toString()}`; + } + + /** + * Execute SQL-like query + */ + async query( + sql: string, + options: PaginationOptions = {} + ): Promise> { + const { startPosition = 1, maxResults = MAX_RESULTS } = options; + + // QBO uses STARTPOSITION and MAXRESULTS in the SQL query itself + const paginatedSql = `${sql} STARTPOSITION ${startPosition} MAXRESULTS ${Math.min(maxResults, MAX_RESULTS)}`; + + return this.retry(async () => { + const url = this.buildUrl('/query', { query: paginatedSql }); + const response = await this.client.get>(url); + return response.data; + }); + } + + /** + * Create entity + */ + async create( + entityType: string, + data: Partial + ): Promise { + return this.retry(async () => { + const url = this.buildUrl(`/${entityType.toLowerCase()}`); + const response = await this.client.post<{ [key: string]: T }>(url, data); + return response.data[entityType]; + }); + } + + /** + * Read entity by ID + */ + async read( + entityType: string, + id: string + ): Promise { + return this.retry(async () => { + const url = this.buildUrl(`/${entityType.toLowerCase()}/${id}`); + const response = await this.client.get<{ [key: string]: T }>(url); + return response.data[entityType]; + }); + } + + /** + * Update entity (requires SyncToken for optimistic locking) + */ + async update( + entityType: string, + data: Partial & { Id: string; SyncToken: string } + ): Promise { + return this.retry(async () => { + const url = this.buildUrl(`/${entityType.toLowerCase()}`); + const response = await this.client.post<{ [key: string]: T }>(url, data); + return response.data[entityType]; + }); + } + + /** + * Delete entity (soft delete - requires SyncToken) + */ + async delete( + entityType: string, + id: string, + syncToken: string + ): Promise { + return this.retry(async () => { + const url = this.buildUrl(`/${entityType.toLowerCase()}`, { + operation: 'delete', + }); + const response = await this.client.post<{ [key: string]: T }>(url, { + Id: id, + SyncToken: syncToken, + }); + return response.data[entityType]; + }); + } + + /** + * Get report + */ + async getReport( + reportName: string, + params: Record = {} + ): Promise { + return this.retry(async () => { + const url = this.buildUrl(`/reports/${reportName}`, params); + const response = await this.client.get(url); + return response.data; + }); + } + + /** + * Refresh access token using refresh token + */ + async refreshAccessToken( + clientId: string, + clientSecret: string + ): Promise<{ access_token: string; refresh_token: string; expires_in: number }> { + if (!this.refreshToken) { + throw new Error('No refresh token available'); + } + + const tokenUrl = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'; + + const response = await axios.post( + tokenUrl, + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.refreshToken, + }), + { + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth: { + username: clientId, + password: clientSecret, + }, + } + ); + + // Update client with new access token + this.client.defaults.headers['Authorization'] = `Bearer ${response.data.access_token}`; + this.refreshToken = response.data.refresh_token; + + return response.data; + } + + /** + * Get company info + */ + async getCompanyInfo(): Promise { + return this.retry(async () => { + const url = this.buildUrl('/companyinfo', { id: this.realmId }); + const response = await this.client.get(url); + return response.data.CompanyInfo; + }); + } + + /** + * Get preferences + */ + async getPreferences(): Promise { + return this.retry(async () => { + const url = this.buildUrl('/preferences'); + const response = await this.client.get(url); + return response.data.Preferences; + }); + } + + /** + * Batch operations (up to 30 operations per batch) + */ + async batch(operations: Array<{ + bId: string; + operation: 'create' | 'update' | 'delete' | 'query'; + entity?: string; + data?: any; + query?: string; + }>): Promise { + return this.retry(async () => { + const url = this.buildUrl('/batch'); + const response = await this.client.post(url, { + BatchItemRequest: operations.map(op => { + if (op.operation === 'query') { + return { + bId: op.bId, + Query: op.query, + }; + } + return { + bId: op.bId, + operation: op.operation, + [op.entity!]: op.data, + }; + }), + }); + return response.data.BatchItemResponse; + }); + } +} diff --git a/servers/quickbooks/src/main.ts b/servers/quickbooks/src/main.ts new file mode 100644 index 0000000..c664e67 --- /dev/null +++ b/servers/quickbooks/src/main.ts @@ -0,0 +1,149 @@ +#!/usr/bin/env node + +import { z } from 'zod'; +import { QuickBooksClient } from './clients/quickbooks.js'; +import { createMCPServer, startServer } from './server.js'; + +/** + * Environment variable schema + */ +const envSchema = z.object({ + QBO_ACCESS_TOKEN: z.string().min(1, 'QBO_ACCESS_TOKEN is required'), + QBO_REALM_ID: z.string().min(1, 'QBO_REALM_ID is required'), + QBO_REFRESH_TOKEN: z.string().optional(), + QBO_SANDBOX: z + .string() + .optional() + .transform(val => val === 'true' || val === '1'), + QBO_MINOR_VERSION: z + .string() + .optional() + .transform(val => (val ? parseInt(val, 10) : undefined)), +}); + +/** + * Validate environment variables + */ +function validateEnvironment() { + try { + return envSchema.parse(process.env); + } catch (error) { + if (error instanceof z.ZodError) { + console.error('āŒ Environment validation failed:'); + error.errors.forEach(err => { + console.error(` - ${err.path.join('.')}: ${err.message}`); + }); + process.exit(1); + } + throw error; + } +} + +/** + * Health check endpoint (optional) + */ +function setupHealthCheck() { + // Simple health indicator - write to stderr so it doesn't interfere with stdio transport + const logHealth = () => { + process.stderr.write( + JSON.stringify({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }) + '\n' + ); + }; + + // Log health every 5 minutes + const healthInterval = setInterval(logHealth, 5 * 60 * 1000); + + return () => clearInterval(healthInterval); +} + +/** + * Graceful shutdown handler + */ +function setupGracefulShutdown(cleanup: () => void) { + const shutdown = (signal: string) => { + process.stderr.write(`\nāš ļø Received ${signal}, shutting down gracefully...\n`); + cleanup(); + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +/** + * Main entry point + */ +async function main() { + // Validate environment + const env = validateEnvironment(); + + process.stderr.write('šŸš€ Starting QuickBooks Online MCP Server...\n'); + process.stderr.write(` Realm ID: ${env.QBO_REALM_ID}\n`); + process.stderr.write(` Sandbox: ${env.QBO_SANDBOX ? 'Yes' : 'No'}\n`); + process.stderr.write(` Minor Version: ${env.QBO_MINOR_VERSION || 73}\n\n`); + + // Create QuickBooks client + const qboClient = new QuickBooksClient({ + accessToken: env.QBO_ACCESS_TOKEN, + realmId: env.QBO_REALM_ID, + refreshToken: env.QBO_REFRESH_TOKEN, + sandbox: env.QBO_SANDBOX, + minorVersion: env.QBO_MINOR_VERSION, + }); + + // Test connection + try { + const companyInfo = await qboClient.getCompanyInfo(); + process.stderr.write(`āœ… Connected to: ${companyInfo.CompanyName}\n\n`); + } catch (error: any) { + process.stderr.write('āŒ Failed to connect to QuickBooks Online:\n'); + process.stderr.write(` ${error.message}\n\n`); + process.exit(1); + } + + // Create MCP server + const server = createMCPServer({ + name: '@mcpengine/quickbooks', + version: '1.0.0', + qboClient, + }); + + // Setup health check + const cleanupHealth = setupHealthCheck(); + + // Setup graceful shutdown + setupGracefulShutdown(() => { + cleanupHealth(); + }); + + // Start server with stdio transport + process.stderr.write('šŸ“” MCP Server ready (stdio transport)\n'); + process.stderr.write(' Waiting for requests...\n\n'); + + await startServer(server); +} + +// Handle unhandled errors +process.on('unhandledRejection', (reason, promise) => { + process.stderr.write('āŒ Unhandled Rejection:\n'); + process.stderr.write(` ${reason}\n`); + process.exit(1); +}); + +process.on('uncaughtException', (error) => { + process.stderr.write('āŒ Uncaught Exception:\n'); + process.stderr.write(` ${error.message}\n`); + process.stderr.write(` ${error.stack}\n`); + process.exit(1); +}); + +// Run +main().catch((error) => { + process.stderr.write('āŒ Fatal error:\n'); + process.stderr.write(` ${error.message}\n`); + process.exit(1); +}); diff --git a/servers/quickbooks/src/server.ts b/servers/quickbooks/src/server.ts new file mode 100644 index 0000000..cbfa700 --- /dev/null +++ b/servers/quickbooks/src/server.ts @@ -0,0 +1,282 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + ErrorCode, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; +import { QuickBooksClient } from './clients/quickbooks.js'; + +export interface MCPServerConfig { + name: string; + version: string; + qboClient: QuickBooksClient; +} + +/** + * Tool module definition for lazy loading + */ +interface ToolModule { + name: string; + description: string; + inputSchema: any; + handler: (client: QuickBooksClient, args: any) => Promise; +} + +/** + * Create MCP server for QuickBooks Online + */ +export function createMCPServer(config: MCPServerConfig): Server { + const server = new Server( + { + name: config.name, + version: config.version, + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + // Tool categories for lazy loading + const toolCategories = [ + 'invoices', + 'customers', + 'payments', + 'estimates', + 'bills', + 'vendors', + 'items', + 'accounts', + 'reports', + 'employees', + 'time-activities', + 'taxes', + 'purchases', + 'journal-entries', + ]; + + // Tool registry - will be populated lazily + const toolRegistry = new Map(); + + /** + * Register a tool module + */ + function registerTool(tool: ToolModule): void { + toolRegistry.set(tool.name, tool); + } + + /** + * Lazy load tool modules + */ + async function loadToolModules(): Promise { + // Tool modules will be created later - for now we register placeholders + // to demonstrate the lazy-loading pattern + + // Example tool structure - actual implementations will be in separate files + registerTool({ + name: 'qbo_query', + description: 'Execute SQL-like query against QuickBooks Online', + inputSchema: { + type: 'object', + properties: { + sql: { + type: 'string', + description: 'SQL-like query (e.g., "SELECT * FROM Invoice WHERE TotalAmt > 1000")', + }, + startPosition: { + type: 'number', + description: 'Start position for pagination (1-indexed)', + default: 1, + }, + maxResults: { + type: 'number', + description: 'Maximum results to return (max 1000)', + default: 100, + }, + }, + required: ['sql'], + }, + handler: async (client, args) => { + return await client.query(args.sql, { + startPosition: args.startPosition, + maxResults: args.maxResults, + }); + }, + }); + + registerTool({ + name: 'qbo_get_company_info', + description: 'Get company information', + inputSchema: { + type: 'object', + properties: {}, + }, + handler: async (client, _args) => { + return await client.getCompanyInfo(); + }, + }); + + registerTool({ + name: 'qbo_get_preferences', + description: 'Get company preferences', + inputSchema: { + type: 'object', + properties: {}, + }, + handler: async (client, _args) => { + return await client.getPreferences(); + }, + }); + + // Placeholder for future tool modules + // These will be implemented in separate files like: + // - src/tools/invoices.ts + // - src/tools/customers.ts + // - etc. + } + + // List available tools + server.setRequestHandler(ListToolsRequestSchema, async () => { + await loadToolModules(); + + return { + tools: Array.from(toolRegistry.values()).map(tool => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + }; + }); + + // Handle tool calls + server.setRequestHandler(CallToolRequestSchema, async (request) => { + await loadToolModules(); + + const tool = toolRegistry.get(request.params.name); + + if (!tool) { + throw new McpError( + ErrorCode.MethodNotFound, + `Unknown tool: ${request.params.name}` + ); + } + + try { + const result = await tool.handler(config.qboClient, request.params.arguments || {}); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error: any) { + // Structured error response + const errorMessage = error.response?.data?.Fault?.Error?.[0]?.Message || error.message; + const errorDetail = error.response?.data?.Fault?.Error?.[0]?.Detail || ''; + const errorCode = error.response?.data?.Fault?.Error?.[0]?.code || 'UNKNOWN'; + + throw new McpError( + ErrorCode.InternalError, + `QuickBooks API error (${errorCode}): ${errorMessage}${errorDetail ? ` - ${errorDetail}` : ''}` + ); + } + }); + + // Resource handlers for UI apps + server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: 'qbo://company/info', + name: 'Company Information', + description: 'Current company information', + mimeType: 'application/json', + }, + { + uri: 'qbo://company/preferences', + name: 'Company Preferences', + description: 'Company preferences and settings', + mimeType: 'application/json', + }, + { + uri: 'qbo://invoices/recent', + name: 'Recent Invoices', + description: 'Recent invoices (last 30 days)', + mimeType: 'application/json', + }, + { + uri: 'qbo://customers/list', + name: 'Customer List', + description: 'All active customers', + mimeType: 'application/json', + }, + ], + }; + }); + + server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const uri = request.params.uri; + + try { + let data: any; + + if (uri === 'qbo://company/info') { + data = await config.qboClient.getCompanyInfo(); + } else if (uri === 'qbo://company/preferences') { + data = await config.qboClient.getPreferences(); + } else if (uri === 'qbo://invoices/recent') { + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const dateStr = thirtyDaysAgo.toISOString().split('T')[0]; + data = await config.qboClient.query( + `SELECT * FROM Invoice WHERE TxnDate >= '${dateStr}'`, + { maxResults: 100 } + ); + } else if (uri === 'qbo://customers/list') { + data = await config.qboClient.query( + 'SELECT * FROM Customer WHERE Active = true', + { maxResults: 1000 } + ); + } else { + throw new McpError( + ErrorCode.InvalidRequest, + `Unknown resource: ${uri}` + ); + } + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(data, null, 2), + }, + ], + }; + } catch (error: any) { + throw new McpError( + ErrorCode.InternalError, + `Failed to read resource: ${error.message}` + ); + } + }); + + return server; +} + +/** + * Start the MCP server with stdio transport + */ +export async function startServer(server: Server): Promise { + const transport = new StdioServerTransport(); + await server.connect(transport); +} diff --git a/servers/quickbooks/src/types/index.ts b/servers/quickbooks/src/types/index.ts new file mode 100644 index 0000000..9dd99fc --- /dev/null +++ b/servers/quickbooks/src/types/index.ts @@ -0,0 +1,791 @@ +// Branded types for IDs +export type CustomerId = string & { readonly __brand: 'CustomerId' }; +export type VendorId = string & { readonly __brand: 'VendorId' }; +export type EmployeeId = string & { readonly __brand: 'EmployeeId' }; +export type InvoiceId = string & { readonly __brand: 'InvoiceId' }; +export type PaymentId = string & { readonly __brand: 'PaymentId' }; +export type BillId = string & { readonly __brand: 'BillId' }; +export type EstimateId = string & { readonly __brand: 'EstimateId' }; +export type ItemId = string & { readonly __brand: 'ItemId' }; +export type AccountId = string & { readonly __brand: 'AccountId' }; +export type TaxCodeId = string & { readonly __brand: 'TaxCodeId' }; +export type ClassId = string & { readonly __brand: 'ClassId' }; +export type DepartmentId = string & { readonly __brand: 'DepartmentId' }; +export type TimeActivityId = string & { readonly __brand: 'TimeActivityId' }; + +// Base entity with SyncToken for optimistic locking +export interface QBOEntity { + Id: string; + SyncToken: string; + MetaData: { + CreateTime: string; + LastUpdatedTime: string; + }; +} + +// Reference types +export interface Ref { + value: string; + name?: string; +} + +export interface CustomerRef extends Ref { + value: CustomerId; +} + +export interface VendorRef extends Ref { + value: VendorId; +} + +export interface EmployeeRef extends Ref { + value: EmployeeId; +} + +export interface ItemRef extends Ref { + value: ItemId; +} + +export interface AccountRef extends Ref { + value: AccountId; +} + +export interface ClassRef extends Ref { + value: ClassId; +} + +export interface DepartmentRef extends Ref { + value: DepartmentId; +} + +export interface TaxCodeRef extends Ref { + value: TaxCodeId; +} + +// Address +export interface PhysicalAddress { + Line1?: string; + Line2?: string; + Line3?: string; + Line4?: string; + Line5?: string; + City?: string; + Country?: string; + CountrySubDivisionCode?: string; + PostalCode?: string; + Lat?: string; + Long?: string; +} + +// Customer +export interface Customer extends QBOEntity { + Id: CustomerId; + DisplayName: string; + Title?: string; + GivenName?: string; + MiddleName?: string; + FamilyName?: string; + Suffix?: string; + FullyQualifiedName?: string; + CompanyName?: string; + PrintOnCheckName?: string; + Active?: boolean; + PrimaryPhone?: { FreeFormNumber: string }; + AlternatePhone?: { FreeFormNumber: string }; + Mobile?: { FreeFormNumber: string }; + Fax?: { FreeFormNumber: string }; + PrimaryEmailAddr?: { Address: string }; + WebAddr?: { URI: string }; + BillAddr?: PhysicalAddress; + ShipAddr?: PhysicalAddress; + Notes?: string; + Taxable?: boolean; + Balance?: number; + BalanceWithJobs?: number; + CurrencyRef?: Ref; + PreferredDeliveryMethod?: string; + ResaleNum?: string; +} + +// Vendor +export interface Vendor extends QBOEntity { + Id: VendorId; + DisplayName: string; + Title?: string; + GivenName?: string; + MiddleName?: string; + FamilyName?: string; + Suffix?: string; + CompanyName?: string; + PrintOnCheckName?: string; + Active?: boolean; + PrimaryPhone?: { FreeFormNumber: string }; + AlternatePhone?: { FreeFormNumber: string }; + Mobile?: { FreeFormNumber: string }; + Fax?: { FreeFormNumber: string }; + PrimaryEmailAddr?: { Address: string }; + WebAddr?: { URI: string }; + BillAddr?: PhysicalAddress; + Balance?: number; + AcctNum?: string; + Vendor1099?: boolean; + CurrencyRef?: Ref; +} + +// Employee +export interface Employee extends QBOEntity { + Id: EmployeeId; + DisplayName: string; + Title?: string; + GivenName?: string; + MiddleName?: string; + FamilyName?: string; + Suffix?: string; + PrintOnCheckName?: string; + Active?: boolean; + PrimaryPhone?: { FreeFormNumber: string }; + Mobile?: { FreeFormNumber: string }; + PrimaryEmailAddr?: { Address: string }; + PrimaryAddr?: PhysicalAddress; + EmployeeNumber?: string; + SSN?: string; + HiredDate?: string; + ReleasedDate?: string; + BillableTime?: boolean; +} + +// Item types +export type ItemType = 'Inventory' | 'NonInventory' | 'Service' | 'Category' | 'Group'; + +export interface Item extends QBOEntity { + Id: ItemId; + Name: string; + Type: ItemType; + Active?: boolean; + FullyQualifiedName?: string; + Taxable?: boolean; + UnitPrice?: number; + Description?: string; + PurchaseDesc?: string; + PurchaseCost?: number; + QtyOnHand?: number; + IncomeAccountRef?: AccountRef; + ExpenseAccountRef?: AccountRef; + AssetAccountRef?: AccountRef; + InvStartDate?: string; + TrackQtyOnHand?: boolean; +} + +// Invoice Line +export interface InvoiceLine { + Id?: string; + LineNum?: number; + Description?: string; + Amount: number; + DetailType: 'SalesItemLineDetail' | 'SubTotalLineDetail' | 'DiscountLineDetail' | 'DescriptionOnly'; + SalesItemLineDetail?: { + ItemRef?: ItemRef; + Qty?: number; + UnitPrice?: number; + TaxCodeRef?: TaxCodeRef; + ClassRef?: ClassRef; + ServiceDate?: string; + }; + DiscountLineDetail?: { + PercentBased?: boolean; + DiscountPercent?: number; + DiscountAccountRef?: AccountRef; + }; +} + +// Invoice +export type InvoiceStatus = 'Draft' | 'Pending' | 'Sent' | 'Paid' | 'Overdue' | 'Void'; + +export interface Invoice extends QBOEntity { + Id: InvoiceId; + DocNumber?: string; + TxnDate: string; + DueDate?: string; + CustomerRef: CustomerRef; + Line: InvoiceLine[]; + TotalAmt: number; + Balance: number; + BillAddr?: PhysicalAddress; + ShipAddr?: PhysicalAddress; + EmailStatus?: 'NotSet' | 'NeedToSend' | 'EmailSent'; + PrintStatus?: 'NotSet' | 'NeedToPrint' | 'PrintComplete'; + TxnStatus?: InvoiceStatus; + PrivateNote?: string; + CustomerMemo?: { value: string }; + BillEmail?: { Address: string }; + AllowOnlineCreditCardPayment?: boolean; + AllowOnlineACHPayment?: boolean; + CurrencyRef?: Ref; + ExchangeRate?: number; + Deposit?: number; + DepositToAccountRef?: AccountRef; + ClassRef?: ClassRef; + DepartmentRef?: DepartmentRef; + SalesTermRef?: Ref; + ShipMethodRef?: Ref; + ShipDate?: string; + TrackingNum?: string; +} + +// Payment +export interface PaymentLine { + Amount: number; + LinkedTxn?: Array<{ + TxnId: string; + TxnType: string; + }>; +} + +export interface Payment extends QBOEntity { + Id: PaymentId; + TxnDate: string; + CustomerRef: CustomerRef; + TotalAmt: number; + UnappliedAmt?: number; + DepositToAccountRef?: AccountRef; + Line?: PaymentLine[]; + PaymentMethodRef?: Ref; + PaymentRefNum?: string; + PrivateNote?: string; + CurrencyRef?: Ref; + ExchangeRate?: number; +} + +// Credit Memo +export interface CreditMemo extends QBOEntity { + Id: string; + DocNumber?: string; + TxnDate: string; + CustomerRef: CustomerRef; + Line: InvoiceLine[]; + TotalAmt: number; + Balance: number; + BillAddr?: PhysicalAddress; + PrivateNote?: string; + CustomerMemo?: { value: string }; + EmailStatus?: 'NotSet' | 'NeedToSend' | 'EmailSent'; + PrintStatus?: 'NotSet' | 'NeedToPrint' | 'PrintComplete'; +} + +// Bill +export interface BillLine { + Id?: string; + LineNum?: number; + Description?: string; + Amount: number; + DetailType: 'AccountBasedExpenseLineDetail' | 'ItemBasedExpenseLineDetail'; + AccountBasedExpenseLineDetail?: { + AccountRef: AccountRef; + TaxCodeRef?: TaxCodeRef; + ClassRef?: ClassRef; + BillableStatus?: 'Billable' | 'NotBillable' | 'HasBeenBilled'; + CustomerRef?: CustomerRef; + }; + ItemBasedExpenseLineDetail?: { + ItemRef: ItemRef; + Qty?: number; + UnitPrice?: number; + TaxCodeRef?: TaxCodeRef; + ClassRef?: ClassRef; + BillableStatus?: 'Billable' | 'NotBillable' | 'HasBeenBilled'; + CustomerRef?: CustomerRef; + }; +} + +export interface Bill extends QBOEntity { + Id: BillId; + DocNumber?: string; + TxnDate: string; + DueDate?: string; + VendorRef: VendorRef; + Line: BillLine[]; + TotalAmt: number; + Balance: number; + APAccountRef?: AccountRef; + PrivateNote?: string; + CurrencyRef?: Ref; + ExchangeRate?: number; + SalesTermRef?: Ref; +} + +// Bill Payment +export interface BillPayment extends QBOEntity { + Id: string; + TxnDate: string; + VendorRef: VendorRef; + TotalAmt: number; + APAccountRef?: AccountRef; + PayType: 'Check' | 'CreditCard'; + CheckPayment?: { + BankAccountRef: AccountRef; + PrintStatus?: 'NotSet' | 'NeedToPrint' | 'PrintComplete'; + }; + CreditCardPayment?: { + CCAccountRef: AccountRef; + }; + Line?: Array<{ + Amount: number; + LinkedTxn?: Array<{ + TxnId: string; + TxnType: string; + }>; + }>; + PrivateNote?: string; +} + +// Purchase +export interface Purchase extends QBOEntity { + Id: string; + DocNumber?: string; + TxnDate: string; + AccountRef?: AccountRef; + PaymentType: 'Cash' | 'Check' | 'CreditCard'; + EntityRef?: VendorRef | CustomerRef | EmployeeRef; + Line: BillLine[]; + TotalAmt: number; + PrivateNote?: string; + CurrencyRef?: Ref; +} + +// Purchase Order +export interface PurchaseOrder extends QBOEntity { + Id: string; + DocNumber?: string; + TxnDate: string; + VendorRef: VendorRef; + Line: Array<{ + Id?: string; + LineNum?: number; + Description?: string; + Amount: number; + DetailType: 'ItemBasedExpenseLineDetail'; + ItemBasedExpenseLineDetail: { + ItemRef: ItemRef; + Qty?: number; + UnitPrice?: number; + ClassRef?: ClassRef; + CustomerRef?: CustomerRef; + }; + }>; + TotalAmt: number; + POStatus?: 'Open' | 'Closed'; + POEmail?: { Address: string }; + ShipAddr?: PhysicalAddress; + ShipMethodRef?: Ref; + PrivateNote?: string; + Memo?: string; +} + +// Estimate +export interface Estimate extends QBOEntity { + Id: EstimateId; + DocNumber?: string; + TxnDate: string; + CustomerRef: CustomerRef; + Line: InvoiceLine[]; + TotalAmt: number; + BillAddr?: PhysicalAddress; + ShipAddr?: PhysicalAddress; + EmailStatus?: 'NotSet' | 'NeedToSend' | 'EmailSent'; + PrintStatus?: 'NotSet' | 'NeedToPrint' | 'PrintComplete'; + TxnStatus?: 'Accepted' | 'Closed' | 'Pending' | 'Rejected'; + ExpirationDate?: string; + AcceptedBy?: string; + AcceptedDate?: string; + PrivateNote?: string; + CustomerMemo?: { value: string }; +} + +// Sales Receipt +export interface SalesReceipt extends QBOEntity { + Id: string; + DocNumber?: string; + TxnDate: string; + CustomerRef: CustomerRef; + Line: InvoiceLine[]; + TotalAmt: number; + Balance: number; + DepositToAccountRef?: AccountRef; + PaymentMethodRef?: Ref; + PaymentRefNum?: string; + BillAddr?: PhysicalAddress; + ShipAddr?: PhysicalAddress; + EmailStatus?: 'NotSet' | 'NeedToSend' | 'EmailSent'; + PrintStatus?: 'NotSet' | 'NeedToPrint' | 'PrintComplete'; + PrivateNote?: string; + CustomerMemo?: { value: string }; +} + +// Account +export type AccountType = + | 'Bank' | 'Other Current Asset' | 'Fixed Asset' | 'Other Asset' + | 'Accounts Receivable' | 'Equity' | 'Expense' | 'Other Expense' + | 'Cost of Goods Sold' | 'Accounts Payable' | 'Credit Card' + | 'Long Term Liability' | 'Other Current Liability' | 'Income' + | 'Other Income'; + +export type AccountSubType = string; + +export interface Account extends QBOEntity { + Id: AccountId; + Name: string; + FullyQualifiedName?: string; + Active?: boolean; + AccountType: AccountType; + AccountSubType?: AccountSubType; + AcctNum?: string; + CurrentBalance?: number; + CurrentBalanceWithSubAccounts?: number; + CurrencyRef?: Ref; + ParentRef?: AccountRef; + Description?: string; + SubAccount?: boolean; +} + +// Journal Entry +export interface JournalEntryLine { + Id?: string; + LineNum?: number; + Description?: string; + Amount: number; + DetailType: 'JournalEntryLineDetail'; + JournalEntryLineDetail: { + PostingType: 'Debit' | 'Credit'; + AccountRef: AccountRef; + Entity?: CustomerRef | VendorRef | EmployeeRef; + ClassRef?: ClassRef; + DepartmentRef?: DepartmentRef; + TaxCodeRef?: TaxCodeRef; + BillableStatus?: 'Billable' | 'NotBillable' | 'HasBeenBilled'; + }; +} + +export interface JournalEntry extends QBOEntity { + Id: string; + DocNumber?: string; + TxnDate: string; + Line: JournalEntryLine[]; + TotalAmt: number; + Adjustment?: boolean; + PrivateNote?: string; + CurrencyRef?: Ref; + ExchangeRate?: number; +} + +// Deposit +export interface Deposit extends QBOEntity { + Id: string; + TxnDate: string; + DepositToAccountRef: AccountRef; + Line: Array<{ + Id?: string; + LineNum?: number; + Description?: string; + Amount: number; + DetailType: 'DepositLineDetail'; + DepositLineDetail: { + Entity?: CustomerRef | VendorRef | EmployeeRef; + AccountRef: AccountRef; + ClassRef?: ClassRef; + PaymentMethodRef?: Ref; + CheckNum?: string; + }; + }>; + TotalAmt: number; + PrivateNote?: string; + CurrencyRef?: Ref; +} + +// Transfer +export interface Transfer extends QBOEntity { + Id: string; + TxnDate: string; + FromAccountRef: AccountRef; + ToAccountRef: AccountRef; + Amount: number; + PrivateNote?: string; +} + +// Tax Code +export interface TaxCode extends QBOEntity { + Id: TaxCodeId; + Name: string; + Description?: string; + Active?: boolean; + Taxable?: boolean; + TaxGroup?: boolean; + SalesTaxRateList?: { + TaxRateDetail: Array<{ + TaxRateRef: Ref; + TaxTypeApplicable?: string; + TaxOrder?: number; + }>; + }; + PurchaseTaxRateList?: { + TaxRateDetail: Array<{ + TaxRateRef: Ref; + TaxTypeApplicable?: string; + TaxOrder?: number; + }>; + }; +} + +// Tax Rate +export interface TaxRate extends QBOEntity { + Id: string; + Name: string; + Description?: string; + Active?: boolean; + RateValue?: number; + AgencyRef?: Ref; + SpecialTaxType?: string; + DisplayType?: string; +} + +// Tax Agency +export interface TaxAgency extends QBOEntity { + Id: string; + DisplayName: string; + TaxTrackedOnPurchases?: boolean; + TaxTrackedOnSales?: boolean; + TaxTrackedOnSalesReceipts?: boolean; +} + +// Time Activity +export interface TimeActivity extends QBOEntity { + Id: TimeActivityId; + TxnDate: string; + EmployeeRef?: EmployeeRef; + VendorRef?: VendorRef; + CustomerRef?: CustomerRef; + ItemRef?: ItemRef; + ClassRef?: ClassRef; + NameOf: 'Employee' | 'Vendor'; + Hours?: number; + Minutes?: number; + StartTime?: string; + EndTime?: string; + Description?: string; + HourlyRate?: number; + BillableStatus?: 'Billable' | 'NotBillable' | 'HasBeenBilled'; + Taxable?: boolean; + BreakHours?: number; + BreakMinutes?: number; +} + +// Class +export interface Class extends QBOEntity { + Id: ClassId; + Name: string; + FullyQualifiedName?: string; + Active?: boolean; + SubClass?: boolean; + ParentRef?: ClassRef; +} + +// Department +export interface Department extends QBOEntity { + Id: DepartmentId; + Name: string; + FullyQualifiedName?: string; + Active?: boolean; + SubDepartment?: boolean; + ParentRef?: DepartmentRef; +} + +// Company Info +export interface CompanyInfo extends QBOEntity { + Id: string; + CompanyName: string; + LegalName?: string; + CompanyAddr?: PhysicalAddress; + CustomerCommunicationAddr?: PhysicalAddress; + LegalAddr?: PhysicalAddress; + PrimaryPhone?: { FreeFormNumber: string }; + CompanyStartDate?: string; + FiscalYearStartMonth?: string; + Country?: string; + Email?: { Address: string }; + WebAddr?: { URI: string }; + SupportedLanguages?: string; + NameValue?: Array<{ + Name: string; + Value: string; + }>; +} + +// Preferences +export interface Preferences extends QBOEntity { + Id: string; + AccountingInfoPrefs?: { + FirstMonthOfFiscalYear?: string; + UseAccountNumbers?: boolean; + TaxYearMonth?: string; + ClassTrackingPerTxn?: boolean; + TrackDepartments?: boolean; + DepartmentTerminology?: string; + ClassTrackingPerTxnLine?: boolean; + }; + ProductAndServicesPrefs?: { + ForSales?: boolean; + ForPurchase?: boolean; + QuantityWithPriceAndRate?: boolean; + QuantityOnHand?: boolean; + }; + SalesFormsPrefs?: { + CustomField?: Array<{ + CustomField: Array<{ + Name: string; + Type: string; + StringValue?: string; + }>; + }>; + AllowDeposit?: boolean; + AllowDiscount?: boolean; + DefaultDiscountAccount?: string; + AllowEstimates?: boolean; + AllowShipping?: boolean; + DefaultShippingAccount?: string; + IPNSupportEnabled?: boolean; + UsingPriceLevels?: boolean; + UsingProgressInvoicing?: boolean; + ETransactionEnabledStatus?: string; + ETransactionPaymentEnabled?: boolean; + ETransactionAttachPDF?: boolean; + }; + EmailMessagesPrefs?: { + InvoiceMessage?: { Subject?: string; Message?: string }; + EstimateMessage?: { Subject?: string; Message?: string }; + SalesReceiptMessage?: { Subject?: string; Message?: string }; + }; + VendorAndPurchasesPrefs?: { + TrackingByCustomer?: boolean; + BillableExpenseTracking?: boolean; + POCustomField?: Array<{ + CustomField: Array<{ + Name: string; + Type: string; + StringValue?: string; + }>; + }>; + }; + TimeTrackingPrefs?: { + UseServices?: boolean; + BillCustomers?: boolean; + ShowBillRateToAll?: boolean; + WorkWeekStartDate?: string; + MarkTimeEntriesBillable?: boolean; + }; + TaxPrefs?: { + UsingSalesTax?: boolean; + TaxGroupCodeRef?: Ref; + }; + CurrencyPrefs?: { + MultiCurrencyEnabled?: boolean; + HomeCurrency?: Ref; + }; + ReportPrefs?: { + ReportBasis?: 'Accrual' | 'Cash'; + CalcAgingReportFromTxnDate?: boolean; + }; +} + +// Report types +export interface ReportColumn { + ColTitle?: string; + ColType?: string; + MetaData?: Array<{ + Name: string; + Value: string; + }>; +} + +export interface ReportRow { + type: string; + Summary?: { + ColData: Array<{ + value: string; + id?: string; + }>; + }; + ColData?: Array<{ + value: string; + id?: string; + }>; + Rows?: { + Row: ReportRow[]; + }; + group?: string; + Header?: { + ColData: Array<{ + value: string; + }>; + }; +} + +export interface Report { + Header: { + Time: string; + ReportName: string; + DateMacro?: string; + StartPeriod?: string; + EndPeriod?: string; + Currency?: string; + Option?: Array<{ + Name: string; + Value: string; + }>; + }; + Columns: { + Column: ReportColumn[]; + }; + Rows: { + Row: ReportRow[]; + }; +} + +export interface ProfitAndLoss extends Report { + Header: Report['Header'] & { + ReportName: 'ProfitAndLoss'; + }; +} + +export interface BalanceSheet extends Report { + Header: Report['Header'] & { + ReportName: 'BalanceSheet'; + }; +} + +export interface CashFlow extends Report { + Header: Report['Header'] & { + ReportName: 'CashFlow'; + }; +} + +// Query Response wrapper +export interface QueryResponse { + QueryResponse: { + [key: string]: T[] | number | undefined; + startPosition: number; + maxResults: number; + totalCount?: number; + }; +} + +// Error response +export interface QBOError { + Fault: { + Error: Array<{ + Message: string; + Detail: string; + code: string; + element?: string; + }>; + type: string; + }; + time: string; +} diff --git a/servers/quickbooks/tsconfig.json b/servers/quickbooks/tsconfig.json new file mode 100644 index 0000000..9a8d1b9 --- /dev/null +++ b/servers/quickbooks/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "lib": ["ES2022"], + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/servers/salesforce/.env.example b/servers/salesforce/.env.example new file mode 100644 index 0000000..9cabc95 --- /dev/null +++ b/servers/salesforce/.env.example @@ -0,0 +1,4 @@ +# Salesforce credentials +SF_ACCESS_TOKEN=your_oauth2_access_token_here +SF_INSTANCE_URL=https://yourinstance.my.salesforce.com +SF_API_VERSION=v59.0 diff --git a/servers/salesforce/.gitignore b/servers/salesforce/.gitignore new file mode 100644 index 0000000..189c0f4 --- /dev/null +++ b/servers/salesforce/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +coverage/ +.nyc_output/ diff --git a/servers/salesforce/README.md b/servers/salesforce/README.md new file mode 100644 index 0000000..d9d6786 --- /dev/null +++ b/servers/salesforce/README.md @@ -0,0 +1,165 @@ +# Salesforce MCP Server + +Model Context Protocol (MCP) server for Salesforce integration. Provides tools for querying and managing Salesforce data through the REST API. + +## Features + +- **SOQL Queries**: Execute powerful SOQL queries with automatic pagination +- **CRUD Operations**: Create, read, update, and delete records across all Salesforce objects +- **Bulk API**: Handle large data operations efficiently with Bulk API 2.0 +- **Composite Requests**: Batch multiple operations in a single API call +- **Object Metadata**: Describe Salesforce objects and fields +- **Rate Limiting**: Automatic retry with exponential backoff and API limit tracking +- **Type Safety**: Comprehensive TypeScript types for all Salesforce entities + +## Installation + +```bash +npm install +npm run build +``` + +## Configuration + +Create a `.env` file based on `.env.example`: + +```bash +SF_ACCESS_TOKEN=your_oauth2_access_token_here +SF_INSTANCE_URL=https://yourinstance.my.salesforce.com +SF_API_VERSION=v59.0 # Optional, defaults to v59.0 +``` + +### Getting an Access Token + +1. **Connected App**: Create a Connected App in Salesforce Setup +2. **OAuth Flow**: Use OAuth 2.0 to obtain an access token +3. **Refresh Token**: Implement token refresh logic for long-running servers + +For development, you can use Salesforce CLI: +```bash +sfdx force:org:display --verbose -u your_org_alias +``` + +## Usage + +### Development Mode + +```bash +npm run dev +``` + +### Production Mode + +```bash +npm run build +npm start +``` + +## Available Tools + +### Query Tools + +- `salesforce_query`: Execute SOQL queries +- `salesforce_describe_object`: Get object metadata + +### CRUD Tools + +- `salesforce_create_record`: Create new records +- `salesforce_update_record`: Update existing records +- `salesforce_delete_record`: Delete records + +### Bulk Operations + +- Bulk API 2.0 support for large data operations +- Automatic CSV upload and job monitoring + +## Architecture + +### Core Components + +- **`src/types/index.ts`**: TypeScript definitions for all Salesforce entities +- **`src/clients/salesforce.ts`**: REST API client with retry logic and caching +- **`src/server.ts`**: MCP server with lazy-loaded tool modules +- **`src/main.ts`**: Entry point with environment validation + +### Supported Objects + +- Standard Objects: Account, Contact, Lead, Opportunity, Case, Task, Event +- Marketing: Campaign, CampaignMember +- Admin: User, UserRole, Profile, PermissionSet +- Reports: Report, Dashboard +- Files: ContentDocument, ContentVersion, Attachment +- Custom Objects: Generic support for any custom object + +## API Patterns + +### SOQL Queries + +```typescript +// Simple query +const result = await client.query('SELECT Id, Name FROM Account LIMIT 10'); + +// Query builder +const soql = client.buildSOQL({ + select: ['Id', 'Name', 'Industry'], + from: 'Account', + where: "Industry = 'Technology'", + orderBy: 'Name ASC', + limit: 100 +}); + +// Query all (auto-pagination) +const allAccounts = await client.queryAll('SELECT Id, Name FROM Account'); +``` + +### CRUD Operations + +```typescript +// Create +const result = await client.createRecord('Account', { + Name: 'Acme Corp', + Industry: 'Technology' +}); + +// Update +await client.updateRecord('Account', accountId, { + Phone: '555-1234' +}); + +// Delete +await client.deleteRecord('Account', accountId); +``` + +### Bulk API + +```typescript +// Create bulk job +const job = await client.createBulkJob({ + object: 'Account', + operation: 'insert' +}); + +// Upload CSV data +await client.uploadBulkData(job.id, csvData); + +// Close job +await client.closeBulkJob(job.id); +``` + +## Error Handling + +The client includes: +- Automatic retry with exponential backoff (3 retries) +- Rate limit detection and handling +- Structured error responses +- API limit tracking via `Sforce-Limit-Info` header + +## Resources + +- [Salesforce REST API Documentation](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/) +- [SOQL Reference](https://developer.salesforce.com/docs/atlas.en-us.soql_sosl.meta/soql_sosl/) +- [Bulk API 2.0](https://developer.salesforce.com/docs/atlas.en-us.api_bulk_v2.meta/api_bulk_v2/) + +## License + +MIT diff --git a/servers/salesforce/package.json b/servers/salesforce/package.json new file mode 100644 index 0000000..35369e3 --- /dev/null +++ b/servers/salesforce/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcpengine/salesforce", + "version": "1.0.0", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "tsx": "^4.19.0", + "@types/node": "^22.0.0" + } +} diff --git a/servers/salesforce/src/clients/salesforce.ts b/servers/salesforce/src/clients/salesforce.ts new file mode 100644 index 0000000..97fcc9c --- /dev/null +++ b/servers/salesforce/src/clients/salesforce.ts @@ -0,0 +1,404 @@ +/** + * Salesforce REST API Client + */ + +import axios, { AxiosInstance, AxiosError } from 'axios'; +import type { + SObject, + QueryResult, + SObjectDescribe, + BulkJob, + BulkJobInfo, + CompositeRequest, + CompositeResponse, + SalesforceErrorResponse, + ApiLimits, +} from '../types/index.js'; + +export interface SalesforceClientConfig { + accessToken: string; + instanceUrl: string; + apiVersion?: string; +} + +export class SalesforceClient { + private client: AxiosInstance; + private instanceUrl: string; + private apiVersion: string; + private describeCache: Map = new Map(); + private apiLimits: ApiLimits | null = null; + + constructor(config: SalesforceClientConfig) { + this.instanceUrl = config.instanceUrl.replace(/\/$/, ''); + this.apiVersion = config.apiVersion || 'v59.0'; + + this.client = axios.create({ + baseURL: `${this.instanceUrl}/services/data/${this.apiVersion}`, + headers: { + 'Authorization': `Bearer ${config.accessToken}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); + + // Intercept responses to extract API limit info + this.client.interceptors.response.use( + (response) => { + const limitInfo = response.headers['sforce-limit-info']; + if (limitInfo) { + this.parseApiLimits(limitInfo); + } + return response; + }, + (error) => error + ); + } + + private parseApiLimits(limitInfo: string): void { + // Format: "api-usage=123/15000" + const match = limitInfo.match(/api-usage=(\d+)\/(\d+)/); + if (match) { + this.apiLimits = { + used: parseInt(match[1], 10), + remaining: parseInt(match[2], 10) - parseInt(match[1], 10), + }; + } + } + + public getApiLimits(): ApiLimits | null { + return this.apiLimits; + } + + /** + * Retry wrapper with exponential backoff + */ + private async retryRequest( + fn: () => Promise, + retries = 3, + delay = 1000 + ): Promise { + try { + return await fn(); + } catch (error) { + if (retries === 0) { + throw error; + } + + const axiosError = error as AxiosError; + const shouldRetry = + axiosError.response?.status === 429 || // Rate limit + axiosError.response?.status === 503 || // Service unavailable + axiosError.code === 'ECONNRESET' || + axiosError.code === 'ETIMEDOUT'; + + if (!shouldRetry) { + throw error; + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.retryRequest(fn, retries - 1, delay * 2); + } + } + + /** + * Execute SOQL query + */ + public async query( + soql: string + ): Promise> { + return this.retryRequest(async () => { + const response = await this.client.get>('/query', { + params: { q: soql }, + }); + return response.data; + }); + } + + /** + * Query more records using nextRecordsUrl + */ + public async queryMore( + nextRecordsUrl: string + ): Promise> { + return this.retryRequest(async () => { + const response = await this.client.get>(nextRecordsUrl); + return response.data; + }); + } + + /** + * Execute SOSL search + */ + public async search( + sosl: string + ): Promise { + return this.retryRequest(async () => { + const response = await this.client.get<{ searchRecords: T[] }>('/search', { + params: { q: sosl }, + }); + return response.data.searchRecords; + }); + } + + /** + * Get a record by ID + */ + public async getRecord( + objectType: string, + id: string, + fields?: string[] + ): Promise { + return this.retryRequest(async () => { + const params = fields ? { fields: fields.join(',') } : undefined; + const response = await this.client.get(`/sobjects/${objectType}/${id}`, { + params, + }); + return response.data; + }); + } + + /** + * Create a record + */ + public async createRecord( + objectType: string, + data: Partial + ): Promise<{ id: string; success: boolean; errors: SalesforceErrorResponse[] }> { + return this.retryRequest(async () => { + const response = await this.client.post(`/sobjects/${objectType}`, data); + return response.data; + }); + } + + /** + * Update a record + */ + public async updateRecord( + objectType: string, + id: string, + data: Partial + ): Promise { + return this.retryRequest(async () => { + await this.client.patch(`/sobjects/${objectType}/${id}`, data); + }); + } + + /** + * Upsert a record (insert or update based on external ID) + */ + public async upsertRecord( + objectType: string, + externalIdField: string, + externalIdValue: string, + data: Partial + ): Promise<{ id: string; success: boolean; created: boolean }> { + return this.retryRequest(async () => { + const response = await this.client.patch( + `/sobjects/${objectType}/${externalIdField}/${externalIdValue}`, + data + ); + return response.data; + }); + } + + /** + * Delete a record + */ + public async deleteRecord(objectType: string, id: string): Promise { + return this.retryRequest(async () => { + await this.client.delete(`/sobjects/${objectType}/${id}`); + }); + } + + /** + * Describe an SObject (with caching) + */ + public async describe(objectType: string, skipCache = false): Promise { + if (!skipCache && this.describeCache.has(objectType)) { + return this.describeCache.get(objectType)!; + } + + return this.retryRequest(async () => { + const response = await this.client.get( + `/sobjects/${objectType}/describe` + ); + this.describeCache.set(objectType, response.data); + return response.data; + }); + } + + /** + * Execute composite request (up to 25 subrequests) + */ + public async composite(request: CompositeRequest): Promise { + return this.retryRequest(async () => { + const response = await this.client.post( + '/composite', + request + ); + return response.data; + }); + } + + /** + * Execute batch request + */ + public async batch( + requests: Array<{ + method: string; + url: string; + richInput?: Record; + }> + ): Promise<{ hasErrors: boolean; results: unknown[] }> { + return this.retryRequest(async () => { + const response = await this.client.post('/composite/batch', { + batchRequests: requests, + }); + return response.data; + }); + } + + /** + * Create a Bulk API 2.0 job + */ + public async createBulkJob(job: BulkJob): Promise { + return this.retryRequest(async () => { + const response = await this.client.post('/jobs/ingest', { + object: job.object, + operation: job.operation, + externalIdFieldName: job.externalIdFieldName, + contentType: job.contentType || 'CSV', + lineEnding: job.lineEnding || 'LF', + }); + return response.data; + }); + } + + /** + * Upload data to a bulk job + */ + public async uploadBulkData(jobId: string, data: string): Promise { + return this.retryRequest(async () => { + await this.client.put(`/jobs/ingest/${jobId}/batches`, data, { + headers: { 'Content-Type': 'text/csv' }, + }); + }); + } + + /** + * Close/complete a bulk job + */ + public async closeBulkJob(jobId: string): Promise { + return this.retryRequest(async () => { + const response = await this.client.patch(`/jobs/ingest/${jobId}`, { + state: 'UploadComplete', + }); + return response.data; + }); + } + + /** + * Abort a bulk job + */ + public async abortBulkJob(jobId: string): Promise { + return this.retryRequest(async () => { + const response = await this.client.patch(`/jobs/ingest/${jobId}`, { + state: 'Aborted', + }); + return response.data; + }); + } + + /** + * Get bulk job info + */ + public async getBulkJobInfo(jobId: string): Promise { + return this.retryRequest(async () => { + const response = await this.client.get(`/jobs/ingest/${jobId}`); + return response.data; + }); + } + + /** + * Get successful results from a bulk job + */ + public async getBulkJobSuccessfulResults(jobId: string): Promise { + return this.retryRequest(async () => { + const response = await this.client.get( + `/jobs/ingest/${jobId}/successfulResults`, + { headers: { Accept: 'text/csv' } } + ); + return response.data; + }); + } + + /** + * Get failed results from a bulk job + */ + public async getBulkJobFailedResults(jobId: string): Promise { + return this.retryRequest(async () => { + const response = await this.client.get( + `/jobs/ingest/${jobId}/failedResults`, + { headers: { Accept: 'text/csv' } } + ); + return response.data; + }); + } + + /** + * SOQL query builder helper + */ + public buildSOQL(options: { + select: string[]; + from: string; + where?: string; + orderBy?: string; + limit?: number; + offset?: number; + }): string { + let soql = `SELECT ${options.select.join(', ')} FROM ${options.from}`; + + if (options.where) { + soql += ` WHERE ${options.where}`; + } + + if (options.orderBy) { + soql += ` ORDER BY ${options.orderBy}`; + } + + if (options.limit) { + soql += ` LIMIT ${options.limit}`; + } + + if (options.offset) { + soql += ` OFFSET ${options.offset}`; + } + + return soql; + } + + /** + * Helper: Query all records (handles pagination automatically) + */ + public async queryAll( + soql: string + ): Promise { + const allRecords: T[] = []; + let result = await this.query(soql); + allRecords.push(...result.records); + + while (!result.done && result.nextRecordsUrl) { + result = await this.queryMore(result.nextRecordsUrl); + allRecords.push(...result.records); + } + + return allRecords; + } + + /** + * Clear describe cache + */ + public clearDescribeCache(): void { + this.describeCache.clear(); + } +} diff --git a/servers/salesforce/src/main.ts b/servers/salesforce/src/main.ts new file mode 100644 index 0000000..eabd73a --- /dev/null +++ b/servers/salesforce/src/main.ts @@ -0,0 +1,105 @@ +/** + * Salesforce MCP Server Entry Point + */ + +import { SalesforceServer } from './server.js'; +import { z } from 'zod'; + +// Environment variable schema +const EnvSchema = z.object({ + SF_ACCESS_TOKEN: z.string().min(1, 'SF_ACCESS_TOKEN is required'), + SF_INSTANCE_URL: z.string().url('SF_INSTANCE_URL must be a valid URL'), + SF_API_VERSION: z.string().optional().default('v59.0'), +}); + +/** + * Validate environment variables + */ +function validateEnv() { + try { + return EnvSchema.parse({ + SF_ACCESS_TOKEN: process.env.SF_ACCESS_TOKEN, + SF_INSTANCE_URL: process.env.SF_INSTANCE_URL, + SF_API_VERSION: process.env.SF_API_VERSION, + }); + } catch (error) { + if (error instanceof z.ZodError) { + console.error('Environment validation failed:'); + error.errors.forEach((err) => { + console.error(` - ${err.path.join('.')}: ${err.message}`); + }); + process.exit(1); + } + throw error; + } +} + +/** + * Health check endpoint (if needed for HTTP transport) + */ +function healthCheck(): { status: string; timestamp: string } { + return { + status: 'ok', + timestamp: new Date().toISOString(), + }; +} + +/** + * Main function + */ +async function main() { + // Validate environment + const env = validateEnv(); + + console.error('Starting Salesforce MCP Server...'); + console.error(`Instance URL: ${env.SF_INSTANCE_URL}`); + console.error(`API Version: ${env.SF_API_VERSION}`); + + // Create and start server + const server = new SalesforceServer({ + accessToken: env.SF_ACCESS_TOKEN, + instanceUrl: env.SF_INSTANCE_URL, + apiVersion: env.SF_API_VERSION, + }); + + // Graceful shutdown handlers + const shutdown = async (signal: string) => { + 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')); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + console.error('Uncaught exception:', error); + shutdown('uncaughtException'); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('Unhandled rejection at:', promise, 'reason:', reason); + shutdown('unhandledRejection'); + }); + + // Start server (stdio transport by default) + try { + await server.start(); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Run main function +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/salesforce/src/server.ts b/servers/salesforce/src/server.ts new file mode 100644 index 0000000..49140d5 --- /dev/null +++ b/servers/salesforce/src/server.ts @@ -0,0 +1,362 @@ +/** + * Salesforce MCP Server + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { SalesforceClient } from './clients/salesforce.js'; +import { z } from 'zod'; + +export interface SalesforceServerConfig { + accessToken: string; + instanceUrl: string; + apiVersion?: string; +} + +export class SalesforceServer { + private server: Server; + private client: SalesforceClient; + + constructor(config: SalesforceServerConfig) { + this.server = new Server( + { + name: 'salesforce-mcp-server', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.client = new SalesforceClient({ + accessToken: config.accessToken, + instanceUrl: config.instanceUrl, + apiVersion: config.apiVersion, + }); + + this.setupHandlers(); + } + + /** + * Setup MCP handlers + */ + private setupHandlers(): void { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [ + // Foundation tools - additional tools can be added via tool modules later + { + name: 'salesforce_query', + description: 'Execute a SOQL query against Salesforce', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'SOQL query string', + }, + }, + required: ['query'], + }, + }, + { + name: 'salesforce_create_record', + description: 'Create a new Salesforce record', + inputSchema: { + type: 'object', + properties: { + objectType: { + type: 'string', + description: 'Salesforce object type (e.g., Account, Contact)', + }, + data: { + type: 'object', + description: 'Record data', + }, + }, + required: ['objectType', 'data'], + }, + }, + { + name: 'salesforce_update_record', + description: 'Update an existing Salesforce record', + inputSchema: { + type: 'object', + properties: { + objectType: { + type: 'string', + description: 'Salesforce object type', + }, + id: { + type: 'string', + description: 'Record ID', + }, + data: { + type: 'object', + description: 'Fields to update', + }, + }, + required: ['objectType', 'id', 'data'], + }, + }, + { + name: 'salesforce_delete_record', + description: 'Delete a Salesforce record', + inputSchema: { + type: 'object', + properties: { + objectType: { + type: 'string', + description: 'Salesforce object type', + }, + id: { + type: 'string', + description: 'Record ID', + }, + }, + required: ['objectType', 'id'], + }, + }, + { + name: 'salesforce_describe_object', + description: 'Get metadata for a Salesforce object', + inputSchema: { + type: 'object', + properties: { + objectType: { + type: 'string', + description: 'Salesforce object type', + }, + }, + required: ['objectType'], + }, + }, + ], + }; + }); + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + // Route to appropriate handler + switch (name) { + case 'salesforce_query': + return await this.handleQuery(args as { query: string }); + + case 'salesforce_create_record': + return await this.handleCreateRecord( + args as { objectType: string; data: Record } + ); + + case 'salesforce_update_record': + return await this.handleUpdateRecord( + args as { objectType: string; id: string; data: Record } + ); + + case 'salesforce_delete_record': + return await this.handleDeleteRecord( + args as { objectType: string; id: string } + ); + + case 'salesforce_describe_object': + return await this.handleDescribeObject( + args as { objectType: string } + ); + + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (error) { + return this.formatError(error); + } + }); + + // List resources (for UI apps) + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { + resources: [ + { + uri: 'salesforce://dashboard', + name: 'Salesforce Dashboard', + description: 'Overview of Salesforce data and metrics', + mimeType: 'application/json', + }, + ], + }; + }); + + // Read resource + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + + if (uri === 'salesforce://dashboard') { + const limits = this.client.getApiLimits(); + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify( + { + apiLimits: limits, + instanceUrl: this.client['instanceUrl'], + }, + null, + 2 + ), + }, + ], + }; + } + + throw new Error(`Unknown resource: ${uri}`); + }); + } + + /** + * Handle SOQL query + */ + private async handleQuery(args: { query: string }) { + const result = await this.client.query(args.query); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + /** + * Handle create record + */ + private async handleCreateRecord(args: { + objectType: string; + data: Record; + }) { + const result = await this.client.createRecord(args.objectType, args.data); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + /** + * Handle update record + */ + private async handleUpdateRecord(args: { + objectType: string; + id: string; + data: Record; + }) { + await this.client.updateRecord(args.objectType, args.id, args.data); + return { + content: [ + { + type: 'text', + text: 'Record updated successfully', + }, + ], + }; + } + + /** + * Handle delete record + */ + private async handleDeleteRecord(args: { objectType: string; id: string }) { + await this.client.deleteRecord(args.objectType, args.id); + return { + content: [ + { + type: 'text', + text: 'Record deleted successfully', + }, + ], + }; + } + + /** + * Handle describe object + */ + private async handleDescribeObject(args: { objectType: string }) { + const result = await this.client.describe(args.objectType); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } + + /** + * Format error response + */ + private formatError(error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + error: true, + message, + }, + null, + 2 + ), + }, + ], + isError: true, + }; + } + + /** + * Start the server + */ + public async start(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Salesforce MCP Server running on stdio'); + } + + /** + * Graceful shutdown + */ + public async close(): Promise { + await this.server.close(); + } + + /** + * Get the underlying Server instance + */ + public getServer(): Server { + return this.server; + } + + /** + * Get the Salesforce client + */ + public getClient(): SalesforceClient { + return this.client; + } +} diff --git a/servers/salesforce/src/types/index.ts b/servers/salesforce/src/types/index.ts new file mode 100644 index 0000000..cf00b5c --- /dev/null +++ b/servers/salesforce/src/types/index.ts @@ -0,0 +1,467 @@ +/** + * Salesforce REST API TypeScript Definitions + */ + +// Branded type for Salesforce IDs (15 or 18 characters) +export type SalesforceId = string & { readonly __brand: 'SalesforceId' }; + +// Helper to create branded IDs +export const salesforceId = (id: string): SalesforceId => id as SalesforceId; + +// Base SObject interface - all Salesforce objects extend this +export interface SObject { + Id?: SalesforceId; + IsDeleted?: boolean; + CreatedDate?: string; + CreatedById?: SalesforceId; + LastModifiedDate?: string; + LastModifiedById?: SalesforceId; + SystemModstamp?: string; + attributes?: { + type: string; + url?: string; + }; +} + +// Standard Objects +export interface Account extends SObject { + Name: string; + Type?: string; + Industry?: string; + BillingStreet?: string; + BillingCity?: string; + BillingState?: string; + BillingPostalCode?: string; + BillingCountry?: string; + ShippingStreet?: string; + ShippingCity?: string; + ShippingState?: string; + ShippingPostalCode?: string; + ShippingCountry?: string; + Phone?: string; + Fax?: string; + Website?: string; + Description?: string; + NumberOfEmployees?: number; + AnnualRevenue?: number; + OwnerId?: SalesforceId; + ParentId?: SalesforceId; +} + +export interface Contact extends SObject { + FirstName?: string; + LastName: string; + AccountId?: SalesforceId; + Email?: string; + Phone?: string; + MobilePhone?: string; + Title?: string; + Department?: string; + MailingStreet?: string; + MailingCity?: string; + MailingState?: string; + MailingPostalCode?: string; + MailingCountry?: string; + Description?: string; + OwnerId?: SalesforceId; + ReportsToId?: SalesforceId; +} + +export interface Lead extends SObject { + FirstName?: string; + LastName: string; + Company: string; + Status: string; + Email?: string; + Phone?: string; + MobilePhone?: string; + Title?: string; + Industry?: string; + Street?: string; + City?: string; + State?: string; + PostalCode?: string; + Country?: string; + Description?: string; + OwnerId?: SalesforceId; + Rating?: string; + LeadSource?: string; + ConvertedAccountId?: SalesforceId; + ConvertedContactId?: SalesforceId; + ConvertedOpportunityId?: SalesforceId; + ConvertedDate?: string; + IsConverted?: boolean; +} + +export interface Opportunity extends SObject { + Name: string; + AccountId?: SalesforceId; + StageName: string; + CloseDate: string; + Amount?: number; + Probability?: number; + Type?: string; + LeadSource?: string; + Description?: string; + OwnerId?: SalesforceId; + IsClosed?: boolean; + IsWon?: boolean; + ForecastCategoryName?: string; + CampaignId?: SalesforceId; +} + +export interface Case extends SObject { + AccountId?: SalesforceId; + ContactId?: SalesforceId; + Status: string; + Priority?: string; + Origin?: string; + Subject?: string; + Description?: string; + Type?: string; + Reason?: string; + OwnerId?: SalesforceId; + IsClosed?: boolean; + ClosedDate?: string; + ParentId?: SalesforceId; +} + +export interface Task extends SObject { + Subject: string; + Status: string; + Priority?: string; + ActivityDate?: string; + Description?: string; + OwnerId?: SalesforceId; + WhoId?: SalesforceId; // Lead or Contact + WhatId?: SalesforceId; // Account, Opportunity, etc. + IsClosed?: boolean; + IsHighPriority?: boolean; +} + +export interface Event extends SObject { + Subject: string; + StartDateTime: string; + EndDateTime: string; + Location?: string; + Description?: string; + OwnerId?: SalesforceId; + WhoId?: SalesforceId; // Lead or Contact + WhatId?: SalesforceId; // Account, Opportunity, etc. + IsAllDayEvent?: boolean; + IsPrivate?: boolean; +} + +export interface Campaign extends SObject { + Name: string; + Type?: string; + Status?: string; + StartDate?: string; + EndDate?: string; + Description?: string; + IsActive?: boolean; + BudgetedCost?: number; + ActualCost?: number; + ExpectedRevenue?: number; + NumberOfLeads?: number; + NumberOfConvertedLeads?: number; + NumberOfContacts?: number; + NumberOfOpportunities?: number; + OwnerId?: SalesforceId; + ParentId?: SalesforceId; +} + +export interface CampaignMember extends SObject { + CampaignId: SalesforceId; + LeadId?: SalesforceId; + ContactId?: SalesforceId; + Status?: string; + HasResponded?: boolean; + FirstRespondedDate?: string; +} + +export interface User extends SObject { + Username: string; + Email: string; + FirstName?: string; + LastName: string; + Name?: string; + IsActive?: boolean; + UserRoleId?: SalesforceId; + ProfileId?: SalesforceId; + Title?: string; + Department?: string; + Phone?: string; + MobilePhone?: string; + TimeZoneSidKey?: string; + LocaleSidKey?: string; + EmailEncodingKey?: string; + LanguageLocaleKey?: string; +} + +export interface UserRole extends SObject { + Name: string; + ParentRoleId?: SalesforceId; + DeveloperName?: string; + PortalType?: string; +} + +export interface Profile extends SObject { + Name: string; + Description?: string; + UserType?: string; + UserLicenseId?: SalesforceId; +} + +export interface PermissionSet extends SObject { + Name: string; + Label: string; + Description?: string; + IsOwnedByProfile?: boolean; + ProfileId?: SalesforceId; +} + +export interface Report extends SObject { + Name: string; + DeveloperName: string; + FolderName?: string; + Description?: string; + Format?: string; + LastRunDate?: string; + OwnerId?: SalesforceId; +} + +export interface Dashboard extends SObject { + Title: string; + DeveloperName: string; + FolderName?: string; + Description?: string; + LeftSize?: string; + MiddleSize?: string; + RightSize?: string; + RunningUserId?: SalesforceId; +} + +export interface ReportMetadata { + name: string; + id?: string; + developerName?: string; + reportType?: string; + reportFormat?: 'TABULAR' | 'SUMMARY' | 'MATRIX'; + aggregates?: Array<{ + acrossGroupingContext?: string; + calculatedFormula?: string; + datatype?: string; + description?: string; + developerName?: string; + downGroupingContext?: string; + isActive?: boolean; + isCrossBlock?: boolean; + masterLabel?: string; + reportType?: string; + scale?: number; + }>; +} + +export interface ContentDocument extends SObject { + Title: string; + FileType?: string; + FileExtension?: string; + ContentSize?: number; + OwnerId?: SalesforceId; + ParentId?: SalesforceId; + PublishStatus?: string; + LatestPublishedVersionId?: SalesforceId; +} + +export interface ContentVersion extends SObject { + Title: string; + PathOnClient?: string; + VersionData?: string; // Base64 encoded + ContentDocumentId?: SalesforceId; + ReasonForChange?: string; + ContentSize?: number; + FileType?: string; + FileExtension?: string; + OwnerId?: SalesforceId; +} + +export interface Attachment extends SObject { + Name: string; + Body: string; // Base64 encoded + ContentType?: string; + ParentId?: SalesforceId; + OwnerId?: SalesforceId; + IsPrivate?: boolean; + Description?: string; + BodyLength?: number; +} + +// SOQL Query Result +export interface QueryResult { + totalSize: number; + done: boolean; + records: T[]; + nextRecordsUrl?: string; +} + +// Describe results +export interface FieldDescribe { + name: string; + label: string; + type: string; + length?: number; + byteLength?: number; + precision?: number; + scale?: number; + digits?: number; + picklistValues?: Array<{ + active: boolean; + defaultValue: boolean; + label: string; + value: string; + }>; + referenceTo?: string[]; + relationshipName?: string; + calculatedFormula?: string; + defaultValue?: unknown; + defaultValueFormula?: string; + defaultedOnCreate?: boolean; + dependentPicklist?: boolean; + externalId?: boolean; + htmlFormatted?: boolean; + idLookup?: boolean; + inlineHelpText?: string; + autoNumber?: boolean; + calculated?: boolean; + cascadeDelete?: boolean; + caseSensitive?: boolean; + createable?: boolean; + custom?: boolean; + updateable?: boolean; + nillable?: boolean; + nameField?: boolean; + unique?: boolean; + sortable?: boolean; + filterable?: boolean; + groupable?: boolean; +} + +export interface SObjectDescribe { + name: string; + label: string; + labelPlural: string; + keyPrefix?: string; + custom: boolean; + fields: FieldDescribe[]; + childRelationships?: Array<{ + field: string; + childSObject: string; + relationshipName?: string; + cascadeDelete: boolean; + }>; + recordTypeInfos?: Array<{ + name: string; + recordTypeId: string; + developerName: string; + active: boolean; + defaultRecordTypeMapping: boolean; + master: boolean; + }>; + createable?: boolean; + updateable?: boolean; + deletable?: boolean; + queryable?: boolean; + searchable?: boolean; + undeletable?: boolean; + mergeable?: boolean; + triggerable?: boolean; + feedEnabled?: boolean; + activateable?: boolean; + urls?: Record; +} + +// Bulk API 2.0 types +export interface BulkJob { + id?: string; + object: string; + operation: 'insert' | 'update' | 'upsert' | 'delete' | 'hardDelete'; + externalIdFieldName?: string; + contentType?: 'CSV'; + lineEnding?: 'LF' | 'CRLF'; + state?: 'Open' | 'UploadComplete' | 'InProgress' | 'Aborted' | 'JobComplete' | 'Failed'; + createdDate?: string; + systemModstamp?: string; + createdById?: string; + numberRecordsProcessed?: number; + numberRecordsFailed?: number; + retries?: number; + totalProcessingTime?: number; + apiVersion?: string; + jobType?: string; +} + +export interface BulkJobInfo extends BulkJob { + state: 'Open' | 'UploadComplete' | 'InProgress' | 'Aborted' | 'JobComplete' | 'Failed'; + numberRecordsProcessed: number; + numberRecordsFailed: number; +} + +export interface BulkResult { + id?: string; + success: boolean; + created: boolean; + errors?: Array<{ + statusCode: string; + message: string; + fields: string[]; + }>; +} + +// Composite request/response types +export interface CompositeSubrequest { + method: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; + url: string; + referenceId: string; + body?: Record; + httpHeaders?: Record; +} + +export interface CompositeRequest { + allOrNone?: boolean; + collateSubrequests?: boolean; + compositeRequest: CompositeSubrequest[]; +} + +export interface CompositeSubresponse { + body: unknown; + httpHeaders: Record; + httpStatusCode: number; + referenceId: string; +} + +export interface CompositeResponse { + compositeResponse: CompositeSubresponse[]; +} + +// Error response +export interface SalesforceError { + message: string; + errorCode: string; + fields?: string[]; +} + +export interface SalesforceErrorResponse { + errors?: SalesforceError[]; + message?: string; + errorCode?: string; +} + +// API limits +export interface ApiLimits { + used: number; + remaining: number; +} + +// Generic custom object support +export type CustomSObject = Record> = SObject & T; diff --git a/servers/salesforce/tsconfig.json b/servers/salesforce/tsconfig.json new file mode 100644 index 0000000..bcbbff1 --- /dev/null +++ b/servers/salesforce/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/servers/shopify/.env.example b/servers/shopify/.env.example new file mode 100644 index 0000000..bbfc2ea --- /dev/null +++ b/servers/shopify/.env.example @@ -0,0 +1,13 @@ +# Shopify MCP Server Configuration + +# Your Shopify store name (without .myshopify.com) +SHOPIFY_STORE=your-store-name + +# Shopify Admin API access token (create in Settings > Apps and sales channels > Develop apps) +SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Optional: API version (defaults to 2024-01) +# SHOPIFY_API_VERSION=2024-01 + +# Optional: Enable debug logging +# DEBUG=true diff --git a/servers/shopify/.gitignore b/servers/shopify/.gitignore new file mode 100644 index 0000000..3b24e4a --- /dev/null +++ b/servers/shopify/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.env +*.log diff --git a/servers/shopify/README.md b/servers/shopify/README.md new file mode 100644 index 0000000..c10d84c --- /dev/null +++ b/servers/shopify/README.md @@ -0,0 +1,277 @@ +# Shopify MCP Server + +Model Context Protocol (MCP) server for Shopify Admin API integration. + +## Features + +- **Full Shopify Admin API Coverage**: Products, Orders, Customers, Inventory, Collections, and more +- **GraphQL Support**: Execute GraphQL Admin API queries +- **Automatic Rate Limiting**: Intelligent retry with exponential backoff +- **Type-Safe**: Comprehensive TypeScript types for all Shopify entities +- **Cursor-Based Pagination**: Automatic handling of paginated responses +- **MCP Resources**: UI-friendly resource endpoints for apps +- **Dual Transport**: Stdio (default) or HTTP/SSE modes + +## Installation + +```bash +npm install +npm run build +``` + +## Configuration + +### Environment Variables + +| Variable | Required | Description | Example | +|----------|----------|-------------|---------| +| `SHOPIFY_ACCESS_TOKEN` | āœ… | Admin API access token | `shpat_xxxxxxxxxxxxx` | +| `SHOPIFY_STORE` | āœ… | Store name (without .myshopify.com) | `my-store` | +| `SHOPIFY_API_VERSION` | āŒ | API version (default: 2024-01) | `2024-01` | +| `DEBUG` | āŒ | Enable debug logging | `true` | + +### Getting Your Access Token + +1. Go to your Shopify admin: `https://your-store.myshopify.com/admin` +2. Navigate to **Settings** → **Apps and sales channels** → **Develop apps** +3. Click **Create an app** or select an existing one +4. Go to **API credentials** tab +5. Click **Install app** (if not already installed) +6. Copy the **Admin API access token** (starts with `shpat_`) + +### Required API Scopes + +Grant the following scopes to your app (based on features needed): + +- `read_products`, `write_products` - Product management +- `read_orders`, `write_orders` - Order management +- `read_customers`, `write_customers` - Customer management +- `read_inventory`, `write_inventory` - Inventory tracking +- `read_content`, `write_content` - Pages, blogs, themes +- `read_discounts`, `write_discounts` - Price rules and discount codes +- `read_shipping`, `write_shipping` - Shipping zones and carriers +- `read_analytics` - Analytics and reports + +## Usage + +### Stdio Mode (Default) + +```bash +# Copy example env file +cp .env.example .env + +# Edit .env with your credentials +# SHOPIFY_ACCESS_TOKEN=shpat_xxxxxxxxxxxxx +# SHOPIFY_STORE=your-store-name + +# Start the server +npm start + +# Or in development mode with auto-reload +npm run dev +``` + +### HTTP Mode + +```bash +# Start with HTTP transport +npm start -- --http --port=3000 + +# Health check +curl http://localhost:3000/health +``` + +## Available Tools + +The server provides the following tool categories (lazy-loaded on demand): + +### Products +- Create, read, update, delete products +- Manage variants and images +- Bulk operations + +### Orders +- List and search orders +- Update order status +- Create refunds +- Manage fulfillments + +### Customers +- Customer CRUD operations +- Customer search +- Manage addresses + +### Inventory +- Track inventory levels +- Manage locations +- Adjust stock quantities + +### Collections +- Smart and custom collections +- Add/remove products from collections + +### Discounts +- Create price rules +- Generate discount codes +- Manage promotions + +### Fulfillments +- Create fulfillments +- Update tracking information +- Cancel fulfillments + +### Shipping +- Shipping zones +- Carrier services +- Delivery profiles + +### Themes +- Theme management +- Asset upload/download + +### Content +- Pages +- Blogs and articles +- Redirects + +### Webhooks +- Register webhooks +- Manage subscriptions + +### Analytics +- Sales reports +- Product analytics +- Customer insights + +## MCP Resources + +Resources are available for UI applications: + +- `shopify://products` - List all products +- `shopify://orders` - List all orders +- `shopify://customers` - List all customers +- `shopify://inventory` - View inventory levels + +## API Client Features + +### Rate Limiting + +The client automatically handles Shopify's rate limits: + +- Parses `X-Shopify-Shop-Api-Call-Limit` header +- Warns when approaching limit (80%+ used) +- Automatic retry with exponential backoff (1s, 2s, 4s) + +### Pagination + +```typescript +// Get single page +const { data, pageInfo } = await client.list('/products.json'); + +// Get all pages (with safety limit) +const allProducts = await client.listAll('/products.json', {}, 10); +``` + +### GraphQL + +```typescript +const result = await client.graphql(` + query { + products(first: 10) { + edges { + node { + id + title + handle + } + } + } + } +`); +``` + +## Error Handling + +All errors are normalized with structured responses: + +```json +{ + "error": "RATE_LIMIT_EXCEEDED", + "message": "Shopify API rate limit exceeded. Please retry after a delay." +} +``` + +Error codes: +- `TOOL_NOT_FOUND` - Requested tool doesn't exist +- `RESOURCE_READ_ERROR` - Failed to read resource +- `AUTHENTICATION_ERROR` - Invalid access token +- `RATE_LIMIT_EXCEEDED` - Too many requests +- `UNKNOWN_ERROR` - Unexpected error + +## Development + +```bash +# Install dependencies +npm install + +# Run TypeScript compiler in watch mode +npx tsc --watch + +# Run development server with auto-reload +npm run dev + +# Type check only (no build) +npx tsc --noEmit + +# Build for production +npm run build +``` + +## Architecture + +``` +src/ +ā”œā”€ā”€ main.ts # Entry point, env validation, transports +ā”œā”€ā”€ server.ts # MCP server setup, handlers +ā”œā”€ā”€ clients/ +│ └── shopify.ts # Shopify API client (REST + GraphQL) +ā”œā”€ā”€ types/ +│ └── index.ts # TypeScript interfaces for Shopify entities +ā”œā”€ā”€ tools/ # Tool modules (created separately) +│ ā”œā”€ā”€ products.ts +│ ā”œā”€ā”€ orders.ts +│ ā”œā”€ā”€ customers.ts +│ └── ... +└── apps/ # UI applications (created separately) +``` + +## Troubleshooting + +### "Authentication failed" +- Verify `SHOPIFY_ACCESS_TOKEN` is correct +- Ensure the app is installed on your store +- Check that the token hasn't been revoked + +### "Resource not found" +- Check that `SHOPIFY_STORE` is correct (without `.myshopify.com`) +- Verify the resource ID exists in your store + +### "Rate limit exceeded" +- The client will automatically retry with backoff +- Consider implementing request batching for bulk operations +- Monitor rate limit info with `client.getRateLimitInfo()` + +### TypeScript errors +- Run `npm install` to ensure all dependencies are installed +- Check that `@types/node` version matches your Node.js version +- Verify `tsconfig.json` settings + +## License + +MIT + +## Support + +For issues and questions: +- GitHub Issues: [mcpengine repository](https://github.com/BusyBee3333/mcpengine) +- Shopify API Docs: https://shopify.dev/docs/api/admin-rest diff --git a/servers/shopify/package.json b/servers/shopify/package.json new file mode 100644 index 0000000..42f9ecb --- /dev/null +++ b/servers/shopify/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcpengine/shopify", + "version": "1.0.0", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "tsx": "^4.19.0", + "@types/node": "^22.0.0" + } +} diff --git a/servers/shopify/src/clients/shopify.ts b/servers/shopify/src/clients/shopify.ts new file mode 100644 index 0000000..a43fb19 --- /dev/null +++ b/servers/shopify/src/clients/shopify.ts @@ -0,0 +1,315 @@ +import axios, { AxiosInstance, AxiosRequestConfig, AxiosError } from 'axios'; +import { GraphQLRequest, GraphQLResponse } from '../types/index.js'; + +interface ShopifyClientConfig { + accessToken: string; + store: string; + apiVersion?: string; + timeout?: number; +} + +interface RateLimitInfo { + current: number; + max: number; +} + +interface PaginatedResponse { + data: T[]; + pageInfo?: { + nextPageUrl?: string; + hasNextPage: boolean; + }; +} + +export class ShopifyClient { + private client: AxiosInstance; + private store: string; + private apiVersion: string; + private rateLimitInfo: RateLimitInfo = { current: 0, max: 40 }; + + constructor(config: ShopifyClientConfig) { + this.store = config.store; + this.apiVersion = config.apiVersion || '2024-01'; + + const baseURL = `https://${this.store}.myshopify.com/admin/api/${this.apiVersion}`; + + this.client = axios.create({ + baseURL, + timeout: config.timeout || 30000, + headers: { + 'X-Shopify-Access-Token': config.accessToken, + 'Content-Type': 'application/json', + }, + }); + + this.setupInterceptors(); + } + + private setupInterceptors(): void { + // Request interceptor for logging + this.client.interceptors.request.use( + (config) => { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] ${config.method?.toUpperCase()} ${config.url}`); + return config; + }, + (error) => { + console.error('[Request Error]', error.message); + return Promise.reject(error); + } + ); + + // Response interceptor for rate limit tracking and error normalization + this.client.interceptors.response.use( + (response) => { + // Parse rate limit header: "12/40" means 12 calls made out of 40 max + const rateLimitHeader = response.headers['x-shopify-shop-api-call-limit']; + if (rateLimitHeader) { + const [current, max] = rateLimitHeader.split('/').map(Number); + this.rateLimitInfo = { current: current || 0, max: max || 40 }; + + // Log warning if approaching rate limit + if (this.rateLimitInfo.current >= this.rateLimitInfo.max * 0.8) { + console.warn( + `[Rate Limit Warning] ${this.rateLimitInfo.current}/${this.rateLimitInfo.max} calls used` + ); + } + } + + return response; + }, + (error) => { + return Promise.reject(this.normalizeError(error)); + } + ); + } + + private normalizeError(error: unknown): Error { + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + const status = axiosError.response?.status; + const data = axiosError.response?.data as { errors?: unknown }; + + let message = axiosError.message; + + if (status === 429) { + message = 'Shopify API rate limit exceeded. Please retry after a delay.'; + } else if (status === 401) { + message = 'Authentication failed. Check your access token.'; + } else if (status === 404) { + message = 'Resource not found.'; + } else if (data?.errors) { + message = typeof data.errors === 'string' + ? data.errors + : JSON.stringify(data.errors); + } + + const normalizedError = new Error(message); + (normalizedError as Error & { status?: number; code?: string }).status = status; + (normalizedError as Error & { status?: number; code?: string }).code = axiosError.code; + return normalizedError; + } + + return error instanceof Error ? error : new Error(String(error)); + } + + private async retryWithBackoff( + fn: () => Promise, + retries = 3, + baseDelay = 1000 + ): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= retries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + // Don't retry on client errors (4xx except 429) + const status = (lastError as Error & { status?: number }).status; + if (status && status >= 400 && status < 500 && status !== 429) { + throw lastError; + } + + if (attempt < retries) { + const delay = baseDelay * Math.pow(2, attempt); // Exponential: 1s, 2s, 4s + console.log(`[Retry ${attempt + 1}/${retries}] Waiting ${delay}ms...`); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + throw lastError; + } + + /** + * Parse Link header for cursor-based pagination + */ + private parseLinkHeader(linkHeader?: string): { nextPageUrl?: string } { + if (!linkHeader) return {}; + + const links = linkHeader.split(',').map(link => { + const parts = link.split(';').map(s => s.trim()); + const url = parts[0]; + const rel = parts[1]; + + if (!url || !rel) return null; + + return { + url: url.slice(1, -1), // Remove angle brackets + rel: rel.split('=')[1]?.replace(/"/g, ''), + }; + }).filter((link): link is { url: string; rel: string | undefined } => link !== null); + + const nextLink = links.find(link => link.rel === 'next'); + return { nextPageUrl: nextLink?.url }; + } + + /** + * GET request with automatic retry + */ + async get(path: string, config?: AxiosRequestConfig): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.get(path, config); + return response.data; + }); + } + + /** + * GET request with pagination support + */ + async list( + path: string, + config?: AxiosRequestConfig + ): Promise> { + return this.retryWithBackoff(async () => { + const response = await this.client.get(path, config); + const linkHeader = response.headers['link']; + const { nextPageUrl } = this.parseLinkHeader(linkHeader); + + return { + data: response.data, + pageInfo: { + nextPageUrl, + hasNextPage: !!nextPageUrl, + }, + }; + }); + } + + /** + * GET all pages automatically (use with caution on large datasets) + */ + async listAll( + path: string, + config?: AxiosRequestConfig, + maxPages = 10 + ): Promise { + const allData: T[] = []; + let currentPath = path; + let pageCount = 0; + + while (currentPath && pageCount < maxPages) { + const response = await this.retryWithBackoff(async () => { + const url = currentPath.startsWith('http') + ? currentPath + : currentPath; + const res = await this.client.get(url, config); + return res; + }); + + allData.push(...response.data); + + const linkHeader = response.headers['link']; + const { nextPageUrl } = this.parseLinkHeader(linkHeader); + + if (!nextPageUrl) break; + + // Extract path from full URL + currentPath = nextPageUrl.replace(this.client.defaults.baseURL || '', ''); + pageCount++; + } + + return allData; + } + + /** + * POST request with automatic retry + */ + async create( + path: string, + data: D, + config?: AxiosRequestConfig + ): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.post(path, data, config); + return response.data; + }); + } + + /** + * PUT request with automatic retry + */ + async update( + path: string, + data: D, + config?: AxiosRequestConfig + ): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.put(path, data, config); + return response.data; + }); + } + + /** + * DELETE request with automatic retry + */ + async delete(path: string, config?: AxiosRequestConfig): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.delete(path, config); + return response.data; + }); + } + + /** + * GraphQL Admin API request + */ + async graphql( + query: string, + variables?: Record + ): Promise> { + const graphqlUrl = `https://${this.store}.myshopify.com/admin/api/${this.apiVersion}/graphql.json`; + + return this.retryWithBackoff(async () => { + const response = await this.client.post>( + graphqlUrl, + { query, variables } as GraphQLRequest, + { + baseURL: '', // Override baseURL for GraphQL endpoint + } + ); + + if (response.data.errors && response.data.errors.length > 0) { + const errorMessages = response.data.errors.map(e => e.message).join(', '); + throw new Error(`GraphQL Error: ${errorMessages}`); + } + + return response.data; + }); + } + + /** + * Get current rate limit info + */ + getRateLimitInfo(): RateLimitInfo { + return { ...this.rateLimitInfo }; + } + + /** + * Check if approaching rate limit (>80% used) + */ + isApproachingRateLimit(): boolean { + return this.rateLimitInfo.current >= this.rateLimitInfo.max * 0.8; + } +} diff --git a/servers/shopify/src/main.ts b/servers/shopify/src/main.ts new file mode 100644 index 0000000..4f32d6a --- /dev/null +++ b/servers/shopify/src/main.ts @@ -0,0 +1,170 @@ +#!/usr/bin/env node +import { ShopifyClient } from './clients/shopify.js'; +import { ShopifyMCPServer } from './server.js'; + +interface EnvironmentConfig { + accessToken: string; + store: string; + apiVersion?: string; + httpMode: boolean; + port?: number; +} + +/** + * Validate and load environment configuration + */ +function loadConfig(): EnvironmentConfig { + const accessToken = process.env.SHOPIFY_ACCESS_TOKEN; + const store = process.env.SHOPIFY_STORE; + const apiVersion = process.env.SHOPIFY_API_VERSION; + const httpMode = process.argv.includes('--http'); + const portArg = process.argv.find(arg => arg.startsWith('--port=')); + const port = portArg ? parseInt(portArg.split('=')[1] || '3000', 10) : 3000; + + // Validate required environment variables + if (!accessToken) { + console.error('Error: SHOPIFY_ACCESS_TOKEN environment variable is required'); + console.error('Get your access token from: Settings > Apps and sales channels > Develop apps'); + process.exit(1); + } + + if (!store) { + console.error('Error: SHOPIFY_STORE environment variable is required'); + console.error('Example: your-store-name (without .myshopify.com)'); + process.exit(1); + } + + // Validate store format (should not include .myshopify.com) + if (store.includes('.myshopify.com')) { + console.error('Error: SHOPIFY_STORE should not include .myshopify.com'); + console.error('Example: Use "your-store-name" instead of "your-store-name.myshopify.com"'); + process.exit(1); + } + + return { + accessToken, + store, + apiVersion, + httpMode, + port, + }; +} + +/** + * Setup graceful shutdown handlers + */ +function setupGracefulShutdown(cleanup: () => Promise): void { + const handleShutdown = async (signal: string) => { + console.log(`\n[${signal}] Shutting down gracefully...`); + try { + await cleanup(); + console.log('Shutdown complete'); + process.exit(0); + } catch (error) { + console.error('Error during shutdown:', error); + process.exit(1); + } + }; + + process.on('SIGINT', () => handleShutdown('SIGINT')); + process.on('SIGTERM', () => handleShutdown('SIGTERM')); +} + +/** + * Start HTTP/SSE server (for --http mode) + */ +async function startHttpServer( + server: ShopifyMCPServer, + port: number +): Promise<() => Promise> { + // Note: HTTP/SSE transport requires additional setup + // For now, this is a placeholder for future implementation + console.log(`HTTP mode requested on port ${port}`); + console.log('Note: HTTP/SSE transport not yet implemented'); + console.log('Falling back to stdio mode...'); + + await server.start(); + + return async () => { + console.log('HTTP server cleanup'); + }; +} + +/** + * Start stdio server (default mode) + */ +async function startStdioServer( + server: ShopifyMCPServer +): Promise<() => Promise> { + await server.start(); + + return async () => { + console.log('Stdio server cleanup'); + }; +} + +/** + * Health check endpoint (when in HTTP mode) + */ +function createHealthCheckHandler() { + return async (req: unknown, res: unknown) => { + // Placeholder for HTTP health check + console.log('Health check called'); + }; +} + +/** + * Main entry point + */ +async function main() { + console.log('='.repeat(50)); + console.log('Shopify MCP Server v1.0.0'); + console.log('='.repeat(50)); + + // Load configuration + const config = loadConfig(); + + console.log(`Store: ${config.store}`); + console.log(`API Version: ${config.apiVersion || '2024-01'}`); + console.log(`Mode: ${config.httpMode ? `HTTP (port ${config.port})` : 'stdio'}`); + console.log('='.repeat(50)); + + // Initialize Shopify client + const shopifyClient = new ShopifyClient({ + accessToken: config.accessToken, + store: config.store, + apiVersion: config.apiVersion, + }); + + // Initialize MCP server + const mcpServer = new ShopifyMCPServer({ + name: '@mcpengine/shopify', + version: '1.0.0', + shopifyClient, + }); + + // Start server in appropriate mode + let cleanup: () => Promise; + + if (config.httpMode) { + cleanup = await startHttpServer(mcpServer, config.port || 3000); + } else { + cleanup = await startStdioServer(mcpServer); + } + + // Setup graceful shutdown + setupGracefulShutdown(cleanup); + + // Keep process alive + if (config.httpMode) { + // In HTTP mode, the server keeps the process alive + console.log(`Server listening on http://localhost:${config.port}`); + console.log(`Health check available at: http://localhost:${config.port}/health`); + } +} + +// Start the server +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/shopify/src/server.ts b/servers/shopify/src/server.ts new file mode 100644 index 0000000..dbd720d --- /dev/null +++ b/servers/shopify/src/server.ts @@ -0,0 +1,337 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { ShopifyClient } from './clients/shopify.js'; + +interface ToolModule { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; + handler: (args: Record, client: ShopifyClient) => Promise; +} + +interface ServerConfig { + name: string; + version: string; + shopifyClient: ShopifyClient; +} + +export class ShopifyMCPServer { + private server: Server; + private client: ShopifyClient; + private toolModules: Map Promise> = new Map(); + + constructor(config: ServerConfig) { + this.client = config.shopifyClient; + + this.server = new Server( + { + name: config.name, + version: config.version, + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupToolModules(); + this.setupHandlers(); + } + + /** + * Register lazy-loaded tool modules + */ + private setupToolModules(): void { + // Tool modules will be dynamically imported when needed + this.toolModules.set('products', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/products.js'); + return module.default; + }); + + this.toolModules.set('orders', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/orders.js'); + return module.default; + }); + + this.toolModules.set('customers', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/customers.js'); + return module.default; + }); + + this.toolModules.set('inventory', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/inventory.js'); + return module.default; + }); + + this.toolModules.set('collections', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/collections.js'); + return module.default; + }); + + this.toolModules.set('discounts', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/discounts.js'); + return module.default; + }); + + this.toolModules.set('shipping', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/shipping.js'); + return module.default; + }); + + this.toolModules.set('fulfillments', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/fulfillments.js'); + return module.default; + }); + + this.toolModules.set('themes', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/themes.js'); + return module.default; + }); + + this.toolModules.set('pages', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/pages.js'); + return module.default; + }); + + this.toolModules.set('blogs', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/blogs.js'); + return module.default; + }); + + this.toolModules.set('analytics', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/analytics.js'); + return module.default; + }); + + this.toolModules.set('webhooks', async () => { + // @ts-ignore - Tool modules not created yet (foundation only) + const module = await import('./tools/webhooks.js'); + return module.default; + }); + } + + /** + * Load all tool modules (called on list_tools) + */ + private async loadAllTools(): Promise { + const allTools: ToolModule[] = []; + + // Only load modules that exist (we haven't created tool files yet) + // This prevents errors during foundation setup + for (const [moduleName, loader] of this.toolModules.entries()) { + try { + const tools = await loader(); + allTools.push(...tools); + } catch (error) { + // Tool module doesn't exist yet - this is expected during foundation setup + console.log(`[Info] Tool module '${moduleName}' not loaded (not created yet)`); + } + } + + return allTools; + } + + /** + * Setup MCP protocol handlers + */ + private setupHandlers(): void { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] Listing tools`); + + 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 timestamp = new Date().toISOString(); + console.log(`[${timestamp}] Tool call: ${request.params.name}`); + + try { + const tools = await this.loadAllTools(); + const tool = tools.find(t => t.name === request.params.name); + + if (!tool) { + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: 'TOOL_NOT_FOUND', + message: `Tool '${request.params.name}' not found`, + }), + }, + ], + isError: true, + }; + } + + const result = await tool.handler( + request.params.arguments as Record, + this.client + ); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorCode = (error as Error & { code?: string }).code || 'UNKNOWN_ERROR'; + + console.error(`[${timestamp}] Tool error:`, errorMessage); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + error: errorCode, + message: errorMessage, + }), + }, + ], + isError: true, + }; + } + }); + + // List resources (for UI apps) + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] Listing resources`); + + return { + resources: [ + { + uri: 'shopify://products', + name: 'Products', + description: 'List all products in the store', + mimeType: 'application/json', + }, + { + uri: 'shopify://orders', + name: 'Orders', + description: 'List all orders in the store', + mimeType: 'application/json', + }, + { + uri: 'shopify://customers', + name: 'Customers', + description: 'List all customers in the store', + mimeType: 'application/json', + }, + { + uri: 'shopify://inventory', + name: 'Inventory', + description: 'View inventory levels across locations', + mimeType: 'application/json', + }, + ], + }; + }); + + // Read resource (for UI apps) + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}] Reading resource: ${request.params.uri}`); + + const uri = request.params.uri; + + try { + let data: unknown; + + if (uri === 'shopify://products') { + data = await this.client.list('/products.json'); + } else if (uri === 'shopify://orders') { + data = await this.client.list('/orders.json'); + } else if (uri === 'shopify://customers') { + data = await this.client.list('/customers.json'); + } else if (uri === 'shopify://inventory') { + data = await this.client.list('/inventory_levels.json'); + } else { + throw new Error(`Unknown resource URI: ${uri}`); + } + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(data, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[${timestamp}] Resource read error:`, errorMessage); + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify({ + error: 'RESOURCE_READ_ERROR', + message: errorMessage, + }), + }, + ], + }; + } + }); + } + + /** + * Start the server with stdio transport + */ + async start(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.log('Shopify MCP server running'); + } + + /** + * Get the underlying MCP server instance (for custom transports) + */ + getServer(): Server { + return this.server; + } +} diff --git a/servers/shopify/src/types/index.ts b/servers/shopify/src/types/index.ts new file mode 100644 index 0000000..ee3a6a3 --- /dev/null +++ b/servers/shopify/src/types/index.ts @@ -0,0 +1,762 @@ +// Branded ID types for type safety +export type ProductId = string & { __brand: 'ProductId' }; +export type VariantId = string & { __brand: 'VariantId' }; +export type CollectionId = string & { __brand: 'CollectionId' }; +export type OrderId = string & { __brand: 'OrderId' }; +export type CustomerId = string & { __brand: 'CustomerId' }; +export type FulfillmentId = string & { __brand: 'FulfillmentId' }; +export type FulfillmentOrderId = string & { __brand: 'FulfillmentOrderId' }; +export type LocationId = string & { __brand: 'LocationId' }; +export type InventoryItemId = string & { __brand: 'InventoryItemId' }; +export type PriceRuleId = string & { __brand: 'PriceRuleId' }; +export type DiscountCodeId = string & { __brand: 'DiscountCodeId' }; +export type ThemeId = string & { __brand: 'ThemeId' }; +export type PageId = string & { __brand: 'PageId' }; +export type BlogId = string & { __brand: 'BlogId' }; +export type ArticleId = string & { __brand: 'ArticleId' }; +export type WebhookId = string & { __brand: 'WebhookId' }; +export type MetafieldId = string & { __brand: 'MetafieldId' }; +export type TransactionId = string & { __brand: 'TransactionId' }; +export type RefundId = string & { __brand: 'RefundId' }; + +// Order status discriminated unions +export type OrderFinancialStatus = + | 'pending' + | 'authorized' + | 'partially_paid' + | 'paid' + | 'partially_refunded' + | 'refunded' + | 'voided'; + +export type OrderFulfillmentStatus = + | 'fulfilled' + | 'null' + | 'partial' + | 'restocked'; + +export type OrderStatus = 'open' | 'archived' | 'cancelled'; + +// Product types +export interface ProductImage { + id?: number; + product_id?: ProductId; + position?: number; + created_at?: string; + updated_at?: string; + alt?: string | null; + width?: number; + height?: number; + src: string; + variant_ids?: VariantId[]; +} + +export interface ProductVariant { + id: VariantId; + product_id: ProductId; + title: string; + price: string; + sku?: string | null; + position?: number; + inventory_policy?: 'deny' | 'continue'; + compare_at_price?: string | null; + fulfillment_service?: string; + inventory_management?: string | null; + option1?: string | null; + option2?: string | null; + option3?: string | null; + created_at?: string; + updated_at?: string; + taxable?: boolean; + barcode?: string | null; + grams?: number; + image_id?: number | null; + weight?: number; + weight_unit?: string; + inventory_item_id?: InventoryItemId; + inventory_quantity?: number; + old_inventory_quantity?: number; + requires_shipping?: boolean; +} + +export interface ProductOption { + id?: number; + product_id?: ProductId; + name: string; + position?: number; + values: string[]; +} + +export interface Product { + id: ProductId; + title: string; + body_html?: string | null; + vendor?: string; + product_type?: string; + created_at?: string; + handle?: string; + updated_at?: string; + published_at?: string | null; + template_suffix?: string | null; + status?: 'active' | 'archived' | 'draft'; + published_scope?: string; + tags?: string; + admin_graphql_api_id?: string; + variants?: ProductVariant[]; + options?: ProductOption[]; + images?: ProductImage[]; + image?: ProductImage | null; +} + +// Collection types +export interface CollectionRule { + column: string; + relation: string; + condition: string; +} + +export interface SmartCollection { + id: CollectionId; + handle?: string; + title: string; + updated_at?: string; + body_html?: string | null; + published_at?: string | null; + sort_order?: string; + template_suffix?: string | null; + published_scope?: string; + admin_graphql_api_id?: string; + rules?: CollectionRule[]; + disjunctive?: boolean; +} + +export interface CustomCollection { + id: CollectionId; + handle?: string; + title: string; + updated_at?: string; + body_html?: string | null; + published_at?: string | null; + sort_order?: string; + template_suffix?: string | null; + published_scope?: string; + admin_graphql_api_id?: string; +} + +export type Collection = SmartCollection | CustomCollection; + +// Order types +export interface OrderLineItem { + id: number; + variant_id?: VariantId | null; + title: string; + quantity: number; + price: string; + grams?: number; + sku?: string | null; + variant_title?: string | null; + vendor?: string | null; + fulfillment_service?: string; + product_id?: ProductId | null; + requires_shipping?: boolean; + taxable?: boolean; + gift_card?: boolean; + name?: string; + properties?: Array<{ name: string; value: string }>; + product_exists?: boolean; + fulfillable_quantity?: number; + total_discount?: string; + fulfillment_status?: 'fulfilled' | 'partial' | 'null'; +} + +export interface ShippingAddress { + first_name?: string | null; + address1?: string | null; + phone?: string | null; + city?: string | null; + zip?: string | null; + province?: string | null; + country?: string | null; + last_name?: string | null; + address2?: string | null; + company?: string | null; + latitude?: number | null; + longitude?: number | null; + name?: string; + country_code?: string | null; + province_code?: string | null; +} + +export interface Transaction { + id: TransactionId; + order_id: OrderId; + kind: 'authorization' | 'capture' | 'sale' | 'void' | 'refund'; + gateway: string; + status: 'pending' | 'failure' | 'success' | 'error'; + message?: string | null; + created_at?: string; + test?: boolean; + authorization?: string | null; + currency?: string; + amount?: string; + parent_id?: TransactionId | null; +} + +export interface Refund { + id: RefundId; + order_id: OrderId; + created_at?: string; + note?: string | null; + user_id?: number | null; + processed_at?: string | null; + restock?: boolean; + transactions?: Transaction[]; + refund_line_items?: Array<{ + id: number; + quantity: number; + line_item_id: number; + location_id?: LocationId | null; + restock_type?: string; + subtotal?: string; + total_tax?: string; + }>; +} + +export interface Fulfillment { + id: FulfillmentId; + order_id: OrderId; + status: 'pending' | 'open' | 'success' | 'cancelled' | 'error' | 'failure'; + created_at?: string; + service?: string | null; + updated_at?: string; + tracking_company?: string | null; + shipment_status?: string | null; + location_id?: LocationId | null; + tracking_number?: string | null; + tracking_numbers?: string[]; + tracking_url?: string | null; + tracking_urls?: string[]; + receipt?: Record; + line_items?: OrderLineItem[]; +} + +export interface FulfillmentOrder { + id: FulfillmentOrderId; + shop_id: number; + order_id: OrderId; + assigned_location_id?: LocationId | null; + request_status: string; + status: 'open' | 'in_progress' | 'cancelled' | 'incomplete' | 'closed'; + supported_actions?: string[]; + destination?: ShippingAddress; + line_items?: Array<{ + id: number; + shop_id: number; + fulfillment_order_id: FulfillmentOrderId; + quantity: number; + line_item_id: number; + inventory_item_id: InventoryItemId; + fulfillable_quantity: number; + variant_id: VariantId; + }>; + fulfillment_service_handle?: string; + created_at?: string; + updated_at?: string; +} + +export interface Order { + id: OrderId; + email?: string | null; + closed_at?: string | null; + created_at?: string; + updated_at?: string; + number?: number; + note?: string | null; + token?: string; + gateway?: string | null; + test?: boolean; + total_price?: string; + subtotal_price?: string; + total_weight?: number; + total_tax?: string; + taxes_included?: boolean; + currency?: string; + financial_status?: OrderFinancialStatus; + confirmed?: boolean; + total_discounts?: string; + total_line_items_price?: string; + cart_token?: string | null; + buyer_accepts_marketing?: boolean; + name?: string; + referring_site?: string | null; + landing_site?: string | null; + cancelled_at?: string | null; + cancel_reason?: string | null; + user_id?: number | null; + location_id?: LocationId | null; + processed_at?: string | null; + device_id?: number | null; + phone?: string | null; + customer_locale?: string | null; + app_id?: number | null; + browser_ip?: string | null; + landing_site_ref?: string | null; + order_number?: number; + discount_codes?: Array<{ code: string; amount: string; type: string }>; + note_attributes?: Array<{ name: string; value: string }>; + payment_gateway_names?: string[]; + processing_method?: string; + source_name?: string; + fulfillment_status?: OrderFulfillmentStatus; + tax_lines?: Array<{ price: string; rate: number; title: string }>; + tags?: string; + contact_email?: string | null; + order_status_url?: string; + presentment_currency?: string; + total_line_items_price_set?: Record; + total_discounts_set?: Record; + total_shipping_price_set?: Record; + subtotal_price_set?: Record; + total_price_set?: Record; + total_tax_set?: Record; + line_items?: OrderLineItem[]; + shipping_lines?: Array<{ + id: number; + title: string; + price: string; + code: string; + source: string; + }>; + shipping_address?: ShippingAddress | null; + billing_address?: ShippingAddress | null; + fulfillments?: Fulfillment[]; + refunds?: Refund[]; + customer?: Customer | null; +} + +// Customer types +export interface CustomerAddress { + id: number; + customer_id: CustomerId; + first_name?: string | null; + last_name?: string | null; + company?: string | null; + address1?: string | null; + address2?: string | null; + city?: string | null; + province?: string | null; + country?: string | null; + zip?: string | null; + phone?: string | null; + name?: string; + province_code?: string | null; + country_code?: string | null; + country_name?: string; + default?: boolean; +} + +export interface Customer { + id: CustomerId; + email?: string | null; + accepts_marketing?: boolean; + created_at?: string; + updated_at?: string; + first_name?: string | null; + last_name?: string | null; + orders_count?: number; + state?: 'disabled' | 'invited' | 'enabled' | 'declined'; + total_spent?: string; + last_order_id?: OrderId | null; + note?: string | null; + verified_email?: boolean; + multipass_identifier?: string | null; + tax_exempt?: boolean; + phone?: string | null; + tags?: string; + last_order_name?: string | null; + currency?: string; + addresses?: CustomerAddress[]; + accepts_marketing_updated_at?: string; + marketing_opt_in_level?: string | null; + admin_graphql_api_id?: string; + default_address?: CustomerAddress; +} + +export interface CustomerSavedSearch { + id: number; + name: string; + created_at?: string; + updated_at?: string; + query: string; +} + +// Inventory types +export interface InventoryItem { + id: InventoryItemId; + sku?: string | null; + created_at?: string; + updated_at?: string; + requires_shipping?: boolean; + cost?: string | null; + country_code_of_origin?: string | null; + province_code_of_origin?: string | null; + harmonized_system_code?: string | null; + tracked?: boolean; + country_harmonized_system_codes?: Array<{ + country_code: string; + harmonized_system_code: string; + }>; +} + +export interface InventoryLevel { + inventory_item_id: InventoryItemId; + location_id: LocationId; + available?: number | null; + updated_at?: string; + admin_graphql_api_id?: string; +} + +export interface Location { + id: LocationId; + name: string; + address1?: string | null; + address2?: string | null; + city?: string | null; + zip?: string | null; + province?: string | null; + country?: string | null; + phone?: string | null; + created_at?: string; + updated_at?: string; + country_code?: string; + country_name?: string; + province_code?: string | null; + legacy?: boolean; + active?: boolean; + admin_graphql_api_id?: string; +} + +// Discount types +export interface PriceRule { + id: PriceRuleId; + title: string; + target_type: 'line_item' | 'shipping_line'; + target_selection: 'all' | 'entitled'; + allocation_method: 'each' | 'across'; + value_type: 'fixed_amount' | 'percentage'; + value: string; + customer_selection: 'all' | 'prerequisite'; + prerequisite_customer_ids?: CustomerId[]; + prerequisite_saved_search_ids?: number[]; + prerequisite_subtotal_range?: { greater_than_or_equal_to?: string }; + prerequisite_quantity_range?: { greater_than_or_equal_to?: number }; + prerequisite_shipping_price_range?: { less_than_or_equal_to?: string }; + entitled_product_ids?: ProductId[]; + entitled_variant_ids?: VariantId[]; + entitled_collection_ids?: CollectionId[]; + entitled_country_ids?: number[]; + starts_at: string; + ends_at?: string | null; + created_at?: string; + updated_at?: string; + once_per_customer?: boolean; + usage_limit?: number | null; + admin_graphql_api_id?: string; +} + +export interface DiscountCode { + id: DiscountCodeId; + price_rule_id: PriceRuleId; + code: string; + usage_count?: number; + created_at?: string; + updated_at?: string; +} + +// Shipping types +export interface ShippingZone { + id: number; + name: string; + countries?: Array<{ + id: number; + name: string; + tax: number; + code: string; + tax_name: string; + provinces: Array<{ + id: number; + country_id: number; + name: string; + code: string; + tax: number; + tax_name: string; + tax_type: string | null; + shipping_zone_id: number; + tax_percentage: number; + }>; + }>; + weight_based_shipping_rates?: Array<{ + id: number; + weight_low: number; + weight_high: number; + price: string; + name: string; + }>; + price_based_shipping_rates?: Array<{ + id: number; + min_order_subtotal: string; + max_order_subtotal: string | null; + price: string; + name: string; + }>; + carrier_shipping_rate_providers?: Array<{ + id: number; + carrier_service_id: number; + flat_modifier: string; + percent_modifier: number; + }>; +} + +export interface CarrierService { + id: number; + name: string; + active: boolean; + service_discovery: boolean; + carrier_service_type: 'api' | 'legacy'; + admin_graphql_api_id?: string; + format: 'json' | 'xml'; + callback_url?: string; +} + +export interface DeliveryProfile { + id: number; + name: string; + default: boolean; + created_at?: string; + updated_at?: string; +} + +// Theme types +export interface Theme { + id: ThemeId; + name: string; + created_at?: string; + updated_at?: string; + role: 'main' | 'unpublished' | 'demo' | 'development'; + theme_store_id?: number | null; + previewable?: boolean; + processing?: boolean; + admin_graphql_api_id?: string; +} + +export interface Asset { + key: string; + public_url?: string | null; + value?: string; + attachment?: string; + content_type?: string; + size?: number; + created_at?: string; + updated_at?: string; + checksum?: string | null; + theme_id?: ThemeId; +} + +// Content types +export interface Page { + id: PageId; + title: string; + shop_id?: number; + handle?: string; + body_html?: string | null; + author?: string; + created_at?: string; + updated_at?: string; + published_at?: string | null; + template_suffix?: string | null; + admin_graphql_api_id?: string; +} + +export interface Blog { + id: BlogId; + handle?: string; + title: string; + updated_at?: string; + commentable?: string; + feedburner?: string | null; + feedburner_location?: string | null; + created_at?: string; + template_suffix?: string | null; + tags?: string; + admin_graphql_api_id?: string; +} + +export interface Article { + id: ArticleId; + title: string; + created_at?: string; + body_html?: string | null; + blog_id: BlogId; + author?: string; + user_id?: number; + published_at?: string | null; + updated_at?: string; + summary_html?: string | null; + template_suffix?: string | null; + handle?: string; + tags?: string; + admin_graphql_api_id?: string; + image?: { + created_at?: string; + alt?: string | null; + width?: number; + height?: number; + src: string; + }; +} + +export interface Redirect { + id: number; + path: string; + target: string; +} + +export interface ScriptTag { + id: number; + src: string; + event: 'onload'; + created_at?: string; + updated_at?: string; + display_scope: 'online_store' | 'order_status' | 'all'; +} + +// Webhook types +export interface Webhook { + id: WebhookId; + address: string; + topic: string; + created_at?: string; + updated_at?: string; + format: 'json' | 'xml'; + fields?: string[]; + metafield_namespaces?: string[]; + api_version?: string; + private_metafield_namespaces?: string[]; +} + +// Event types +export interface Event { + id: number; + subject_id: number; + created_at?: string; + subject_type: string; + verb: string; + arguments?: unknown[]; + body?: string | null; + message?: string; + author?: string; + description?: string; + path?: string; +} + +// Metafield types +export interface Metafield { + id: MetafieldId; + namespace: string; + key: string; + value: string; + type: string; + description?: string | null; + owner_id?: number; + created_at?: string; + updated_at?: string; + owner_resource?: string; + admin_graphql_api_id?: string; +} + +// Shop types +export interface Shop { + id: number; + name: string; + email: string; + domain: string; + province?: string; + country?: string; + address1?: string; + zip?: string; + city?: string; + source?: string | null; + phone?: string; + latitude?: number | null; + longitude?: number | null; + primary_locale?: string; + address2?: string | null; + created_at?: string; + updated_at?: string; + country_code?: string; + country_name?: string; + currency?: string; + customer_email?: string; + timezone?: string; + iana_timezone?: string; + shop_owner?: string; + money_format?: string; + money_with_currency_format?: string; + weight_unit?: string; + province_code?: string | null; + taxes_included?: boolean | null; + auto_configure_tax_inclusivity?: boolean | null; + tax_shipping?: boolean | null; + county_taxes?: boolean; + plan_display_name?: string; + plan_name?: string; + has_discounts?: boolean; + has_gift_cards?: boolean; + myshopify_domain?: string; + google_apps_domain?: string | null; + google_apps_login_enabled?: boolean | null; + money_in_emails_format?: string; + money_with_currency_in_emails_format?: string; + eligible_for_payments?: boolean; + requires_extra_payments_agreement?: boolean; + password_enabled?: boolean; + has_storefront?: boolean; + finances?: boolean; + primary_location_id?: LocationId; + checkout_api_supported?: boolean; + multi_location_enabled?: boolean; + setup_required?: boolean; + pre_launch_enabled?: boolean; + enabled_presentment_currencies?: string[]; +} + +// API Response wrappers +export interface ShopifyListResponse { + data: T[]; + pageInfo?: { + hasNextPage: boolean; + hasPreviousPage: boolean; + nextCursor?: string; + previousCursor?: string; + }; +} + +export interface ShopifyError { + errors: string | Record; +} + +// GraphQL types +export interface GraphQLRequest { + query: string; + variables?: Record; +} + +export interface GraphQLResponse { + data?: T; + errors?: Array<{ + message: string; + locations?: Array<{ line: number; column: number }>; + path?: string[]; + extensions?: Record; + }>; +} diff --git a/servers/shopify/tsconfig.json b/servers/shopify/tsconfig.json new file mode 100644 index 0000000..66126e5 --- /dev/null +++ b/servers/shopify/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/servers/stripe/.env.example b/servers/stripe/.env.example new file mode 100644 index 0000000..1c30f4e --- /dev/null +++ b/servers/stripe/.env.example @@ -0,0 +1,8 @@ +# Required: Your Stripe secret key (starts with sk_test_ or sk_live_) +STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx + +# Optional: Webhook signing secret for webhook verification +STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx + +# Optional: HTTP server port for SSE transport +PORT=3000 diff --git a/servers/stripe/.gitignore b/servers/stripe/.gitignore new file mode 100644 index 0000000..5dc77dc --- /dev/null +++ b/servers/stripe/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +*.tsbuildinfo diff --git a/servers/stripe/README.md b/servers/stripe/README.md new file mode 100644 index 0000000..6771ce3 --- /dev/null +++ b/servers/stripe/README.md @@ -0,0 +1,290 @@ +# Stripe MCP Server + +Model Context Protocol (MCP) server for Stripe API integration. Provides comprehensive access to Stripe's payment processing, subscription management, and customer data. + +## Features + +- šŸ” **Secure Authentication** - Bearer token authentication with API key +- šŸ”„ **Automatic Retries** - Exponential backoff with configurable retry logic +- šŸ“Š **Pagination Support** - Cursor-based pagination with automatic iteration +- šŸŽÆ **Idempotency** - Built-in idempotency key support for safe retries +- šŸ›”ļø **Type Safety** - Comprehensive TypeScript types for all Stripe entities +- šŸš€ **Lazy Loading** - Tool modules loaded on-demand for better performance +- šŸ”Œ **Dual Transport** - Supports both stdio and HTTP/SSE transports +- šŸ“¦ **Resource Handlers** - Pre-built resources for common UI needs + +## Installation + +```bash +npm install +``` + +## Configuration + +Create a `.env` file based on `.env.example`: + +```bash +# Required +STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxx + +# Optional +STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx +PORT=3000 +TRANSPORT_MODE=stdio # stdio | http | dual +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `STRIPE_SECRET_KEY` | āœ… Yes | Your Stripe secret API key (starts with `sk_test_` or `sk_live_`) | +| `STRIPE_WEBHOOK_SECRET` | āŒ No | Webhook signing secret for webhook verification (starts with `whsec_`) | +| `PORT` | āŒ No | HTTP server port for SSE transport (default: 3000) | +| `TRANSPORT_MODE` | āŒ No | Transport mode: `stdio`, `http`, or `dual` (default: stdio) | + +## Usage + +### Development Mode + +```bash +npm run dev +``` + +### Production Build + +```bash +npm run build +npm start +``` + +### Transport Modes + +#### stdio (Default) +Used by MCP clients like Claude Desktop: + +```bash +npm start +# or +TRANSPORT_MODE=stdio npm start +``` + +#### HTTP/SSE +For web-based integrations: + +```bash +TRANSPORT_MODE=http npm start +# Server available at http://localhost:3000 +``` + +#### Dual Transport +Run both simultaneously: + +```bash +TRANSPORT_MODE=dual npm start +``` + +## Tool Categories + +The server provides lazy-loaded tool modules organized by functionality: + +### šŸ’³ Payments +- `charges` - Create, retrieve, update, and list charges +- `payment-intents` - Manage payment intents +- `payment-methods` - Handle payment methods +- `refunds` - Process and manage refunds +- `disputes` - Handle payment disputes + +### šŸ‘„ Customers +- `customers` - Create and manage customers + +### šŸ“… Subscriptions +- `subscriptions` - Manage recurring subscriptions +- `invoices` - Handle invoices and billing + +### šŸ·ļø Products & Pricing +- `products` - Manage products +- `prices` - Handle pricing models + +### šŸ’° Payouts & Balance +- `payouts` - Manage payouts to bank accounts +- `balance` - Check account balance and transactions + +### šŸ”” Events & Webhooks +- `events` - Retrieve and search events +- `webhooks` - Manage webhook endpoints + +## Resource URIs + +Pre-built resources for UI applications: + +| URI | Description | +|-----|-------------| +| `stripe://dashboard` | Account overview and key metrics | +| `stripe://customers` | Customer list | +| `stripe://payments/recent` | Recent payments and charges | +| `stripe://subscriptions/active` | Active subscriptions | +| `stripe://invoices/unpaid` | Unpaid invoices | + +## API Client Features + +### Automatic Pagination + +```typescript +// Iterate through all customers +for await (const customer of client.paginate('customers')) { + console.log(customer.email); +} +``` + +### Idempotency Keys + +```typescript +// Safe retry with idempotency +const result = await client.create('charges', data, { + idempotencyKey: client.generateIdempotencyKey() +}); +``` + +### Retry Logic + +- Automatic retry on 429 (rate limit), 409 (conflict), and 5xx errors +- Exponential backoff with jitter +- Respects `Stripe-Should-Retry` and `Retry-After` headers +- Configurable retry count (default: 3) + +### Form Encoding + +Stripe requires `application/x-www-form-urlencoded` for POST requests. The client automatically handles: + +- Nested object encoding with dot notation +- Array parameters +- Metadata fields +- Null/undefined value filtering + +## Type Safety + +All Stripe entities use branded ID types for additional type safety: + +```typescript +type CustomerId = string & { __brand: 'CustomerId' }; +type PaymentIntentId = string & { __brand: 'PaymentIntentId' }; +``` + +This prevents accidentally mixing different ID types. + +## Error Handling + +The server provides structured error responses: + +```json +{ + "error": { + "type": "api_error", + "message": "The API key provided is invalid.", + "code": "invalid_api_key" + } +} +``` + +Common error types: +- `api_error` - Stripe API errors +- `card_error` - Card-related errors +- `rate_limit_error` - Too many requests +- `invalid_request_error` - Invalid parameters +- `authentication_error` - Invalid API key + +## Health Check + +When running in HTTP mode, health check available at: + +```bash +curl http://localhost:3000/health +``` + +Response: +```json +{ + "status": "healthy", + "service": "@mcpengine/stripe", + "version": "1.0.0", + "timestamp": "2024-01-18T12:00:00.000Z", + "uptime": 123.45, + "memory": { "rss": 45678, "heapTotal": 23456, "heapUsed": 12345 }, + "node": "v22.0.0" +} +``` + +## Graceful Shutdown + +The server handles shutdown signals gracefully: + +- `SIGTERM` - Kubernetes/Docker shutdown +- `SIGINT` - Ctrl+C in terminal +- `SIGUSR2` - Nodemon restart + +Cleanup process: +1. Stop accepting new connections +2. Finish processing active requests +3. Close MCP server connection +4. Exit with code 0 + +## Development + +### Project Structure + +``` +stripe/ +ā”œā”€ā”€ src/ +│ ā”œā”€ā”€ clients/ +│ │ └── stripe.ts # Stripe API client +│ ā”œā”€ā”€ types/ +│ │ └── index.ts # TypeScript type definitions +│ ā”œā”€ā”€ tools/ # Tool modules (lazy-loaded) +│ │ ā”œā”€ā”€ customers.ts +│ │ ā”œā”€ā”€ charges.ts +│ │ └── ... +│ ā”œā”€ā”€ server.ts # MCP server implementation +│ └── main.ts # Entry point +ā”œā”€ā”€ package.json +ā”œā”€ā”€ tsconfig.json +ā”œā”€ā”€ .env.example +└── README.md +``` + +### Adding New Tools + +1. Create a new file in `src/tools/` +2. Export an array of `ToolModule` objects +3. Register in `TOOL_MODULES` in `src/server.ts` + +### Testing + +```bash +# Type check +npm run build + +# Run in dev mode +npm run dev +``` + +## Security Considerations + +- **Never commit `.env` file** - Contains sensitive API keys +- **Use test keys in development** - Keys starting with `sk_test_` +- **Rotate keys regularly** - Especially after security incidents +- **Webhook signature verification** - Use `STRIPE_WEBHOOK_SECRET` for webhooks +- **Idempotency keys** - Prevent duplicate charges on retries + +## Stripe API Version + +This server uses Stripe API version `2024-01-18`. The version is set in the `Stripe-Version` header for all requests. + +## License + +MIT + +## Support + +For issues and questions: +- Stripe API Docs: https://stripe.com/docs/api +- MCP Protocol: https://github.com/anthropics/model-context-protocol diff --git a/servers/stripe/package.json b/servers/stripe/package.json new file mode 100644 index 0000000..133e538 --- /dev/null +++ b/servers/stripe/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mcpengine/stripe", + "version": "1.0.0", + "type": "module", + "main": "dist/main.js", + "scripts": { + "build": "tsc", + "start": "node dist/main.js", + "dev": "tsx watch src/main.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "tsx": "^4.19.0", + "@types/node": "^22.0.0" + } +} diff --git a/servers/stripe/src/clients/stripe.ts b/servers/stripe/src/clients/stripe.ts new file mode 100644 index 0000000..2f53e53 --- /dev/null +++ b/servers/stripe/src/clients/stripe.ts @@ -0,0 +1,352 @@ +import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios'; +import { StripeList, StripeError } from '../types/index.js'; + +const STRIPE_API_BASE = 'https://api.stripe.com/v1'; +const STRIPE_API_VERSION = '2024-01-18'; +const DEFAULT_TIMEOUT = 30000; +const MAX_RETRIES = 3; +const INITIAL_RETRY_DELAY = 1000; + +export interface StripeClientConfig { + apiKey: string; + apiVersion?: string; + timeout?: number; + maxRetries?: number; +} + +export interface PaginationParams { + limit?: number; + starting_after?: string; + ending_before?: string; +} + +export interface RequestOptions { + idempotencyKey?: string; + stripeAccount?: string; +} + +/** + * Convert object to application/x-www-form-urlencoded format + * Handles nested objects with dot notation (e.g., metadata.key=value) + */ +function encodeFormData(data: Record, prefix = ''): string { + const pairs: string[] = []; + + for (const key in data) { + if (!Object.prototype.hasOwnProperty.call(data, key)) continue; + + const value = data[key]; + const encodedKey = prefix ? `${prefix}[${key}]` : key; + + if (value === null || value === undefined) { + continue; + } else if (typeof value === 'object' && !Array.isArray(value)) { + // Recursively handle nested objects + pairs.push(encodeFormData(value, encodedKey)); + } else if (Array.isArray(value)) { + // Handle arrays + value.forEach((item, index) => { + if (typeof item === 'object') { + pairs.push(encodeFormData(item, `${encodedKey}[${index}]`)); + } else { + pairs.push(`${encodeURIComponent(`${encodedKey}[]`)}=${encodeURIComponent(String(item))}`); + } + }); + } else { + pairs.push(`${encodeURIComponent(encodedKey)}=${encodeURIComponent(String(value))}`); + } + } + + return pairs.filter(p => p).join('&'); +} + +/** + * Calculate exponential backoff delay + */ +function getRetryDelay(attempt: number, baseDelay = INITIAL_RETRY_DELAY): number { + return Math.min(baseDelay * Math.pow(2, attempt - 1), 10000); +} + +/** + * Check if error is retryable + */ +function isRetryableError(error: AxiosError): boolean { + if (!error.response) return true; // Network errors are retryable + + const status = error.response.status; + const shouldRetry = error.response.headers['stripe-should-retry']; + + // Respect Stripe-Should-Retry header if present + if (shouldRetry !== undefined) { + return shouldRetry === 'true'; + } + + // Retry on 429 (rate limit), 500s, and 409 (conflict) + return status === 429 || status === 409 || (status >= 500 && status < 600); +} + +export class StripeClient { + private client: AxiosInstance; + private apiKey: string; + private maxRetries: number; + + constructor(config: StripeClientConfig) { + this.apiKey = config.apiKey; + this.maxRetries = config.maxRetries ?? MAX_RETRIES; + + this.client = axios.create({ + baseURL: STRIPE_API_BASE, + timeout: config.timeout ?? DEFAULT_TIMEOUT, + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Stripe-Version': config.apiVersion ?? STRIPE_API_VERSION, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + this.setupInterceptors(); + } + + private setupInterceptors(): void { + // Request interceptor + this.client.interceptors.request.use( + (config) => { + // Log request (optional - can be removed in production) + console.log(`[Stripe API] ${config.method?.toUpperCase()} ${config.url}`); + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + + // Response interceptor + this.client.interceptors.response.use( + (response) => { + // Log successful response + console.log(`[Stripe API] ${response.status} ${response.config.url}`); + return response; + }, + async (error: AxiosError) => { + const config = error.config as AxiosRequestConfig & { _retryCount?: number }; + + if (!config) { + return Promise.reject(error); + } + + // Initialize retry count + config._retryCount = config._retryCount ?? 0; + + // Check if we should retry + if (config._retryCount < this.maxRetries && isRetryableError(error)) { + config._retryCount++; + + // Calculate delay + let delay = getRetryDelay(config._retryCount); + + // Respect Retry-After header if present + if (error.response?.headers['retry-after']) { + const retryAfter = parseInt(error.response.headers['retry-after'], 10); + delay = retryAfter * 1000; + } + + console.log(`[Stripe API] Retrying request (${config._retryCount}/${this.maxRetries}) after ${delay}ms`); + + // Wait before retrying + await new Promise(resolve => setTimeout(resolve, delay)); + + return this.client.request(config); + } + + // Format error + const stripeError = this.formatError(error); + return Promise.reject(stripeError); + } + ); + } + + private formatError(error: AxiosError): Error { + if (error.response?.data) { + const data = error.response.data as any; + if (data.error) { + const stripeError = data.error as StripeError; + return new Error(`Stripe API Error (${stripeError.type}): ${stripeError.message}`); + } + } + return new Error(error.message || 'Unknown Stripe API error'); + } + + /** + * GET request + */ + async get( + path: string, + params?: Record, + options?: RequestOptions + ): Promise { + const config: AxiosRequestConfig = { + params, + headers: this.buildHeaders(options), + }; + + const response = await this.client.get(path, config); + return response.data; + } + + /** + * POST request (form-encoded) + */ + async post( + path: string, + data?: Record, + options?: RequestOptions + ): Promise { + const config: AxiosRequestConfig = { + headers: this.buildHeaders(options), + }; + + const formData = data ? encodeFormData(data) : ''; + const response = await this.client.post(path, formData, config); + return response.data; + } + + /** + * DELETE request + */ + async delete( + path: string, + options?: RequestOptions + ): Promise { + const config: AxiosRequestConfig = { + headers: this.buildHeaders(options), + }; + + const response = await this.client.delete(path, config); + return response.data; + } + + /** + * Paginate through all results automatically + */ + async *paginate( + path: string, + params?: Record, + options?: RequestOptions + ): AsyncGenerator { + let hasMore = true; + let startingAfter: string | undefined; + + while (hasMore) { + const response = await this.get>( + path, + { + ...params, + starting_after: startingAfter, + }, + options + ); + + for (const item of response.data) { + yield item; + } + + hasMore = response.has_more; + if (hasMore && response.data.length > 0) { + // Get the ID of the last item for the next request + const lastItem = response.data[response.data.length - 1] as any; + startingAfter = lastItem.id; + } + } + } + + /** + * List resources with pagination support + */ + async list( + path: string, + params?: PaginationParams & Record, + options?: RequestOptions + ): Promise> { + return this.get>(path, params, options); + } + + /** + * Retrieve a single resource + */ + async retrieve( + path: string, + id: string, + params?: Record, + options?: RequestOptions + ): Promise { + return this.get(`${path}/${id}`, params, options); + } + + /** + * Create a resource + */ + async create( + path: string, + data: Record, + options?: RequestOptions + ): Promise { + return this.post(path, data, options); + } + + /** + * Update a resource + */ + async update( + path: string, + id: string, + data: Record, + options?: RequestOptions + ): Promise { + return this.post(`${path}/${id}`, data, options); + } + + /** + * Delete/cancel a resource + */ + async remove( + path: string, + id: string, + options?: RequestOptions + ): Promise { + return this.delete(`${path}/${id}`, options); + } + + /** + * Build headers with optional idempotency key and Stripe-Account + */ + private buildHeaders(options?: RequestOptions): Record { + const headers: Record = {}; + + if (options?.idempotencyKey) { + headers['Idempotency-Key'] = options.idempotencyKey; + } + + if (options?.stripeAccount) { + headers['Stripe-Account'] = options.stripeAccount; + } + + return headers; + } + + /** + * Generate idempotency key (UUID v4) + */ + generateIdempotencyKey(): string { + return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`; + } +} + +/** + * Create a Stripe client instance + */ +export function createStripeClient(apiKey: string, config?: Partial): StripeClient { + return new StripeClient({ + apiKey, + ...config, + }); +} diff --git a/servers/stripe/src/main.ts b/servers/stripe/src/main.ts new file mode 100644 index 0000000..7784ea7 --- /dev/null +++ b/servers/stripe/src/main.ts @@ -0,0 +1,185 @@ +#!/usr/bin/env node +import { z } from 'zod'; +import { createStripeServer } from './server.js'; + +/** + * Environment variable schema + */ +const EnvSchema = z.object({ + STRIPE_SECRET_KEY: z.string().min(1, 'STRIPE_SECRET_KEY is required'), + STRIPE_WEBHOOK_SECRET: z.string().optional(), + PORT: z.string().optional().default('3000'), + NODE_ENV: z.enum(['development', 'production', 'test']).optional().default('production'), +}); + +type Env = z.infer; + +/** + * Validate environment variables + */ +function validateEnv(): Env { + try { + return EnvSchema.parse(process.env); + } catch (error) { + if (error instanceof z.ZodError) { + console.error('āŒ Environment validation failed:'); + for (const issue of error.issues) { + console.error(` - ${issue.path.join('.')}: ${issue.message}`); + } + console.error('\nRequired environment variables:'); + console.error(' STRIPE_SECRET_KEY - Your Stripe secret key (starts with sk_test_ or sk_live_)'); + console.error('\nOptional environment variables:'); + console.error(' STRIPE_WEBHOOK_SECRET - Webhook signing secret (starts with whsec_)'); + console.error(' PORT - HTTP server port for SSE transport (default: 3000)'); + console.error('\nExample:'); + console.error(' export STRIPE_SECRET_KEY=sk_test_xxxxx'); + console.error(' npm start'); + process.exit(1); + } + throw error; + } +} + +/** + * Health check endpoint data + */ +function getHealthInfo() { + return { + status: 'healthy', + service: '@mcpengine/stripe', + version: '1.0.0', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + memory: process.memoryUsage(), + node: process.version, + }; +} + +/** + * Setup graceful shutdown + */ +function setupGracefulShutdown(cleanup: () => Promise): void { + const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGUSR2']; + + signals.forEach(signal => { + process.on(signal, async () => { + console.error(`\nšŸ“” Received ${signal}, shutting down gracefully...`); + + try { + await cleanup(); + console.error('āœ… Cleanup complete'); + process.exit(0); + } catch (error) { + console.error('āŒ Error during cleanup:', error); + process.exit(1); + } + }); + }); + + // Handle uncaught errors + process.on('uncaughtException', (error) => { + console.error('āŒ Uncaught exception:', error); + process.exit(1); + }); + + process.on('unhandledRejection', (reason, promise) => { + console.error('āŒ Unhandled rejection at:', promise, 'reason:', reason); + process.exit(1); + }); +} + +/** + * Start HTTP/SSE server for dual transport support + */ +async function startHttpServer(server: ReturnType, port: number): Promise { + const { createServer } = await import('http'); + const { SSEServerTransport } = await import('@modelcontextprotocol/sdk/server/sse.js'); + + const httpServer = createServer(async (req, res) => { + // Health check endpoint + if (req.url === '/health' || req.url === '/') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(getHealthInfo(), null, 2)); + return; + } + + // SSE endpoint + if (req.url === '/sse') { + console.error('šŸ“” SSE client connected'); + + const transport = new SSEServerTransport('/message', res); + await server.getServer().connect(transport); + + // Handle client disconnect + req.on('close', () => { + console.error('šŸ“” SSE client disconnected'); + }); + return; + } + + // 404 for other routes + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + }); + + httpServer.listen(port, () => { + console.error(`🌐 HTTP/SSE server listening on http://localhost:${port}`); + console.error(` Health: http://localhost:${port}/health`); + console.error(` SSE: http://localhost:${port}/sse`); + }); +} + +/** + * Main entry point + */ +async function main(): Promise { + console.error('šŸš€ Starting Stripe MCP Server...\n'); + + // Validate environment + const env = validateEnv(); + + console.error('āœ… Environment validated'); + console.error(` API Key: ${env.STRIPE_SECRET_KEY.substring(0, 12)}...`); + if (env.STRIPE_WEBHOOK_SECRET) { + console.error(` Webhook Secret: ${env.STRIPE_WEBHOOK_SECRET.substring(0, 12)}...`); + } + console.error(''); + + // Create server + const server = createStripeServer({ + apiKey: env.STRIPE_SECRET_KEY, + webhookSecret: env.STRIPE_WEBHOOK_SECRET, + }); + + // Setup graceful shutdown + setupGracefulShutdown(async () => { + await server.close(); + }); + + // Determine transport mode + const transportMode = process.env.TRANSPORT_MODE || 'stdio'; + + if (transportMode === 'http' || transportMode === 'dual') { + const port = parseInt(env.PORT, 10); + await startHttpServer(server, port); + } + + if (transportMode === 'stdio' || transportMode === 'dual') { + // Connect to stdio (this is the default for MCP) + await server.connectStdio(); + } + + if (transportMode !== 'http' && transportMode !== 'stdio' && transportMode !== 'dual') { + console.error(`āŒ Unknown transport mode: ${transportMode}`); + console.error(' Valid modes: stdio, http, dual'); + process.exit(1); + } + + console.error('āœ… Server ready\n'); +} + +// Run the server +main().catch((error) => { + console.error('āŒ Fatal error:', error); + process.exit(1); +}); diff --git a/servers/stripe/src/server.ts b/servers/stripe/src/server.ts new file mode 100644 index 0000000..3645c22 --- /dev/null +++ b/servers/stripe/src/server.ts @@ -0,0 +1,324 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + ListResourcesRequestSchema, + ReadResourceRequestSchema, + ErrorCode, + McpError, +} from '@modelcontextprotocol/sdk/types.js'; +import { StripeClient } from './clients/stripe.js'; + +export interface StripeServerConfig { + apiKey: string; + webhookSecret?: string; +} + +/** + * Tool module interface for lazy loading + */ +interface ToolModule { + name: string; + description: string; + inputSchema: any; + handler: (client: StripeClient, args: any) => Promise; +} + +/** + * Lazy-loaded tool registry + * Tools are loaded on-demand to reduce startup time and memory footprint + */ +const TOOL_MODULES: Record Promise> = { + 'customers': async () => (await import('./tools/customers.js')).default, + 'charges': async () => (await import('./tools/charges.js')).default, + 'payment-intents': async () => (await import('./tools/payment-intents.js')).default, + 'payment-methods': async () => (await import('./tools/payment-methods.js')).default, + 'refunds': async () => (await import('./tools/refunds.js')).default, + 'disputes': async () => (await import('./tools/disputes.js')).default, + 'subscriptions': async () => (await import('./tools/subscriptions.js')).default, + 'invoices': async () => (await import('./tools/invoices.js')).default, + 'products': async () => (await import('./tools/products.js')).default, + 'prices': async () => (await import('./tools/prices.js')).default, + 'payouts': async () => (await import('./tools/payouts.js')).default, + 'balance': async () => (await import('./tools/balance.js')).default, + 'events': async () => (await import('./tools/events.js')).default, + 'webhooks': async () => (await import('./tools/webhooks.js')).default, +}; + +/** + * Resource definitions for UI apps + */ +const RESOURCES = [ + { + uri: 'stripe://dashboard', + name: 'Stripe Dashboard', + description: 'Overview of account activity and key metrics', + mimeType: 'application/json', + }, + { + uri: 'stripe://customers', + name: 'Customer List', + description: 'List of all customers', + mimeType: 'application/json', + }, + { + uri: 'stripe://payments/recent', + name: 'Recent Payments', + description: 'Recent payment intents and charges', + mimeType: 'application/json', + }, + { + uri: 'stripe://subscriptions/active', + name: 'Active Subscriptions', + description: 'Currently active subscriptions', + mimeType: 'application/json', + }, + { + uri: 'stripe://invoices/unpaid', + name: 'Unpaid Invoices', + description: 'Invoices that are unpaid or overdue', + mimeType: 'application/json', + }, +]; + +export class StripeServer { + private server: Server; + private client: StripeClient; + private toolCache: Map = new Map(); + + constructor(config: StripeServerConfig) { + this.client = new StripeClient({ apiKey: config.apiKey }); + + this.server = new Server( + { + name: '@mcpengine/stripe', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + resources: {}, + }, + } + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + // List available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools: any[] = []; + + // Load all tool modules to get their definitions + for (const [moduleName, loader] of Object.entries(TOOL_MODULES)) { + try { + const moduleTools = await this.getToolModule(moduleName); + for (const tool of moduleTools) { + tools.push({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + }); + } + } catch (error) { + console.error(`Failed to load tool module ${moduleName}:`, error); + } + } + + return { tools }; + }); + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + // Find the tool handler + const handler = await this.findToolHandler(name); + + if (!handler) { + throw new McpError( + ErrorCode.MethodNotFound, + `Tool not found: ${name}` + ); + } + + // Execute the tool + const result = await handler(this.client, args || {}); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + if (error instanceof McpError) { + throw error; + } + + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new McpError( + ErrorCode.InternalError, + `Tool execution failed: ${errorMessage}` + ); + } + }); + + // List available resources + this.server.setRequestHandler(ListResourcesRequestSchema, async () => { + return { resources: RESOURCES }; + }); + + // Read resource data + this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => { + const { uri } = request.params; + + try { + const content = await this.handleResourceRead(uri); + + return { + contents: [ + { + uri, + mimeType: 'application/json', + text: JSON.stringify(content, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + throw new McpError( + ErrorCode.InternalError, + `Failed to read resource: ${errorMessage}` + ); + } + }); + } + + /** + * Get tool module (with caching) + */ + private async getToolModule(moduleName: string): Promise { + if (this.toolCache.has(moduleName)) { + return this.toolCache.get(moduleName)!; + } + + const loader = TOOL_MODULES[moduleName]; + if (!loader) { + throw new Error(`Tool module not found: ${moduleName}`); + } + + const tools = await loader(); + this.toolCache.set(moduleName, tools); + return tools; + } + + /** + * Find tool handler by name + */ + private async findToolHandler(toolName: string): Promise<((client: StripeClient, args: any) => Promise) | null> { + for (const moduleName of Object.keys(TOOL_MODULES)) { + const tools = await this.getToolModule(moduleName); + const tool = tools.find(t => t.name === toolName); + if (tool) { + return tool.handler; + } + } + return null; + } + + /** + * Handle resource read requests + */ + private async handleResourceRead(uri: string): Promise { + // Parse the URI + const url = new URL(uri); + const path = url.pathname; + + switch (path) { + case '/dashboard': + return this.getDashboardData(); + + case '/customers': + return this.client.list('customers', { limit: 100 }); + + case '/payments/recent': + return this.getRecentPayments(); + + case '/subscriptions/active': + return this.client.list('subscriptions', { status: 'active', limit: 100 }); + + case '/invoices/unpaid': + return this.client.list('invoices', { status: 'open', limit: 100 }); + + default: + throw new Error(`Unknown resource path: ${path}`); + } + } + + /** + * Get dashboard data (overview metrics) + */ + private async getDashboardData(): Promise { + const [balance, recentCharges, recentCustomers] = await Promise.all([ + this.client.get('balance'), + this.client.list('charges', { limit: 10 }), + this.client.list('customers', { limit: 10 }), + ]); + + return { + balance, + recent_charges: recentCharges, + recent_customers: recentCustomers, + timestamp: new Date().toISOString(), + }; + } + + /** + * Get recent payments + */ + private async getRecentPayments(): Promise { + const [paymentIntents, charges] = await Promise.all([ + this.client.list('payment_intents', { limit: 50 }), + this.client.list('charges', { limit: 50 }), + ]); + + return { + payment_intents: paymentIntents, + charges, + timestamp: new Date().toISOString(), + }; + } + + /** + * Connect to stdio transport + */ + async connectStdio(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Stripe MCP server running on stdio'); + } + + /** + * Get the underlying MCP server instance + */ + getServer(): Server { + return this.server; + } + + /** + * Close the server + */ + async close(): Promise { + await this.server.close(); + } +} + +export function createStripeServer(config: StripeServerConfig): StripeServer { + return new StripeServer(config); +} diff --git a/servers/stripe/src/tools/balance.ts b/servers/stripe/src/tools/balance.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/balance.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/charges.ts b/servers/stripe/src/tools/charges.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/charges.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/customers.ts b/servers/stripe/src/tools/customers.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/customers.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/disputes.ts b/servers/stripe/src/tools/disputes.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/disputes.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/events.ts b/servers/stripe/src/tools/events.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/events.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/invoices.ts b/servers/stripe/src/tools/invoices.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/invoices.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/payment-intents.ts b/servers/stripe/src/tools/payment-intents.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/payment-intents.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/payment-methods.ts b/servers/stripe/src/tools/payment-methods.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/payment-methods.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/payouts.ts b/servers/stripe/src/tools/payouts.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/payouts.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/prices.ts b/servers/stripe/src/tools/prices.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/prices.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/products.ts b/servers/stripe/src/tools/products.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/products.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/refunds.ts b/servers/stripe/src/tools/refunds.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/refunds.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/subscriptions.ts b/servers/stripe/src/tools/subscriptions.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/subscriptions.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/tools/webhooks.ts b/servers/stripe/src/tools/webhooks.ts new file mode 100644 index 0000000..0c8177e --- /dev/null +++ b/servers/stripe/src/tools/webhooks.ts @@ -0,0 +1,2 @@ +// Placeholder - tools to be implemented +export default []; diff --git a/servers/stripe/src/types/index.ts b/servers/stripe/src/types/index.ts new file mode 100644 index 0000000..3514f35 --- /dev/null +++ b/servers/stripe/src/types/index.ts @@ -0,0 +1,1132 @@ +// Branded ID types for type safety +export type CustomerId = string & { __brand: 'CustomerId' }; +export type PaymentIntentId = string & { __brand: 'PaymentIntentId' }; +export type PaymentMethodId = string & { __brand: 'PaymentMethodId' }; +export type SetupIntentId = string & { __brand: 'SetupIntentId' }; +export type ChargeId = string & { __brand: 'ChargeId' }; +export type RefundId = string & { __brand: 'RefundId' }; +export type DisputeId = string & { __brand: 'DisputeId' }; +export type BalanceTransactionId = string & { __brand: 'BalanceTransactionId' }; +export type SubscriptionId = string & { __brand: 'SubscriptionId' }; +export type SubscriptionItemId = string & { __brand: 'SubscriptionItemId' }; +export type SubscriptionScheduleId = string & { __brand: 'SubscriptionScheduleId' }; +export type InvoiceId = string & { __brand: 'InvoiceId' }; +export type InvoiceItemId = string & { __brand: 'InvoiceItemId' }; +export type ProductId = string & { __brand: 'ProductId' }; +export type PriceId = string & { __brand: 'PriceId' }; +export type CouponId = string & { __brand: 'CouponId' }; +export type PromotionCodeId = string & { __brand: 'PromotionCodeId' }; +export type PaymentLinkId = string & { __brand: 'PaymentLinkId' }; +export type CheckoutSessionId = string & { __brand: 'CheckoutSessionId' }; +export type PayoutId = string & { __brand: 'PayoutId' }; +export type TransferId = string & { __brand: 'TransferId' }; +export type AccountId = string & { __brand: 'AccountId' }; +export type EventId = string & { __brand: 'EventId' }; +export type WebhookEndpointId = string & { __brand: 'WebhookEndpointId' }; +export type TaxRateId = string & { __brand: 'TaxRateId' }; +export type TaxIdId = string & { __brand: 'TaxIdId' }; +export type FileId = string & { __brand: 'FileId' }; +export type FileLinkId = string & { __brand: 'FileLinkId' }; + +// Common Stripe response structure +export interface StripeList { + object: 'list'; + data: T[]; + has_more: boolean; + url: string; +} + +// Address +export interface Address { + line1?: string | null; + line2?: string | null; + city?: string | null; + state?: string | null; + postal_code?: string | null; + country?: string | null; +} + +// Customer +export interface Customer { + id: CustomerId; + object: 'customer'; + address?: Address | null; + balance?: number; + created: number; + currency?: string | null; + default_source?: string | null; + delinquent?: boolean | null; + description?: string | null; + discount?: any | null; + email?: string | null; + invoice_prefix?: string | null; + invoice_settings?: { + custom_fields?: any | null; + default_payment_method?: string | null; + footer?: string | null; + }; + livemode: boolean; + metadata: Record; + name?: string | null; + phone?: string | null; + preferred_locales?: string[]; + shipping?: { + address?: Address; + name?: string; + phone?: string; + } | null; + tax_exempt?: 'none' | 'exempt' | 'reverse'; + test_clock?: string | null; +} + +// Payment Intent +export type PaymentIntentStatus = + | 'requires_payment_method' + | 'requires_confirmation' + | 'requires_action' + | 'processing' + | 'requires_capture' + | 'canceled' + | 'succeeded'; + +export interface PaymentIntent { + id: PaymentIntentId; + object: 'payment_intent'; + amount: number; + amount_capturable?: number; + amount_details?: { + tip?: { amount?: number }; + }; + amount_received?: number; + application?: string | null; + application_fee_amount?: number | null; + automatic_payment_methods?: { + enabled?: boolean; + } | null; + canceled_at?: number | null; + cancellation_reason?: string | null; + capture_method: 'automatic' | 'manual'; + client_secret?: string | null; + confirmation_method: 'automatic' | 'manual'; + created: number; + currency: string; + customer?: CustomerId | null; + description?: string | null; + invoice?: InvoiceId | null; + last_payment_error?: any | null; + livemode: boolean; + metadata: Record; + next_action?: any | null; + on_behalf_of?: string | null; + payment_method?: PaymentMethodId | null; + payment_method_options?: any; + payment_method_types: string[]; + processing?: any | null; + receipt_email?: string | null; + setup_future_usage?: 'on_session' | 'off_session' | null; + shipping?: any | null; + statement_descriptor?: string | null; + statement_descriptor_suffix?: string | null; + status: PaymentIntentStatus; + transfer_data?: any | null; + transfer_group?: string | null; +} + +// Payment Method +export type PaymentMethodType = + | 'card' + | 'card_present' + | 'us_bank_account' + | 'sepa_debit' + | 'acss_debit' + | 'link' + | 'affirm' + | 'afterpay_clearpay' + | 'klarna'; + +export interface PaymentMethod { + id: PaymentMethodId; + object: 'payment_method'; + billing_details: { + address?: Address | null; + email?: string | null; + name?: string | null; + phone?: string | null; + }; + card?: { + brand: string; + checks?: { + address_line1_check?: string | null; + address_postal_code_check?: string | null; + cvc_check?: string | null; + }; + country?: string; + exp_month: number; + exp_year: number; + fingerprint?: string; + funding: 'credit' | 'debit' | 'prepaid' | 'unknown'; + last4: string; + networks?: { + available: string[]; + preferred?: string | null; + }; + three_d_secure_usage?: { + supported: boolean; + }; + wallet?: any | null; + }; + created: number; + customer?: CustomerId | null; + livemode: boolean; + metadata: Record; + type: PaymentMethodType; + us_bank_account?: any; +} + +// Setup Intent +export type SetupIntentStatus = + | 'requires_payment_method' + | 'requires_confirmation' + | 'requires_action' + | 'processing' + | 'canceled' + | 'succeeded'; + +export interface SetupIntent { + id: SetupIntentId; + object: 'setup_intent'; + application?: string | null; + cancellation_reason?: string | null; + client_secret?: string | null; + created: number; + customer?: CustomerId | null; + description?: string | null; + last_setup_error?: any | null; + livemode: boolean; + mandate?: string | null; + metadata: Record; + next_action?: any | null; + on_behalf_of?: string | null; + payment_method?: PaymentMethodId | null; + payment_method_options?: any; + payment_method_types: string[]; + single_use_mandate?: string | null; + status: SetupIntentStatus; + usage: string; +} + +// Charge +export interface Charge { + id: ChargeId; + object: 'charge'; + amount: number; + amount_captured: number; + amount_refunded: number; + application?: string | null; + application_fee?: string | null; + application_fee_amount?: number | null; + balance_transaction?: BalanceTransactionId | null; + billing_details: { + address?: Address | null; + email?: string | null; + name?: string | null; + phone?: string | null; + }; + calculated_statement_descriptor?: string | null; + captured: boolean; + created: number; + currency: string; + customer?: CustomerId | null; + description?: string | null; + disputed: boolean; + failure_balance_transaction?: string | null; + failure_code?: string | null; + failure_message?: string | null; + fraud_details?: any; + invoice?: InvoiceId | null; + livemode: boolean; + metadata: Record; + on_behalf_of?: string | null; + outcome?: any; + paid: boolean; + payment_intent?: PaymentIntentId | null; + payment_method?: PaymentMethodId | null; + payment_method_details?: any; + receipt_email?: string | null; + receipt_number?: string | null; + receipt_url?: string; + refunded: boolean; + refunds?: StripeList; + review?: string | null; + shipping?: any | null; + source_transfer?: string | null; + statement_descriptor?: string | null; + statement_descriptor_suffix?: string | null; + status: 'succeeded' | 'pending' | 'failed'; + transfer_data?: any | null; + transfer_group?: string | null; +} + +// Refund +export type RefundStatus = 'pending' | 'succeeded' | 'failed' | 'canceled'; +export type RefundReason = 'duplicate' | 'fraudulent' | 'requested_by_customer' | null; + +export interface Refund { + id: RefundId; + object: 'refund'; + amount: number; + balance_transaction?: BalanceTransactionId | null; + charge?: ChargeId | null; + created: number; + currency: string; + metadata: Record; + payment_intent?: PaymentIntentId | null; + reason?: RefundReason; + receipt_number?: string | null; + source_transfer_reversal?: string | null; + status: RefundStatus; + transfer_reversal?: string | null; +} + +// Dispute +export type DisputeStatus = + | 'warning_needs_response' + | 'warning_under_review' + | 'warning_closed' + | 'needs_response' + | 'under_review' + | 'charge_refunded' + | 'won' + | 'lost'; + +export interface Dispute { + id: DisputeId; + object: 'dispute'; + amount: number; + balance_transactions: BalanceTransaction[]; + charge: ChargeId; + created: number; + currency: string; + evidence: any; + evidence_details: { + due_by?: number | null; + has_evidence: boolean; + past_due: boolean; + submission_count: number; + }; + is_charge_refundable: boolean; + livemode: boolean; + metadata: Record; + network_reason_code?: string | null; + payment_intent?: PaymentIntentId | null; + reason: string; + status: DisputeStatus; +} + +// Balance Transaction +export type BalanceTransactionType = + | 'charge' + | 'refund' + | 'adjustment' + | 'application_fee' + | 'application_fee_refund' + | 'transfer' + | 'payment' + | 'payout' + | 'payout_cancel' + | 'payout_failure' + | 'stripe_fee' + | 'tax'; + +export interface BalanceTransaction { + id: BalanceTransactionId; + object: 'balance_transaction'; + amount: number; + available_on: number; + created: number; + currency: string; + description?: string | null; + exchange_rate?: number | null; + fee: number; + fee_details: Array<{ + amount: number; + application?: string | null; + currency: string; + description?: string | null; + type: string; + }>; + net: number; + reporting_category: string; + source?: string | null; + status: 'available' | 'pending'; + type: BalanceTransactionType; +} + +// Subscription +export type SubscriptionStatus = + | 'incomplete' + | 'incomplete_expired' + | 'trialing' + | 'active' + | 'past_due' + | 'canceled' + | 'unpaid' + | 'paused'; + +export interface Subscription { + id: SubscriptionId; + object: 'subscription'; + application?: string | null; + application_fee_percent?: number | null; + automatic_tax: { + enabled: boolean; + }; + billing_cycle_anchor: number; + billing_thresholds?: any | null; + cancel_at?: number | null; + cancel_at_period_end: boolean; + canceled_at?: number | null; + collection_method: 'charge_automatically' | 'send_invoice'; + created: number; + currency: string; + current_period_end: number; + current_period_start: number; + customer: CustomerId; + days_until_due?: number | null; + default_payment_method?: PaymentMethodId | null; + default_source?: string | null; + default_tax_rates?: TaxRate[]; + description?: string | null; + discount?: any | null; + ended_at?: number | null; + items: StripeList; + latest_invoice?: InvoiceId | null; + livemode: boolean; + metadata: Record; + next_pending_invoice_item_invoice?: number | null; + on_behalf_of?: string | null; + pause_collection?: any | null; + payment_settings?: { + payment_method_options?: any; + payment_method_types?: string[] | null; + save_default_payment_method?: 'on_subscription' | 'off' | null; + }; + pending_invoice_item_interval?: any | null; + pending_setup_intent?: SetupIntentId | null; + pending_update?: any | null; + schedule?: SubscriptionScheduleId | null; + start_date: number; + status: SubscriptionStatus; + test_clock?: string | null; + transfer_data?: any | null; + trial_end?: number | null; + trial_settings?: { + end_behavior: { + missing_payment_method: 'create_invoice' | 'pause' | 'cancel'; + }; + }; + trial_start?: number | null; +} + +// Subscription Item +export interface SubscriptionItem { + id: SubscriptionItemId; + object: 'subscription_item'; + billing_thresholds?: any | null; + created: number; + metadata: Record; + price: Price; + quantity?: number; + subscription: SubscriptionId; + tax_rates?: TaxRate[]; +} + +// Subscription Schedule +export type SubscriptionScheduleStatus = + | 'not_started' + | 'active' + | 'completed' + | 'released' + | 'canceled'; + +export interface SubscriptionSchedule { + id: SubscriptionScheduleId; + object: 'subscription_schedule'; + application?: string | null; + canceled_at?: number | null; + completed_at?: number | null; + created: number; + current_phase?: any | null; + customer: CustomerId; + default_settings: any; + end_behavior: 'release' | 'cancel'; + livemode: boolean; + metadata: Record; + phases: any[]; + released_at?: number | null; + released_subscription?: SubscriptionId | null; + status: SubscriptionScheduleStatus; + subscription?: SubscriptionId | null; + test_clock?: string | null; +} + +// Invoice +export type InvoiceStatus = + | 'draft' + | 'open' + | 'paid' + | 'uncollectible' + | 'void'; + +export interface Invoice { + id: InvoiceId; + object: 'invoice'; + account_country?: string | null; + account_name?: string | null; + account_tax_ids?: string[] | null; + amount_due: number; + amount_paid: number; + amount_remaining: number; + application?: string | null; + application_fee_amount?: number | null; + attempt_count: number; + attempted: boolean; + auto_advance?: boolean; + automatic_tax: { + enabled: boolean; + status?: 'complete' | 'failed' | 'requires_location_inputs' | null; + }; + billing_reason?: + | 'subscription_cycle' + | 'subscription_create' + | 'subscription_update' + | 'subscription' + | 'manual' + | 'upcoming' + | 'subscription_threshold' + | null; + charge?: ChargeId | null; + collection_method: 'charge_automatically' | 'send_invoice'; + created: number; + currency: string; + custom_fields?: any[] | null; + customer?: CustomerId | null; + customer_address?: Address | null; + customer_email?: string | null; + customer_name?: string | null; + customer_phone?: string | null; + customer_shipping?: any | null; + customer_tax_exempt?: 'none' | 'exempt' | 'reverse'; + customer_tax_ids?: any[]; + default_payment_method?: PaymentMethodId | null; + default_source?: string | null; + default_tax_rates: TaxRate[]; + description?: string | null; + discount?: any | null; + discounts?: any[]; + due_date?: number | null; + ending_balance?: number | null; + footer?: string | null; + from_invoice?: any | null; + hosted_invoice_url?: string | null; + invoice_pdf?: string | null; + last_finalization_error?: any | null; + latest_revision?: string | null; + lines: StripeList; + livemode: boolean; + metadata: Record; + next_payment_attempt?: number | null; + number?: string | null; + on_behalf_of?: string | null; + paid: boolean; + paid_out_of_band: boolean; + payment_intent?: PaymentIntentId | null; + payment_settings?: { + default_mandate?: string | null; + payment_method_options?: any; + payment_method_types?: string[] | null; + }; + period_end: number; + period_start: number; + post_payment_credit_notes_amount: number; + pre_payment_credit_notes_amount: number; + quote?: string | null; + receipt_number?: string | null; + rendering_options?: any | null; + starting_balance: number; + statement_descriptor?: string | null; + status: InvoiceStatus; + status_transitions: { + finalized_at?: number | null; + marked_uncollectible_at?: number | null; + paid_at?: number | null; + voided_at?: number | null; + }; + subscription?: SubscriptionId | null; + subscription_proration_date?: number; + subtotal: number; + subtotal_excluding_tax?: number | null; + tax?: number | null; + test_clock?: string | null; + total: number; + total_discount_amounts?: any[]; + total_excluding_tax?: number | null; + total_tax_amounts: any[]; + transfer_data?: any | null; + webhooks_delivered_at?: number | null; +} + +// Invoice Item +export interface InvoiceItem { + id: InvoiceItemId; + object: 'invoiceitem'; + amount: number; + currency: string; + customer: CustomerId; + date: number; + description?: string | null; + discountable: boolean; + discounts?: any[]; + invoice?: InvoiceId | null; + livemode: boolean; + metadata: Record; + period: { + end: number; + start: number; + }; + price?: Price | null; + proration: boolean; + quantity: number; + subscription?: SubscriptionId | null; + tax_rates?: TaxRate[]; + test_clock?: string | null; + unit_amount?: number | null; + unit_amount_decimal?: string | null; +} + +// Invoice Line Item +export interface InvoiceLineItem { + id: string; + object: 'line_item'; + amount: number; + amount_excluding_tax?: number | null; + currency: string; + description?: string | null; + discount_amounts?: any[]; + discountable: boolean; + discounts?: any[]; + invoice_item?: InvoiceItemId; + livemode: boolean; + metadata: Record; + period: { + end: number; + start: number; + }; + price?: Price; + proration: boolean; + proration_details?: any; + quantity?: number; + subscription?: SubscriptionId | null; + subscription_item?: SubscriptionItemId; + tax_amounts?: any[]; + tax_rates?: TaxRate[]; + type: 'invoiceitem' | 'subscription'; + unit_amount_excluding_tax?: string | null; +} + +// Product +export interface Product { + id: ProductId; + object: 'product'; + active: boolean; + attributes?: string[] | null; + created: number; + default_price?: PriceId | null; + description?: string | null; + images: string[]; + livemode: boolean; + metadata: Record; + name: string; + package_dimensions?: { + height: number; + length: number; + weight: number; + width: number; + } | null; + shippable?: boolean | null; + statement_descriptor?: string | null; + tax_code?: string | null; + type: 'service' | 'good'; + unit_label?: string | null; + updated: number; + url?: string | null; +} + +// Price +export type PriceType = 'one_time' | 'recurring'; +export type PriceBillingScheme = 'per_unit' | 'tiered'; + +export interface Price { + id: PriceId; + object: 'price'; + active: boolean; + billing_scheme: PriceBillingScheme; + created: number; + currency: string; + custom_unit_amount?: { + maximum?: number | null; + minimum?: number | null; + preset?: number | null; + } | null; + livemode: boolean; + lookup_key?: string | null; + metadata: Record; + nickname?: string | null; + product: ProductId | Product; + recurring?: { + aggregate_usage?: 'sum' | 'last_during_period' | 'last_ever' | 'max' | null; + interval: 'day' | 'week' | 'month' | 'year'; + interval_count: number; + trial_period_days?: number | null; + usage_type: 'metered' | 'licensed'; + } | null; + tax_behavior?: 'inclusive' | 'exclusive' | 'unspecified' | null; + tiers?: any[] | null; + tiers_mode?: 'graduated' | 'volume' | null; + transform_quantity?: any | null; + type: PriceType; + unit_amount?: number | null; + unit_amount_decimal?: string | null; +} + +// Coupon +export interface Coupon { + id: CouponId; + object: 'coupon'; + amount_off?: number | null; + applies_to?: { + products: ProductId[]; + }; + created: number; + currency?: string | null; + duration: 'forever' | 'once' | 'repeating'; + duration_in_months?: number | null; + livemode: boolean; + max_redemptions?: number | null; + metadata: Record; + name?: string | null; + percent_off?: number | null; + redeem_by?: number | null; + times_redeemed: number; + valid: boolean; +} + +// Promotion Code +export interface PromotionCode { + id: PromotionCodeId; + object: 'promotion_code'; + active: boolean; + code: string; + coupon: Coupon; + created: number; + customer?: CustomerId | null; + expires_at?: number | null; + livemode: boolean; + max_redemptions?: number | null; + metadata: Record; + restrictions: { + first_time_transaction: boolean; + minimum_amount?: number | null; + minimum_amount_currency?: string | null; + }; + times_redeemed: number; +} + +// Payment Link +export interface PaymentLink { + id: PaymentLinkId; + object: 'payment_link'; + active: boolean; + after_completion: any; + allow_promotion_codes: boolean; + application?: string | null; + application_fee_amount?: number | null; + application_fee_percent?: number | null; + automatic_tax: { + enabled: boolean; + }; + billing_address_collection: 'auto' | 'required'; + consent_collection?: any | null; + currency: string; + custom_fields: any[]; + custom_text: any; + customer_creation: 'if_required' | 'always'; + inactive_message?: string | null; + invoice_creation?: any; + livemode: boolean; + metadata: Record; + on_behalf_of?: string | null; + payment_intent_data?: any | null; + payment_method_collection: 'always' | 'if_required'; + payment_method_types?: string[] | null; + phone_number_collection: { + enabled: boolean; + }; + shipping_address_collection?: any | null; + shipping_options: any[]; + submit_type: 'auto' | 'pay' | 'donate' | 'book'; + subscription_data?: any | null; + tax_id_collection: { + enabled: boolean; + }; + transfer_data?: any | null; + url: string; +} + +// Checkout Session +export type CheckoutSessionMode = 'payment' | 'setup' | 'subscription'; +export type CheckoutSessionStatus = 'open' | 'complete' | 'expired'; + +export interface CheckoutSession { + id: CheckoutSessionId; + object: 'checkout.session'; + after_expiration?: any | null; + allow_promotion_codes?: boolean | null; + amount_subtotal?: number | null; + amount_total?: number | null; + automatic_tax: { + enabled: boolean; + status?: 'complete' | 'failed' | 'requires_location_inputs' | null; + }; + billing_address_collection?: 'auto' | 'required' | null; + cancel_url: string; + client_reference_id?: string | null; + consent?: any | null; + consent_collection?: any | null; + created: number; + currency?: string | null; + currency_conversion?: any | null; + custom_fields: any[]; + custom_text: any; + customer?: CustomerId | null; + customer_creation?: 'if_required' | 'always' | null; + customer_details?: { + address?: Address | null; + email?: string | null; + name?: string | null; + phone?: string | null; + tax_exempt?: 'none' | 'exempt' | 'reverse'; + tax_ids?: any[]; + } | null; + customer_email?: string | null; + expires_at: number; + invoice?: InvoiceId | null; + invoice_creation?: any; + livemode: boolean; + locale?: string | null; + metadata: Record; + mode: CheckoutSessionMode; + payment_intent?: PaymentIntentId | null; + payment_link?: PaymentLinkId | null; + payment_method_collection?: 'always' | 'if_required'; + payment_method_options?: any; + payment_method_types: string[]; + payment_status: 'paid' | 'unpaid' | 'no_payment_required'; + phone_number_collection?: { + enabled: boolean; + }; + recovered_from?: string | null; + setup_intent?: SetupIntentId | null; + shipping_address_collection?: any | null; + shipping_cost?: any | null; + shipping_details?: any | null; + shipping_options: any[]; + status: CheckoutSessionStatus; + submit_type?: 'auto' | 'pay' | 'donate' | 'book' | null; + subscription?: SubscriptionId | null; + success_url: string; + total_details?: { + amount_discount: number; + amount_shipping?: number | null; + amount_tax: number; + } | null; + url?: string | null; +} + +// Payout +export type PayoutStatus = + | 'paid' + | 'pending' + | 'in_transit' + | 'canceled' + | 'failed'; + +export interface Payout { + id: PayoutId; + object: 'payout'; + amount: number; + arrival_date: number; + automatic: boolean; + balance_transaction?: BalanceTransactionId | null; + created: number; + currency: string; + description?: string | null; + destination?: string | null; + failure_balance_transaction?: BalanceTransactionId | null; + failure_code?: string | null; + failure_message?: string | null; + livemode: boolean; + metadata: Record; + method: 'standard' | 'instant'; + original_payout?: PayoutId | null; + reconciliation_status: 'not_applicable' | 'in_progress' | 'completed'; + reversed_by?: PayoutId | null; + source_type: 'card' | 'bank_account' | 'fpx'; + statement_descriptor?: string | null; + status: PayoutStatus; + type: 'bank_account' | 'card'; +} + +// Transfer +export interface Transfer { + id: TransferId; + object: 'transfer'; + amount: number; + amount_reversed: number; + balance_transaction?: BalanceTransactionId | null; + created: number; + currency: string; + description?: string | null; + destination: AccountId; + destination_payment?: string; + livemode: boolean; + metadata: Record; + reversals: StripeList; + reversed: boolean; + source_transaction?: string | null; + source_type?: 'card' | 'bank_account' | 'fpx'; + transfer_group?: string | null; +} + +// Account (Connect) +export type AccountType = 'standard' | 'express' | 'custom'; + +export interface Account { + id: AccountId; + object: 'account'; + business_profile?: { + mcc?: string | null; + name?: string | null; + product_description?: string | null; + support_address?: Address | null; + support_email?: string | null; + support_phone?: string | null; + support_url?: string | null; + url?: string | null; + }; + business_type?: 'individual' | 'company' | 'non_profit' | 'government_entity' | null; + capabilities?: Record; + charges_enabled: boolean; + company?: any; + controller?: any; + country: string; + created: number; + default_currency: string; + details_submitted: boolean; + email?: string | null; + external_accounts?: StripeList; + future_requirements?: any; + individual?: any; + metadata: Record; + payouts_enabled: boolean; + requirements?: any; + settings?: any; + tos_acceptance?: { + date?: number | null; + ip?: string | null; + user_agent?: string | null; + }; + type: AccountType; +} + +// Event +export interface Event { + id: EventId; + object: 'event'; + account?: AccountId; + api_version?: string | null; + created: number; + data: { + object: any; + previous_attributes?: any; + }; + livemode: boolean; + pending_webhooks: number; + request?: { + id?: string | null; + idempotency_key?: string | null; + } | null; + type: string; +} + +// Webhook Endpoint +export interface WebhookEndpoint { + id: WebhookEndpointId; + object: 'webhook_endpoint'; + api_version?: string | null; + application?: string | null; + created: number; + description?: string | null; + enabled_events: string[]; + livemode: boolean; + metadata: Record; + status: 'enabled' | 'disabled'; + url: string; +} + +// Tax Rate +export interface TaxRate { + id: TaxRateId; + object: 'tax_rate'; + active: boolean; + country?: string | null; + created: number; + description?: string | null; + display_name: string; + inclusive: boolean; + jurisdiction?: string | null; + livemode: boolean; + metadata: Record; + percentage: number; + state?: string | null; + tax_type?: string | null; +} + +// Tax ID +export type TaxIdType = + | 'ae_trn' + | 'au_abn' + | 'au_arn' + | 'bg_uic' + | 'br_cnpj' + | 'br_cpf' + | 'ca_bn' + | 'ca_gst_hst' + | 'ca_pst_bc' + | 'ca_pst_mb' + | 'ca_pst_sk' + | 'ca_qst' + | 'ch_vat' + | 'cl_tin' + | 'eg_tin' + | 'es_cif' + | 'eu_oss_vat' + | 'eu_vat' + | 'gb_vat' + | 'ge_vat' + | 'hk_br' + | 'hu_tin' + | 'id_npwp' + | 'il_vat' + | 'in_gst' + | 'is_vat' + | 'jp_cn' + | 'jp_rn' + | 'jp_trn' + | 'ke_pin' + | 'kr_brn' + | 'li_uid' + | 'mx_rfc' + | 'my_frp' + | 'my_itn' + | 'my_sst' + | 'no_vat' + | 'nz_gst' + | 'ph_tin' + | 'ru_inn' + | 'ru_kpp' + | 'sa_vat' + | 'sg_gst' + | 'sg_uen' + | 'si_tin' + | 'th_vat' + | 'tr_tin' + | 'tw_vat' + | 'ua_vat' + | 'us_ein' + | 'za_vat'; + +export interface TaxId { + id: TaxIdId; + object: 'tax_id'; + country?: string | null; + created: number; + customer?: CustomerId | null; + livemode: boolean; + type: TaxIdType; + value: string; + verification?: { + status: 'pending' | 'verified' | 'unverified' | 'unavailable'; + verified_address?: string | null; + verified_name?: string | null; + } | null; +} + +// File +export type FilePurpose = + | 'account_requirement' + | 'additional_verification' + | 'business_icon' + | 'business_logo' + | 'customer_signature' + | 'dispute_evidence' + | 'document_provider_identity_document' + | 'finance_report_run' + | 'identity_document' + | 'identity_document_downloadable' + | 'pci_document' + | 'selfie' + | 'sigma_scheduled_query' + | 'tax_document_user_upload' + | 'terminal_reader_splashscreen'; + +export interface File { + id: FileId; + object: 'file'; + created: number; + expires_at?: number | null; + filename?: string | null; + links?: StripeList | null; + purpose: FilePurpose; + size: number; + title?: string | null; + type?: string | null; + url?: string | null; +} + +// File Link +export interface FileLink { + id: FileLinkId; + object: 'file_link'; + created: number; + expired: boolean; + expires_at?: number | null; + file: FileId | File; + livemode: boolean; + metadata: Record; + url?: string | null; +} + +// Error type +export interface StripeError { + type: 'api_error' | 'card_error' | 'idempotency_error' | 'invalid_request_error' | 'authentication_error' | 'rate_limit_error'; + code?: string; + decline_code?: string; + message: string; + param?: string; + charge?: string; + payment_intent?: PaymentIntent; + payment_method?: PaymentMethod; + setup_intent?: SetupIntent; + source?: any; +} diff --git a/servers/stripe/tsconfig.json b/servers/stripe/tsconfig.json new file mode 100644 index 0000000..38a0f2f --- /dev/null +++ b/servers/stripe/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}