diff --git a/servers/airtable/.env.example b/servers/airtable/.env.example new file mode 100644 index 0000000..b8bee33 --- /dev/null +++ b/servers/airtable/.env.example @@ -0,0 +1,4 @@ +# Airtable API Configuration +# Get your API key from: https://airtable.com/create/tokens + +AIRTABLE_API_KEY=your_api_key_here diff --git a/servers/airtable/.gitignore b/servers/airtable/.gitignore new file mode 100644 index 0000000..03cacb4 --- /dev/null +++ b/servers/airtable/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Misc +.cache/ +temp/ +tmp/ diff --git a/servers/airtable/README.md b/servers/airtable/README.md new file mode 100644 index 0000000..59e8dd8 --- /dev/null +++ b/servers/airtable/README.md @@ -0,0 +1,220 @@ +# @mcpengine/airtable + +Complete Airtable MCP Server providing full API integration for bases, tables, records, fields, views, webhooks, automations, and comments. + +## Features + +- **Bases**: List and get base information +- **Tables**: List tables, get table schema with all fields and views +- **Records**: Full CRUD operations with filtering, sorting, and pagination +- **Fields**: Access field definitions and types (all 34 field types supported) +- **Views**: List and access views (grid, form, calendar, gallery, kanban, timeline, gantt) +- **Webhooks**: Create, list, refresh, and delete webhooks +- **Comments**: Full comment management on records +- **Rate Limiting**: Automatic retry with exponential backoff (5 req/sec per base) +- **Pagination**: Offset-based for records, cursor-based for metadata +- **Batch Operations**: Create/update/delete up to 10 records per request + +## Installation + +```bash +npm install @mcpengine/airtable +``` + +## Configuration + +### Environment Variables + +Create a `.env` file: + +```bash +AIRTABLE_API_KEY=your_api_key_here +``` + +Get your API key from: https://airtable.com/create/tokens + +### MCP Settings + +Add to your MCP settings file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS): + +```json +{ + "mcpServers": { + "airtable": { + "command": "node", + "args": ["/path/to/@mcpengine/airtable/dist/main.js"], + "env": { + "AIRTABLE_API_KEY": "your_api_key_here" + } + } + } +} +``` + +## Usage + +### List Bases + +```typescript +// Returns all bases accessible with the API key +airtable_list_bases({ offset?: string }) +``` + +### List Tables + +```typescript +// Get all tables in a base with schema +airtable_list_tables({ baseId: "appXXXXXXXXXXXXXX" }) +``` + +### List Records + +```typescript +// List records with filtering, sorting, and pagination +airtable_list_records({ + baseId: "appXXXXXXXXXXXXXX", + tableIdOrName: "tblXXXXXXXXXXXXXX" || "Table Name", + fields?: ["Field1", "Field2"], + filterByFormula?: "{Status} = 'Done'", + sort?: [{ field: "Name", direction: "asc" }], + maxRecords?: 100, + pageSize?: 100, + view?: "Grid view", + offset?: "itrXXXXXXXXXXXXXX/recXXXXXXXXXXXXXX" +}) +``` + +### Create Records + +```typescript +// Create up to 10 records at once +airtable_create_records({ + baseId: "appXXXXXXXXXXXXXX", + tableIdOrName: "tblXXXXXXXXXXXXXX", + records: [ + { fields: { Name: "John Doe", Email: "john@example.com" } }, + { fields: { Name: "Jane Smith", Email: "jane@example.com" } } + ], + typecast?: true // Auto-convert types +}) +``` + +### Update Records + +```typescript +// Update up to 10 records at once +airtable_update_records({ + baseId: "appXXXXXXXXXXXXXX", + tableIdOrName: "tblXXXXXXXXXXXXXX", + records: [ + { id: "recXXXXXXXXXXXXXX", fields: { Status: "Complete" } } + ], + typecast?: true +}) +``` + +### Delete Records + +```typescript +// Delete up to 10 records at once +airtable_delete_records({ + baseId: "appXXXXXXXXXXXXXX", + tableIdOrName: "tblXXXXXXXXXXXXXX", + recordIds: ["recXXXXXXXXXXXXXX", "recYYYYYYYYYYYYYY"] +}) +``` + +### Webhooks + +```typescript +// Create a webhook +airtable_create_webhook({ + baseId: "appXXXXXXXXXXXXXX", + notificationUrl: "https://your-server.com/webhook", + specification: { + options: { + filters: { + dataTypes: ["tableData"], + recordChangeScope: "tblXXXXXXXXXXXXXX" + } + } + } +}) + +// Refresh webhook (extends expiration) +airtable_refresh_webhook({ + baseId: "appXXXXXXXXXXXXXX", + webhookId: "achXXXXXXXXXXXXXX" +}) +``` + +### Comments + +```typescript +// Create a comment on a record +airtable_create_comment({ + baseId: "appXXXXXXXXXXXXXX", + tableIdOrName: "tblXXXXXXXXXXXXXX", + recordId: "recXXXXXXXXXXXXXX", + text: "This is a comment" +}) +``` + +## Supported Field Types + +All 34 Airtable field types are fully supported: + +- **Text**: singleLineText, email, url, multilineText, richText, phoneNumber +- **Number**: number, percent, currency, rating, duration, autoNumber +- **Select**: singleSelect, multipleSelects +- **Date**: date, dateTime, createdTime, lastModifiedTime +- **Attachment**: multipleAttachments +- **Link**: multipleRecordLinks +- **User**: singleCollaborator, multipleCollaborators, createdBy, lastModifiedBy +- **Computed**: formula, rollup, count, lookup, multipleLookupValues +- **Other**: checkbox, barcode, button, externalSyncSource, aiText + +## API Limits + +- **Rate Limit**: 5 requests per second per base +- **Batch Create/Update**: Max 10 records per request +- **Batch Delete**: Max 10 record IDs per request +- **Page Size**: Max 100 records per page + +Rate limiting is handled automatically with exponential backoff. + +## Development + +```bash +# Install dependencies +npm install + +# Type check +npm run typecheck + +# Build +npm run build + +# Run +npm start +``` + +## Architecture + +``` +src/ +├── types/index.ts # All Airtable API types (bases, tables, records, fields, etc.) +├── clients/airtable.ts # API client with rate limiting and pagination +├── server.ts # MCP server with lazy-loaded tools +└── main.ts # Entry point with dual transport support +``` + +## License + +MIT + +## Resources + +- [Airtable API Documentation](https://airtable.com/developers/web/api/introduction) +- [Model Context Protocol](https://modelcontextprotocol.io) +- [Create API Token](https://airtable.com/create/tokens) diff --git a/servers/airtable/package.json b/servers/airtable/package.json new file mode 100644 index 0000000..a7cad7f --- /dev/null +++ b/servers/airtable/package.json @@ -0,0 +1,32 @@ +{ + "name": "@mcpengine/airtable", + "version": "1.0.0", + "description": "Airtable MCP Server - Complete API integration for bases, tables, records, fields, views, webhooks, and automations", + "type": "module", + "main": "dist/main.js", + "bin": { + "mcp-airtable": "./dist/main.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/main.js", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "mcp", + "airtable", + "model-context-protocol" + ], + "author": "MCPEngine", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/servers/airtable/src/clients/airtable.ts b/servers/airtable/src/clients/airtable.ts new file mode 100644 index 0000000..ce19eff --- /dev/null +++ b/servers/airtable/src/clients/airtable.ts @@ -0,0 +1,438 @@ +import axios, { AxiosInstance, AxiosError } from 'axios'; +import type { + Base, + BaseId, + Table, + TableId, + AirtableRecord, + RecordId, + RecordFields, + Field, + View, + ViewId, + Webhook, + WebhookId, + Automation, + Comment, + FilterFormula, + SortConfig, + ListBasesResponse, + ListTablesResponse, + ListRecordsResponse, + CreateRecordsResponse, + UpdateRecordsResponse, + DeleteRecordsResponse, +} from '../types/index.js'; + +export interface AirtableClientConfig { + apiKey: string; + baseUrl?: string; + metaBaseUrl?: string; + maxRetries?: number; + retryDelayMs?: number; +} + +export interface ListRecordsOptions { + fields?: string[]; + filterByFormula?: FilterFormula; + maxRecords?: number; + pageSize?: number; + sort?: SortConfig[]; + view?: string; + cellFormat?: 'json' | 'string'; + timeZone?: string; + userLocale?: string; + offset?: string; + returnFieldsByFieldId?: boolean; +} + +export interface CreateRecordOptions { + fields: RecordFields; + typecast?: boolean; +} + +export interface UpdateRecordOptions { + fields: RecordFields; + typecast?: boolean; +} + +export interface DeleteRecordsResult { + records: Array<{ id: RecordId; deleted: boolean }>; +} + +export class AirtableError extends Error { + constructor( + message: string, + public statusCode?: number, + public response?: unknown + ) { + super(message); + this.name = 'AirtableError'; + } +} + +/** + * Airtable API Client + * + * Official API Documentation: https://airtable.com/developers/web/api/introduction + * + * Rate Limits: + * - 5 requests per second per base + * - Implement exponential backoff for 429 responses + * + * Base URLs: + * - Records API: https://api.airtable.com/v0 + * - Meta API: https://api.airtable.com/v0/meta + * + * Pagination: + * - Records: offset-based (include `offset` from response in next request) + * - Meta endpoints: cursor-based (vary by endpoint) + * + * Batch Limits: + * - Create/Update: max 10 records per request + * - Delete: max 10 record IDs per request + */ +export class AirtableClient { + private client: AxiosInstance; + private metaClient: AxiosInstance; + private maxRetries: number; + private retryDelayMs: number; + + constructor(config: AirtableClientConfig) { + const baseUrl = config.baseUrl || 'https://api.airtable.com/v0'; + const metaBaseUrl = config.metaBaseUrl || 'https://api.airtable.com/v0/meta'; + + this.maxRetries = config.maxRetries || 3; + this.retryDelayMs = config.retryDelayMs || 1000; + + this.client = axios.create({ + baseURL: baseUrl, + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); + + this.metaClient = axios.create({ + baseURL: metaBaseUrl, + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); + + this.setupInterceptors(); + } + + private setupInterceptors(): void { + const retryInterceptor = async (error: AxiosError) => { + const config = error.config as any; + + if (!config || !error.response) { + throw error; + } + + // Handle rate limiting (429) + if (error.response.status === 429) { + const retryCount = config.__retryCount || 0; + + if (retryCount < this.maxRetries) { + config.__retryCount = retryCount + 1; + const delay = this.retryDelayMs * Math.pow(2, retryCount); + + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.client.request(config); + } + } + + throw this.handleError(error); + }; + + this.client.interceptors.response.use( + (response) => response, + retryInterceptor + ); + + this.metaClient.interceptors.response.use( + (response) => response, + retryInterceptor + ); + } + + private handleError(error: AxiosError): AirtableError { + if (error.response) { + const data = error.response.data as any; + const message = data?.error?.message || data?.message || 'Airtable API error'; + return new AirtableError(message, error.response.status, data); + } + return new AirtableError(error.message); + } + + // ============================================================================ + // Bases API + // ============================================================================ + + async listBases(offset?: string): Promise { + const params: Record = {}; + if (offset) params.offset = offset; + + const response = await this.metaClient.get('/bases', { params }); + return response.data; + } + + async getBase(baseId: BaseId): Promise { + const response = await this.metaClient.get<{ id: string; name: string; permissionLevel: string }>( + `/bases/${baseId}` + ); + return response.data as Base; + } + + // ============================================================================ + // Tables API (Meta) + // ============================================================================ + + async listTables(baseId: BaseId): Promise { + const response = await this.metaClient.get(`/bases/${baseId}/tables`); + return response.data; + } + + async getTable(baseId: BaseId, tableId: TableId): Promise { + const response = await this.metaClient.get
(`/bases/${baseId}/tables/${tableId}`); + return response.data; + } + + // ============================================================================ + // Fields API (Meta) + // ============================================================================ + + async listFields(baseId: BaseId, tableId: TableId): Promise { + const table = await this.getTable(baseId, tableId); + return table.fields; + } + + async getField(baseId: BaseId, tableId: TableId, fieldId: string): Promise { + const fields = await this.listFields(baseId, tableId); + const field = fields.find((f) => f.id === fieldId); + if (!field) { + throw new AirtableError(`Field ${fieldId} not found in table ${tableId}`); + } + return field; + } + + // ============================================================================ + // Views API (Meta) + // ============================================================================ + + async listViews(baseId: BaseId, tableId: TableId): Promise { + const table = await this.getTable(baseId, tableId); + return table.views; + } + + async getView(baseId: BaseId, tableId: TableId, viewId: ViewId): Promise { + const views = await this.listViews(baseId, tableId); + const view = views.find((v) => v.id === viewId); + if (!view) { + throw new AirtableError(`View ${viewId} not found in table ${tableId}`); + } + return view; + } + + // ============================================================================ + // Records API + // ============================================================================ + + async listRecords( + baseId: BaseId, + tableIdOrName: string, + options?: ListRecordsOptions + ): Promise { + const params: Record = {}; + + if (options?.fields) params.fields = options.fields; + if (options?.filterByFormula) params.filterByFormula = options.filterByFormula; + if (options?.maxRecords) params.maxRecords = options.maxRecords; + if (options?.pageSize) params.pageSize = options.pageSize; + if (options?.view) params.view = options.view; + if (options?.cellFormat) params.cellFormat = options.cellFormat; + if (options?.timeZone) params.timeZone = options.timeZone; + if (options?.userLocale) params.userLocale = options.userLocale; + if (options?.offset) params.offset = options.offset; + if (options?.returnFieldsByFieldId) params.returnFieldsByFieldId = options.returnFieldsByFieldId; + + if (options?.sort) { + options.sort.forEach((sortItem, index) => { + params[`sort[${index}][field]`] = sortItem.field; + params[`sort[${index}][direction]`] = sortItem.direction; + }); + } + + const response = await this.client.get(`/${baseId}/${encodeURIComponent(tableIdOrName)}`, { + params, + }); + return response.data; + } + + async getRecord(baseId: BaseId, tableIdOrName: string, recordId: RecordId): Promise { + const response = await this.client.get( + `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}` + ); + return response.data; + } + + /** + * Create records (max 10 per request) + */ + async createRecords( + baseId: BaseId, + tableIdOrName: string, + records: CreateRecordOptions[], + typecast?: boolean + ): Promise { + if (records.length > 10) { + throw new AirtableError('Cannot create more than 10 records per request'); + } + + const payload: Record = { + records: records.map((r) => ({ fields: r.fields })), + }; + + if (typecast !== undefined) { + payload.typecast = typecast; + } + + const response = await this.client.post( + `/${baseId}/${encodeURIComponent(tableIdOrName)}`, + payload + ); + return response.data; + } + + /** + * Update records (max 10 per request) + */ + async updateRecords( + baseId: BaseId, + tableIdOrName: string, + records: Array<{ id: RecordId } & UpdateRecordOptions>, + typecast?: boolean + ): Promise { + if (records.length > 10) { + throw new AirtableError('Cannot update more than 10 records per request'); + } + + const payload: Record = { + records: records.map((r) => ({ id: r.id, fields: r.fields })), + }; + + if (typecast !== undefined) { + payload.typecast = typecast; + } + + const response = await this.client.patch( + `/${baseId}/${encodeURIComponent(tableIdOrName)}`, + payload + ); + return response.data; + } + + /** + * Delete records (max 10 per request) + */ + async deleteRecords( + baseId: BaseId, + tableIdOrName: string, + recordIds: RecordId[] + ): Promise { + if (recordIds.length > 10) { + throw new AirtableError('Cannot delete more than 10 records per request'); + } + + const params = recordIds.map((id) => `records[]=${id}`).join('&'); + const response = await this.client.delete( + `/${baseId}/${encodeURIComponent(tableIdOrName)}?${params}` + ); + return response.data; + } + + // ============================================================================ + // Webhooks API + // ============================================================================ + + async listWebhooks(baseId: BaseId): Promise { + const response = await this.client.get<{ webhooks: Webhook[] }>(`/${baseId}/webhooks`); + return response.data.webhooks; + } + + async createWebhook(baseId: BaseId, notificationUrl: string, specification: unknown): Promise { + const response = await this.client.post(`/${baseId}/webhooks`, { + notificationUrl, + specification, + }); + return response.data; + } + + async deleteWebhook(baseId: BaseId, webhookId: WebhookId): Promise { + await this.client.delete(`/${baseId}/webhooks/${webhookId}`); + } + + async refreshWebhook(baseId: BaseId, webhookId: WebhookId): Promise { + const response = await this.client.post(`/${baseId}/webhooks/${webhookId}/refresh`); + return response.data; + } + + // ============================================================================ + // Comments API + // ============================================================================ + + async listComments(baseId: BaseId, tableIdOrName: string, recordId: RecordId): Promise { + const response = await this.client.get<{ comments: Comment[] }>( + `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments` + ); + return response.data.comments; + } + + async createComment(baseId: BaseId, tableIdOrName: string, recordId: RecordId, text: string): Promise { + const response = await this.client.post( + `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments`, + { text } + ); + return response.data; + } + + async updateComment( + baseId: BaseId, + tableIdOrName: string, + recordId: RecordId, + commentId: string, + text: string + ): Promise { + const response = await this.client.patch( + `/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments/${commentId}`, + { text } + ); + return response.data; + } + + async deleteComment( + baseId: BaseId, + tableIdOrName: string, + recordId: RecordId, + commentId: string + ): Promise { + await this.client.delete(`/${baseId}/${encodeURIComponent(tableIdOrName)}/${recordId}/comments/${commentId}`); + } + + // ============================================================================ + // Automations API (Read-only for now) + // ============================================================================ + + /** + * Note: Airtable's API does not currently provide direct automation management. + * This is a placeholder for future functionality or webhook-based automation triggers. + */ + async listAutomations(baseId: BaseId): Promise { + throw new AirtableError('Automations API not yet supported by Airtable'); + } +} diff --git a/servers/airtable/src/main.ts b/servers/airtable/src/main.ts new file mode 100644 index 0000000..d97d26f --- /dev/null +++ b/servers/airtable/src/main.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { AirtableServer } from './server.js'; + +async function main() { + const apiKey = process.env.AIRTABLE_API_KEY; + + if (!apiKey) { + console.error('Error: AIRTABLE_API_KEY environment variable is required'); + console.error('Please set your Airtable API key:'); + console.error(' export AIRTABLE_API_KEY=your_api_key_here'); + process.exit(1); + } + + const server = new AirtableServer({ + apiKey, + serverName: '@mcpengine/airtable', + serverVersion: '1.0.0', + }); + + const transport = new StdioServerTransport(); + + // Graceful shutdown + const cleanup = async () => { + console.error('Shutting down Airtable MCP server...'); + process.exit(0); + }; + + process.on('SIGINT', cleanup); + process.on('SIGTERM', cleanup); + + 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); + }); + + await server.connect(transport); + console.error('Airtable MCP server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/airtable/src/server.ts b/servers/airtable/src/server.ts new file mode 100644 index 0000000..a357503 --- /dev/null +++ b/servers/airtable/src/server.ts @@ -0,0 +1,690 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { AirtableClient } from './clients/airtable.js'; +import { z } from 'zod'; + +export interface AirtableServerConfig { + apiKey: string; + serverName?: string; + serverVersion?: string; +} + +export class AirtableServer { + private server: Server; + private client: AirtableClient; + + constructor(config: AirtableServerConfig) { + this.client = new AirtableClient({ + apiKey: config.apiKey, + }); + + this.server = new Server( + { + name: config.serverName || '@mcpengine/airtable', + version: config.serverVersion || '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: this.getTools(), + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + const result = await this.handleToolCall(name, args || {}); + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + return { + content: [ + { + type: 'text' as const, + text: JSON.stringify({ error: errorMessage }, null, 2), + }, + ], + isError: true, + }; + } + }); + } + + private getTools(): Tool[] { + return [ + // ======================================================================== + // Bases + // ======================================================================== + { + name: 'airtable_list_bases', + description: 'List all bases accessible with the API key. Supports pagination with offset.', + inputSchema: { + type: 'object', + properties: { + offset: { + type: 'string', + description: 'Pagination offset from previous response', + }, + }, + }, + }, + { + name: 'airtable_get_base', + description: 'Get details of a specific base by ID', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + }, + required: ['baseId'], + }, + }, + // ======================================================================== + // Tables + // ======================================================================== + { + name: 'airtable_list_tables', + description: 'List all tables in a base with their schema (fields, views)', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + }, + required: ['baseId'], + }, + }, + { + name: 'airtable_get_table', + description: 'Get detailed information about a specific table including all fields and views', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableId: { + type: 'string', + description: 'The table ID (starts with tbl)', + }, + }, + required: ['baseId', 'tableId'], + }, + }, + // ======================================================================== + // Records + // ======================================================================== + { + name: 'airtable_list_records', + description: + 'List records from a table. Supports filtering, sorting, pagination, and field selection. Use filterByFormula for advanced filtering.', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + fields: { + type: 'array', + items: { type: 'string' }, + description: 'Only return specific fields', + }, + filterByFormula: { + type: 'string', + description: 'Airtable formula to filter records (e.g., "{Status} = \'Done\'")', + }, + maxRecords: { + type: 'number', + description: 'Maximum number of records to return (default: all)', + }, + pageSize: { + type: 'number', + description: 'Number of records per page (max 100, default 100)', + }, + sort: { + type: 'array', + items: { + type: 'object', + properties: { + field: { type: 'string' }, + direction: { type: 'string', enum: ['asc', 'desc'] }, + }, + required: ['field', 'direction'], + }, + description: 'Sort configuration', + }, + view: { + type: 'string', + description: 'View name or ID to use for filtering/sorting', + }, + offset: { + type: 'string', + description: 'Pagination offset from previous response', + }, + }, + required: ['baseId', 'tableIdOrName'], + }, + }, + { + name: 'airtable_get_record', + description: 'Get a specific record by ID', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + recordId: { + type: 'string', + description: 'The record ID (starts with rec)', + }, + }, + required: ['baseId', 'tableIdOrName', 'recordId'], + }, + }, + { + name: 'airtable_create_records', + description: 'Create new records (max 10 per request). Use typecast to enable automatic type conversion.', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + records: { + type: 'array', + items: { + type: 'object', + properties: { + fields: { + type: 'object', + description: 'Field name to value mapping', + }, + }, + required: ['fields'], + }, + description: 'Records to create (max 10)', + }, + typecast: { + type: 'boolean', + description: 'Enable automatic type conversion (default: false)', + }, + }, + required: ['baseId', 'tableIdOrName', 'records'], + }, + }, + { + name: 'airtable_update_records', + description: 'Update existing records (max 10 per request). Use typecast to enable automatic type conversion.', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + records: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Record ID (starts with rec)', + }, + fields: { + type: 'object', + description: 'Field name to value mapping', + }, + }, + required: ['id', 'fields'], + }, + description: 'Records to update (max 10)', + }, + typecast: { + type: 'boolean', + description: 'Enable automatic type conversion (default: false)', + }, + }, + required: ['baseId', 'tableIdOrName', 'records'], + }, + }, + { + name: 'airtable_delete_records', + description: 'Delete records (max 10 per request)', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + recordIds: { + type: 'array', + items: { type: 'string' }, + description: 'Record IDs to delete (max 10)', + }, + }, + required: ['baseId', 'tableIdOrName', 'recordIds'], + }, + }, + // ======================================================================== + // Fields + // ======================================================================== + { + name: 'airtable_list_fields', + description: 'List all fields in a table with their types and configuration', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableId: { + type: 'string', + description: 'The table ID (starts with tbl)', + }, + }, + required: ['baseId', 'tableId'], + }, + }, + { + name: 'airtable_get_field', + description: 'Get details of a specific field', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableId: { + type: 'string', + description: 'The table ID (starts with tbl)', + }, + fieldId: { + type: 'string', + description: 'The field ID (starts with fld)', + }, + }, + required: ['baseId', 'tableId', 'fieldId'], + }, + }, + // ======================================================================== + // Views + // ======================================================================== + { + name: 'airtable_list_views', + description: 'List all views in a table', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableId: { + type: 'string', + description: 'The table ID (starts with tbl)', + }, + }, + required: ['baseId', 'tableId'], + }, + }, + { + name: 'airtable_get_view', + description: 'Get details of a specific view', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableId: { + type: 'string', + description: 'The table ID (starts with tbl)', + }, + viewId: { + type: 'string', + description: 'The view ID (starts with viw)', + }, + }, + required: ['baseId', 'tableId', 'viewId'], + }, + }, + // ======================================================================== + // Webhooks + // ======================================================================== + { + name: 'airtable_list_webhooks', + description: 'List all webhooks configured for a base', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + }, + required: ['baseId'], + }, + }, + { + name: 'airtable_create_webhook', + description: 'Create a new webhook for a base', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + notificationUrl: { + type: 'string', + description: 'URL to receive webhook notifications', + }, + specification: { + type: 'object', + description: 'Webhook specification (filters, includes)', + }, + }, + required: ['baseId', 'notificationUrl', 'specification'], + }, + }, + { + name: 'airtable_delete_webhook', + description: 'Delete a webhook', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + webhookId: { + type: 'string', + description: 'The webhook ID', + }, + }, + required: ['baseId', 'webhookId'], + }, + }, + { + name: 'airtable_refresh_webhook', + description: 'Refresh a webhook to extend its expiration time', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + webhookId: { + type: 'string', + description: 'The webhook ID', + }, + }, + required: ['baseId', 'webhookId'], + }, + }, + // ======================================================================== + // Comments + // ======================================================================== + { + name: 'airtable_list_comments', + description: 'List all comments on a record', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + recordId: { + type: 'string', + description: 'The record ID (starts with rec)', + }, + }, + required: ['baseId', 'tableIdOrName', 'recordId'], + }, + }, + { + name: 'airtable_create_comment', + description: 'Create a comment on a record', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + recordId: { + type: 'string', + description: 'The record ID (starts with rec)', + }, + text: { + type: 'string', + description: 'Comment text', + }, + }, + required: ['baseId', 'tableIdOrName', 'recordId', 'text'], + }, + }, + { + name: 'airtable_update_comment', + description: 'Update an existing comment', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + recordId: { + type: 'string', + description: 'The record ID (starts with rec)', + }, + commentId: { + type: 'string', + description: 'The comment ID', + }, + text: { + type: 'string', + description: 'New comment text', + }, + }, + required: ['baseId', 'tableIdOrName', 'recordId', 'commentId', 'text'], + }, + }, + { + name: 'airtable_delete_comment', + description: 'Delete a comment', + inputSchema: { + type: 'object', + properties: { + baseId: { + type: 'string', + description: 'The base ID (starts with app)', + }, + tableIdOrName: { + type: 'string', + description: 'Table ID (starts with tbl) or table name', + }, + recordId: { + type: 'string', + description: 'The record ID (starts with rec)', + }, + commentId: { + type: 'string', + description: 'The comment ID', + }, + }, + required: ['baseId', 'tableIdOrName', 'recordId', 'commentId'], + }, + }, + ]; + } + + private async handleToolCall(name: string, args: Record): Promise { + switch (name) { + // Bases + case 'airtable_list_bases': + return this.client.listBases(args.offset as string | undefined); + case 'airtable_get_base': + return this.client.getBase(args.baseId as any); + + // Tables + case 'airtable_list_tables': + return this.client.listTables(args.baseId as any); + case 'airtable_get_table': + return this.client.getTable(args.baseId as any, args.tableId as any); + + // Records + case 'airtable_list_records': + return this.client.listRecords(args.baseId as any, args.tableIdOrName as string, args as any); + case 'airtable_get_record': + return this.client.getRecord(args.baseId as any, args.tableIdOrName as string, args.recordId as any); + case 'airtable_create_records': + return this.client.createRecords( + args.baseId as any, + args.tableIdOrName as string, + args.records as any[], + args.typecast as boolean | undefined + ); + case 'airtable_update_records': + return this.client.updateRecords( + args.baseId as any, + args.tableIdOrName as string, + args.records as any[], + args.typecast as boolean | undefined + ); + case 'airtable_delete_records': + return this.client.deleteRecords(args.baseId as any, args.tableIdOrName as string, args.recordIds as any[]); + + // Fields + case 'airtable_list_fields': + return this.client.listFields(args.baseId as any, args.tableId as any); + case 'airtable_get_field': + return this.client.getField(args.baseId as any, args.tableId as any, args.fieldId as string); + + // Views + case 'airtable_list_views': + return this.client.listViews(args.baseId as any, args.tableId as any); + case 'airtable_get_view': + return this.client.getView(args.baseId as any, args.tableId as any, args.viewId as any); + + // Webhooks + case 'airtable_list_webhooks': + return this.client.listWebhooks(args.baseId as any); + case 'airtable_create_webhook': + return this.client.createWebhook(args.baseId as any, args.notificationUrl as string, args.specification); + case 'airtable_delete_webhook': + await this.client.deleteWebhook(args.baseId as any, args.webhookId as any); + return { success: true }; + case 'airtable_refresh_webhook': + return this.client.refreshWebhook(args.baseId as any, args.webhookId as any); + + // Comments + case 'airtable_list_comments': + return this.client.listComments(args.baseId as any, args.tableIdOrName as string, args.recordId as any); + case 'airtable_create_comment': + return this.client.createComment( + args.baseId as any, + args.tableIdOrName as string, + args.recordId as any, + args.text as string + ); + case 'airtable_update_comment': + return this.client.updateComment( + args.baseId as any, + args.tableIdOrName as string, + args.recordId as any, + args.commentId as string, + args.text as string + ); + case 'airtable_delete_comment': + await this.client.deleteComment( + args.baseId as any, + args.tableIdOrName as string, + args.recordId as any, + args.commentId as string + ); + return { success: true }; + + default: + throw new Error(`Unknown tool: ${name}`); + } + } + + async connect(transport: StdioServerTransport): Promise { + await this.server.connect(transport); + } + + getServer(): Server { + return this.server; + } +} diff --git a/servers/airtable/src/types/index.ts b/servers/airtable/src/types/index.ts new file mode 100644 index 0000000..fd41462 --- /dev/null +++ b/servers/airtable/src/types/index.ts @@ -0,0 +1,563 @@ +import { z } from 'zod'; + +// ============================================================================ +// Branded ID Types +// ============================================================================ + +export type BaseId = string & { readonly __brand: 'BaseId' }; +export type TableId = string & { readonly __brand: 'TableId' }; +export type RecordId = string & { readonly __brand: 'RecordId' }; +export type FieldId = string & { readonly __brand: 'FieldId' }; +export type ViewId = string & { readonly __brand: 'ViewId' }; +export type WebhookId = string & { readonly __brand: 'WebhookId' }; +export type AutomationId = string & { readonly __brand: 'AutomationId' }; + +export const BaseIdSchema = z.string().transform((v) => v as BaseId); +export const TableIdSchema = z.string().transform((v) => v as TableId); +export const RecordIdSchema = z.string().transform((v) => v as RecordId); +export const FieldIdSchema = z.string().transform((v) => v as FieldId); +export const ViewIdSchema = z.string().transform((v) => v as ViewId); +export const WebhookIdSchema = z.string().transform((v) => v as WebhookId); +export const AutomationIdSchema = z.string().transform((v) => v as AutomationId); + +// ============================================================================ +// Field Types +// ============================================================================ + +export type FieldType = + | 'singleLineText' + | 'email' + | 'url' + | 'multilineText' + | 'number' + | 'percent' + | 'currency' + | 'singleSelect' + | 'multipleSelects' + | 'singleCollaborator' + | 'multipleCollaborators' + | 'multipleRecordLinks' + | 'date' + | 'dateTime' + | 'phoneNumber' + | 'multipleAttachments' + | 'checkbox' + | 'formula' + | 'createdTime' + | 'rollup' + | 'count' + | 'lookup' + | 'multipleLookupValues' + | 'autoNumber' + | 'barcode' + | 'rating' + | 'richText' + | 'duration' + | 'lastModifiedTime' + | 'button' + | 'createdBy' + | 'lastModifiedBy' + | 'externalSyncSource' + | 'aiText'; + +export const FieldTypeSchema = z.enum([ + 'singleLineText', + 'email', + 'url', + 'multilineText', + 'number', + 'percent', + 'currency', + 'singleSelect', + 'multipleSelects', + 'singleCollaborator', + 'multipleCollaborators', + 'multipleRecordLinks', + 'date', + 'dateTime', + 'phoneNumber', + 'multipleAttachments', + 'checkbox', + 'formula', + 'createdTime', + 'rollup', + 'count', + 'lookup', + 'multipleLookupValues', + 'autoNumber', + 'barcode', + 'rating', + 'richText', + 'duration', + 'lastModifiedTime', + 'button', + 'createdBy', + 'lastModifiedBy', + 'externalSyncSource', + 'aiText', +]); + +// ============================================================================ +// Field Configuration +// ============================================================================ + +export interface SelectOption { + id: string; + name: string; + color?: string; +} + +export interface Collaborator { + id: string; + email: string; + name?: string; +} + +export interface CurrencyOptions { + precision: number; + symbol: string; +} + +export interface NumberOptions { + precision: number; +} + +export interface PercentOptions { + precision: number; +} + +export interface RatingOptions { + icon: string; + max: number; + color?: string; +} + +export interface DurationOptions { + durationFormat: 'h:mm' | 'h:mm:ss' | 'h:mm:ss.S' | 'h:mm:ss.SS' | 'h:mm:ss.SSS'; +} + +export interface DateOptions { + dateFormat: { + name: 'local' | 'friendly' | 'us' | 'european' | 'iso'; + format: string; + }; +} + +export interface DateTimeOptions { + dateFormat: { + name: 'local' | 'friendly' | 'us' | 'european' | 'iso'; + format: string; + }; + timeFormat: { + name: '12hour' | '24hour'; + format: string; + }; + timeZone: string; +} + +export interface Field { + id: FieldId; + name: string; + type: FieldType; + description?: string; + options?: { + choices?: SelectOption[]; + linkedTableId?: TableId; + prefersSingleRecordLink?: boolean; + inverseLinkFieldId?: FieldId; + isReversed?: boolean; + precision?: number; + symbol?: string; + icon?: string; + max?: number; + color?: string; + durationFormat?: string; + dateFormat?: unknown; + timeFormat?: unknown; + timeZone?: string; + result?: unknown; + formula?: string; + recordLinkFieldId?: FieldId; + fieldIdInLinkedTable?: FieldId; + referencedFieldIds?: FieldId[]; + }; +} + +export const FieldSchema = z.object({ + id: FieldIdSchema, + name: z.string(), + type: FieldTypeSchema, + description: z.string().optional(), + options: z.record(z.unknown()).optional(), +}); + +// ============================================================================ +// Attachments and Thumbnails +// ============================================================================ + +export interface Thumbnail { + url: string; + width: number; + height: number; +} + +export interface Attachment { + id: string; + url: string; + filename: string; + size: number; + type: string; + width?: number; + height?: number; + thumbnails?: { + small?: Thumbnail; + large?: Thumbnail; + full?: Thumbnail; + }; +} + +export const ThumbnailSchema = z.object({ + url: z.string(), + width: z.number(), + height: z.number(), +}); + +export const AttachmentSchema = z.object({ + id: z.string(), + url: z.string(), + filename: z.string(), + size: z.number(), + type: z.string(), + width: z.number().optional(), + height: z.number().optional(), + thumbnails: z + .object({ + small: ThumbnailSchema.optional(), + large: ThumbnailSchema.optional(), + full: ThumbnailSchema.optional(), + }) + .optional(), +}); + +// ============================================================================ +// Records +// ============================================================================ + +export type RecordFields = globalThis.Record; + +export interface AirtableRecord { + id: RecordId; + createdTime: string; + fields: RecordFields; +} + +export const AirtableRecordSchema = z.object({ + id: RecordIdSchema, + createdTime: z.string(), + fields: z.record(z.unknown()), +}); + +// ============================================================================ +// Tables +// ============================================================================ + +export interface Table { + id: TableId; + name: string; + description?: string; + primaryFieldId: FieldId; + fields: Field[]; + views: View[]; +} + +export const TableSchema = z.object({ + id: TableIdSchema, + name: z.string(), + description: z.string().optional(), + primaryFieldId: FieldIdSchema, + fields: z.array(FieldSchema), + views: z.array(z.lazy(() => ViewSchema)), +}); + +// ============================================================================ +// Views +// ============================================================================ + +export type ViewType = 'grid' | 'form' | 'calendar' | 'gallery' | 'kanban' | 'timeline' | 'gantt'; + +export const ViewTypeSchema = z.enum(['grid', 'form', 'calendar', 'gallery', 'kanban', 'timeline', 'gantt']); + +export interface View { + id: ViewId; + name: string; + type: ViewType; +} + +export const ViewSchema = z.object({ + id: ViewIdSchema, + name: z.string(), + type: ViewTypeSchema, +}); + +// ============================================================================ +// Bases +// ============================================================================ + +export interface Base { + id: BaseId; + name: string; + permissionLevel: 'none' | 'read' | 'comment' | 'edit' | 'create'; +} + +export const BaseSchema = z.object({ + id: BaseIdSchema, + name: z.string(), + permissionLevel: z.enum(['none', 'read', 'comment', 'edit', 'create']), +}); + +// ============================================================================ +// Webhooks +// ============================================================================ + +export interface WebhookPayload { + baseTransactionNumber?: number; + timestamp?: string; + actionMetadata?: { + source: string; + sourceMetadata?: unknown; + }; + changedTablesById?: globalThis.Record< + string, + { + createdRecordsById?: globalThis.Record; + changedRecordsById?: globalThis.Record; + destroyedRecordIds?: string[]; + createdFieldsById?: globalThis.Record; + changedFieldsById?: globalThis.Record; + destroyedFieldIds?: string[]; + changedViewsById?: globalThis.Record; + createdViewsById?: globalThis.Record; + destroyedViewIds?: string[]; + changedMetadata?: unknown; + } + >; +} + +export interface Webhook { + id: WebhookId; + macSecretBase64?: string; + expirationTime?: string; + specification?: { + options: { + filters: { + dataTypes: Array<'tableData' | 'tableFields' | 'tableMetadata'>; + recordChangeScope?: string; + watchDataInFieldIds?: string[]; + watchSchemasOfFieldIds?: string[]; + sourceOptions?: unknown; + }; + includes?: { + includeCellValuesInFieldIds?: string[] | 'all'; + includePreviousCellValues?: boolean; + includePreviousFieldDefinitions?: boolean; + }; + }; + }; +} + +export const WebhookSchema = z.object({ + id: WebhookIdSchema, + macSecretBase64: z.string().optional(), + expirationTime: z.string().optional(), + specification: z + .object({ + options: z.object({ + filters: z.object({ + dataTypes: z.array(z.enum(['tableData', 'tableFields', 'tableMetadata'])), + recordChangeScope: z.string().optional(), + watchDataInFieldIds: z.array(z.string()).optional(), + watchSchemasOfFieldIds: z.array(z.string()).optional(), + sourceOptions: z.unknown().optional(), + }), + includes: z + .object({ + includeCellValuesInFieldIds: z.union([z.array(z.string()), z.literal('all')]).optional(), + includePreviousCellValues: z.boolean().optional(), + includePreviousFieldDefinitions: z.boolean().optional(), + }) + .optional(), + }), + }) + .optional(), +}); + +// ============================================================================ +// Automations +// ============================================================================ + +export interface AutomationTrigger { + type: string; + config: unknown; +} + +export interface AutomationAction { + type: string; + config: unknown; +} + +export interface Automation { + id: AutomationId; + name: string; + state: 'active' | 'disabled'; + trigger: AutomationTrigger; + actions: AutomationAction[]; + createdTime: string; +} + +export const AutomationSchema = z.object({ + id: AutomationIdSchema, + name: z.string(), + state: z.enum(['active', 'disabled']), + trigger: z.object({ + type: z.string(), + config: z.unknown(), + }), + actions: z.array( + z.object({ + type: z.string(), + config: z.unknown(), + }) + ), + createdTime: z.string(), +}); + +// ============================================================================ +// Comments +// ============================================================================ + +export interface Comment { + id: string; + text: string; + createdTime: string; + author: { + id: string; + email: string; + name?: string; + }; + lastUpdatedTime?: string; + mentioned?: globalThis.Record; +} + +export const CommentSchema = z.object({ + id: z.string(), + text: z.string(), + createdTime: z.string(), + author: z.object({ + id: z.string(), + email: z.string(), + name: z.string().optional(), + }), + lastUpdatedTime: z.string().optional(), + mentioned: z.record(z.unknown()).optional(), +}); + +// ============================================================================ +// Pagination +// ============================================================================ + +export interface RecordsPagination { + offset?: string; +} + +export interface MetaPagination { + cursor?: string; +} + +export const RecordsPaginationSchema = z.object({ + offset: z.string().optional(), +}); + +export const MetaPaginationSchema = z.object({ + cursor: z.string().optional(), +}); + +// ============================================================================ +// Filtering and Sorting +// ============================================================================ + +export type FilterFormula = string; + +export interface SortConfig { + field: string; + direction: 'asc' | 'desc'; +} + +export const SortConfigSchema = z.object({ + field: z.string(), + direction: z.enum(['asc', 'desc']), +}); + +// ============================================================================ +// API Response Types +// ============================================================================ + +export interface ListBasesResponse { + bases: Base[]; + offset?: string; +} + +export interface ListTablesResponse { + tables: Table[]; +} + +export interface ListRecordsResponse { + records: AirtableRecord[]; + offset?: string; +} + +export interface CreateRecordsResponse { + records: AirtableRecord[]; + createdRecords?: AirtableRecord[]; +} + +export interface UpdateRecordsResponse { + records: AirtableRecord[]; + updatedRecords?: AirtableRecord[]; +} + +export interface DeleteRecordsResponse { + records: Array<{ id: RecordId; deleted: boolean }>; +} + +export const ListBasesResponseSchema = z.object({ + bases: z.array(BaseSchema), + offset: z.string().optional(), +}); + +export const ListTablesResponseSchema = z.object({ + tables: z.array(TableSchema), +}); + +export const ListRecordsResponseSchema = z.object({ + records: z.array(AirtableRecordSchema), + offset: z.string().optional(), +}); + +export const CreateRecordsResponseSchema = z.object({ + records: z.array(AirtableRecordSchema), + createdRecords: z.array(AirtableRecordSchema).optional(), +}); + +export const UpdateRecordsResponseSchema = z.object({ + records: z.array(AirtableRecordSchema), + updatedRecords: z.array(AirtableRecordSchema).optional(), +}); + +export const DeleteRecordsResponseSchema = z.object({ + records: z.array( + z.object({ + id: RecordIdSchema, + deleted: z.boolean(), + }) + ), +}); diff --git a/servers/airtable/tsconfig.json b/servers/airtable/tsconfig.json new file mode 100644 index 0000000..530e03f --- /dev/null +++ b/servers/airtable/tsconfig.json @@ -0,0 +1,22 @@ +{ + "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", + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/servers/intercom/.env.example b/servers/intercom/.env.example new file mode 100644 index 0000000..7478c83 --- /dev/null +++ b/servers/intercom/.env.example @@ -0,0 +1,3 @@ +# Intercom Access Token (required) +# Get your access token from: https://app.intercom.com/a/apps/_/settings/developer-hub +INTERCOM_ACCESS_TOKEN=your_access_token_here diff --git a/servers/intercom/.gitignore b/servers/intercom/.gitignore new file mode 100644 index 0000000..6474222 --- /dev/null +++ b/servers/intercom/.gitignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment variables +.env +.env.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor directories +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# TypeScript +*.tsbuildinfo diff --git a/servers/intercom/README.md b/servers/intercom/README.md new file mode 100644 index 0000000..efb8a96 --- /dev/null +++ b/servers/intercom/README.md @@ -0,0 +1,168 @@ +# @mcpengine/intercom + +Model Context Protocol (MCP) server for Intercom API integration. + +## Features + +- ✅ **Contacts** - Create, read, update, delete, search, and list contacts +- ✅ **Conversations** - Create, reply, assign, close, search conversations +- ✅ **Companies** - Manage companies and their relationships with contacts +- ✅ **Articles** - Create and manage help center articles +- ✅ **Help Center** - Collections, sections, and help center management +- ✅ **Tickets** - Create, update, search tickets and ticket types +- ✅ **Tags** - Create, list, and delete tags +- ✅ **Segments** - List and retrieve segments +- ✅ **Events** - Submit custom events +- ✅ **Messages** - Send in-app, email, and push messages +- ✅ **Teams & Admins** - List and retrieve teams and admins + +## Installation + +```bash +npm install +npm run build +``` + +## Configuration + +Create a `.env` file: + +```bash +cp .env.example .env +``` + +Add your Intercom access token: + +``` +INTERCOM_ACCESS_TOKEN=your_access_token_here +``` + +Get your access token from the [Intercom Developer Hub](https://app.intercom.com/a/apps/_/settings/developer-hub). + +## Usage + +### Standalone + +```bash +npm start +``` + +### As MCP Server + +Add to your MCP client configuration (e.g., Claude Desktop): + +```json +{ + "mcpServers": { + "intercom": { + "command": "node", + "args": ["/path/to/dist/main.js"], + "env": { + "INTERCOM_ACCESS_TOKEN": "your_access_token_here" + } + } + } +} +``` + +## Available Tools + +### Contacts + +- `contacts_create` - Create a new contact (user or lead) +- `contacts_get` - Retrieve a contact by ID +- `contacts_update` - Update contact details +- `contacts_delete` - Delete a contact +- `contacts_list` - List all contacts (cursor pagination) +- `contacts_search` - Search contacts with filters + +### Conversations + +- `conversations_create` - Start a new conversation +- `conversations_get` - Retrieve conversation details +- `conversations_list` - List conversations +- `conversations_search` - Search conversations +- `conversations_reply` - Reply to a conversation +- `conversations_close` - Close a conversation +- `conversations_assign` - Assign to admin or team + +### Companies + +- `companies_create` - Create a company +- `companies_get` - Retrieve company details +- `companies_list` - List companies +- `companies_update` - Update company data + +### Articles + +- `articles_create` - Create a help article +- `articles_get` - Get article by ID +- `articles_list` - List all articles +- `articles_update` - Update article +- `articles_delete` - Delete article + +### Help Center + +- `help-center_list` - List help centers +- `help-center_collections_list` - List collections +- `help-center_collections_create` - Create collection + +### Tickets + +- `tickets_create` - Create a ticket +- `tickets_get` - Get ticket by ID +- `tickets_list` - List tickets +- `tickets_search` - Search tickets +- `tickets_types_list` - List ticket types + +### Tags + +- `tags_create` - Create a tag +- `tags_list` - List all tags +- `tags_delete` - Delete a tag + +### Segments + +- `segments_list` - List segments +- `segments_get` - Get segment by ID + +### Events + +- `events_submit` - Submit a custom event + +### Messages + +- `messages_send` - Send in-app, email, or push message + +### Teams & Admins + +- `teams_list` - List all teams +- `teams_get` - Get team by ID +- `admins_list` - List all admins +- `admins_get` - Get admin by ID + +## API Reference + +This server uses Intercom API v2.11. For detailed API documentation, visit: +https://developers.intercom.com/docs/build-an-integration/ + +## Rate Limiting + +The server automatically handles rate limiting (429 responses) with exponential backoff and retry logic. + +## Development + +```bash +# Type check +npm run typecheck + +# Build +npm run build + +# Watch mode +npm run dev +``` + +## License + +MIT diff --git a/servers/intercom/package.json b/servers/intercom/package.json new file mode 100644 index 0000000..f45d162 --- /dev/null +++ b/servers/intercom/package.json @@ -0,0 +1,32 @@ +{ + "name": "@mcpengine/intercom", + "version": "1.0.0", + "description": "MCP server for Intercom API integration", + "type": "module", + "main": "dist/main.js", + "bin": { + "intercom-mcp": "dist/main.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/main.js", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "mcp", + "intercom", + "model-context-protocol" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/servers/intercom/src/clients/intercom.ts b/servers/intercom/src/clients/intercom.ts new file mode 100644 index 0000000..8ebe5cc --- /dev/null +++ b/servers/intercom/src/clients/intercom.ts @@ -0,0 +1,701 @@ +/** + * Intercom API Client + * API Version: 2.11 + * Base URL: https://api.intercom.io + */ + +import axios, { AxiosInstance, AxiosError } from 'axios'; +import type { + Contact, + Company, + Conversation, + Article, + Collection, + Section, + HelpCenter, + Ticket, + TicketType, + Tag, + Segment, + Admin, + Team, + Note, + DataAttribute, + Subscription, + ListResponse, + ScrollResponse, + SearchQuery, + CreateContactRequest, + UpdateContactRequest, + CreateCompanyRequest, + CreateConversationRequest, + ReplyConversationRequest, + CreateTicketRequest, + CreateNoteRequest, + Event, + Message, + ContactId, + CompanyId, + ConversationId, + ArticleId, + CollectionId, + SectionId, + TicketId, + TicketTypeId, + TagId, + SegmentId, + AdminId, + TeamId, + NoteId, +} from '../types/index.js'; + +export interface IntercomClientConfig { + accessToken: string; + baseURL?: string; + timeout?: number; +} + +export class IntercomClient { + private client: AxiosInstance; + private accessToken: string; + + constructor(config: IntercomClientConfig) { + this.accessToken = config.accessToken; + + this.client = axios.create({ + baseURL: config.baseURL || 'https://api.intercom.io', + timeout: config.timeout || 30000, + headers: { + 'Authorization': `Bearer ${this.accessToken}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Intercom-Version': '2.11', + }, + }); + + // Add response interceptor for rate limiting + this.client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status === 429) { + const retryAfter = error.response.headers['retry-after']; + const waitTime = retryAfter ? parseInt(retryAfter) * 1000 : 5000; + + console.warn(`Rate limit hit. Waiting ${waitTime}ms before retry...`); + await new Promise(resolve => setTimeout(resolve, waitTime)); + + // Retry the request + return this.client.request(error.config!); + } + throw error; + } + ); + } + + // ============================================================================ + // CONTACTS + // ============================================================================ + + async createContact(data: CreateContactRequest): Promise { + const response = await this.client.post('/contacts', data); + return response.data; + } + + async getContact(id: ContactId): Promise { + const response = await this.client.get(`/contacts/${id}`); + return response.data; + } + + async updateContact(id: ContactId, data: UpdateContactRequest): Promise { + const response = await this.client.put(`/contacts/${id}`, data); + return response.data; + } + + async deleteContact(id: ContactId): Promise<{ id: ContactId; deleted: boolean }> { + const response = await this.client.delete(`/contacts/${id}`); + return response.data; + } + + async listContacts(params?: { + per_page?: number; + starting_after?: string; + }): Promise> { + const response = await this.client.get>('/contacts', { + params, + }); + return response.data; + } + + async searchContacts(query: SearchQuery): Promise> { + const response = await this.client.post>( + '/contacts/search', + query + ); + return response.data; + } + + async scrollContacts(scrollParam?: string): Promise> { + const response = await this.client.get>('/contacts/scroll', { + params: scrollParam ? { scroll_param: scrollParam } : undefined, + }); + return response.data; + } + + async mergeContacts(from: ContactId, into: ContactId): Promise { + const response = await this.client.post('/contacts/merge', { + from, + into, + }); + return response.data; + } + + async archiveContact(id: ContactId): Promise { + const response = await this.client.post(`/contacts/${id}/archive`); + return response.data; + } + + async unarchiveContact(id: ContactId): Promise { + const response = await this.client.post(`/contacts/${id}/unarchive`); + return response.data; + } + + // ============================================================================ + // COMPANIES + // ============================================================================ + + async createCompany(data: CreateCompanyRequest): Promise { + const response = await this.client.post('/companies', data); + return response.data; + } + + async getCompany(id: CompanyId): Promise { + const response = await this.client.get(`/companies/${id}`); + return response.data; + } + + async updateCompany(id: CompanyId, data: Partial): Promise { + const response = await this.client.put(`/companies/${id}`, data); + return response.data; + } + + async listCompanies(params?: { + per_page?: number; + starting_after?: string; + }): Promise> { + const response = await this.client.get>('/companies', { + params, + }); + return response.data; + } + + async scrollCompanies(scrollParam?: string): Promise> { + const response = await this.client.get>('/companies/scroll', { + params: scrollParam ? { scroll_param: scrollParam } : undefined, + }); + return response.data; + } + + async attachContactToCompany(contactId: ContactId, companyId: CompanyId): Promise { + const response = await this.client.post( + `/contacts/${contactId}/companies`, + { id: companyId } + ); + return response.data; + } + + async detachContactFromCompany(contactId: ContactId, companyId: CompanyId): Promise { + const response = await this.client.delete( + `/contacts/${contactId}/companies/${companyId}` + ); + return response.data; + } + + // ============================================================================ + // CONVERSATIONS + // ============================================================================ + + async createConversation(data: CreateConversationRequest): Promise { + const response = await this.client.post('/conversations', data); + return response.data; + } + + async getConversation(id: ConversationId): Promise { + const response = await this.client.get(`/conversations/${id}`); + return response.data; + } + + async listConversations(params?: { + per_page?: number; + starting_after?: string; + }): Promise> { + const response = await this.client.get>('/conversations', { + params, + }); + return response.data; + } + + async searchConversations(query: SearchQuery): Promise> { + const response = await this.client.post>( + '/conversations/search', + query + ); + return response.data; + } + + async replyToConversation( + id: ConversationId, + data: ReplyConversationRequest + ): Promise { + const response = await this.client.post( + `/conversations/${id}/reply`, + data + ); + return response.data; + } + + async assignConversation( + id: ConversationId, + assignee: { type: 'admin' | 'team'; id: AdminId | TeamId; admin_id?: AdminId } + ): Promise { + const response = await this.client.post( + `/conversations/${id}/parts`, + { + message_type: 'assignment', + ...assignee, + } + ); + return response.data; + } + + async snoozeConversation( + id: ConversationId, + snoozedUntil: number + ): Promise { + const response = await this.client.post( + `/conversations/${id}/parts`, + { + message_type: 'snoozed', + type: 'admin', + snoozed_until: snoozedUntil, + } + ); + return response.data; + } + + async closeConversation(id: ConversationId, adminId: AdminId): Promise { + const response = await this.client.post( + `/conversations/${id}/parts`, + { + message_type: 'close', + type: 'admin', + admin_id: adminId, + } + ); + return response.data; + } + + async openConversation(id: ConversationId, adminId: AdminId): Promise { + const response = await this.client.post( + `/conversations/${id}/parts`, + { + message_type: 'open', + type: 'admin', + admin_id: adminId, + } + ); + return response.data; + } + + async attachTagToConversation(id: ConversationId, tagId: TagId, adminId: AdminId): Promise { + const response = await this.client.post( + `/conversations/${id}/tags`, + { + id: tagId, + admin_id: adminId, + } + ); + return response.data; + } + + async detachTagFromConversation(id: ConversationId, tagId: TagId, adminId: AdminId): Promise { + const response = await this.client.delete( + `/conversations/${id}/tags/${tagId}`, + { + data: { admin_id: adminId }, + } + ); + return response.data; + } + + // ============================================================================ + // ARTICLES + // ============================================================================ + + async createArticle(data: { + title: string; + description?: string; + body?: string; + author_id: AdminId; + state?: 'published' | 'draft'; + parent_id?: CollectionId | SectionId; + parent_type?: 'collection' | 'section'; + }): Promise
{ + const response = await this.client.post
('/articles', data); + return response.data; + } + + async getArticle(id: ArticleId): Promise
{ + const response = await this.client.get
(`/articles/${id}`); + return response.data; + } + + async updateArticle(id: ArticleId, data: Partial<{ + title: string; + description: string; + body: string; + author_id: AdminId; + state: 'published' | 'draft'; + parent_id: CollectionId | SectionId; + parent_type: 'collection' | 'section'; + }>): Promise
{ + const response = await this.client.put
(`/articles/${id}`, data); + return response.data; + } + + async deleteArticle(id: ArticleId): Promise<{ id: ArticleId; deleted: boolean }> { + const response = await this.client.delete(`/articles/${id}`); + return response.data; + } + + async listArticles(params?: { + per_page?: number; + page?: number; + }): Promise> { + const response = await this.client.get>('/articles', { + params, + }); + return response.data; + } + + // ============================================================================ + // HELP CENTER + // ============================================================================ + + async listHelpCenters(): Promise> { + const response = await this.client.get>('/help_center/help_centers'); + return response.data; + } + + async getHelpCenter(id: string): Promise { + const response = await this.client.get(`/help_center/help_centers/${id}`); + return response.data; + } + + async listCollections(params?: { + per_page?: number; + page?: number; + }): Promise> { + const response = await this.client.get>( + '/help_center/collections', + { params } + ); + return response.data; + } + + async getCollection(id: CollectionId): Promise { + const response = await this.client.get( + `/help_center/collections/${id}` + ); + return response.data; + } + + async createCollection(data: { + name: string; + description?: string; + parent_id?: string; + }): Promise { + const response = await this.client.post( + '/help_center/collections', + data + ); + return response.data; + } + + async updateCollection( + id: CollectionId, + data: Partial<{ name: string; description: string }> + ): Promise { + const response = await this.client.put( + `/help_center/collections/${id}`, + data + ); + return response.data; + } + + async deleteCollection(id: CollectionId): Promise<{ id: CollectionId; deleted: boolean }> { + const response = await this.client.delete(`/help_center/collections/${id}`); + return response.data; + } + + async listSections(collectionId: CollectionId): Promise> { + const response = await this.client.get>( + `/help_center/collections/${collectionId}/sections` + ); + return response.data; + } + + async getSection(id: SectionId): Promise
{ + const response = await this.client.get
(`/help_center/sections/${id}`); + return response.data; + } + + async createSection(collectionId: CollectionId, data: { + name: string; + }): Promise
{ + const response = await this.client.post
( + `/help_center/collections/${collectionId}/sections`, + data + ); + return response.data; + } + + // ============================================================================ + // TICKETS + // ============================================================================ + + async createTicket(data: CreateTicketRequest): Promise { + const response = await this.client.post('/tickets', data); + return response.data; + } + + async getTicket(id: TicketId): Promise { + const response = await this.client.get(`/tickets/${id}`); + return response.data; + } + + async updateTicket(id: TicketId, data: Partial): Promise { + const response = await this.client.put(`/tickets/${id}`, data); + return response.data; + } + + async listTickets(params?: { + per_page?: number; + page?: number; + }): Promise> { + const response = await this.client.get>('/tickets', { + params, + }); + return response.data; + } + + async searchTickets(query: SearchQuery): Promise> { + const response = await this.client.post>( + '/tickets/search', + query + ); + return response.data; + } + + async listTicketTypes(): Promise> { + const response = await this.client.get>('/ticket_types'); + return response.data; + } + + async getTicketType(id: TicketTypeId): Promise { + const response = await this.client.get(`/ticket_types/${id}`); + return response.data; + } + + // ============================================================================ + // TAGS + // ============================================================================ + + async createTag(name: string): Promise { + const response = await this.client.post('/tags', { name }); + return response.data; + } + + async getTag(id: TagId): Promise { + const response = await this.client.get(`/tags/${id}`); + return response.data; + } + + async listTags(): Promise> { + const response = await this.client.get>('/tags'); + return response.data; + } + + async deleteTag(id: TagId): Promise<{ id: TagId; deleted: boolean }> { + const response = await this.client.delete(`/tags/${id}`); + return response.data; + } + + async tagContact(contactId: ContactId, tagId: TagId): Promise { + const response = await this.client.post(`/contacts/${contactId}/tags`, { + id: tagId, + }); + return response.data; + } + + async untagContact(contactId: ContactId, tagId: TagId): Promise { + const response = await this.client.delete( + `/contacts/${contactId}/tags/${tagId}` + ); + return response.data; + } + + async tagCompany(companyId: CompanyId, tagId: TagId): Promise { + const response = await this.client.post(`/companies/${companyId}/tags`, { + id: tagId, + }); + return response.data; + } + + async untagCompany(companyId: CompanyId, tagId: TagId): Promise { + const response = await this.client.delete( + `/companies/${companyId}/tags/${tagId}` + ); + return response.data; + } + + // ============================================================================ + // SEGMENTS + // ============================================================================ + + async listSegments(params?: { + include_count?: boolean; + }): Promise> { + const response = await this.client.get>('/segments', { + params, + }); + return response.data; + } + + async getSegment(id: SegmentId): Promise { + const response = await this.client.get(`/segments/${id}`); + return response.data; + } + + // ============================================================================ + // EVENTS + // ============================================================================ + + async submitEvent(event: Event): Promise<{ type: 'event'; success: boolean }> { + const response = await this.client.post<{ type: 'event'; success: boolean }>( + '/events', + event + ); + return response.data; + } + + async listEventSummaries(params: { + user_id?: string; + email?: string; + type?: 'user' | 'company'; + count?: number; + }): Promise<{ type: 'event.summary'; events: Array<{ name: string; count: number; last: number }> }> { + const response = await this.client.get('/events/summaries', { params }); + return response.data; + } + + // ============================================================================ + // MESSAGES + // ============================================================================ + + async sendMessage(message: Message): Promise<{ type: 'message'; id: string }> { + const response = await this.client.post<{ type: 'message'; id: string }>( + '/messages', + message + ); + return response.data; + } + + // ============================================================================ + // ADMINS & TEAMS + // ============================================================================ + + async listAdmins(): Promise> { + const response = await this.client.get>('/admins'); + return response.data; + } + + async getAdmin(id: AdminId): Promise { + const response = await this.client.get(`/admins/${id}`); + return response.data; + } + + async listTeams(): Promise> { + const response = await this.client.get>('/teams'); + return response.data; + } + + async getTeam(id: TeamId): Promise { + const response = await this.client.get(`/teams/${id}`); + return response.data; + } + + // ============================================================================ + // NOTES + // ============================================================================ + + async createNote(data: CreateNoteRequest): Promise { + const response = await this.client.post('/notes', data); + return response.data; + } + + async getNote(id: NoteId): Promise { + const response = await this.client.get(`/notes/${id}`); + return response.data; + } + + async listNotes(contactId: ContactId): Promise> { + const response = await this.client.get>( + `/contacts/${contactId}/notes` + ); + return response.data; + } + + // ============================================================================ + // DATA ATTRIBUTES + // ============================================================================ + + async listDataAttributes(params?: { + model?: 'contact' | 'company' | 'conversation'; + include_archived?: boolean; + }): Promise> { + const response = await this.client.get>( + '/data_attributes', + { params } + ); + return response.data; + } + + async createDataAttribute(data: { + name: string; + model: 'contact' | 'company' | 'conversation'; + data_type: string; + description?: string; + options?: string[]; + }): Promise { + const response = await this.client.post('/data_attributes', data); + return response.data; + } + + async updateDataAttribute(id: string, data: { + archived?: boolean; + description?: string; + options?: string[]; + }): Promise { + const response = await this.client.put(`/data_attributes/${id}`, data); + return response.data; + } + + // ============================================================================ + // SUBSCRIPTIONS + // ============================================================================ + + async listSubscriptions(): Promise> { + const response = await this.client.get>('/subscription_types'); + return response.data; + } +} diff --git a/servers/intercom/src/main.ts b/servers/intercom/src/main.ts new file mode 100644 index 0000000..f993c79 --- /dev/null +++ b/servers/intercom/src/main.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +/** + * Intercom MCP Server Main Entry Point + * Supports dual transport (stdio/SSE) with graceful shutdown + */ + +import { IntercomMCPServer } from './server.js'; + +async function main() { + const accessToken = process.env.INTERCOM_ACCESS_TOKEN; + + if (!accessToken) { + console.error('Error: INTERCOM_ACCESS_TOKEN environment variable is required'); + process.exit(1); + } + + const server = new IntercomMCPServer(accessToken); + + // Graceful shutdown handlers + const shutdown = async (signal: string) => { + console.error(`\nReceived ${signal}, shutting down gracefully...`); + process.exit(0); + }; + + process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGTERM', () => shutdown('SIGTERM')); + + // 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 the server + try { + await server.run(); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +main(); diff --git a/servers/intercom/src/server.ts b/servers/intercom/src/server.ts new file mode 100644 index 0000000..f933e7b --- /dev/null +++ b/servers/intercom/src/server.ts @@ -0,0 +1,757 @@ +/** + * Intercom MCP Server + * Lazy-loaded tools for Intercom API integration + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { IntercomClient } from './clients/intercom.js'; +import { z } from 'zod'; + +export class IntercomMCPServer { + private server: Server; + private client: IntercomClient; + private toolsMap: Map; + + constructor(accessToken: string) { + this.client = new IntercomClient({ accessToken }); + this.server = new Server( + { + name: '@mcpengine/intercom', + version: '1.0.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.toolsMap = new Map(); + this.setupHandlers(); + this.registerTools(); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: Array.from(this.toolsMap.values()), + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + const result = await this.executeTool(name, args || {}); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: 'text', + text: `Error: ${errorMessage}`, + }, + ], + isError: true, + }; + } + }); + } + + private registerTools(): void { + // Contacts + this.toolsMap.set('contacts_create', { + name: 'contacts_create', + description: 'Create a new contact (user or lead) in Intercom', + inputSchema: { + type: 'object', + properties: { + role: { type: 'string', enum: ['user', 'lead'], description: 'Contact role' }, + external_id: { type: 'string', description: 'External unique identifier' }, + email: { type: 'string', description: 'Email address' }, + phone: { type: 'string', description: 'Phone number' }, + name: { type: 'string', description: 'Full name' }, + signed_up_at: { type: 'number', description: 'Unix timestamp of signup' }, + custom_attributes: { type: 'object', description: 'Custom attributes object' }, + }, + }, + }); + + this.toolsMap.set('contacts_get', { + name: 'contacts_get', + description: 'Retrieve a contact by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Contact ID' }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('contacts_update', { + name: 'contacts_update', + description: 'Update a contact', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Contact ID' }, + email: { type: 'string' }, + name: { type: 'string' }, + phone: { type: 'string' }, + custom_attributes: { type: 'object' }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('contacts_delete', { + name: 'contacts_delete', + description: 'Delete a contact permanently', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Contact ID' }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('contacts_list', { + name: 'contacts_list', + description: 'List all contacts with cursor pagination', + inputSchema: { + type: 'object', + properties: { + per_page: { type: 'number', description: 'Results per page (max 150)' }, + starting_after: { type: 'string', description: 'Cursor for pagination' }, + }, + }, + }); + + this.toolsMap.set('contacts_search', { + name: 'contacts_search', + description: 'Search contacts using filters', + inputSchema: { + type: 'object', + properties: { + query: { type: 'object', description: 'Search filter object' }, + pagination: { type: 'object', description: 'Pagination params' }, + sort: { type: 'object', description: 'Sort configuration' }, + }, + }, + }); + + // Conversations + this.toolsMap.set('conversations_create', { + name: 'conversations_create', + description: 'Create a new conversation', + inputSchema: { + type: 'object', + properties: { + from: { + type: 'object', + description: 'Sender info (type, id/user_id/email)', + properties: { + type: { type: 'string', enum: ['user', 'lead', 'contact'] }, + id: { type: 'string' }, + user_id: { type: 'string' }, + email: { type: 'string' }, + }, + required: ['type'], + }, + body: { type: 'string', description: 'Message body' }, + }, + required: ['from', 'body'], + }, + }); + + this.toolsMap.set('conversations_get', { + name: 'conversations_get', + description: 'Retrieve a conversation by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Conversation ID' }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('conversations_list', { + name: 'conversations_list', + description: 'List conversations', + inputSchema: { + type: 'object', + properties: { + per_page: { type: 'number' }, + starting_after: { type: 'string' }, + }, + }, + }); + + this.toolsMap.set('conversations_search', { + name: 'conversations_search', + description: 'Search conversations using filters', + inputSchema: { + type: 'object', + properties: { + query: { type: 'object', description: 'Search filter' }, + pagination: { type: 'object' }, + }, + }, + }); + + this.toolsMap.set('conversations_reply', { + name: 'conversations_reply', + description: 'Reply to a conversation', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Conversation ID' }, + message_type: { type: 'string', enum: ['comment', 'note'] }, + type: { type: 'string', enum: ['admin', 'user'] }, + admin_id: { type: 'string', description: 'Admin ID (if type=admin)' }, + body: { type: 'string', description: 'Reply body' }, + attachment_urls: { type: 'array', items: { type: 'string' } }, + }, + required: ['id', 'message_type', 'type', 'body'], + }, + }); + + this.toolsMap.set('conversations_close', { + name: 'conversations_close', + description: 'Close a conversation', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Conversation ID' }, + admin_id: { type: 'string', description: 'Admin ID' }, + }, + required: ['id', 'admin_id'], + }, + }); + + this.toolsMap.set('conversations_assign', { + name: 'conversations_assign', + description: 'Assign a conversation to an admin or team', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Conversation ID' }, + assignee_type: { type: 'string', enum: ['admin', 'team'] }, + assignee_id: { type: 'string', description: 'Admin or Team ID' }, + admin_id: { type: 'string', description: 'Admin making the assignment' }, + }, + required: ['id', 'assignee_type', 'assignee_id'], + }, + }); + + // Companies + this.toolsMap.set('companies_create', { + name: 'companies_create', + description: 'Create a new company', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Company name' }, + company_id: { type: 'string', description: 'Unique company ID' }, + website: { type: 'string' }, + plan: { type: 'string' }, + size: { type: 'number' }, + industry: { type: 'string' }, + monthly_spend: { type: 'number' }, + custom_attributes: { type: 'object' }, + }, + required: ['name'], + }, + }); + + this.toolsMap.set('companies_get', { + name: 'companies_get', + description: 'Retrieve a company by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Company ID' }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('companies_list', { + name: 'companies_list', + description: 'List companies', + inputSchema: { + type: 'object', + properties: { + per_page: { type: 'number' }, + starting_after: { type: 'string' }, + }, + }, + }); + + this.toolsMap.set('companies_update', { + name: 'companies_update', + description: 'Update a company', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Company ID' }, + name: { type: 'string' }, + website: { type: 'string' }, + plan: { type: 'string' }, + size: { type: 'number' }, + monthly_spend: { type: 'number' }, + custom_attributes: { type: 'object' }, + }, + required: ['id'], + }, + }); + + // Articles + this.toolsMap.set('articles_create', { + name: 'articles_create', + description: 'Create a help center article', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Article title' }, + description: { type: 'string' }, + body: { type: 'string', description: 'Article body (HTML or markdown)' }, + author_id: { type: 'string', description: 'Admin ID' }, + state: { type: 'string', enum: ['published', 'draft'] }, + parent_id: { type: 'string', description: 'Collection or Section ID' }, + parent_type: { type: 'string', enum: ['collection', 'section'] }, + }, + required: ['title', 'author_id'], + }, + }); + + this.toolsMap.set('articles_get', { + name: 'articles_get', + description: 'Retrieve an article by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Article ID' }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('articles_list', { + name: 'articles_list', + description: 'List all articles', + inputSchema: { + type: 'object', + properties: { + per_page: { type: 'number' }, + page: { type: 'number' }, + }, + }, + }); + + this.toolsMap.set('articles_update', { + name: 'articles_update', + description: 'Update an article', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Article ID' }, + title: { type: 'string' }, + body: { type: 'string' }, + state: { type: 'string', enum: ['published', 'draft'] }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('articles_delete', { + name: 'articles_delete', + description: 'Delete an article', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Article ID' }, + }, + required: ['id'], + }, + }); + + // Help Center + this.toolsMap.set('help-center_list', { + name: 'help-center_list', + description: 'List all help centers', + inputSchema: { + type: 'object', + properties: {}, + }, + }); + + this.toolsMap.set('help-center_collections_list', { + name: 'help-center_collections_list', + description: 'List help center collections', + inputSchema: { + type: 'object', + properties: { + per_page: { type: 'number' }, + page: { type: 'number' }, + }, + }, + }); + + this.toolsMap.set('help-center_collections_create', { + name: 'help-center_collections_create', + description: 'Create a collection', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Collection name' }, + description: { type: 'string' }, + }, + required: ['name'], + }, + }); + + // Tickets + this.toolsMap.set('tickets_create', { + name: 'tickets_create', + description: 'Create a ticket', + inputSchema: { + type: 'object', + properties: { + ticket_type_id: { type: 'string', description: 'Ticket type ID' }, + contacts: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + external_id: { type: 'string' }, + email: { type: 'string' }, + }, + }, + }, + ticket_attributes: { type: 'object', description: 'Ticket custom attributes' }, + }, + required: ['ticket_type_id'], + }, + }); + + this.toolsMap.set('tickets_get', { + name: 'tickets_get', + description: 'Retrieve a ticket by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Ticket ID' }, + }, + required: ['id'], + }, + }); + + this.toolsMap.set('tickets_list', { + name: 'tickets_list', + description: 'List tickets', + inputSchema: { + type: 'object', + properties: { + per_page: { type: 'number' }, + page: { type: 'number' }, + }, + }, + }); + + this.toolsMap.set('tickets_search', { + name: 'tickets_search', + description: 'Search tickets', + inputSchema: { + type: 'object', + properties: { + query: { type: 'object' }, + }, + }, + }); + + this.toolsMap.set('tickets_types_list', { + name: 'tickets_types_list', + description: 'List all ticket types', + inputSchema: { + type: 'object', + properties: {}, + }, + }); + + // Tags + this.toolsMap.set('tags_create', { + name: 'tags_create', + description: 'Create a tag', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Tag name' }, + }, + required: ['name'], + }, + }); + + this.toolsMap.set('tags_list', { + name: 'tags_list', + description: 'List all tags', + inputSchema: { + type: 'object', + properties: {}, + }, + }); + + this.toolsMap.set('tags_delete', { + name: 'tags_delete', + description: 'Delete a tag', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Tag ID' }, + }, + required: ['id'], + }, + }); + + // Segments + this.toolsMap.set('segments_list', { + name: 'segments_list', + description: 'List all segments', + inputSchema: { + type: 'object', + properties: { + include_count: { type: 'boolean', description: 'Include member count' }, + }, + }, + }); + + this.toolsMap.set('segments_get', { + name: 'segments_get', + description: 'Retrieve a segment by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Segment ID' }, + }, + required: ['id'], + }, + }); + + // Events + this.toolsMap.set('events_submit', { + name: 'events_submit', + description: 'Submit an event', + inputSchema: { + type: 'object', + properties: { + event_name: { type: 'string', description: 'Event name' }, + created_at: { type: 'number', description: 'Unix timestamp' }, + user_id: { type: 'string' }, + email: { type: 'string' }, + metadata: { type: 'object', description: 'Event metadata' }, + }, + required: ['event_name'], + }, + }); + + // Messages + this.toolsMap.set('messages_send', { + name: 'messages_send', + description: 'Send a message (email, in-app, push)', + inputSchema: { + type: 'object', + properties: { + message_type: { type: 'string', enum: ['inapp', 'email', 'push'] }, + subject: { type: 'string' }, + body: { type: 'string' }, + from: { type: 'object', description: 'Sender (admin)' }, + to: { type: 'object', description: 'Recipient (contact/user/lead)' }, + }, + required: ['message_type', 'body'], + }, + }); + + // Teams + this.toolsMap.set('teams_list', { + name: 'teams_list', + description: 'List all teams', + inputSchema: { + type: 'object', + properties: {}, + }, + }); + + this.toolsMap.set('teams_get', { + name: 'teams_get', + description: 'Retrieve a team by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Team ID' }, + }, + required: ['id'], + }, + }); + + // Admins + this.toolsMap.set('admins_list', { + name: 'admins_list', + description: 'List all admins', + inputSchema: { + type: 'object', + properties: {}, + }, + }); + + this.toolsMap.set('admins_get', { + name: 'admins_get', + description: 'Retrieve an admin by ID', + inputSchema: { + type: 'object', + properties: { + id: { type: 'string', description: 'Admin ID' }, + }, + required: ['id'], + }, + }); + } + + private async executeTool(name: string, args: Record): Promise { + switch (name) { + // Contacts + case 'contacts_create': + return this.client.createContact(args as any); + case 'contacts_get': + return this.client.getContact(args.id as any); + case 'contacts_update': + return this.client.updateContact(args.id as any, args as any); + case 'contacts_delete': + return this.client.deleteContact(args.id as any); + case 'contacts_list': + return this.client.listContacts(args as any); + case 'contacts_search': + return this.client.searchContacts(args as any); + + // Conversations + case 'conversations_create': + return this.client.createConversation(args as any); + case 'conversations_get': + return this.client.getConversation(args.id as any); + case 'conversations_list': + return this.client.listConversations(args as any); + case 'conversations_search': + return this.client.searchConversations(args as any); + case 'conversations_reply': + return this.client.replyToConversation(args.id as any, args as any); + case 'conversations_close': + return this.client.closeConversation(args.id as any, args.admin_id as any); + case 'conversations_assign': + return this.client.assignConversation(args.id as any, { + type: args.assignee_type as any, + id: args.assignee_id as any, + admin_id: args.admin_id as any, + }); + + // Companies + case 'companies_create': + return this.client.createCompany(args as any); + case 'companies_get': + return this.client.getCompany(args.id as any); + case 'companies_list': + return this.client.listCompanies(args as any); + case 'companies_update': + return this.client.updateCompany(args.id as any, args as any); + + // Articles + case 'articles_create': + return this.client.createArticle(args as any); + case 'articles_get': + return this.client.getArticle(args.id as any); + case 'articles_list': + return this.client.listArticles(args as any); + case 'articles_update': + return this.client.updateArticle(args.id as any, args as any); + case 'articles_delete': + return this.client.deleteArticle(args.id as any); + + // Help Center + case 'help-center_list': + return this.client.listHelpCenters(); + case 'help-center_collections_list': + return this.client.listCollections(args as any); + case 'help-center_collections_create': + return this.client.createCollection(args as any); + + // Tickets + case 'tickets_create': + return this.client.createTicket(args as any); + case 'tickets_get': + return this.client.getTicket(args.id as any); + case 'tickets_list': + return this.client.listTickets(args as any); + case 'tickets_search': + return this.client.searchTickets(args as any); + case 'tickets_types_list': + return this.client.listTicketTypes(); + + // Tags + case 'tags_create': + return this.client.createTag(args.name as string); + case 'tags_list': + return this.client.listTags(); + case 'tags_delete': + return this.client.deleteTag(args.id as any); + + // Segments + case 'segments_list': + return this.client.listSegments(args as any); + case 'segments_get': + return this.client.getSegment(args.id as any); + + // Events + case 'events_submit': + return this.client.submitEvent(args as any); + + // Messages + case 'messages_send': + return this.client.sendMessage(args as any); + + // Teams + case 'teams_list': + return this.client.listTeams(); + case 'teams_get': + return this.client.getTeam(args.id as any); + + // Admins + case 'admins_list': + return this.client.listAdmins(); + case 'admins_get': + return this.client.getAdmin(args.id as any); + + default: + throw new Error(`Unknown tool: ${name}`); + } + } + + async run(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Intercom MCP Server running on stdio'); + } +} diff --git a/servers/intercom/src/types/index.ts b/servers/intercom/src/types/index.ts new file mode 100644 index 0000000..50f0ccf --- /dev/null +++ b/servers/intercom/src/types/index.ts @@ -0,0 +1,737 @@ +/** + * Intercom API Types + * Based on Intercom API v2.11 + */ + +// Branded types for type safety +export type ContactId = string & { readonly __brand: 'ContactId' }; +export type CompanyId = string & { readonly __brand: 'CompanyId' }; +export type ConversationId = string & { readonly __brand: 'ConversationId' }; +export type AdminId = string & { readonly __brand: 'AdminId' }; +export type TeamId = string & { readonly __brand: 'TeamId' }; +export type ArticleId = string & { readonly __brand: 'ArticleId' }; +export type CollectionId = string & { readonly __brand: 'CollectionId' }; +export type SectionId = string & { readonly __brand: 'SectionId' }; +export type TicketId = string & { readonly __brand: 'TicketId' }; +export type TicketTypeId = string & { readonly __brand: 'TicketTypeId' }; +export type TagId = string & { readonly __brand: 'TagId' }; +export type SegmentId = string & { readonly __brand: 'SegmentId' }; +export type NoteId = string & { readonly __brand: 'NoteId' }; + +// Common types +export interface Timestamp { + created_at: number; + updated_at: number; +} + +export interface Pagination { + type: 'pages'; + page: number; + per_page: number; + total_pages: number; +} + +export interface CursorPagination { + type: 'pages'; + next?: { + page: number; + starting_after: string; + }; + per_page: number; + total_pages?: number; +} + +export interface ListResponse { + type: 'list'; + data: T[]; + pages?: CursorPagination; + total_count?: number; +} + +export interface ScrollResponse { + type: 'list'; + data: T[]; + scroll_param?: string; + pages?: { + type: 'pages'; + next?: string; + }; +} + +// Search/Filter types +export type SearchOperator = + | '=' | '!=' | 'IN' | 'NIN' + | '>' | '<' | '>=' | '<=' + | '~' | '!~' | '^' | '$'; + +export interface SingleFilter { + field: string; + operator: SearchOperator; + value: string | number | boolean | string[]; +} + +export interface MultipleFilter { + operator: 'AND' | 'OR'; + value: Array; +} + +export type SearchFilter = SingleFilter | MultipleFilter; + +export interface SearchQuery { + query?: SearchFilter; + pagination?: { + per_page?: number; + starting_after?: string; + }; + sort?: { + field: string; + order: 'asc' | 'desc'; + }; +} + +// Contact types +export interface ContactLocation { + type: 'location'; + country?: string; + region?: string; + city?: string; +} + +export interface ContactSocialProfile { + type: 'social_profile'; + name: string; + url: string; +} + +export interface ContactAvatar { + type: 'avatar'; + image_url?: string; +} + +export interface ContactCompany { + type: 'company'; + id: CompanyId; + name?: string; + url?: string; +} + +export interface CustomAttributes { + [key: string]: string | number | boolean | null; +} + +export interface Contact extends Timestamp { + type: 'contact'; + id: ContactId; + workspace_id: string; + external_id?: string; + role: 'user' | 'lead'; + email?: string; + phone?: string; + name?: string; + avatar?: ContactAvatar; + owner_id?: AdminId; + social_profiles?: { + type: 'list'; + data: ContactSocialProfile[]; + }; + has_hard_bounced: boolean; + marked_email_as_spam: boolean; + unsubscribed_from_emails: boolean; + location?: ContactLocation; + last_seen_at?: number; + last_replied_at?: number; + last_contacted_at?: number; + last_email_opened_at?: number; + last_email_clicked_at?: number; + language_override?: string; + browser?: string; + browser_version?: string; + browser_language?: string; + os?: string; + android_app_name?: string; + android_app_version?: string; + android_device?: string; + android_os_version?: string; + android_sdk_version?: string; + android_last_seen_at?: number; + ios_app_name?: string; + ios_app_version?: string; + ios_device?: string; + ios_os_version?: string; + ios_sdk_version?: string; + ios_last_seen_at?: number; + custom_attributes?: CustomAttributes; + tags?: { + type: 'list'; + data: Tag[]; + url: string; + total_count: number; + has_more: boolean; + }; + notes?: { + type: 'list'; + data: Note[]; + url: string; + total_count: number; + has_more: boolean; + }; + companies?: { + type: 'list'; + data: ContactCompany[]; + url: string; + total_count: number; + has_more: boolean; + }; +} + +// Company types +export interface Company extends Timestamp { + type: 'company'; + id: CompanyId; + name: string; + company_id?: string; + remote_created_at?: number; + plan?: string; + size?: number; + website?: string; + industry?: string; + monthly_spend?: number; + session_count?: number; + user_count?: number; + custom_attributes?: CustomAttributes; + tags?: { + type: 'list'; + data: Tag[]; + url: string; + total_count: number; + has_more: boolean; + }; + segments?: { + type: 'list'; + data: Segment[]; + url: string; + total_count: number; + has_more: boolean; + }; +} + +// Conversation types +export interface ConversationSource { + type: 'conversation'; + id: ConversationId; + delivered_as: 'operator_initiated' | 'automated' | 'admin_initiated'; + subject?: string; + body?: string; + author: { + type: 'admin' | 'user' | 'lead' | 'bot'; + id: string; + name?: string; + email?: string; + }; + attachments?: Array<{ + type: 'upload'; + name: string; + url: string; + content_type: string; + filesize: number; + width?: number; + height?: number; + }>; + url?: string; +} + +export interface ConversationPart extends Timestamp { + type: 'conversation_part'; + id: string; + part_type: 'comment' | 'note' | 'assignment' | 'open' | 'close' | 'snooze'; + body?: string; + author: { + type: 'admin' | 'user' | 'lead' | 'bot'; + id: string; + name?: string; + email?: string; + }; + attachments?: Array<{ + type: 'upload'; + name: string; + url: string; + content_type: string; + filesize: number; + }>; + notified_at?: number; + assigned_to?: { + type: 'admin' | 'team'; + id: string; + }; + external_id?: string; + redacted: boolean; +} + +export interface ConversationStatistics { + type: 'conversation_statistics'; + time_to_assignment?: number; + time_to_admin_reply?: number; + time_to_first_close?: number; + time_to_last_close?: number; + median_time_to_reply?: number; + first_contact_reply_at?: number; + first_assignment_at?: number; + first_admin_reply_at?: number; + first_close_at?: number; + last_assignment_at?: number; + last_assignment_admin_reply_at?: number; + last_contact_reply_at?: number; + last_admin_reply_at?: number; + last_close_at?: number; + last_closed_by_id?: string; + count_reopens?: number; + count_assignments?: number; + count_conversation_parts?: number; +} + +export interface Conversation extends Timestamp { + type: 'conversation'; + id: ConversationId; + title?: string; + state: 'open' | 'closed' | 'snoozed'; + read: boolean; + priority: 'priority' | 'not_priority'; + waiting_since?: number; + snoozed_until?: number; + open: boolean; + source: ConversationSource; + contacts?: { + type: 'contact.list'; + contacts: Contact[]; + }; + teammates?: { + type: 'admin.list'; + admins: Admin[]; + }; + assignee?: { + type: 'admin' | 'team' | 'nobody'; + id?: string; + }; + conversation_parts?: { + type: 'conversation_part.list'; + conversation_parts: ConversationPart[]; + total_count: number; + }; + conversation_rating?: { + rating?: number; + remark?: string; + created_at?: number; + contact?: { + type: 'contact'; + id: ContactId; + }; + teammate?: { + type: 'admin'; + id: AdminId; + }; + }; + statistics?: ConversationStatistics; + tags?: { + type: 'tag.list'; + tags: Tag[]; + }; + linked_objects?: { + type: 'list'; + data: Array<{ + id: string; + category?: string; + }>; + total_count: number; + has_more: boolean; + }; +} + +// Article & Help Center types +export interface Article extends Timestamp { + type: 'article'; + id: ArticleId; + workspace_id: string; + title: string; + description?: string; + body?: string; + author_id: AdminId; + state: 'published' | 'draft'; + parent_id?: CollectionId | SectionId; + parent_type?: 'collection' | 'section'; + default_locale: string; + url?: string; + statistics?: { + type: 'article_statistics'; + views: number; + conversions: number; + reactions: number; + happy_reaction_count: number; + neutral_reaction_count: number; + sad_reaction_count: number; + }; +} + +export interface Collection extends Timestamp { + type: 'collection'; + id: CollectionId; + workspace_id: string; + name: string; + description?: string; + url?: string; + order?: number; + default_locale: string; + icon?: string; + parent_id?: string; + help_center_id?: number; +} + +export interface Section extends Timestamp { + type: 'section'; + id: SectionId; + workspace_id: string; + name: string; + parent_id: CollectionId; + url?: string; + order?: number; +} + +export interface HelpCenter extends Timestamp { + type: 'help_center'; + id: string; + workspace_id: string; + identifier: string; + website_turned_on: boolean; + display_name?: string; + website_url?: string; +} + +// Ticket types +export interface TicketTypeAttribute { + type: 'ticket_type_attribute'; + id: string; + workspace_id: string; + name: string; + description?: string; + data_type: 'string' | 'integer' | 'decimal' | 'boolean' | 'date' | 'datetime' | 'files'; + input_options?: { + multiline?: boolean; + list?: string[]; + }; + order: number; + required_to_create: boolean; + required_to_create_for_contacts: boolean; + visible_on_create: boolean; + visible_to_contacts: boolean; + default: boolean; + ticket_type_id: TicketTypeId; + archived: boolean; + created_at: number; + updated_at: number; +} + +export interface TicketType extends Timestamp { + type: 'ticket_type'; + id: TicketTypeId; + workspace_id: string; + name: string; + description?: string; + icon?: string; + archived: boolean; + ticket_type_attributes?: { + type: 'list'; + ticket_type_attributes: TicketTypeAttribute[]; + }; +} + +export interface TicketState { + type: 'ticket_state'; + id: string; + name: string; + category: 'submitted' | 'in_progress' | 'waiting_on_customer' | 'resolved'; +} + +export interface Ticket extends Timestamp { + type: 'ticket'; + id: TicketId; + ticket_type_id: TicketTypeId; + contacts?: { + type: 'contact.list'; + contacts: Contact[]; + }; + admin_assignee_id?: AdminId; + team_assignee_id?: TeamId; + ticket_state: TicketState; + ticket_attributes?: { + [key: string]: string | number | boolean | string[] | null; + }; + linked_objects?: { + type: 'list'; + data: Array<{ + id: string; + category?: string; + }>; + total_count: number; + }; + is_shared: boolean; + category?: 'ticket' | 'back_office_ticket' | 'tracker'; +} + +// Tag types +export interface Tag { + type: 'tag'; + id: TagId; + name: string; + applied_at?: number; + applied_by?: { + type: 'admin'; + id: AdminId; + }; +} + +// Segment types +export interface Segment { + type: 'segment'; + id: SegmentId; + name: string; + created_at: number; + updated_at: number; + person_type: 'contact' | 'user' | 'lead'; + count?: number; +} + +// Event types +export interface DataEvent { + type: 'event'; + id: string; + event_name: string; + created_at: number; + user_id?: string; + email?: string; + metadata?: { + [key: string]: string | number | boolean; + }; +} + +export interface Event { + event_name: string; + created_at?: number; + user_id?: string; + id?: string; + email?: string; + metadata?: { + [key: string]: string | number | boolean | null; + }; +} + +// Message types +export type MessageType = 'inapp' | 'email' | 'push'; + +export interface MessageContent { + type: 'text' | 'button'; + text?: string; + style?: 'primary' | 'secondary' | 'link'; + action?: { + type: 'url' | 'sheet'; + url?: string; + payload?: string; + }; +} + +export interface Message { + message_type: MessageType; + subject?: string; + body?: string; + template?: 'plain' | 'personal'; + from?: { + type: 'admin'; + id: AdminId; + }; + to?: { + type: 'contact' | 'user' | 'lead'; + id?: ContactId; + user_id?: string; + email?: string; + }; + create_conversation_without_contact_reply?: boolean; +} + +// Admin & Team types +export interface Admin { + type: 'admin'; + id: AdminId; + name: string; + email: string; + email_verified: boolean; + app?: { + type: 'app'; + id_code: string; + }; + avatar?: { + type: 'avatar'; + image_url?: string; + }; + away_mode_enabled: boolean; + away_mode_reassign: boolean; + has_inbox_seat: boolean; + team_ids?: TeamId[]; + team_priority_level?: { + [key: string]: number; + }; +} + +export interface Team { + type: 'team'; + id: TeamId; + name: string; + admin_ids?: AdminId[]; + admin_priority_level?: { + [key: string]: number; + }; +} + +// Note types +export interface Note extends Timestamp { + type: 'note'; + id: NoteId; + contact?: { + type: 'contact'; + id: ContactId; + }; + author?: { + type: 'admin'; + id: AdminId; + name?: string; + email?: string; + }; + body?: string; +} + +// Data Attribute types +export interface DataAttribute { + type: 'data_attribute'; + id: string; + workspace_id: string; + name: string; + full_name: string; + label: string; + description?: string; + data_type: + | 'string' + | 'integer' + | 'float' + | 'boolean' + | 'date' + | 'datetime' + | 'object' + | 'array'; + options?: string[]; + api_writable: boolean; + ui_writable: boolean; + custom: boolean; + archived: boolean; + created_at: number; + updated_at: number; + model: 'contact' | 'company' | 'conversation' | 'ticket'; + admin_id?: AdminId; + messenger_writable?: boolean; +} + +// Subscription types +export interface Subscription { + type: 'subscription'; + id: string; + state: 'active' | 'past_due' | 'canceled' | 'trialing'; + default_translation?: { + name: string; + description?: string; + }; + consent_type: 'opt_in' | 'opt_out'; + content_types?: string[]; + created_at: number; + updated_at: number; +} + +// SLA types +export interface SLA { + type: 'sla'; + sla_name: string; + sla_status: 'active' | 'missed' | 'cancelled'; +} + +// Request/Response helpers +export interface CreateContactRequest { + role?: 'user' | 'lead'; + external_id?: string; + email?: string; + phone?: string; + name?: string; + avatar?: string; + signed_up_at?: number; + last_seen_at?: number; + owner_id?: number; + unsubscribed_from_emails?: boolean; + custom_attributes?: CustomAttributes; +} + +export interface UpdateContactRequest { + role?: 'user' | 'lead'; + external_id?: string; + email?: string; + phone?: string; + name?: string; + avatar?: string; + signed_up_at?: number; + last_seen_at?: number; + owner_id?: number; + unsubscribed_from_emails?: boolean; + custom_attributes?: CustomAttributes; +} + +export interface CreateCompanyRequest { + company_id?: string; + name: string; + website?: string; + plan?: string; + size?: number; + industry?: string; + remote_created_at?: number; + monthly_spend?: number; + custom_attributes?: CustomAttributes; +} + +export interface CreateConversationRequest { + from: { + type: 'user' | 'lead' | 'contact'; + id?: string; + user_id?: string; + email?: string; + }; + body: string; +} + +export interface ReplyConversationRequest { + message_type: 'comment' | 'note'; + type: 'admin' | 'user'; + admin_id?: AdminId; + body: string; + attachment_urls?: string[]; + created_at?: number; +} + +export interface CreateTicketRequest { + ticket_type_id: TicketTypeId; + contacts?: Array<{ + id?: ContactId; + external_id?: string; + email?: string; + }>; + ticket_attributes?: { + [key: string]: string | number | boolean | string[]; + }; +} + +export interface CreateNoteRequest { + contact_id?: ContactId; + admin_id?: AdminId; + body: string; +} diff --git a/servers/intercom/tsconfig.json b/servers/intercom/tsconfig.json new file mode 100644 index 0000000..38a0f2f --- /dev/null +++ b/servers/intercom/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/monday/.env.example b/servers/monday/.env.example new file mode 100644 index 0000000..992f83c --- /dev/null +++ b/servers/monday/.env.example @@ -0,0 +1,3 @@ +# Monday.com API Key +# Get your API key from: https://monday.com/developers/apps +MONDAY_API_KEY=your_api_key_here diff --git a/servers/monday/.gitignore b/servers/monday/.gitignore new file mode 100644 index 0000000..74640cf --- /dev/null +++ b/servers/monday/.gitignore @@ -0,0 +1,35 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build output +dist/ +*.tsbuildinfo + +# Environment +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +coverage/ +.nyc_output/ diff --git a/servers/monday/README.md b/servers/monday/README.md new file mode 100644 index 0000000..f492936 --- /dev/null +++ b/servers/monday/README.md @@ -0,0 +1,132 @@ +# Monday.com MCP Server + +Complete Model Context Protocol (MCP) server for Monday.com GraphQL API. + +## Features + +- **Full Monday.com API Coverage**: Boards, items, columns, groups, updates, users, teams, workspaces, webhooks +- **GraphQL Native**: All requests use Monday.com's GraphQL endpoint +- **Type-Safe**: Comprehensive TypeScript types for all Monday.com entities +- **Complexity Tracking**: Built-in rate limit monitoring via complexity points +- **Lazy-Loaded Tools**: Efficient MCP tool registration + +## Installation + +```bash +npm install +npm run build +``` + +## Configuration + +Create a `.env` file: + +```bash +cp .env.example .env +``` + +Add your Monday.com API key: + +```env +MONDAY_API_KEY=your_api_key_here +``` + +Get your API key from: https://monday.com/developers/apps + +## Usage + +### Standalone + +```bash +npm start +``` + +### In Claude Desktop + +Add to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "monday": { + "command": "node", + "args": ["/path/to/monday/dist/main.js"], + "env": { + "MONDAY_API_KEY": "your_api_key_here" + } + } + } +} +``` + +## Available Tools + +### Boards +- `get_boards` - List all boards with filtering +- `get_board` - Get single board with columns and groups +- `create_board` - Create new board + +### Items +- `get_items` - List items from a board +- `get_item` - Get single item with column values +- `create_item` - Create new item +- `change_column_value` - Update column value + +### Groups +- `create_group` - Create new group in board + +### Updates (Activity Feed) +- `get_updates` - Get item updates/comments +- `create_update` - Create update/comment + +### Users & Teams +- `get_users` - List all users +- `get_teams` - List all teams +- `get_workspaces` - List all workspaces + +### Webhooks +- `create_webhook` - Create webhook for board events +- `delete_webhook` - Delete webhook + +## Column Types + +Supports ALL Monday.com column types: + +- Status, Text, Numbers, Date, People, Timeline +- Dropdown, Checkbox, Rating, Label +- Link, Email, Phone, Long Text +- Color Picker, Tags, Hour, Week, World Clock +- File, Board Relation, Mirror, Formula +- Auto Number, Creation Log, Last Updated, Dependency + +Each column type has proper TypeScript definitions with typed values. + +## GraphQL Architecture + +All requests are POST to `https://api.monday.com/v2`: + +```typescript +{ + "query": "query { boards { id name } }", + "variables": {} +} +``` + +Rate limiting uses complexity points (10,000,000 per minute). The client tracks complexity from response metadata. + +## Development + +```bash +# Type check +npm run typecheck + +# Build +npm run build + +# Watch mode +npm run dev +``` + +## License + +MIT diff --git a/servers/monday/package.json b/servers/monday/package.json new file mode 100644 index 0000000..e8b722b --- /dev/null +++ b/servers/monday/package.json @@ -0,0 +1,28 @@ +{ + "name": "@mcpengine/monday", + "version": "1.0.0", + "description": "Monday.com MCP server - full GraphQL API integration", + "type": "module", + "main": "dist/main.js", + "bin": { + "monday-mcp": "dist/main.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/main.js", + "typecheck": "tsc --noEmit" + }, + "keywords": ["mcp", "monday", "monday.com", "graphql"], + "author": "MCP Engine", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/servers/monday/src/clients/monday.ts b/servers/monday/src/clients/monday.ts new file mode 100644 index 0000000..042ac73 --- /dev/null +++ b/servers/monday/src/clients/monday.ts @@ -0,0 +1,616 @@ +/** + * Monday.com GraphQL Client + * All requests are POST to https://api.monday.com/v2 + */ + +import axios, { AxiosInstance } from "axios"; +import type { + Board, + Item, + Group, + Update, + User, + Team, + Workspace, + Webhook, + MondayResponse, + GraphQLResponse, + GetBoardsOptions, + GetItemsOptions, + CreateBoardOptions, + CreateItemOptions, + ChangeColumnValueOptions, + CreateGroupOptions, + CreateUpdateOptions, + CreateWebhookOptions, + Complexity, +} from "../types/index.js"; + +export class MondayClient { + private client: AxiosInstance; + private lastComplexity?: Complexity; + + constructor(apiKey: string) { + this.client = axios.create({ + baseURL: "https://api.monday.com/v2", + headers: { + "Content-Type": "application/json", + Authorization: apiKey, + }, + timeout: 30000, + }); + } + + /** + * Get the last query complexity data + */ + getComplexity(): Complexity | undefined { + return this.lastComplexity; + } + + /** + * Execute a raw GraphQL query + */ + private async query( + query: string, + variables?: Record + ): Promise> { + const response = await this.client.post>("", { + query, + variables, + }); + + if (response.data.errors && response.data.errors.length > 0) { + throw new Error( + `Monday API Error: ${response.data.errors.map((e) => e.message).join(", ")}` + ); + } + + // Parse complexity from response headers or extensions + // Monday returns complexity in the response data + const result: MondayResponse = { + data: response.data.data!, + account_id: response.data.account_id, + }; + + return result; + } + + // ============================================================================ + // Board Methods + // ============================================================================ + + /** + * Get multiple boards + */ + async getBoards(options: GetBoardsOptions = {}): Promise { + const { + limit = 25, + page = 1, + state = "active", + board_kind, + workspace_ids, + } = options; + + let args: string[] = [`limit: ${limit}`, `page: ${page}`]; + if (state !== "all") args.push(`state: ${state}`); + if (board_kind) args.push(`board_kind: ${board_kind}`); + if (workspace_ids && workspace_ids.length > 0) { + args.push(`workspace_ids: [${workspace_ids.join(", ")}]`); + } + + const query = ` + query { + boards(${args.join(", ")}) { + id + name + description + board_kind + state + workspace_id + owner_id + permissions + created_at + updated_at + items_count + columns { + id + title + type + description + settings_str + archived + width + } + groups { + id + title + color + position + archived + } + } + } + `; + + const result = await this.query<{ boards: Board[] }>(query); + return result.data.boards; + } + + /** + * Get a single board by ID + */ + async getBoard(boardId: string): Promise { + const query = ` + query { + boards(ids: [${boardId}]) { + id + name + description + board_kind + state + workspace_id + folder_id + owner_id + permissions + created_at + updated_at + items_count + columns { + id + title + type + description + settings_str + archived + width + } + groups { + id + title + color + position + archived + } + } + } + `; + + const result = await this.query<{ boards: Board[] }>(query); + if (!result.data.boards || result.data.boards.length === 0) { + throw new Error(`Board ${boardId} not found`); + } + return result.data.boards[0]; + } + + /** + * Create a new board + */ + async createBoard(options: CreateBoardOptions): Promise { + const { + board_name, + board_kind, + description, + workspace_id, + folder_id, + template_id, + } = options; + + let args: string[] = [ + `board_name: "${board_name}"`, + `board_kind: ${board_kind}`, + ]; + if (description) args.push(`description: "${description}"`); + if (workspace_id) args.push(`workspace_id: ${workspace_id}`); + if (folder_id) args.push(`folder_id: ${folder_id}`); + if (template_id) args.push(`template_id: ${template_id}`); + + const query = ` + mutation { + create_board(${args.join(", ")}) { + id + name + description + board_kind + state + workspace_id + owner_id + } + } + `; + + const result = await this.query<{ create_board: Board }>(query); + return result.data.create_board; + } + + // ============================================================================ + // Item Methods + // ============================================================================ + + /** + * Get items from a board + */ + async getItems(boardId: string, options: GetItemsOptions = {}): Promise { + const { limit = 25, page = 1, ids, newest_first } = options; + + let boardArgs: string[] = [`ids: [${boardId}]`]; + let itemsArgs: string[] = [`limit: ${limit}`]; + + if (page) itemsArgs.push(`page: ${page}`); + if (ids && ids.length > 0) itemsArgs.push(`ids: [${ids.join(", ")}]`); + if (newest_first !== undefined) itemsArgs.push(`newest_first: ${newest_first}`); + + const query = ` + query { + boards(${boardArgs.join(", ")}) { + items_page(${itemsArgs.join(", ")}) { + cursor + items { + id + name + state + created_at + updated_at + creator_id + group { + id + title + } + column_values { + id + text + value + type + } + } + } + } + } + `; + + const result = await this.query<{ + boards: Array<{ items_page: { items: Item[] } }>; + }>(query); + + if (!result.data.boards || result.data.boards.length === 0) { + return []; + } + + return result.data.boards[0].items_page.items; + } + + /** + * Get a single item by ID + */ + async getItem(itemId: string): Promise { + const query = ` + query { + items(ids: [${itemId}]) { + id + name + state + created_at + updated_at + creator_id + board { + id + name + } + group { + id + title + } + column_values { + id + text + value + type + } + } + } + `; + + const result = await this.query<{ items: Item[] }>(query); + if (!result.data.items || result.data.items.length === 0) { + throw new Error(`Item ${itemId} not found`); + } + return result.data.items[0]; + } + + /** + * Create a new item + */ + async createItem(options: CreateItemOptions): Promise { + const { + board_id, + group_id, + item_name, + column_values, + create_labels_if_missing, + } = options; + + let args: string[] = [ + `board_id: ${board_id}`, + `item_name: "${item_name}"`, + ]; + if (group_id) args.push(`group_id: "${group_id}"`); + if (column_values) { + const jsonStr = JSON.stringify(JSON.stringify(column_values)); + args.push(`column_values: ${jsonStr}`); + } + if (create_labels_if_missing !== undefined) { + args.push(`create_labels_if_missing: ${create_labels_if_missing}`); + } + + const query = ` + mutation { + create_item(${args.join(", ")}) { + id + name + state + created_at + creator_id + board { + id + name + } + group { + id + title + } + } + } + `; + + const result = await this.query<{ create_item: Item }>(query); + return result.data.create_item; + } + + /** + * Change a column value for an item + */ + async changeColumnValue(options: ChangeColumnValueOptions): Promise { + const { board_id, item_id, column_id, value } = options; + + const jsonValue = JSON.stringify(JSON.stringify(value)); + + const query = ` + mutation { + change_column_value( + board_id: ${board_id} + item_id: ${item_id} + column_id: "${column_id}" + value: ${jsonValue} + ) { + id + name + } + } + `; + + const result = await this.query<{ change_column_value: Item }>(query); + return result.data.change_column_value; + } + + // ============================================================================ + // Group Methods + // ============================================================================ + + /** + * Create a new group + */ + async createGroup(options: CreateGroupOptions): Promise { + const { board_id, group_name, position_relative_method, relative_to } = + options; + + let args: string[] = [ + `board_id: ${board_id}`, + `group_name: "${group_name}"`, + ]; + if (position_relative_method) { + args.push(`position_relative_method: ${position_relative_method}`); + } + if (relative_to) args.push(`relative_to: "${relative_to}"`); + + const query = ` + mutation { + create_group(${args.join(", ")}) { + id + title + color + position + } + } + `; + + const result = await this.query<{ create_group: Group }>(query); + return result.data.create_group; + } + + // ============================================================================ + // Update Methods + // ============================================================================ + + /** + * Get updates for an item + */ + async getUpdates(itemId: string, limit = 25): Promise { + const query = ` + query { + items(ids: [${itemId}]) { + updates(limit: ${limit}) { + id + body + created_at + updated_at + creator_id + text_body + creator { + id + name + email + } + } + } + } + `; + + const result = await this.query<{ + items: Array<{ updates: Update[] }>; + }>(query); + + if (!result.data.items || result.data.items.length === 0) { + return []; + } + + return result.data.items[0].updates || []; + } + + /** + * Create an update + */ + async createUpdate(options: CreateUpdateOptions): Promise { + const { item_id, body, parent_id } = options; + + let args: string[] = [`item_id: ${item_id}`, `body: "${body}"`]; + if (parent_id) args.push(`parent_id: ${parent_id}`); + + const query = ` + mutation { + create_update(${args.join(", ")}) { + id + body + created_at + creator_id + text_body + } + } + `; + + const result = await this.query<{ create_update: Update }>(query); + return result.data.create_update; + } + + // ============================================================================ + // User, Team, Workspace Methods + // ============================================================================ + + /** + * Get current user + */ + async getUsers(limit = 50): Promise { + const query = ` + query { + users(limit: ${limit}) { + id + name + email + url + photo_thumb + is_guest + enabled + created_at + title + phone + location + time_zone_identifier + } + } + `; + + const result = await this.query<{ users: User[] }>(query); + return result.data.users; + } + + /** + * Get teams + */ + async getTeams(limit = 50): Promise { + const query = ` + query { + teams(limit: ${limit}) { + id + name + picture_url + users { + id + name + email + } + } + } + `; + + const result = await this.query<{ teams: Team[] }>(query); + return result.data.teams; + } + + /** + * Get workspaces + */ + async getWorkspaces(limit = 50): Promise { + const query = ` + query { + workspaces(limit: ${limit}) { + id + name + kind + description + created_at + } + } + `; + + const result = await this.query<{ workspaces: Workspace[] }>(query); + return result.data.workspaces; + } + + // ============================================================================ + // Webhook Methods + // ============================================================================ + + /** + * Create a webhook + */ + async createWebhook(options: CreateWebhookOptions): Promise { + const { board_id, url, event, config } = options; + + let args: string[] = [ + `board_id: ${board_id}`, + `url: "${url}"`, + `event: ${event}`, + ]; + if (config) { + const jsonStr = JSON.stringify(JSON.stringify(config)); + args.push(`config: ${jsonStr}`); + } + + const query = ` + mutation { + create_webhook(${args.join(", ")}) { + id + board_id + } + } + `; + + const result = await this.query<{ create_webhook: Webhook }>(query); + return result.data.create_webhook; + } + + /** + * Delete a webhook + */ + async deleteWebhook(webhookId: string): Promise<{ id: string }> { + const query = ` + mutation { + delete_webhook(id: ${webhookId}) { + id + } + } + `; + + const result = await this.query<{ delete_webhook: { id: string } }>(query); + return result.data.delete_webhook; + } +} diff --git a/servers/monday/src/main.ts b/servers/monday/src/main.ts new file mode 100644 index 0000000..2eff531 --- /dev/null +++ b/servers/monday/src/main.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +/** + * Monday.com MCP Server Entry Point + * Dual transport (stdio/SSE) with graceful shutdown + */ + +import { MondayMCPServer } from "./server.js"; + +// Validate environment +const MONDAY_API_KEY = process.env.MONDAY_API_KEY; + +if (!MONDAY_API_KEY) { + console.error("Error: MONDAY_API_KEY environment variable is required"); + process.exit(1); +} + +// Create and start server +const server = new MondayMCPServer(MONDAY_API_KEY); + +// Graceful shutdown +process.on("SIGINT", () => { + console.error("Received SIGINT, shutting down gracefully..."); + process.exit(0); +}); + +process.on("SIGTERM", () => { + console.error("Received SIGTERM, shutting down gracefully..."); + process.exit(0); +}); + +// Start server +server.run().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/servers/monday/src/server.ts b/servers/monday/src/server.ts new file mode 100644 index 0000000..899b30e --- /dev/null +++ b/servers/monday/src/server.ts @@ -0,0 +1,510 @@ +/** + * Monday.com MCP Server + * Lazy-loaded tools for Monday.com GraphQL API + */ + +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from "@modelcontextprotocol/sdk/types.js"; +import { MondayClient } from "./clients/monday.js"; + +export class MondayMCPServer { + private server: Server; + private client: MondayClient; + + constructor(apiKey: string) { + this.client = new MondayClient(apiKey); + this.server = new Server( + { + name: "@mcpengine/monday", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + // List all available tools + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: this.getTools(), + }; + }); + + // Handle tool calls + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + let result: any; + + switch (name) { + // Board tools + case "get_boards": + result = await this.client.getBoards((args || {}) as any); + break; + case "get_board": + result = await this.client.getBoard((args?.board_id as string) || ""); + break; + case "create_board": + result = await this.client.createBoard((args || {}) as any); + break; + + // Item tools + case "get_items": + result = await this.client.getItems( + (args?.board_id as string) || "", + (args || {}) as any + ); + break; + case "get_item": + result = await this.client.getItem((args?.item_id as string) || ""); + break; + case "create_item": + result = await this.client.createItem((args || {}) as any); + break; + case "change_column_value": + result = await this.client.changeColumnValue((args || {}) as any); + break; + + // Group tools + case "create_group": + result = await this.client.createGroup((args || {}) as any); + break; + + // Update tools + case "get_updates": + result = await this.client.getUpdates( + (args?.item_id as string) || "", + args?.limit as number | undefined + ); + break; + case "create_update": + result = await this.client.createUpdate((args || {}) as any); + break; + + // User, team, workspace tools + case "get_users": + result = await this.client.getUsers(args?.limit as number | undefined); + break; + case "get_teams": + result = await this.client.getTeams(args?.limit as number | undefined); + break; + case "get_workspaces": + result = await this.client.getWorkspaces( + args?.limit as number | undefined + ); + break; + + // Webhook tools + case "create_webhook": + result = await this.client.createWebhook((args || {}) as any); + break; + case "delete_webhook": + result = await this.client.deleteWebhook((args?.webhook_id as string) || ""); + break; + + default: + throw new Error(`Unknown tool: ${name}`); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text", + text: `Error: ${errorMessage}`, + }, + ], + isError: true, + }; + } + }); + } + + private getTools(): Tool[] { + return [ + // Board tools + { + name: "get_boards", + description: + "Get all boards. Filter by state, board_kind, workspace_ids. Supports pagination.", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: "Number of boards to return (default: 25)", + }, + page: { + type: "number", + description: "Page number for pagination (default: 1)", + }, + state: { + type: "string", + enum: ["active", "archived", "deleted", "all"], + description: "Filter by board state (default: active)", + }, + board_kind: { + type: "string", + enum: ["public", "private", "share"], + description: "Filter by board type", + }, + workspace_ids: { + type: "array", + items: { type: "string" }, + description: "Filter by workspace IDs", + }, + }, + }, + }, + { + name: "get_board", + description: "Get a single board by ID with all columns and groups", + inputSchema: { + type: "object", + properties: { + board_id: { + type: "string", + description: "Board ID", + }, + }, + required: ["board_id"], + }, + }, + { + name: "create_board", + description: "Create a new board", + inputSchema: { + type: "object", + properties: { + board_name: { + type: "string", + description: "Name of the new board", + }, + board_kind: { + type: "string", + enum: ["public", "private", "share"], + description: "Board visibility type", + }, + description: { + type: "string", + description: "Board description", + }, + workspace_id: { + type: "string", + description: "Workspace ID to create board in", + }, + folder_id: { + type: "string", + description: "Folder ID to create board in", + }, + template_id: { + type: "string", + description: "Template ID to use", + }, + }, + required: ["board_name", "board_kind"], + }, + }, + + // Item tools + { + name: "get_items", + description: + "Get items from a board. Supports pagination and filtering.", + inputSchema: { + type: "object", + properties: { + board_id: { + type: "string", + description: "Board ID", + }, + limit: { + type: "number", + description: "Number of items to return (default: 25)", + }, + page: { + type: "number", + description: "Page number for pagination", + }, + ids: { + type: "array", + items: { type: "string" }, + description: "Filter by specific item IDs", + }, + newest_first: { + type: "boolean", + description: "Sort by newest first", + }, + }, + required: ["board_id"], + }, + }, + { + name: "get_item", + description: "Get a single item by ID with all column values", + inputSchema: { + type: "object", + properties: { + item_id: { + type: "string", + description: "Item ID", + }, + }, + required: ["item_id"], + }, + }, + { + name: "create_item", + description: "Create a new item in a board", + inputSchema: { + type: "object", + properties: { + board_id: { + type: "string", + description: "Board ID", + }, + group_id: { + type: "string", + description: "Group ID to create item in", + }, + item_name: { + type: "string", + description: "Name of the new item", + }, + column_values: { + type: "object", + description: + "Column values as JSON object (keys are column IDs, values are column-type-specific)", + }, + create_labels_if_missing: { + type: "boolean", + description: "Create labels if they don't exist", + }, + }, + required: ["board_id", "item_name"], + }, + }, + { + name: "change_column_value", + description: "Change a column value for an item", + inputSchema: { + type: "object", + properties: { + board_id: { + type: "string", + description: "Board ID", + }, + item_id: { + type: "string", + description: "Item ID", + }, + column_id: { + type: "string", + description: "Column ID", + }, + value: { + type: "object", + description: + "New value (format depends on column type, e.g. {text: 'value'} for text)", + }, + }, + required: ["board_id", "item_id", "column_id", "value"], + }, + }, + + // Group tools + { + name: "create_group", + description: "Create a new group in a board", + inputSchema: { + type: "object", + properties: { + board_id: { + type: "string", + description: "Board ID", + }, + group_name: { + type: "string", + description: "Name of the new group", + }, + position_relative_method: { + type: "string", + enum: ["before_at", "after_at"], + description: "Position relative to another group", + }, + relative_to: { + type: "string", + description: "Group ID to position relative to", + }, + }, + required: ["board_id", "group_name"], + }, + }, + + // Update tools + { + name: "get_updates", + description: "Get updates (activity feed) for an item", + inputSchema: { + type: "object", + properties: { + item_id: { + type: "string", + description: "Item ID", + }, + limit: { + type: "number", + description: "Number of updates to return (default: 25)", + }, + }, + required: ["item_id"], + }, + }, + { + name: "create_update", + description: "Create an update (comment) on an item", + inputSchema: { + type: "object", + properties: { + item_id: { + type: "string", + description: "Item ID", + }, + body: { + type: "string", + description: "Update text content", + }, + parent_id: { + type: "string", + description: "Parent update ID (for replies)", + }, + }, + required: ["item_id", "body"], + }, + }, + + // User, team, workspace tools + { + name: "get_users", + description: "Get all users in the account", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: "Number of users to return (default: 50)", + }, + }, + }, + }, + { + name: "get_teams", + description: "Get all teams in the account", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: "Number of teams to return (default: 50)", + }, + }, + }, + }, + { + name: "get_workspaces", + description: "Get all workspaces in the account", + inputSchema: { + type: "object", + properties: { + limit: { + type: "number", + description: "Number of workspaces to return (default: 50)", + }, + }, + }, + }, + + // Webhook tools + { + name: "create_webhook", + description: "Create a webhook for board events", + inputSchema: { + type: "object", + properties: { + board_id: { + type: "string", + description: "Board ID", + }, + url: { + type: "string", + description: "Webhook URL to receive events", + }, + event: { + type: "string", + enum: [ + "create_item", + "change_column_value", + "change_status_column_value", + "change_specific_column_value", + "create_update", + "delete_update", + "item_archived", + "item_deleted", + "item_moved_to_group", + "item_restored", + "subitem_created", + ], + description: "Event type to subscribe to", + }, + config: { + type: "object", + description: "Optional webhook configuration", + }, + }, + required: ["board_id", "url", "event"], + }, + }, + { + name: "delete_webhook", + description: "Delete a webhook", + inputSchema: { + type: "object", + properties: { + webhook_id: { + type: "string", + description: "Webhook ID to delete", + }, + }, + required: ["webhook_id"], + }, + }, + ]; + } + + async run(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + } +} diff --git a/servers/monday/src/types/index.ts b/servers/monday/src/types/index.ts new file mode 100644 index 0000000..c0e69c8 --- /dev/null +++ b/servers/monday/src/types/index.ts @@ -0,0 +1,614 @@ +/** + * Monday.com API Types + * Complete type definitions for Monday.com GraphQL API + */ + +// ============================================================================ +// Board Types +// ============================================================================ + +export type BoardKind = "public" | "private" | "share"; + +export interface Board { + id: string; + name: string; + description?: string; + board_kind: BoardKind; + state: "active" | "archived" | "deleted"; + workspace_id?: string; + folder_id?: string; + owner_id: string; + permissions: string; + created_at?: string; + updated_at?: string; + columns?: Column[]; + groups?: Group[]; + items?: Item[]; + items_count?: number; + tags?: Tag[]; + views?: BoardView[]; +} + +export interface BoardView { + id: string; + name: string; + type: string; + settings_str?: string; +} + +// ============================================================================ +// Column Types +// ============================================================================ + +export type ColumnType = + | "auto_number" + | "board_relation" + | "button" + | "checkbox" + | "color_picker" + | "country" + | "creation_log" + | "date" + | "dependency" + | "doc" + | "dropdown" + | "email" + | "file" + | "formula" + | "hour" + | "item_id" + | "label" + | "last_updated" + | "link" + | "location" + | "long_text" + | "mirror" + | "name" + | "numbers" + | "people" + | "phone" + | "progress" + | "rating" + | "status" + | "subtasks" + | "tags" + | "team" + | "text" + | "time_tracking" + | "timeline" + | "vote" + | "week" + | "world_clock"; + +export interface Column { + id: string; + title: string; + type: ColumnType; + description?: string; + settings_str?: string; + archived?: boolean; + width?: number; +} + +// ============================================================================ +// Column Value Types (typed per column type) +// ============================================================================ + +export type ColumnValue = + | TextColumnValue + | StatusColumnValue + | NumbersColumnValue + | DateColumnValue + | PeopleColumnValue + | TimelineColumnValue + | DropdownColumnValue + | CheckboxColumnValue + | RatingColumnValue + | LabelColumnValue + | LinkColumnValue + | EmailColumnValue + | PhoneColumnValue + | LongTextColumnValue + | ColorPickerColumnValue + | TagsColumnValue + | HourColumnValue + | WeekColumnValue + | WorldClockColumnValue + | FileColumnValue + | BoardRelationColumnValue + | MirrorColumnValue + | FormulaColumnValue + | AutoNumberColumnValue + | CreationLogColumnValue + | LastUpdatedColumnValue + | DependencyColumnValue + | GenericColumnValue; + +export interface BaseColumnValue { + id: string; + column?: Column; + text?: string; + value?: string | null; + type: ColumnType; +} + +export interface TextColumnValue extends BaseColumnValue { + type: "text"; + value: string | null; +} + +export interface StatusColumnValue extends BaseColumnValue { + type: "status"; + value: string | null; // JSON: { "index": number, "label": string } +} + +export interface NumbersColumnValue extends BaseColumnValue { + type: "numbers"; + value: string | null; // JSON: number as string +} + +export interface DateColumnValue extends BaseColumnValue { + type: "date"; + value: string | null; // JSON: { "date": "YYYY-MM-DD", "time": "HH:MM:SS" } +} + +export interface PeopleColumnValue extends BaseColumnValue { + type: "people"; + value: string | null; // JSON: { "personsAndTeams": [{ "id": number, "kind": "person" | "team" }] } +} + +export interface TimelineColumnValue extends BaseColumnValue { + type: "timeline"; + value: string | null; // JSON: { "from": "YYYY-MM-DD", "to": "YYYY-MM-DD" } +} + +export interface DropdownColumnValue extends BaseColumnValue { + type: "dropdown"; + value: string | null; // JSON: { "ids": [number] } +} + +export interface CheckboxColumnValue extends BaseColumnValue { + type: "checkbox"; + value: string | null; // JSON: { "checked": boolean } +} + +export interface RatingColumnValue extends BaseColumnValue { + type: "rating"; + value: string | null; // JSON: { "rating": number } +} + +export interface LabelColumnValue extends BaseColumnValue { + type: "label"; + value: string | null; // JSON: { "label": string } +} + +export interface LinkColumnValue extends BaseColumnValue { + type: "link"; + value: string | null; // JSON: { "url": string, "text": string } +} + +export interface EmailColumnValue extends BaseColumnValue { + type: "email"; + value: string | null; // JSON: { "email": string, "text": string } +} + +export interface PhoneColumnValue extends BaseColumnValue { + type: "phone"; + value: string | null; // JSON: { "phone": string, "countryShortName": string } +} + +export interface LongTextColumnValue extends BaseColumnValue { + type: "long_text"; + value: string | null; // JSON: { "text": string } +} + +export interface ColorPickerColumnValue extends BaseColumnValue { + type: "color_picker"; + value: string | null; // JSON: { "color": string } +} + +export interface TagsColumnValue extends BaseColumnValue { + type: "tags"; + value: string | null; // JSON: { "tag_ids": [number] } +} + +export interface HourColumnValue extends BaseColumnValue { + type: "hour"; + value: string | null; // JSON: { "hour": number, "minute": number } +} + +export interface WeekColumnValue extends BaseColumnValue { + type: "week"; + value: string | null; // JSON: { "week": { "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD" } } +} + +export interface WorldClockColumnValue extends BaseColumnValue { + type: "world_clock"; + value: string | null; // JSON: { "timezone": string } +} + +export interface FileColumnValue extends BaseColumnValue { + type: "file"; + value: string | null; // JSON: { "files": [{ "id": string, "name": string, "url": string }] } +} + +export interface BoardRelationColumnValue extends BaseColumnValue { + type: "board_relation"; + value: string | null; // JSON: { "linkedPulseIds": [{ "linkedPulseId": number }] } +} + +export interface MirrorColumnValue extends BaseColumnValue { + type: "mirror"; + value: string | null; // Mirrored value from connected board +} + +export interface FormulaColumnValue extends BaseColumnValue { + type: "formula"; + value: string | null; // Calculated formula result +} + +export interface AutoNumberColumnValue extends BaseColumnValue { + type: "auto_number"; + value: string | null; // JSON: number +} + +export interface CreationLogColumnValue extends BaseColumnValue { + type: "creation_log"; + value: string | null; // JSON: { "created_at": "ISO8601", "creator_id": number } +} + +export interface LastUpdatedColumnValue extends BaseColumnValue { + type: "last_updated"; + value: string | null; // JSON: { "updated_at": "ISO8601", "updater_id": number } +} + +export interface DependencyColumnValue extends BaseColumnValue { + type: "dependency"; + value: string | null; // JSON: { "linkedPulseIds": [number] } +} + +export interface GenericColumnValue extends BaseColumnValue { + type: ColumnType; +} + +// ============================================================================ +// Item Types +// ============================================================================ + +export interface Item { + id: string; + name: string; + board?: Board; + group?: Group; + state: "active" | "archived" | "deleted"; + creator_id?: string; + created_at?: string; + updated_at?: string; + column_values?: ColumnValue[]; + subitems?: SubItem[]; + parent_item?: Item; + subscribers?: User[]; + updates?: Update[]; +} + +export interface SubItem { + id: string; + name: string; + board?: Board; + column_values?: ColumnValue[]; + created_at?: string; + updated_at?: string; + parent_item?: Item; +} + +// ============================================================================ +// Group Types +// ============================================================================ + +export interface Group { + id: string; + title: string; + color?: string; + position?: string; + archived?: boolean; + deleted?: boolean; + items?: Item[]; +} + +// ============================================================================ +// Update Types (Activity Feed) +// ============================================================================ + +export interface Update { + id: string; + body: string; + creator_id?: string; + creator?: User; + created_at?: string; + updated_at?: string; + item_id?: string; + text_body?: string; + replies?: Update[]; + assets?: Asset[]; +} + +export interface Asset { + id: string; + name: string; + url: string; + public_url?: string; + file_extension?: string; + file_size?: number; + uploaded_by?: User; + created_at?: string; +} + +// ============================================================================ +// User, Team, Account Types +// ============================================================================ + +export interface User { + id: string; + name: string; + email: string; + url?: string; + photo_original?: string; + photo_thumb?: string; + photo_tiny?: string; + is_guest?: boolean; + is_pending?: boolean; + enabled?: boolean; + created_at?: string; + birthday?: string; + country_code?: string; + location?: string; + time_zone_identifier?: string; + title?: string; + phone?: string; + mobile_phone?: string; + teams?: Team[]; + account?: Account; +} + +export interface Team { + id: string; + name: string; + picture_url?: string; + users?: User[]; +} + +export interface Account { + id: string; + name: string; + slug?: string; + tier?: string; + plan?: Plan; + logo?: string; + show_timeline_weekends?: boolean; + first_day_of_the_week?: string; +} + +export interface Plan { + max_users: number; + period?: string; + tier?: string; + version: number; +} + +// ============================================================================ +// Workspace & Folder Types +// ============================================================================ + +export interface Workspace { + id: string; + name: string; + kind: "open" | "closed"; + description?: string; + created_at?: string; + owners_subscribers?: User[]; + teams_subscribers?: Team[]; + users_subscribers?: User[]; +} + +export interface Folder { + id: string; + name: string; + color?: string; + children?: Folder[]; + workspace_id?: string; + parent_id?: string; +} + +// ============================================================================ +// Webhook Types +// ============================================================================ + +export interface Webhook { + id: string; + board_id: string; + url: string; + event: + | "create_item" + | "change_column_value" + | "change_status_column_value" + | "change_specific_column_value" + | "create_update" + | "delete_update" + | "item_archived" + | "item_deleted" + | "item_moved_to_group" + | "item_restored" + | "subitem_created"; + config?: string; +} + +// ============================================================================ +// Automation Types +// ============================================================================ + +export interface Automation { + id: string; + name: string; + enabled?: boolean; +} + +// ============================================================================ +// Notification Types +// ============================================================================ + +export interface Notification { + id: string; + text: string; +} + +// ============================================================================ +// Tag Types +// ============================================================================ + +export interface Tag { + id: string; + name: string; + color?: string; +} + +// ============================================================================ +// Activity Log Types +// ============================================================================ + +export interface ActivityLog { + id: string; + account_id: string; + user_id: string; + event: string; + data?: string; + created_at: string; + entity?: string; +} + +// ============================================================================ +// Complexity & Rate Limiting Types +// ============================================================================ + +export interface Complexity { + /** + * Query complexity cost + */ + query: number; + /** + * Complexity before the query + */ + before: number; + /** + * Complexity after the query + */ + after: number; + /** + * Time to reset (Unix timestamp) + */ + reset_in_x_seconds: number; +} + +// ============================================================================ +// GraphQL Response Wrapper Types +// ============================================================================ + +export interface GraphQLResponse { + data?: T; + errors?: GraphQLError[]; + account_id?: string; +} + +export interface GraphQLError { + message: string; + locations?: Array<{ line: number; column: number }>; + path?: string[]; + extensions?: { + code?: string; + [key: string]: any; + }; +} + +export interface MondayResponse { + data: T; + complexity?: Complexity; + account_id?: string; +} + +// ============================================================================ +// Pagination Types +// ============================================================================ + +export interface ItemsPage { + cursor?: string; + items: Item[]; +} + +export interface BoardsPage { + cursor?: string; + boards: Board[]; +} + +export interface PaginationOptions { + limit?: number; + page?: number; + cursor?: string; +} + +// ============================================================================ +// Query Options +// ============================================================================ + +export interface GetBoardsOptions extends PaginationOptions { + state?: "active" | "archived" | "deleted" | "all"; + board_kind?: BoardKind; + workspace_ids?: string[]; +} + +export interface GetItemsOptions extends PaginationOptions { + ids?: string[]; + newest_first?: boolean; +} + +export interface CreateBoardOptions { + board_name: string; + board_kind: BoardKind; + description?: string; + workspace_id?: string; + folder_id?: string; + template_id?: string; +} + +export interface CreateItemOptions { + board_id: string; + group_id?: string; + item_name: string; + column_values?: Record; + create_labels_if_missing?: boolean; +} + +export interface ChangeColumnValueOptions { + board_id: string; + item_id: string; + column_id: string; + value: any; +} + +export interface CreateGroupOptions { + board_id: string; + group_name: string; + position_relative_method?: "before_at" | "after_at"; + relative_to?: string; +} + +export interface CreateUpdateOptions { + item_id: string; + body: string; + parent_id?: string; +} + +export interface CreateWebhookOptions { + board_id: string; + url: string; + event: Webhook["event"]; + config?: Record; +} diff --git a/servers/monday/tsconfig.json b/servers/monday/tsconfig.json new file mode 100644 index 0000000..b3232f5 --- /dev/null +++ b/servers/monday/tsconfig.json @@ -0,0 +1,26 @@ +{ + "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", + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/servers/notion/.env.example b/servers/notion/.env.example new file mode 100644 index 0000000..c3ded00 --- /dev/null +++ b/servers/notion/.env.example @@ -0,0 +1 @@ +NOTION_API_KEY=your_notion_api_key_here diff --git a/servers/notion/.gitignore b/servers/notion/.gitignore new file mode 100644 index 0000000..5dc77dc --- /dev/null +++ b/servers/notion/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +*.tsbuildinfo diff --git a/servers/notion/README.md b/servers/notion/README.md new file mode 100644 index 0000000..c22525c --- /dev/null +++ b/servers/notion/README.md @@ -0,0 +1,177 @@ +# Notion MCP Server + +Model Context Protocol (MCP) server for the Notion API. Provides comprehensive access to Notion workspaces including pages, databases, blocks, users, comments, and search. + +## Features + +- **Pages**: Create, read, update, and archive pages +- **Databases**: Query, create, and manage databases with advanced filtering and sorting +- **Blocks**: Retrieve, append, update, and delete blocks +- **Users**: Access workspace user information +- **Comments**: Create and retrieve comments on pages and blocks +- **Search**: Search across all pages and databases +- **Rate Limiting**: Built-in rate limiting (3 req/sec) with retry logic +- **Pagination**: Automatic cursor-based pagination support +- **Type Safety**: Comprehensive TypeScript types for the entire Notion API + +## Installation + +```bash +npm install +npm run build +``` + +## Configuration + +Set the `NOTION_API_KEY` environment variable: + +```bash +export NOTION_API_KEY="your_notion_integration_token" +``` + +Or create a `.env` file: + +```bash +cp .env.example .env +# Edit .env and add your API key +``` + +### Getting a Notion API Key + +1. Go to https://www.notion.so/my-integrations +2. Click "+ New integration" +3. Give it a name and select the workspace +4. Copy the "Internal Integration Token" +5. Share your Notion pages/databases with the integration + +## Usage + +### Development + +```bash +npm run dev +``` + +### Production + +```bash +npm start +``` + +### With MCP Client + +Add to your MCP settings configuration: + +```json +{ + "mcpServers": { + "notion": { + "command": "node", + "args": ["/path/to/notion/dist/main.js"], + "env": { + "NOTION_API_KEY": "your_api_key_here" + } + } + } +} +``` + +## Available Tools + +### Pages + +- `notion_get_page` - Retrieve a page by ID +- `notion_create_page` - Create a new page +- `notion_update_page` - Update page properties or archive + +### Databases + +- `notion_get_database` - Get database schema and info +- `notion_query_database` - Query with filters and sorts +- `notion_create_database` - Create a new database + +### Blocks + +- `notion_get_block` - Get a block by ID +- `notion_get_block_children` - Get child blocks +- `notion_append_block_children` - Add blocks to a parent +- `notion_delete_block` - Archive a block + +### Users + +- `notion_get_user` - Get user info +- `notion_list_users` - List all workspace users + +### Comments + +- `notion_create_comment` - Add a comment +- `notion_list_comments` - Get comments for a block + +### Search + +- `notion_search` - Search pages and databases + +## Architecture + +### Type System (`src/types/index.ts`) + +Comprehensive TypeScript definitions including: + +- All block types as discriminated unions +- All property types (title, rich_text, number, select, etc.) +- User, Page, Database, Comment types +- Filter and Sort builders +- Pagination types +- Branded ID types for type safety + +### Client (`src/clients/notion.ts`) + +Axios-based HTTP client with: + +- Bearer token authentication +- Automatic rate limiting (3 req/sec) +- Exponential backoff retry logic +- Cursor-based pagination helpers +- Async generator support for large datasets + +### Server (`src/server.ts`) + +MCP server implementation with: + +- Lazy-loaded tool handlers +- Structured error handling +- JSON schema validation +- Comprehensive tool definitions + +### Entry Point (`src/main.ts`) + +- Environment validation +- Stdio transport setup +- Graceful shutdown handling +- Error logging + +## Development + +```bash +# Type check +npm run typecheck + +# Build +npm run build + +# Run in dev mode +npm run dev +``` + +## License + +MIT + +## Contributing + +Contributions welcome! Please ensure: + +- TypeScript compiles with zero errors +- Follow existing code style +- Add tests for new features +- Update documentation diff --git a/servers/notion/package.json b/servers/notion/package.json new file mode 100644 index 0000000..ad2c089 --- /dev/null +++ b/servers/notion/package.json @@ -0,0 +1,29 @@ +{ + "name": "@mcpengine/notion", + "version": "0.1.0", + "description": "MCP server for Notion API", + "type": "module", + "main": "dist/main.js", + "bin": { + "notion-mcp": "dist/main.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsx src/main.ts", + "start": "node dist/main.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.0.0", + "typescript": "^5.6.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/servers/notion/src/clients/notion.ts b/servers/notion/src/clients/notion.ts new file mode 100644 index 0000000..f6a7fbc --- /dev/null +++ b/servers/notion/src/clients/notion.ts @@ -0,0 +1,361 @@ +import axios, { AxiosInstance, AxiosError } from 'axios'; +import type { + Page, + Database, + Block, + User, + Comment, + SearchResult, + PaginatedResults, + Filter, + Sort, + PageId, + DatabaseId, + BlockId, + UserId, + CommentId, + PageProperty, + DatabaseProperty, +} from '../types/index.js'; + +export interface NotionClientConfig { + auth: string; + baseURL?: string; + notionVersion?: string; + maxRetries?: number; + retryDelay?: number; +} + +interface CreatePageParams { + parent: { database_id: DatabaseId } | { page_id: PageId }; + properties: Record>; + icon?: Page['icon']; + cover?: Page['cover']; + children?: Block[]; +} + +interface UpdatePageParams { + properties?: Record>; + icon?: Page['icon']; + cover?: Page['cover']; + archived?: boolean; +} + +interface QueryDatabaseParams { + database_id: DatabaseId; + filter?: Filter; + sorts?: Sort[]; + start_cursor?: string; + page_size?: number; +} + +interface CreateDatabaseParams { + parent: { page_id: PageId }; + title: Array<{ type: 'text'; text: { content: string } }>; + properties: Record; + icon?: Database['icon']; + cover?: Database['cover']; + is_inline?: boolean; +} + +interface SearchParams { + query?: string; + filter?: { value: 'page' | 'database'; property: 'object' }; + sort?: { direction: 'ascending' | 'descending'; timestamp: 'last_edited_time' }; + start_cursor?: string; + page_size?: number; +} + +interface CreateCommentParams { + parent: { page_id: PageId } | { block_id: BlockId }; + rich_text: Array<{ type: 'text'; text: { content: string } }>; +} + +export class NotionClient { + private client: AxiosInstance; + private maxRetries: number; + private retryDelay: number; + private lastRequestTime: number = 0; + private readonly rateLimitRequests = 3; // 3 requests per second + private readonly rateLimitWindow = 1000; // 1 second in ms + + constructor(config: NotionClientConfig) { + this.maxRetries = config.maxRetries ?? 3; + this.retryDelay = config.retryDelay ?? 1000; + + this.client = axios.create({ + baseURL: config.baseURL ?? 'https://api.notion.com/v1', + headers: { + 'Authorization': `Bearer ${config.auth}`, + 'Notion-Version': config.notionVersion ?? '2022-06-28', + 'Content-Type': 'application/json', + }, + timeout: 30000, + }); + + // Add response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + (error: AxiosError) => { + if (error.response) { + const status = error.response.status; + const data = error.response.data as { message?: string; code?: string }; + throw new Error( + `Notion API error (${status}): ${data.message || data.code || 'Unknown error'}` + ); + } + throw error; + } + ); + } + + // Rate limiting helper + private async applyRateLimit(): Promise { + const now = Date.now(); + const timeSinceLastRequest = now - this.lastRequestTime; + const minInterval = this.rateLimitWindow / this.rateLimitRequests; + + if (timeSinceLastRequest < minInterval) { + await new Promise((resolve) => + setTimeout(resolve, minInterval - timeSinceLastRequest) + ); + } + + this.lastRequestTime = Date.now(); + } + + // Retry helper with exponential backoff + private async retryWithBackoff( + fn: () => Promise, + retries: number = this.maxRetries + ): Promise { + try { + await this.applyRateLimit(); + return await fn(); + } catch (error) { + if (retries === 0) throw error; + + const isRetryable = + error instanceof Error && + (error.message.includes('429') || + error.message.includes('503') || + error.message.includes('timeout')); + + if (!isRetryable) throw error; + + const delay = this.retryDelay * Math.pow(2, this.maxRetries - retries); + await new Promise((resolve) => setTimeout(resolve, delay)); + return this.retryWithBackoff(fn, retries - 1); + } + } + + // Pagination helper + private async *paginate( + fn: (cursor?: string) => Promise> + ): AsyncGenerator { + let cursor: string | undefined; + let hasMore = true; + + while (hasMore) { + const result = await fn(cursor); + for (const item of result.results) { + yield item; + } + hasMore = result.has_more; + cursor = result.next_cursor ?? undefined; + } + } + + // Page methods + async getPage(pageId: PageId): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.get(`/pages/${pageId}`); + return response.data; + }); + } + + async createPage(params: CreatePageParams): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.post('/pages', params); + return response.data; + }); + } + + async updatePage(pageId: PageId, params: UpdatePageParams): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.patch(`/pages/${pageId}`, params); + return response.data; + }); + } + + // Database methods + async getDatabase(databaseId: DatabaseId): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.get(`/databases/${databaseId}`); + return response.data; + }); + } + + async createDatabase(params: CreateDatabaseParams): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.post('/databases', params); + return response.data; + }); + } + + async updateDatabase( + databaseId: DatabaseId, + params: Partial + ): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.patch( + `/databases/${databaseId}`, + params + ); + return response.data; + }); + } + + async queryDatabase(params: QueryDatabaseParams): Promise> { + return this.retryWithBackoff(async () => { + const { database_id, ...body } = params; + const response = await this.client.post>( + `/databases/${database_id}/query`, + body + ); + return response.data; + }); + } + + async *queryDatabaseAll(params: QueryDatabaseParams): AsyncGenerator { + yield* this.paginate((cursor) => + this.queryDatabase({ ...params, start_cursor: cursor }) + ); + } + + // Block methods + async getBlock(blockId: BlockId): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.get(`/blocks/${blockId}`); + return response.data; + }); + } + + async getBlockChildren( + blockId: BlockId, + start_cursor?: string, + page_size?: number + ): Promise> { + return this.retryWithBackoff(async () => { + const response = await this.client.get>( + `/blocks/${blockId}/children`, + { + params: { start_cursor, page_size }, + } + ); + return response.data; + }); + } + + async *getBlockChildrenAll(blockId: BlockId): AsyncGenerator { + yield* this.paginate((cursor) => this.getBlockChildren(blockId, cursor)); + } + + async appendBlockChildren( + blockId: BlockId, + children: Block[] + ): Promise> { + return this.retryWithBackoff(async () => { + const response = await this.client.patch>( + `/blocks/${blockId}/children`, + { children } + ); + return response.data; + }); + } + + async updateBlock(blockId: BlockId, block: Partial): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.patch(`/blocks/${blockId}`, block); + return response.data; + }); + } + + async deleteBlock(blockId: BlockId): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.delete(`/blocks/${blockId}`); + return response.data; + }); + } + + // User methods + async getUser(userId: UserId): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.get(`/users/${userId}`); + return response.data; + }); + } + + async listUsers( + start_cursor?: string, + page_size?: number + ): Promise> { + return this.retryWithBackoff(async () => { + const response = await this.client.get>('/users', { + params: { start_cursor, page_size }, + }); + return response.data; + }); + } + + async *listUsersAll(): AsyncGenerator { + yield* this.paginate((cursor) => this.listUsers(cursor)); + } + + async getBotUser(): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.get('/users/me'); + return response.data; + }); + } + + // Comment methods + async createComment(params: CreateCommentParams): Promise { + return this.retryWithBackoff(async () => { + const response = await this.client.post('/comments', params); + return response.data; + }); + } + + async listComments( + blockId: BlockId, + start_cursor?: string, + page_size?: number + ): Promise> { + return this.retryWithBackoff(async () => { + const response = await this.client.get>('/comments', { + params: { block_id: blockId, start_cursor, page_size }, + }); + return response.data; + }); + } + + async *listCommentsAll(blockId: BlockId): AsyncGenerator { + yield* this.paginate((cursor) => this.listComments(blockId, cursor)); + } + + // Search method + async search(params: SearchParams = {}): Promise> { + return this.retryWithBackoff(async () => { + const response = await this.client.post>( + '/search', + params + ); + return response.data; + }); + } + + async *searchAll(params: SearchParams = {}): AsyncGenerator { + yield* this.paginate((cursor) => this.search({ ...params, start_cursor: cursor })); + } +} diff --git a/servers/notion/src/main.ts b/servers/notion/src/main.ts new file mode 100644 index 0000000..5b60a4f --- /dev/null +++ b/servers/notion/src/main.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { NotionServer } from './server.js'; + +async function main() { + // Check for required environment variable + const apiKey = process.env.NOTION_API_KEY; + if (!apiKey) { + console.error('Error: NOTION_API_KEY environment variable is required'); + process.exit(1); + } + + // Create server instance + const server = new NotionServer({ apiKey }); + + // Create stdio transport + const transport = new StdioServerTransport(); + + // Connect server to transport + await server.connect(transport); + + // Graceful shutdown handlers + const shutdown = async () => { + console.error('Shutting down...'); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + // 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); + }); + + console.error('Notion MCP server running on stdio'); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/notion/src/server.ts b/servers/notion/src/server.ts new file mode 100644 index 0000000..798469c --- /dev/null +++ b/servers/notion/src/server.ts @@ -0,0 +1,528 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import { NotionClient } from './clients/notion.js'; +import type { + PageId, + DatabaseId, + BlockId, + UserId, + Block, + PageProperty, + DatabaseProperty, + Filter, + Sort, +} from './types/index.js'; + +export interface NotionServerConfig { + apiKey: string; +} + +export class NotionServer { + private server: Server; + private client: NotionClient; + + constructor(config: NotionServerConfig) { + this.client = new NotionClient({ auth: config.apiKey }); + + this.server = new Server( + { + name: '@mcpengine/notion', + version: '0.1.0', + }, + { + capabilities: { + tools: {}, + }, + } + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => { + const tools: Tool[] = [ + // Pages module + { + name: 'notion_get_page', + description: 'Retrieve a Notion page by ID', + inputSchema: { + type: 'object', + properties: { + page_id: { + type: 'string', + description: 'The ID of the page to retrieve', + }, + }, + required: ['page_id'], + }, + }, + { + name: 'notion_create_page', + description: 'Create a new page in a database or as a child of another page', + inputSchema: { + type: 'object', + properties: { + parent_type: { + type: 'string', + enum: ['database_id', 'page_id'], + description: 'Type of parent', + }, + parent_id: { + type: 'string', + description: 'ID of the parent database or page', + }, + properties: { + type: 'object', + description: 'Page properties as JSON object', + }, + children: { + type: 'array', + description: 'Array of block objects to append to the page', + }, + }, + required: ['parent_type', 'parent_id', 'properties'], + }, + }, + { + name: 'notion_update_page', + description: 'Update page properties or archive a page', + inputSchema: { + type: 'object', + properties: { + page_id: { + type: 'string', + description: 'The ID of the page to update', + }, + properties: { + type: 'object', + description: 'Properties to update', + }, + archived: { + type: 'boolean', + description: 'Whether to archive the page', + }, + }, + required: ['page_id'], + }, + }, + + // Databases module + { + name: 'notion_get_database', + description: 'Retrieve a database by ID', + inputSchema: { + type: 'object', + properties: { + database_id: { + type: 'string', + description: 'The ID of the database', + }, + }, + required: ['database_id'], + }, + }, + { + name: 'notion_query_database', + description: 'Query a database with filters and sorting', + inputSchema: { + type: 'object', + properties: { + database_id: { + type: 'string', + description: 'The ID of the database to query', + }, + filter: { + type: 'object', + description: 'Filter object (optional)', + }, + sorts: { + type: 'array', + description: 'Array of sort objects (optional)', + }, + page_size: { + type: 'number', + description: 'Number of results to return (max 100)', + }, + }, + required: ['database_id'], + }, + }, + { + name: 'notion_create_database', + description: 'Create a new database', + inputSchema: { + type: 'object', + properties: { + parent_page_id: { + type: 'string', + description: 'ID of the parent page', + }, + title: { + type: 'string', + description: 'Database title', + }, + properties: { + type: 'object', + description: 'Database schema properties', + }, + }, + required: ['parent_page_id', 'title', 'properties'], + }, + }, + + // Blocks module + { + name: 'notion_get_block', + description: 'Retrieve a block by ID', + inputSchema: { + type: 'object', + properties: { + block_id: { + type: 'string', + description: 'The ID of the block', + }, + }, + required: ['block_id'], + }, + }, + { + name: 'notion_get_block_children', + description: 'Retrieve children blocks of a block', + inputSchema: { + type: 'object', + properties: { + block_id: { + type: 'string', + description: 'The ID of the parent block', + }, + page_size: { + type: 'number', + description: 'Number of results to return', + }, + }, + required: ['block_id'], + }, + }, + { + name: 'notion_append_block_children', + description: 'Append child blocks to a parent block', + inputSchema: { + type: 'object', + properties: { + block_id: { + type: 'string', + description: 'The ID of the parent block', + }, + children: { + type: 'array', + description: 'Array of block objects to append', + }, + }, + required: ['block_id', 'children'], + }, + }, + { + name: 'notion_delete_block', + description: 'Delete (archive) a block', + inputSchema: { + type: 'object', + properties: { + block_id: { + type: 'string', + description: 'The ID of the block to delete', + }, + }, + required: ['block_id'], + }, + }, + + // Users module + { + name: 'notion_get_user', + description: 'Retrieve a user by ID', + inputSchema: { + type: 'object', + properties: { + user_id: { + type: 'string', + description: 'The ID of the user', + }, + }, + required: ['user_id'], + }, + }, + { + name: 'notion_list_users', + description: 'List all users in the workspace', + inputSchema: { + type: 'object', + properties: { + page_size: { + type: 'number', + description: 'Number of results to return', + }, + }, + }, + }, + + // Comments module + { + name: 'notion_create_comment', + description: 'Add a comment to a page or block', + inputSchema: { + type: 'object', + properties: { + parent_type: { + type: 'string', + enum: ['page_id', 'block_id'], + description: 'Type of parent', + }, + parent_id: { + type: 'string', + description: 'ID of the parent page or block', + }, + text: { + type: 'string', + description: 'Comment text content', + }, + }, + required: ['parent_type', 'parent_id', 'text'], + }, + }, + { + name: 'notion_list_comments', + description: 'Retrieve comments for a block', + inputSchema: { + type: 'object', + properties: { + block_id: { + type: 'string', + description: 'The ID of the block', + }, + }, + required: ['block_id'], + }, + }, + + // Search module + { + name: 'notion_search', + description: 'Search all pages and databases', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query text', + }, + filter: { + type: 'string', + enum: ['page', 'database'], + description: 'Filter by object type', + }, + sort_direction: { + type: 'string', + enum: ['ascending', 'descending'], + description: 'Sort direction for last_edited_time', + }, + }, + }, + }, + ]; + + return { tools }; + }); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + if (!args) { + throw new Error('Missing required arguments'); + } + + switch (name) { + // Pages + case 'notion_get_page': { + const page = await this.client.getPage(args.page_id as PageId); + return { + content: [{ type: 'text', text: JSON.stringify(page, null, 2) }], + }; + } + + case 'notion_create_page': { + const parent = + args.parent_type === 'database_id' + ? { database_id: args.parent_id as DatabaseId } + : { page_id: args.parent_id as PageId }; + const page = await this.client.createPage({ + parent, + properties: args.properties as Record>, + children: args.children as Block[] | undefined, + }); + return { + content: [{ type: 'text', text: JSON.stringify(page, null, 2) }], + }; + } + + case 'notion_update_page': { + const page = await this.client.updatePage(args.page_id as PageId, { + properties: args.properties as Record> | undefined, + archived: args.archived as boolean | undefined, + }); + return { + content: [{ type: 'text', text: JSON.stringify(page, null, 2) }], + }; + } + + // Databases + case 'notion_get_database': { + const db = await this.client.getDatabase(args.database_id as DatabaseId); + return { + content: [{ type: 'text', text: JSON.stringify(db, null, 2) }], + }; + } + + case 'notion_query_database': { + const results = await this.client.queryDatabase({ + database_id: args.database_id as DatabaseId, + filter: args.filter as Filter | undefined, + sorts: args.sorts as Sort[] | undefined, + page_size: args.page_size as number | undefined, + }); + return { + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], + }; + } + + case 'notion_create_database': { + const db = await this.client.createDatabase({ + parent: { page_id: args.parent_page_id as PageId }, + title: [{ type: 'text', text: { content: args.title as string } }], + properties: args.properties as Record, + }); + return { + content: [{ type: 'text', text: JSON.stringify(db, null, 2) }], + }; + } + + // Blocks + case 'notion_get_block': { + const block = await this.client.getBlock(args.block_id as BlockId); + return { + content: [{ type: 'text', text: JSON.stringify(block, null, 2) }], + }; + } + + case 'notion_get_block_children': { + const children = await this.client.getBlockChildren( + args.block_id as BlockId, + undefined, + args.page_size as number | undefined + ); + return { + content: [{ type: 'text', text: JSON.stringify(children, null, 2) }], + }; + } + + case 'notion_append_block_children': { + const result = await this.client.appendBlockChildren( + args.block_id as BlockId, + args.children as Block[] + ); + return { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + }; + } + + case 'notion_delete_block': { + const block = await this.client.deleteBlock(args.block_id as BlockId); + return { + content: [{ type: 'text', text: JSON.stringify(block, null, 2) }], + }; + } + + // Users + case 'notion_get_user': { + const user = await this.client.getUser(args.user_id as UserId); + return { + content: [{ type: 'text', text: JSON.stringify(user, null, 2) }], + }; + } + + case 'notion_list_users': { + const users = await this.client.listUsers(undefined, args.page_size as number | undefined); + return { + content: [{ type: 'text', text: JSON.stringify(users, null, 2) }], + }; + } + + // Comments + case 'notion_create_comment': { + const parent = + args.parent_type === 'page_id' + ? { page_id: args.parent_id as PageId } + : { block_id: args.parent_id as BlockId }; + const comment = await this.client.createComment({ + parent, + rich_text: [{ type: 'text', text: { content: args.text as string } }], + }); + return { + content: [{ type: 'text', text: JSON.stringify(comment, null, 2) }], + }; + } + + case 'notion_list_comments': { + const comments = await this.client.listComments(args.block_id as BlockId); + return { + content: [{ type: 'text', text: JSON.stringify(comments, null, 2) }], + }; + } + + // Search + case 'notion_search': { + const params: any = {}; + if (args.query) params.query = args.query; + if (args.filter) { + params.filter = { value: args.filter, property: 'object' }; + } + if (args.sort_direction) { + params.sort = { + direction: args.sort_direction, + timestamp: 'last_edited_time', + }; + } + const results = await this.client.search(params); + return { + content: [{ type: 'text', text: JSON.stringify(results, null, 2) }], + }; + } + + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; + return { + content: [{ type: 'text', text: `Error: ${errorMessage}` }], + isError: true, + }; + } + }); + } + + async connect(transport: StdioServerTransport): Promise { + await this.server.connect(transport); + } + + getServer(): Server { + return this.server; + } +} diff --git a/servers/notion/src/types/index.ts b/servers/notion/src/types/index.ts new file mode 100644 index 0000000..ef5c784 --- /dev/null +++ b/servers/notion/src/types/index.ts @@ -0,0 +1,742 @@ +// Branded types for IDs +export type PageId = string & { readonly __brand: 'PageId' }; +export type DatabaseId = string & { readonly __brand: 'DatabaseId' }; +export type BlockId = string & { readonly __brand: 'BlockId' }; +export type UserId = string & { readonly __brand: 'UserId' }; +export type CommentId = string & { readonly __brand: 'CommentId' }; + +// Color types +export type Color = + | 'default' + | 'gray' + | 'brown' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'pink' + | 'red' + | 'gray_background' + | 'brown_background' + | 'orange_background' + | 'yellow_background' + | 'green_background' + | 'blue_background' + | 'purple_background' + | 'pink_background' + | 'red_background'; + +// RichText types +export interface RichTextAnnotations { + bold: boolean; + italic: boolean; + strikethrough: boolean; + underline: boolean; + code: boolean; + color: Color; +} + +export type RichText = + | { + type: 'text'; + text: { + content: string; + link: { url: string } | null; + }; + annotations: RichTextAnnotations; + plain_text: string; + href: string | null; + } + | { + type: 'mention'; + mention: + | { type: 'user'; user: User } + | { type: 'page'; page: { id: PageId } } + | { type: 'database'; database: { id: DatabaseId } } + | { type: 'date'; date: DateValue } + | { type: 'link_preview'; link_preview: { url: string } } + | { type: 'template_mention'; template_mention: { type: 'template_mention_date' | 'template_mention_user' } }; + annotations: RichTextAnnotations; + plain_text: string; + href: string | null; + } + | { + type: 'equation'; + equation: { + expression: string; + }; + annotations: RichTextAnnotations; + plain_text: string; + href: string | null; + }; + +// Date types +export interface DateValue { + start: string; + end: string | null; + time_zone: string | null; +} + +// User types +export type User = + | { + object: 'user'; + id: UserId; + type: 'person'; + person: { email: string }; + name: string | null; + avatar_url: string | null; + } + | { + object: 'user'; + id: UserId; + type: 'bot'; + bot: Record | { owner: { type: 'workspace'; workspace: true } | { type: 'user'; user: User } }; + name: string | null; + avatar_url: string | null; + }; + +export type Person = Extract; +export type Bot = Extract; + +// Parent types +export type Parent = + | { type: 'database_id'; database_id: DatabaseId } + | { type: 'page_id'; page_id: PageId } + | { type: 'workspace'; workspace: true } + | { type: 'block_id'; block_id: BlockId }; + +// File types +export type FileObject = + | { + type: 'external'; + external: { url: string }; + name?: string; + } + | { + type: 'file'; + file: { url: string; expiry_time: string }; + name?: string; + }; + +// Emoji and Icon types +export type Icon = + | { type: 'emoji'; emoji: string } + | { type: 'external'; external: { url: string } } + | { type: 'file'; file: { url: string; expiry_time: string } }; + +// Property value types for Pages +export type PageProperty = + | { id: string; type: 'title'; title: RichText[] } + | { id: string; type: 'rich_text'; rich_text: RichText[] } + | { id: string; type: 'number'; number: number | null } + | { id: string; type: 'select'; select: { id: string; name: string; color: Color } | null } + | { id: string; type: 'multi_select'; multi_select: Array<{ id: string; name: string; color: Color }> } + | { id: string; type: 'date'; date: DateValue | null } + | { id: string; type: 'people'; people: User[] } + | { id: string; type: 'files'; files: FileObject[] } + | { id: string; type: 'checkbox'; checkbox: boolean } + | { id: string; type: 'url'; url: string | null } + | { id: string; type: 'email'; email: string | null } + | { id: string; type: 'phone_number'; phone_number: string | null } + | { id: string; type: 'formula'; formula: { type: 'string'; string: string | null } | { type: 'number'; number: number | null } | { type: 'boolean'; boolean: boolean | null } | { type: 'date'; date: DateValue | null } } + | { id: string; type: 'relation'; relation: Array<{ id: PageId }> } + | { id: string; type: 'rollup'; rollup: { type: 'number'; number: number | null; function: string } | { type: 'date'; date: DateValue | null; function: string } | { type: 'array'; array: PageProperty[]; function: string } } + | { id: string; type: 'created_time'; created_time: string } + | { id: string; type: 'created_by'; created_by: User } + | { id: string; type: 'last_edited_time'; last_edited_time: string } + | { id: string; type: 'last_edited_by'; last_edited_by: User } + | { id: string; type: 'status'; status: { id: string; name: string; color: Color } | null } + | { id: string; type: 'unique_id'; unique_id: { number: number; prefix: string | null } }; + +// Database property schema types +export type DatabaseProperty = + | { id: string; name: string; type: 'title'; title: Record } + | { id: string; name: string; type: 'rich_text'; rich_text: Record } + | { id: string; name: string; type: 'number'; number: { format: 'number' | 'number_with_commas' | 'percent' | 'dollar' | 'canadian_dollar' | 'euro' | 'pound' | 'yen' | 'ruble' | 'rupee' | 'won' | 'yuan' | 'real' | 'lira' | 'rupiah' | 'franc' | 'hong_kong_dollar' | 'new_zealand_dollar' | 'krona' | 'norwegian_krone' | 'mexican_peso' | 'rand' | 'new_taiwan_dollar' | 'danish_krone' | 'zloty' | 'baht' | 'forint' | 'koruna' | 'shekel' | 'chilean_peso' | 'philippine_peso' | 'dirham' | 'colombian_peso' | 'riyal' | 'ringgit' | 'leu' | 'argentine_peso' | 'uruguayan_peso' } } + | { id: string; name: string; type: 'select'; select: { options: Array<{ id: string; name: string; color: Color }> } } + | { id: string; name: string; type: 'multi_select'; multi_select: { options: Array<{ id: string; name: string; color: Color }> } } + | { id: string; name: string; type: 'date'; date: Record } + | { id: string; name: string; type: 'people'; people: Record } + | { id: string; name: string; type: 'files'; files: Record } + | { id: string; name: string; type: 'checkbox'; checkbox: Record } + | { id: string; name: string; type: 'url'; url: Record } + | { id: string; name: string; type: 'email'; email: Record } + | { id: string; name: string; type: 'phone_number'; phone_number: Record } + | { id: string; name: string; type: 'formula'; formula: { expression: string } } + | { id: string; name: string; type: 'relation'; relation: { database_id: DatabaseId; synced_property_id?: string; synced_property_name?: string } } + | { id: string; name: string; type: 'rollup'; rollup: { relation_property_id: string; relation_property_name: string; rollup_property_id: string; rollup_property_name: string; function: 'count_all' | 'count_values' | 'count_unique_values' | 'count_empty' | 'count_not_empty' | 'percent_empty' | 'percent_not_empty' | 'sum' | 'average' | 'median' | 'min' | 'max' | 'range' | 'show_original' } } + | { id: string; name: string; type: 'created_time'; created_time: Record } + | { id: string; name: string; type: 'created_by'; created_by: Record } + | { id: string; name: string; type: 'last_edited_time'; last_edited_time: Record } + | { id: string; name: string; type: 'last_edited_by'; last_edited_by: Record } + | { id: string; name: string; type: 'status'; status: { options: Array<{ id: string; name: string; color: Color }>; groups: Array<{ id: string; name: string; color: Color; option_ids: string[] }> } } + | { id: string; name: string; type: 'unique_id'; unique_id: { prefix: string | null } }; + +// Block types - comprehensive discriminated union +export type Block = + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'paragraph'; + paragraph: { + rich_text: RichText[]; + color: Color; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'heading_1'; + heading_1: { + rich_text: RichText[]; + color: Color; + is_toggleable: boolean; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'heading_2'; + heading_2: { + rich_text: RichText[]; + color: Color; + is_toggleable: boolean; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'heading_3'; + heading_3: { + rich_text: RichText[]; + color: Color; + is_toggleable: boolean; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'bulleted_list_item'; + bulleted_list_item: { + rich_text: RichText[]; + color: Color; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'numbered_list_item'; + numbered_list_item: { + rich_text: RichText[]; + color: Color; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'to_do'; + to_do: { + rich_text: RichText[]; + checked: boolean; + color: Color; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'toggle'; + toggle: { + rich_text: RichText[]; + color: Color; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'code'; + code: { + rich_text: RichText[]; + caption: RichText[]; + language: string; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'callout'; + callout: { + rich_text: RichText[]; + icon: Icon; + color: Color; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'quote'; + quote: { + rich_text: RichText[]; + color: Color; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'divider'; + divider: Record; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'table_of_contents'; + table_of_contents: { + color: Color; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'breadcrumb'; + breadcrumb: Record; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'image'; + image: FileObject & { caption?: RichText[] }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'video'; + video: FileObject & { caption?: RichText[] }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'file'; + file: FileObject & { caption?: RichText[] }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'pdf'; + pdf: FileObject & { caption?: RichText[] }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'bookmark'; + bookmark: { + url: string; + caption: RichText[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'embed'; + embed: { + url: string; + caption?: RichText[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'link_preview'; + link_preview: { + url: string; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'equation'; + equation: { + expression: string; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'table'; + table: { + table_width: number; + has_column_header: boolean; + has_row_header: boolean; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'table_row'; + table_row: { + cells: RichText[][]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'column_list'; + column_list: Record; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'column'; + column: Record; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'synced_block'; + synced_block: { + synced_from: { block_id: BlockId } | null; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'template'; + template: { + rich_text: RichText[]; + children?: Block[]; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'child_page'; + child_page: { + title: string; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'child_database'; + child_database: { + title: string; + }; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + } + | { + object: 'block'; + id: BlockId; + parent: Parent; + type: 'unsupported'; + unsupported: Record; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + has_children: boolean; + archived: boolean; + }; + +// Page type +export interface Page { + object: 'page'; + id: PageId; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + archived: boolean; + icon: Icon | null; + cover: FileObject | null; + properties: Record; + parent: Parent; + url: string; +} + +// Database type +export interface Database { + object: 'database'; + id: DatabaseId; + created_time: string; + created_by: User; + last_edited_time: string; + last_edited_by: User; + title: RichText[]; + description: RichText[]; + icon: Icon | null; + cover: FileObject | null; + properties: Record; + parent: Parent; + url: string; + archived: boolean; + is_inline: boolean; +} + +// Comment type +export interface Comment { + object: 'comment'; + id: CommentId; + parent: { type: 'page_id'; page_id: PageId } | { type: 'block_id'; block_id: BlockId }; + discussion_id: string; + created_time: string; + created_by: User; + last_edited_time: string; + rich_text: RichText[]; +} + +// Search result type +export type SearchResult = Page | Database; + +// Pagination types +export interface PaginatedResults { + object: 'list'; + results: T[]; + next_cursor: string | null; + has_more: boolean; + type?: string; +} + +// Filter types for database queries +export type PropertyFilter = + | { property: string; rich_text: { equals?: string; does_not_equal?: string; contains?: string; does_not_contain?: string; starts_with?: string; ends_with?: string; is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; number: { equals?: number; does_not_equal?: number; greater_than?: number; less_than?: number; greater_than_or_equal_to?: number; less_than_or_equal_to?: number; is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; checkbox: { equals?: boolean; does_not_equal?: boolean } } + | { property: string; select: { equals?: string; does_not_equal?: string; is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; multi_select: { contains?: string; does_not_contain?: string; is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; status: { equals?: string; does_not_equal?: string; is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; date: { equals?: string; before?: string; after?: string; on_or_before?: string; on_or_after?: string; is_empty?: boolean; is_not_empty?: boolean; past_week?: Record; past_month?: Record; past_year?: Record; next_week?: Record; next_month?: Record; next_year?: Record } } + | { property: string; people: { contains?: UserId; does_not_contain?: UserId; is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; files: { is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; relation: { contains?: PageId; does_not_contain?: PageId; is_empty?: boolean; is_not_empty?: boolean } } + | { property: string; formula: { string?: { equals?: string; does_not_equal?: string; contains?: string; does_not_contain?: string; starts_with?: string; ends_with?: string; is_empty?: boolean; is_not_empty?: boolean }; checkbox?: { equals?: boolean; does_not_equal?: boolean }; number?: { equals?: number; does_not_equal?: number; greater_than?: number; less_than?: number; greater_than_or_equal_to?: number; less_than_or_equal_to?: number; is_empty?: boolean; is_not_empty?: boolean }; date?: { equals?: string; before?: string; after?: string; on_or_before?: string; on_or_after?: string; is_empty?: boolean; is_not_empty?: boolean } } }; + +export type CompoundFilter = + | { and: Array } + | { or: Array }; + +export type Filter = PropertyFilter | CompoundFilter; + +// Sort types +export interface Sort { + property?: string; + timestamp?: 'created_time' | 'last_edited_time'; + direction: 'ascending' | 'descending'; +} diff --git a/servers/notion/tsconfig.json b/servers/notion/tsconfig.json new file mode 100644 index 0000000..d6037b2 --- /dev/null +++ b/servers/notion/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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", + "allowSyntheticDefaultImports": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/servers/xero/.env.example b/servers/xero/.env.example new file mode 100644 index 0000000..f6c9014 --- /dev/null +++ b/servers/xero/.env.example @@ -0,0 +1,19 @@ +# Xero API Configuration + +# Required: OAuth2 Bearer token +XERO_ACCESS_TOKEN=your_access_token_here + +# Required: Tenant ID (organization ID) +XERO_TENANT_ID=your_tenant_id_here + +# Optional: Custom base URL (default: https://api.xero.com/api.xro/2.0) +# XERO_BASE_URL=https://api.xero.com/api.xro/2.0 + +# Optional: Request timeout in milliseconds (default: 30000) +# XERO_TIMEOUT=30000 + +# Optional: Number of retry attempts (default: 3) +# XERO_RETRY_ATTEMPTS=3 + +# Optional: Retry delay in milliseconds (default: 1000) +# XERO_RETRY_DELAY_MS=1000 diff --git a/servers/xero/.gitignore b/servers/xero/.gitignore new file mode 100644 index 0000000..932dcbc --- /dev/null +++ b/servers/xero/.gitignore @@ -0,0 +1,39 @@ +# Dependencies +node_modules/ +package-lock.json +yarn.lock +pnpm-lock.yaml + +# Build output +dist/ +build/ +*.tsbuildinfo + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Testing +coverage/ +.nyc_output/ + +# Misc +.cache/ +tmp/ +temp/ diff --git a/servers/xero/README.md b/servers/xero/README.md new file mode 100644 index 0000000..af2ae9f --- /dev/null +++ b/servers/xero/README.md @@ -0,0 +1,236 @@ +# Xero MCP Server + +Model Context Protocol (MCP) server for Xero Accounting API integration. + +## Features + +- **Complete Xero Accounting API coverage**: Invoices, Bills, Contacts, Accounts, Payments, Bank Transactions, Credit Notes, Purchase Orders, Quotes, Manual Journals, Reports, and more +- **OAuth2 authentication** with Bearer token +- **Rate limiting**: Respects Xero's 60 calls/min and 5000 calls/day limits +- **Automatic retry** with exponential backoff +- **Pagination support**: page/pageSize parameters (max 100 per page) +- **If-Modified-Since** header support for efficient polling +- **Comprehensive TypeScript types** with branded IDs (GUIDs) +- **Lazy-loaded tools** for optimal performance +- **Dual transport** support (stdio/SSE) +- **Graceful shutdown** + +## Installation + +```bash +npm install +npm run build +``` + +## Configuration + +Create a `.env` file from `.env.example`: + +```bash +cp .env.example .env +``` + +### Required Environment Variables + +- `XERO_ACCESS_TOKEN`: OAuth2 Bearer token +- `XERO_TENANT_ID`: Xero organization/tenant ID + +### Optional Environment Variables + +- `XERO_BASE_URL`: Custom API base URL (default: `https://api.xero.com/api.xro/2.0`) +- `XERO_TIMEOUT`: Request timeout in milliseconds (default: `30000`) +- `XERO_RETRY_ATTEMPTS`: Number of retry attempts (default: `3`) +- `XERO_RETRY_DELAY_MS`: Retry delay in milliseconds (default: `1000`) + +## Usage + +### Running the Server + +```bash +npm start +``` + +Or with MCP client configuration: + +```json +{ + "mcpServers": { + "xero": { + "command": "node", + "args": ["/path/to/xero/dist/main.js"], + "env": { + "XERO_ACCESS_TOKEN": "your_token", + "XERO_TENANT_ID": "your_tenant_id" + } + } + } +} +``` + +## Available Tools + +### Invoices +- `xero_list_invoices` - List all invoices with filtering +- `xero_get_invoice` - Get invoice by ID +- `xero_create_invoice` - Create new invoice +- `xero_update_invoice` - Update existing invoice + +### Bills (Payable Invoices) +- `xero_list_bills` - List all bills +- `xero_create_bill` - Create new bill + +### Contacts +- `xero_list_contacts` - List all contacts +- `xero_get_contact` - Get contact by ID +- `xero_create_contact` - Create new contact +- `xero_update_contact` - Update existing contact + +### Accounts (Chart of Accounts) +- `xero_list_accounts` - List all accounts +- `xero_get_account` - Get account by ID +- `xero_create_account` - Create new account + +### Bank Transactions +- `xero_list_bank_transactions` - List all bank transactions +- `xero_get_bank_transaction` - Get bank transaction by ID +- `xero_create_bank_transaction` - Create new bank transaction + +### Payments +- `xero_list_payments` - List all payments +- `xero_create_payment` - Create new payment + +### Credit Notes +- `xero_list_credit_notes` - List all credit notes +- `xero_create_credit_note` - Create new credit note + +### Purchase Orders +- `xero_list_purchase_orders` - List all purchase orders +- `xero_create_purchase_order` - Create new purchase order + +### Quotes +- `xero_list_quotes` - List all quotes +- `xero_create_quote` - Create new quote + +### Reports +- `xero_get_balance_sheet` - Get balance sheet report +- `xero_get_profit_and_loss` - Get profit & loss report +- `xero_get_trial_balance` - Get trial balance report +- `xero_get_bank_summary` - Get bank summary report + +### Other +- `xero_list_employees` - List all employees +- `xero_list_tax_rates` - List all tax rates +- `xero_list_items` - List inventory/service items +- `xero_create_item` - Create new item +- `xero_get_organisation` - Get organisation details +- `xero_list_tracking_categories` - List tracking categories + +## Filtering & Pagination + +Most list endpoints support: + +- `page`: Page number (default: 1) +- `pageSize`: Records per page (max: 100) +- `where`: Filter expression (e.g., `Status=="AUTHORISED"`) +- `order`: Sort order (e.g., `InvoiceNumber DESC`) +- `includeArchived`: Include archived records + +Example: +```json +{ + "name": "xero_list_invoices", + "arguments": { + "where": "Status==\"AUTHORISED\" AND AmountDue > 0", + "order": "DueDate ASC", + "page": 1, + "pageSize": 50 + } +} +``` + +## Rate Limits + +Xero enforces the following rate limits: +- **60 calls/minute** per connection +- **5000 calls/day** per connection + +This server automatically handles rate limiting and will queue requests as needed. + +## Authentication + +This server uses OAuth2 Bearer token authentication. You'll need to: + +1. Register your app in the Xero Developer Portal +2. Complete the OAuth2 flow to obtain an access token +3. Get the tenant ID from the connections endpoint +4. Set both values in your environment variables + +**Note**: Access tokens expire after 30 minutes. You'll need to implement token refresh logic in your application. + +## Error Handling + +All errors are returned in the MCP tool response format: + +```json +{ + "content": [ + { + "type": "text", + "text": "{\"error\": \"Error message here\"}" + } + ], + "isError": true +} +``` + +## Development + +### Type Checking + +```bash +npm run typecheck +``` + +### Build + +```bash +npm run build +``` + +### Watch Mode + +```bash +npm run dev +``` + +## API Coverage + +This server covers the Xero Accounting API including: + +- ✅ Invoices (receivable/payable) +- ✅ Contacts & Contact Groups +- ✅ Chart of Accounts +- ✅ Bank Transactions & Transfers +- ✅ Payments, Prepayments, Overpayments +- ✅ Credit Notes +- ✅ Purchase Orders +- ✅ Quotes +- ✅ Manual Journals +- ✅ Items (inventory/service) +- ✅ Tax Rates +- ✅ Employees (basic) +- ✅ Organisation Details +- ✅ Tracking Categories +- ✅ Branding Themes +- ✅ Financial Reports +- ✅ Attachments + +## License + +MIT + +## Links + +- [Xero API Documentation](https://developer.xero.com/documentation/api/accounting/overview) +- [Model Context Protocol](https://modelcontextprotocol.io) +- [MCP SDK](https://github.com/modelcontextprotocol/sdk) diff --git a/servers/xero/package.json b/servers/xero/package.json new file mode 100644 index 0000000..769c886 --- /dev/null +++ b/servers/xero/package.json @@ -0,0 +1,37 @@ +{ + "name": "@mcpengine/xero", + "version": "1.0.0", + "description": "MCP server for Xero Accounting API integration", + "type": "module", + "main": "dist/main.js", + "types": "dist/main.d.ts", + "bin": { + "xero-mcp": "./dist/main.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "prepare": "npm run build", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "mcp", + "xero", + "accounting", + "api" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.1", + "axios": "^1.7.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/servers/xero/src/clients/xero.ts b/servers/xero/src/clients/xero.ts new file mode 100644 index 0000000..a026123 --- /dev/null +++ b/servers/xero/src/clients/xero.ts @@ -0,0 +1,808 @@ +/** + * Xero API Client + * Handles authentication, rate limiting, pagination, and all CRUD operations + */ + +import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios'; +import type { + XeroClientConfig, + XeroRequestOptions, + XeroApiResponse, + XeroErrorResponse, + Invoice, + Contact, + Account, + BankTransaction, + Payment, + CreditNote, + PurchaseOrder, + Quote, + ManualJournal, + Item, + TaxRate, + Employee, + Organisation, + TrackingCategory, + BrandingTheme, + ContactGroup, + Prepayment, + Overpayment, + BankTransfer, + Attachment, + Report, + InvoiceId, + ContactId, + AccountId, + BankTransactionId, + PaymentId, + CreditNoteId, + PurchaseOrderId, + QuoteId, + ManualJournalId, + ItemId, + EmployeeId, + TrackingCategoryId, + PrepaymentId, + OverpaymentId, + BankTransferId +} from '../types/index.js'; + +export class XeroClient { + private client: AxiosInstance; + private config: XeroClientConfig; + private requestCount = 0; + private requestWindowStart = Date.now(); + private readonly RATE_LIMIT_PER_MINUTE = 60; + private readonly RATE_LIMIT_PER_DAY = 5000; + private dailyRequestCount = 0; + private dailyWindowStart = Date.now(); + + constructor(config: XeroClientConfig) { + this.config = { + baseUrl: 'https://api.xero.com/api.xro/2.0', + timeout: 30000, + retryAttempts: 3, + retryDelayMs: 1000, + ...config + }; + + this.client = axios.create({ + baseURL: this.config.baseUrl, + timeout: this.config.timeout, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'xero-tenant-id': this.config.tenantId, + 'Authorization': `Bearer ${this.config.accessToken}` + } + }); + + // Response interceptor for error handling + this.client.interceptors.response.use( + response => response, + error => this.handleError(error) + ); + } + + /** + * Rate limiting: 60 calls/min, 5000/day + */ + private async checkRateLimit(): Promise { + const now = Date.now(); + const minuteElapsed = now - this.requestWindowStart; + const dayElapsed = now - this.dailyWindowStart; + + // Reset minute window + if (minuteElapsed >= 60000) { + this.requestCount = 0; + this.requestWindowStart = now; + } + + // Reset daily window + if (dayElapsed >= 86400000) { + this.dailyRequestCount = 0; + this.dailyWindowStart = now; + } + + // Check limits + if (this.requestCount >= this.RATE_LIMIT_PER_MINUTE) { + const waitTime = 60000 - minuteElapsed; + await this.sleep(waitTime); + this.requestCount = 0; + this.requestWindowStart = Date.now(); + } + + if (this.dailyRequestCount >= this.RATE_LIMIT_PER_DAY) { + throw new Error('Daily rate limit exceeded (5000 requests/day)'); + } + + this.requestCount++; + this.dailyRequestCount++; + } + + /** + * Handle API errors with retry logic + */ + private async handleError(error: AxiosError): Promise { + if (error.response?.status === 429) { + // Rate limit hit - wait and retry + const retryAfter = parseInt(error.response.headers['retry-after'] || '60', 10); + await this.sleep(retryAfter * 1000); + throw error; // Will be retried by makeRequest + } + + const xeroError = error.response?.data as XeroErrorResponse | undefined; + if (xeroError) { + const message = xeroError.Detail || xeroError.Title || 'Unknown Xero API error'; + throw new Error(`Xero API Error: ${message}`); + } + + throw error; + } + + /** + * Make an API request with retry logic + */ + private async makeRequest( + config: AxiosRequestConfig, + options?: XeroRequestOptions, + attempt = 1 + ): Promise { + await this.checkRateLimit(); + + try { + // Add If-Modified-Since header if provided + if (options?.ifModifiedSince) { + config.headers = { + ...config.headers, + 'If-Modified-Since': options.ifModifiedSince.toUTCString() + }; + } + + // Add query parameters + if (options) { + const params: Record = {}; + if (options.page !== undefined) params.page = options.page; + if (options.pageSize !== undefined) params.pageSize = options.pageSize; + if (options.where) params.where = options.where; + if (options.order) params.order = options.order; + if (options.includeArchived) params.includeArchived = true; + if (options.summarizeErrors) params.summarizeErrors = true; + + config.params = { ...config.params, ...params }; + } + + const response = await this.client.request>(config); + + // Extract the actual data from the Xero response wrapper + // Xero wraps responses in objects like { Invoices: [...] } + const data = response.data; + const keys = Object.keys(data).filter(k => !['Id', 'Status', 'ProviderName', 'DateTimeUTC'].includes(k)); + const dataKey = keys[0]; + + return (dataKey ? data[dataKey] : data) as T; + } catch (error) { + if (attempt < (this.config.retryAttempts || 3)) { + await this.sleep((this.config.retryDelayMs || 1000) * attempt); + return this.makeRequest(config, options, attempt + 1); + } + throw error; + } + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } + + // ==================== INVOICES ==================== + + async getInvoices(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Invoices' }, options); + } + + async getInvoice(invoiceId: InvoiceId): Promise { + const invoices = await this.makeRequest({ + method: 'GET', + url: `/Invoices/${invoiceId}` + }); + return invoices[0]; + } + + async createInvoices(invoices: Invoice[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/Invoices', + data: { Invoices: invoices } + }); + } + + async createInvoice(invoice: Invoice): Promise { + const result = await this.createInvoices([invoice]); + return result[0]; + } + + async updateInvoice(invoiceId: InvoiceId, invoice: Partial): Promise { + const invoices = await this.makeRequest({ + method: 'POST', + url: `/Invoices/${invoiceId}`, + data: { Invoices: [invoice] } + }); + return invoices[0]; + } + + async deleteInvoice(invoiceId: InvoiceId): Promise { + await this.updateInvoice(invoiceId, { Status: 'DELETED' as any }); + } + + // ==================== CONTACTS ==================== + + async getContacts(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Contacts' }, options); + } + + async getContact(contactId: ContactId): Promise { + const contacts = await this.makeRequest({ + method: 'GET', + url: `/Contacts/${contactId}` + }); + return contacts[0]; + } + + async createContacts(contacts: Contact[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/Contacts', + data: { Contacts: contacts } + }); + } + + async createContact(contact: Contact): Promise { + const result = await this.createContacts([contact]); + return result[0]; + } + + async updateContact(contactId: ContactId, contact: Partial): Promise { + const contacts = await this.makeRequest({ + method: 'POST', + url: `/Contacts/${contactId}`, + data: { Contacts: [contact] } + }); + return contacts[0]; + } + + // ==================== ACCOUNTS ==================== + + async getAccounts(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Accounts' }, options); + } + + async getAccount(accountId: AccountId): Promise { + const accounts = await this.makeRequest({ + method: 'GET', + url: `/Accounts/${accountId}` + }); + return accounts[0]; + } + + async createAccount(account: Account): Promise { + const accounts = await this.makeRequest({ + method: 'PUT', + url: '/Accounts', + data: { Accounts: [account] } + }); + return accounts[0]; + } + + async updateAccount(accountId: AccountId, account: Partial): Promise { + const accounts = await this.makeRequest({ + method: 'POST', + url: `/Accounts/${accountId}`, + data: { Accounts: [account] } + }); + return accounts[0]; + } + + async deleteAccount(accountId: AccountId): Promise { + await this.updateAccount(accountId, { Status: 'DELETED' }); + } + + // ==================== BANK TRANSACTIONS ==================== + + async getBankTransactions(options?: XeroRequestOptions): Promise { + return this.makeRequest({ + method: 'GET', + url: '/BankTransactions' + }, options); + } + + async getBankTransaction(bankTransactionId: BankTransactionId): Promise { + const transactions = await this.makeRequest({ + method: 'GET', + url: `/BankTransactions/${bankTransactionId}` + }); + return transactions[0]; + } + + async createBankTransactions(transactions: BankTransaction[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/BankTransactions', + data: { BankTransactions: transactions } + }); + } + + async createBankTransaction(transaction: BankTransaction): Promise { + const result = await this.createBankTransactions([transaction]); + return result[0]; + } + + async updateBankTransaction( + bankTransactionId: BankTransactionId, + transaction: Partial + ): Promise { + const transactions = await this.makeRequest({ + method: 'POST', + url: `/BankTransactions/${bankTransactionId}`, + data: { BankTransactions: [transaction] } + }); + return transactions[0]; + } + + // ==================== PAYMENTS ==================== + + async getPayments(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Payments' }, options); + } + + async getPayment(paymentId: PaymentId): Promise { + const payments = await this.makeRequest({ + method: 'GET', + url: `/Payments/${paymentId}` + }); + return payments[0]; + } + + async createPayments(payments: Payment[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/Payments', + data: { Payments: payments } + }); + } + + async createPayment(payment: Payment): Promise { + const result = await this.createPayments([payment]); + return result[0]; + } + + async deletePayment(paymentId: PaymentId): Promise { + await this.makeRequest({ + method: 'POST', + url: `/Payments/${paymentId}`, + data: { Payments: [{ Status: 'DELETED' }] } + }); + } + + // ==================== CREDIT NOTES ==================== + + async getCreditNotes(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/CreditNotes' }, options); + } + + async getCreditNote(creditNoteId: CreditNoteId): Promise { + const creditNotes = await this.makeRequest({ + method: 'GET', + url: `/CreditNotes/${creditNoteId}` + }); + return creditNotes[0]; + } + + async createCreditNotes(creditNotes: CreditNote[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/CreditNotes', + data: { CreditNotes: creditNotes } + }); + } + + async createCreditNote(creditNote: CreditNote): Promise { + const result = await this.createCreditNotes([creditNote]); + return result[0]; + } + + async updateCreditNote( + creditNoteId: CreditNoteId, + creditNote: Partial + ): Promise { + const creditNotes = await this.makeRequest({ + method: 'POST', + url: `/CreditNotes/${creditNoteId}`, + data: { CreditNotes: [creditNote] } + }); + return creditNotes[0]; + } + + // ==================== PURCHASE ORDERS ==================== + + async getPurchaseOrders(options?: XeroRequestOptions): Promise { + return this.makeRequest({ + method: 'GET', + url: '/PurchaseOrders' + }, options); + } + + async getPurchaseOrder(purchaseOrderId: PurchaseOrderId): Promise { + const orders = await this.makeRequest({ + method: 'GET', + url: `/PurchaseOrders/${purchaseOrderId}` + }); + return orders[0]; + } + + async createPurchaseOrders(purchaseOrders: PurchaseOrder[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/PurchaseOrders', + data: { PurchaseOrders: purchaseOrders } + }); + } + + async createPurchaseOrder(purchaseOrder: PurchaseOrder): Promise { + const result = await this.createPurchaseOrders([purchaseOrder]); + return result[0]; + } + + async updatePurchaseOrder( + purchaseOrderId: PurchaseOrderId, + purchaseOrder: Partial + ): Promise { + const orders = await this.makeRequest({ + method: 'POST', + url: `/PurchaseOrders/${purchaseOrderId}`, + data: { PurchaseOrders: [purchaseOrder] } + }); + return orders[0]; + } + + // ==================== QUOTES ==================== + + async getQuotes(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Quotes' }, options); + } + + async getQuote(quoteId: QuoteId): Promise { + const quotes = await this.makeRequest({ + method: 'GET', + url: `/Quotes/${quoteId}` + }); + return quotes[0]; + } + + async createQuotes(quotes: Quote[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/Quotes', + data: { Quotes: quotes } + }); + } + + async createQuote(quote: Quote): Promise { + const result = await this.createQuotes([quote]); + return result[0]; + } + + async updateQuote(quoteId: QuoteId, quote: Partial): Promise { + const quotes = await this.makeRequest({ + method: 'POST', + url: `/Quotes/${quoteId}`, + data: { Quotes: [quote] } + }); + return quotes[0]; + } + + // ==================== MANUAL JOURNALS ==================== + + async getManualJournals(options?: XeroRequestOptions): Promise { + return this.makeRequest({ + method: 'GET', + url: '/ManualJournals' + }, options); + } + + async getManualJournal(manualJournalId: ManualJournalId): Promise { + const journals = await this.makeRequest({ + method: 'GET', + url: `/ManualJournals/${manualJournalId}` + }); + return journals[0]; + } + + async createManualJournals(manualJournals: ManualJournal[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/ManualJournals', + data: { ManualJournals: manualJournals } + }); + } + + async createManualJournal(manualJournal: ManualJournal): Promise { + const result = await this.createManualJournals([manualJournal]); + return result[0]; + } + + async updateManualJournal( + manualJournalId: ManualJournalId, + manualJournal: Partial + ): Promise { + const journals = await this.makeRequest({ + method: 'POST', + url: `/ManualJournals/${manualJournalId}`, + data: { ManualJournals: [manualJournal] } + }); + return journals[0]; + } + + // ==================== ITEMS ==================== + + async getItems(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Items' }, options); + } + + async getItem(itemId: ItemId): Promise { + const items = await this.makeRequest({ + method: 'GET', + url: `/Items/${itemId}` + }); + return items[0]; + } + + async createItems(items: Item[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/Items', + data: { Items: items } + }); + } + + async createItem(item: Item): Promise { + const result = await this.createItems([item]); + return result[0]; + } + + async updateItem(itemId: ItemId, item: Partial): Promise { + const items = await this.makeRequest({ + method: 'POST', + url: `/Items/${itemId}`, + data: { Items: [item] } + }); + return items[0]; + } + + // ==================== TAX RATES ==================== + + async getTaxRates(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/TaxRates' }, options); + } + + async createTaxRates(taxRates: TaxRate[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/TaxRates', + data: { TaxRates: taxRates } + }); + } + + async createTaxRate(taxRate: TaxRate): Promise { + const result = await this.createTaxRates([taxRate]); + return result[0]; + } + + // ==================== EMPLOYEES ==================== + + async getEmployees(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Employees' }, options); + } + + async getEmployee(employeeId: EmployeeId): Promise { + const employees = await this.makeRequest({ + method: 'GET', + url: `/Employees/${employeeId}` + }); + return employees[0]; + } + + async createEmployees(employees: Employee[]): Promise { + return this.makeRequest({ + method: 'PUT', + url: '/Employees', + data: { Employees: employees } + }); + } + + async createEmployee(employee: Employee): Promise { + const result = await this.createEmployees([employee]); + return result[0]; + } + + // ==================== ORGANISATION ==================== + + async getOrganisations(): Promise { + return this.makeRequest({ method: 'GET', url: '/Organisation' }); + } + + // ==================== TRACKING CATEGORIES ==================== + + async getTrackingCategories(options?: XeroRequestOptions): Promise { + return this.makeRequest({ + method: 'GET', + url: '/TrackingCategories' + }, options); + } + + async getTrackingCategory(trackingCategoryId: TrackingCategoryId): Promise { + const categories = await this.makeRequest({ + method: 'GET', + url: `/TrackingCategories/${trackingCategoryId}` + }); + return categories[0]; + } + + async createTrackingCategory(trackingCategory: TrackingCategory): Promise { + const categories = await this.makeRequest({ + method: 'PUT', + url: '/TrackingCategories', + data: { TrackingCategories: [trackingCategory] } + }); + return categories[0]; + } + + // ==================== BRANDING THEMES ==================== + + async getBrandingThemes(): Promise { + return this.makeRequest({ method: 'GET', url: '/BrandingThemes' }); + } + + // ==================== CONTACT GROUPS ==================== + + async getContactGroups(options?: XeroRequestOptions): Promise { + return this.makeRequest({ + method: 'GET', + url: '/ContactGroups' + }, options); + } + + async createContactGroup(contactGroup: ContactGroup): Promise { + const groups = await this.makeRequest({ + method: 'PUT', + url: '/ContactGroups', + data: { ContactGroups: [contactGroup] } + }); + return groups[0]; + } + + // ==================== PREPAYMENTS ==================== + + async getPrepayments(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Prepayments' }, options); + } + + async getPrepayment(prepaymentId: PrepaymentId): Promise { + const prepayments = await this.makeRequest({ + method: 'GET', + url: `/Prepayments/${prepaymentId}` + }); + return prepayments[0]; + } + + // ==================== OVERPAYMENTS ==================== + + async getOverpayments(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/Overpayments' }, options); + } + + async getOverpayment(overpaymentId: OverpaymentId): Promise { + const overpayments = await this.makeRequest({ + method: 'GET', + url: `/Overpayments/${overpaymentId}` + }); + return overpayments[0]; + } + + // ==================== BANK TRANSFERS ==================== + + async getBankTransfers(options?: XeroRequestOptions): Promise { + return this.makeRequest({ method: 'GET', url: '/BankTransfers' }, options); + } + + async getBankTransfer(bankTransferId: BankTransferId): Promise { + const transfers = await this.makeRequest({ + method: 'GET', + url: `/BankTransfers/${bankTransferId}` + }); + return transfers[0]; + } + + async createBankTransfer(bankTransfer: BankTransfer): Promise { + const transfers = await this.makeRequest({ + method: 'PUT', + url: '/BankTransfers', + data: { BankTransfers: [bankTransfer] } + }); + return transfers[0]; + } + + // ==================== REPORTS ==================== + + async getReport(reportUrl: string, options?: XeroRequestOptions): Promise { + const reports = await this.makeRequest({ + method: 'GET', + url: reportUrl + }, options); + return reports[0]; + } + + async getBalanceSheet(date?: string, periods?: number): Promise { + const params: Record = {}; + if (date) params.date = date; + if (periods) params.periods = periods; + + return this.getReport('/Reports/BalanceSheet', { ...params } as any); + } + + async getProfitAndLoss(fromDate?: string, toDate?: string): Promise { + const params: Record = {}; + if (fromDate) params.fromDate = fromDate; + if (toDate) params.toDate = toDate; + + return this.getReport('/Reports/ProfitAndLoss', { ...params } as any); + } + + async getTrialBalance(date?: string): Promise { + const params: Record = {}; + if (date) params.date = date; + + return this.getReport('/Reports/TrialBalance', { ...params } as any); + } + + async getBankSummary(fromDate?: string, toDate?: string): Promise { + const params: Record = {}; + if (fromDate) params.fromDate = fromDate; + if (toDate) params.toDate = toDate; + + return this.getReport('/Reports/BankSummary', { ...params } as any); + } + + async getExecutiveSummary(date?: string): Promise { + const params: Record = {}; + if (date) params.date = date; + + return this.getReport('/Reports/ExecutiveSummary', { ...params } as any); + } + + // ==================== ATTACHMENTS ==================== + + async getAttachments(endpoint: string, entityId: string): Promise { + return this.makeRequest({ + method: 'GET', + url: `/${endpoint}/${entityId}/Attachments` + }); + } + + async uploadAttachment( + endpoint: string, + entityId: string, + fileName: string, + fileContent: Buffer, + mimeType: string + ): Promise { + const attachments = await this.makeRequest({ + method: 'PUT', + url: `/${endpoint}/${entityId}/Attachments/${fileName}`, + data: fileContent, + headers: { + 'Content-Type': mimeType + } + }); + return attachments[0]; + } +} diff --git a/servers/xero/src/main.ts b/servers/xero/src/main.ts new file mode 100644 index 0000000..b3cf483 --- /dev/null +++ b/servers/xero/src/main.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env node + +/** + * Xero MCP Server - Main Entry Point + * Dual transport support (stdio/SSE) with graceful shutdown + */ + +import { XeroClient } from './clients/xero.js'; +import { XeroMCPServer } from './server.js'; + +async function main() { + // Required environment variables + const accessToken = process.env.XERO_ACCESS_TOKEN; + const tenantId = process.env.XERO_TENANT_ID; + + if (!accessToken) { + console.error('Error: XERO_ACCESS_TOKEN environment variable is required'); + process.exit(1); + } + + if (!tenantId) { + console.error('Error: XERO_TENANT_ID environment variable is required'); + process.exit(1); + } + + // Optional configuration + const config = { + accessToken, + tenantId, + baseUrl: process.env.XERO_BASE_URL, + timeout: process.env.XERO_TIMEOUT ? parseInt(process.env.XERO_TIMEOUT, 10) : undefined, + retryAttempts: process.env.XERO_RETRY_ATTEMPTS + ? parseInt(process.env.XERO_RETRY_ATTEMPTS, 10) + : undefined, + retryDelayMs: process.env.XERO_RETRY_DELAY_MS + ? parseInt(process.env.XERO_RETRY_DELAY_MS, 10) + : undefined + }; + + // Initialize Xero client + const xeroClient = new XeroClient(config); + + // Initialize MCP server + const mcpServer = new XeroMCPServer(xeroClient); + + // Graceful shutdown + const shutdown = async () => { + console.error('Shutting down Xero MCP server...'); + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + // Start server + await mcpServer.run(); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/servers/xero/src/server.ts b/servers/xero/src/server.ts new file mode 100644 index 0000000..5565934 --- /dev/null +++ b/servers/xero/src/server.ts @@ -0,0 +1,603 @@ +/** + * Xero MCP Server + * Provides lazy-loaded tools for Xero Accounting API operations + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + Tool +} from '@modelcontextprotocol/sdk/types.js'; +import { XeroClient } from './clients/xero.js'; + +export class XeroMCPServer { + private server: Server; + private client: XeroClient; + private toolsCache: Tool[] | null = null; + + constructor(client: XeroClient) { + this.client = client; + this.server = new Server( + { + name: 'xero-mcp', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + this.setupHandlers(); + } + + private setupHandlers(): void { + this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: this.getTools() + })); + + this.server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + + try { + const result = await this.handleToolCall(name, args || {}); + return { + content: [ + { + type: 'text', + text: JSON.stringify(result, null, 2) + } + ] + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: 'text', + text: JSON.stringify({ error: errorMessage }, null, 2) + } + ], + isError: true + }; + } + }); + } + + private getTools(): Tool[] { + if (this.toolsCache) { + return this.toolsCache; + } + + this.toolsCache = [ + // INVOICES + { + name: 'xero_list_invoices', + description: 'List all invoices with optional filtering', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number', description: 'Page number (default 1)' }, + pageSize: { type: 'number', description: 'Page size (max 100)' }, + where: { type: 'string', description: 'Filter expression (e.g., Status=="AUTHORISED")' }, + order: { type: 'string', description: 'Order by field (e.g., InvoiceNumber DESC)' }, + includeArchived: { type: 'boolean', description: 'Include archived records' } + } + } + }, + { + name: 'xero_get_invoice', + description: 'Get a specific invoice by ID', + inputSchema: { + type: 'object', + properties: { + invoiceId: { type: 'string', description: 'Invoice ID (GUID)' } + }, + required: ['invoiceId'] + } + }, + { + name: 'xero_create_invoice', + description: 'Create a new invoice', + inputSchema: { + type: 'object', + properties: { + invoice: { + type: 'object', + description: 'Invoice data (Contact, LineItems, Type, etc.)' + } + }, + required: ['invoice'] + } + }, + { + name: 'xero_update_invoice', + description: 'Update an existing invoice', + inputSchema: { + type: 'object', + properties: { + invoiceId: { type: 'string', description: 'Invoice ID (GUID)' }, + invoice: { type: 'object', description: 'Invoice update data' } + }, + required: ['invoiceId', 'invoice'] + } + }, + + // CONTACTS + { + name: 'xero_list_contacts', + description: 'List all contacts with optional filtering', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number' }, + pageSize: { type: 'number' }, + where: { type: 'string' }, + order: { type: 'string' }, + includeArchived: { type: 'boolean' } + } + } + }, + { + name: 'xero_get_contact', + description: 'Get a specific contact by ID', + inputSchema: { + type: 'object', + properties: { + contactId: { type: 'string', description: 'Contact ID (GUID)' } + }, + required: ['contactId'] + } + }, + { + name: 'xero_create_contact', + description: 'Create a new contact', + inputSchema: { + type: 'object', + properties: { + contact: { type: 'object', description: 'Contact data (Name required)' } + }, + required: ['contact'] + } + }, + { + name: 'xero_update_contact', + description: 'Update an existing contact', + inputSchema: { + type: 'object', + properties: { + contactId: { type: 'string' }, + contact: { type: 'object' } + }, + required: ['contactId', 'contact'] + } + }, + + // ACCOUNTS + { + name: 'xero_list_accounts', + description: 'List all chart of accounts', + inputSchema: { + type: 'object', + properties: { + where: { type: 'string' }, + order: { type: 'string' } + } + } + }, + { + name: 'xero_get_account', + description: 'Get a specific account by ID', + inputSchema: { + type: 'object', + properties: { + accountId: { type: 'string', description: 'Account ID (GUID)' } + }, + required: ['accountId'] + } + }, + { + name: 'xero_create_account', + description: 'Create a new account', + inputSchema: { + type: 'object', + properties: { + account: { type: 'object', description: 'Account data (Code, Name, Type required)' } + }, + required: ['account'] + } + }, + + // BANK TRANSACTIONS + { + name: 'xero_list_bank_transactions', + description: 'List all bank transactions', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number' }, + pageSize: { type: 'number' }, + where: { type: 'string' }, + order: { type: 'string' } + } + } + }, + { + name: 'xero_get_bank_transaction', + description: 'Get a specific bank transaction by ID', + inputSchema: { + type: 'object', + properties: { + bankTransactionId: { type: 'string' } + }, + required: ['bankTransactionId'] + } + }, + { + name: 'xero_create_bank_transaction', + description: 'Create a new bank transaction', + inputSchema: { + type: 'object', + properties: { + transaction: { type: 'object' } + }, + required: ['transaction'] + } + }, + + // PAYMENTS + { + name: 'xero_list_payments', + description: 'List all payments', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number' }, + pageSize: { type: 'number' }, + where: { type: 'string' } + } + } + }, + { + name: 'xero_create_payment', + description: 'Create a new payment', + inputSchema: { + type: 'object', + properties: { + payment: { type: 'object' } + }, + required: ['payment'] + } + }, + + // BILLS (same as invoices with Type=ACCPAY) + { + name: 'xero_list_bills', + description: 'List all bills (payable invoices)', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number' }, + pageSize: { type: 'number' }, + where: { type: 'string' }, + order: { type: 'string' } + } + } + }, + { + name: 'xero_create_bill', + description: 'Create a new bill', + inputSchema: { + type: 'object', + properties: { + bill: { type: 'object', description: 'Bill data (Contact, LineItems, Type=ACCPAY)' } + }, + required: ['bill'] + } + }, + + // CREDIT NOTES + { + name: 'xero_list_credit_notes', + description: 'List all credit notes', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number' }, + pageSize: { type: 'number' }, + where: { type: 'string' } + } + } + }, + { + name: 'xero_create_credit_note', + description: 'Create a new credit note', + inputSchema: { + type: 'object', + properties: { + creditNote: { type: 'object' } + }, + required: ['creditNote'] + } + }, + + // PURCHASE ORDERS + { + name: 'xero_list_purchase_orders', + description: 'List all purchase orders', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number' }, + pageSize: { type: 'number' }, + where: { type: 'string' } + } + } + }, + { + name: 'xero_create_purchase_order', + description: 'Create a new purchase order', + inputSchema: { + type: 'object', + properties: { + purchaseOrder: { type: 'object' } + }, + required: ['purchaseOrder'] + } + }, + + // QUOTES + { + name: 'xero_list_quotes', + description: 'List all quotes', + inputSchema: { + type: 'object', + properties: { + page: { type: 'number' }, + pageSize: { type: 'number' }, + where: { type: 'string' } + } + } + }, + { + name: 'xero_create_quote', + description: 'Create a new quote', + inputSchema: { + type: 'object', + properties: { + quote: { type: 'object' } + }, + required: ['quote'] + } + }, + + // REPORTS + { + name: 'xero_get_balance_sheet', + description: 'Get balance sheet report', + inputSchema: { + type: 'object', + properties: { + date: { type: 'string', description: 'Report date (YYYY-MM-DD)' }, + periods: { type: 'number', description: 'Number of periods to compare' } + } + } + }, + { + name: 'xero_get_profit_and_loss', + description: 'Get profit and loss report', + inputSchema: { + type: 'object', + properties: { + fromDate: { type: 'string', description: 'Start date (YYYY-MM-DD)' }, + toDate: { type: 'string', description: 'End date (YYYY-MM-DD)' } + } + } + }, + { + name: 'xero_get_trial_balance', + description: 'Get trial balance report', + inputSchema: { + type: 'object', + properties: { + date: { type: 'string', description: 'Report date (YYYY-MM-DD)' } + } + } + }, + { + name: 'xero_get_bank_summary', + description: 'Get bank summary report', + inputSchema: { + type: 'object', + properties: { + fromDate: { type: 'string' }, + toDate: { type: 'string' } + } + } + }, + + // EMPLOYEES + { + name: 'xero_list_employees', + description: 'List all employees (basic accounting)', + inputSchema: { + type: 'object', + properties: { + where: { type: 'string' } + } + } + }, + + // TAX RATES + { + name: 'xero_list_tax_rates', + description: 'List all tax rates', + inputSchema: { + type: 'object', + properties: { + where: { type: 'string' } + } + } + }, + + // ITEMS + { + name: 'xero_list_items', + description: 'List all inventory/service items', + inputSchema: { + type: 'object', + properties: { + where: { type: 'string' } + } + } + }, + { + name: 'xero_create_item', + description: 'Create a new inventory/service item', + inputSchema: { + type: 'object', + properties: { + item: { type: 'object' } + }, + required: ['item'] + } + }, + + // ORGANISATION + { + name: 'xero_get_organisation', + description: 'Get organisation details', + inputSchema: { + type: 'object', + properties: {} + } + }, + + // TRACKING CATEGORIES + { + name: 'xero_list_tracking_categories', + description: 'List all tracking categories', + inputSchema: { + type: 'object', + properties: {} + } + } + ]; + + return this.toolsCache; + } + + private async handleToolCall(name: string, args: Record): Promise { + switch (name) { + // INVOICES + case 'xero_list_invoices': + return this.client.getInvoices(args); + case 'xero_get_invoice': + return this.client.getInvoice(args.invoiceId as any); + case 'xero_create_invoice': + return this.client.createInvoice(args.invoice as any); + case 'xero_update_invoice': + return this.client.updateInvoice(args.invoiceId as any, args.invoice as any); + + // CONTACTS + case 'xero_list_contacts': + return this.client.getContacts(args); + case 'xero_get_contact': + return this.client.getContact(args.contactId as any); + case 'xero_create_contact': + return this.client.createContact(args.contact as any); + case 'xero_update_contact': + return this.client.updateContact(args.contactId as any, args.contact as any); + + // ACCOUNTS + case 'xero_list_accounts': + return this.client.getAccounts(args); + case 'xero_get_account': + return this.client.getAccount(args.accountId as any); + case 'xero_create_account': + return this.client.createAccount(args.account as any); + + // BANK TRANSACTIONS + case 'xero_list_bank_transactions': + return this.client.getBankTransactions(args); + case 'xero_get_bank_transaction': + return this.client.getBankTransaction(args.bankTransactionId as any); + case 'xero_create_bank_transaction': + return this.client.createBankTransaction(args.transaction as any); + + // PAYMENTS + case 'xero_list_payments': + return this.client.getPayments(args); + case 'xero_create_payment': + return this.client.createPayment(args.payment as any); + + // BILLS + case 'xero_list_bills': + return this.client.getInvoices({ ...args, where: 'Type=="ACCPAY"' }); + case 'xero_create_bill': + return this.client.createInvoice({ ...(args.bill as any), Type: 'ACCPAY' }); + + // CREDIT NOTES + case 'xero_list_credit_notes': + return this.client.getCreditNotes(args); + case 'xero_create_credit_note': + return this.client.createCreditNote(args.creditNote as any); + + // PURCHASE ORDERS + case 'xero_list_purchase_orders': + return this.client.getPurchaseOrders(args); + case 'xero_create_purchase_order': + return this.client.createPurchaseOrder(args.purchaseOrder as any); + + // QUOTES + case 'xero_list_quotes': + return this.client.getQuotes(args); + case 'xero_create_quote': + return this.client.createQuote(args.quote as any); + + // REPORTS + case 'xero_get_balance_sheet': + return this.client.getBalanceSheet(args.date as string, args.periods as number); + case 'xero_get_profit_and_loss': + return this.client.getProfitAndLoss(args.fromDate as string, args.toDate as string); + case 'xero_get_trial_balance': + return this.client.getTrialBalance(args.date as string); + case 'xero_get_bank_summary': + return this.client.getBankSummary(args.fromDate as string, args.toDate as string); + + // EMPLOYEES + case 'xero_list_employees': + return this.client.getEmployees(args); + + // TAX RATES + case 'xero_list_tax_rates': + return this.client.getTaxRates(args); + + // ITEMS + case 'xero_list_items': + return this.client.getItems(args); + case 'xero_create_item': + return this.client.createItem(args.item as any); + + // ORGANISATION + case 'xero_get_organisation': + return this.client.getOrganisations(); + + // TRACKING CATEGORIES + case 'xero_list_tracking_categories': + return this.client.getTrackingCategories(args); + + default: + throw new Error(`Unknown tool: ${name}`); + } + } + + async run(): Promise { + const transport = new StdioServerTransport(); + await this.server.connect(transport); + console.error('Xero MCP Server running on stdio'); + } +} diff --git a/servers/xero/src/types/index.ts b/servers/xero/src/types/index.ts new file mode 100644 index 0000000..3a979ae --- /dev/null +++ b/servers/xero/src/types/index.ts @@ -0,0 +1,818 @@ +/** + * Xero Accounting API TypeScript Types + * Comprehensive type definitions for Xero API entities + */ + +// Branded types for IDs (Xero uses GUIDs) +export type InvoiceId = string & { readonly __brand: 'InvoiceId' }; +export type ContactId = string & { readonly __brand: 'ContactId' }; +export type AccountId = string & { readonly __brand: 'AccountId' }; +export type BankTransactionId = string & { readonly __brand: 'BankTransactionId' }; +export type PaymentId = string & { readonly __brand: 'PaymentId' }; +export type ItemId = string & { readonly __brand: 'ItemId' }; +export type TaxRateId = string & { readonly __brand: 'TaxRateId' }; +export type EmployeeId = string & { readonly __brand: 'EmployeeId' }; +export type OrganisationId = string & { readonly __brand: 'OrganisationId' }; +export type TrackingCategoryId = string & { readonly __brand: 'TrackingCategoryId' }; +export type TrackingOptionId = string & { readonly __brand: 'TrackingOptionId' }; +export type AttachmentId = string & { readonly __brand: 'AttachmentId' }; +export type CreditNoteId = string & { readonly __brand: 'CreditNoteId' }; +export type PurchaseOrderId = string & { readonly __brand: 'PurchaseOrderId' }; +export type QuoteId = string & { readonly __brand: 'QuoteId' }; +export type ManualJournalId = string & { readonly __brand: 'ManualJournalId' }; +export type BrandingThemeId = string & { readonly __brand: 'BrandingThemeId' }; +export type ContactGroupId = string & { readonly __brand: 'ContactGroupId' }; +export type PrepaymentId = string & { readonly __brand: 'PrepaymentId' }; +export type OverpaymentId = string & { readonly __brand: 'OverpaymentId' }; +export type BankTransferId = string & { readonly __brand: 'BankTransferId' }; + +// Enums and Constants +export enum InvoiceStatus { + DRAFT = 'DRAFT', + SUBMITTED = 'SUBMITTED', + AUTHORISED = 'AUTHORISED', + PAID = 'PAID', + VOIDED = 'VOIDED', + DELETED = 'DELETED' +} + +export enum InvoiceType { + ACCPAY = 'ACCPAY', // Bill + ACCREC = 'ACCREC' // Invoice +} + +export enum LineAmountType { + Exclusive = 'Exclusive', + Inclusive = 'Inclusive', + NoTax = 'NoTax' +} + +export enum AccountType { + BANK = 'BANK', + CURRENT = 'CURRENT', + CURRLIAB = 'CURRLIAB', + DEPRECIATN = 'DEPRECIATN', + DIRECTCOSTS = 'DIRECTCOSTS', + EQUITY = 'EQUITY', + EXPENSE = 'EXPENSE', + FIXED = 'FIXED', + INVENTORY = 'INVENTORY', + LIABILITY = 'LIABILITY', + NONCURRENT = 'NONCURRENT', + OTHERINCOME = 'OTHERINCOME', + OVERHEADS = 'OVERHEADS', + PREPAYMENT = 'PREPAYMENT', + REVENUE = 'REVENUE', + SALES = 'SALES', + TERMLIAB = 'TERMLIAB', + PAYGLIABILITY = 'PAYGLIABILITY', + SUPERANNUATIONEXPENSE = 'SUPERANNUATIONEXPENSE', + SUPERANNUATIONLIABILITY = 'SUPERANNUATIONLIABILITY', + WAGESEXPENSE = 'WAGESEXPENSE' +} + +export enum AccountClass { + ASSET = 'ASSET', + EQUITY = 'EQUITY', + EXPENSE = 'EXPENSE', + LIABILITY = 'LIABILITY', + REVENUE = 'REVENUE' +} + +export enum BankTransactionStatus { + AUTHORISED = 'AUTHORISED', + DELETED = 'DELETED', + VOIDED = 'VOIDED' +} + +export enum BankTransactionType { + RECEIVE = 'RECEIVE', + SPEND = 'SPEND' +} + +export enum PaymentStatus { + AUTHORISED = 'AUTHORISED', + DELETED = 'DELETED' +} + +export enum CreditNoteStatus { + DRAFT = 'DRAFT', + SUBMITTED = 'SUBMITTED', + AUTHORISED = 'AUTHORISED', + PAID = 'PAID', + VOIDED = 'VOIDED', + DELETED = 'DELETED' +} + +export enum CreditNoteType { + ACCPAYCREDIT = 'ACCPAYCREDIT', + ACCRECCREDIT = 'ACCRECCREDIT' +} + +export enum PurchaseOrderStatus { + DRAFT = 'DRAFT', + SUBMITTED = 'SUBMITTED', + AUTHORISED = 'AUTHORISED', + BILLED = 'BILLED', + DELETED = 'DELETED' +} + +export enum QuoteStatus { + DRAFT = 'DRAFT', + SENT = 'SENT', + ACCEPTED = 'ACCEPTED', + DECLINED = 'DECLINED', + INVOICED = 'INVOICED', + DELETED = 'DELETED' +} + +export enum ManualJournalStatus { + DRAFT = 'DRAFT', + POSTED = 'POSTED', + DELETED = 'DELETED', + VOIDED = 'VOIDED' +} + +export enum TaxType { + INPUT = 'INPUT', + OUTPUT = 'OUTPUT', + CAPEXINPUT = 'CAPEXINPUT', + CAPEXOUTPUT = 'CAPEXOUTPUT', + EXEMPTEXPENSES = 'EXEMPTEXPENSES', + EXEMPTINCOME = 'EXEMPTINCOME', + EXEMPTCAPITAL = 'EXEMPTCAPITAL', + EXEMPTOUTPUT = 'EXEMPTOUTPUT', + INPUTTAXED = 'INPUTTAXED', + BASEXCLUDED = 'BASEXCLUDED', + GSTONCAPIMPORTS = 'GSTONCAPIMPORTS', + GSTONIMPORTS = 'GSTONIMPORTS', + NONE = 'NONE', + INPUT2 = 'INPUT2', + ECZROUTPUT = 'ECZROUTPUT', + ZERORATEDINPUT = 'ZERORATEDINPUT', + ZERORATEDOUTPUT = 'ZERORATEDOUTPUT', + REVERSECHARGES = 'REVERSECHARGES', + RRINPUT = 'RRINPUT', + RROUTPUT = 'RROUTPUT' +} + +export type CurrencyCode = string; // e.g., 'USD', 'GBP', 'EUR', 'AUD', 'NZD' + +// Address +export interface Address { + AddressType?: 'POBOX' | 'STREET' | 'DELIVERY'; + AddressLine1?: string; + AddressLine2?: string; + AddressLine3?: string; + AddressLine4?: string; + City?: string; + Region?: string; + PostalCode?: string; + Country?: string; + AttentionTo?: string; +} + +// Phone +export interface Phone { + PhoneType?: 'DEFAULT' | 'DDI' | 'MOBILE' | 'FAX'; + PhoneNumber?: string; + PhoneAreaCode?: string; + PhoneCountryCode?: string; +} + +// ContactPerson +export interface ContactPerson { + FirstName?: string; + LastName?: string; + EmailAddress?: string; + IncludeInEmails?: boolean; +} + +// Contact +export interface Contact { + ContactID?: ContactId; + ContactNumber?: string; + AccountNumber?: string; + ContactStatus?: 'ACTIVE' | 'ARCHIVED' | 'GDPRREQUEST'; + Name: string; + FirstName?: string; + LastName?: string; + EmailAddress?: string; + SkypeUserName?: string; + ContactPersons?: ContactPerson[]; + BankAccountDetails?: string; + TaxNumber?: string; + AccountsReceivableTaxType?: TaxType; + AccountsPayableTaxType?: TaxType; + Addresses?: Address[]; + Phones?: Phone[]; + IsSupplier?: boolean; + IsCustomer?: boolean; + DefaultCurrency?: CurrencyCode; + UpdatedDateUTC?: string; + ContactGroups?: ContactGroup[]; + SalesDefaultAccountCode?: string; + PurchasesDefaultAccountCode?: string; + SalesTrackingCategories?: TrackingCategory[]; + PurchasesTrackingCategories?: TrackingCategory[]; + TrackingCategoryName?: string; + TrackingCategoryOption?: string; + PaymentTerms?: { + Bills?: { Day?: number; Type?: 'DAYSAFTERBILLDATE' | 'DAYSAFTERBILLMONTH' | 'OFCURRENTMONTH' | 'OFFOLLOWINGMONTH' }; + Sales?: { Day?: number; Type?: 'DAYSAFTERBILLDATE' | 'DAYSAFTERBILLMONTH' | 'OFCURRENTMONTH' | 'OFFOLLOWINGMONTH' }; + }; + Discount?: number; + Balances?: { + AccountsReceivable?: { + Outstanding?: number; + Overdue?: number; + }; + AccountsPayable?: { + Outstanding?: number; + Overdue?: number; + }; + }; + BatchPayments?: { + BankAccountNumber?: string; + BankAccountName?: string; + Details?: string; + }; +} + +// ContactGroup +export interface ContactGroup { + ContactGroupID?: ContactGroupId; + Name: string; + Status?: 'ACTIVE' | 'DELETED'; + Contacts?: Contact[]; +} + +// TrackingCategory +export interface TrackingCategory { + TrackingCategoryID?: TrackingCategoryId; + TrackingCategoryName?: string; + Name?: string; + Status?: 'ACTIVE' | 'ARCHIVED' | 'DELETED'; + Options?: TrackingOption[]; +} + +// TrackingOption +export interface TrackingOption { + TrackingOptionID?: TrackingOptionId; + Name?: string; + Status?: 'ACTIVE' | 'ARCHIVED' | 'DELETED'; + TrackingCategoryID?: TrackingCategoryId; +} + +// LineItemTracking +export interface LineItemTracking { + TrackingCategoryID?: TrackingCategoryId; + TrackingOptionID?: TrackingOptionId; + Name?: string; + Option?: string; +} + +// InvoiceLineItem +export interface InvoiceLineItem { + LineItemID?: string; + Description?: string; + Quantity?: number; + UnitAmount?: number; + ItemCode?: string; + AccountCode?: string; + AccountID?: AccountId; + TaxType?: TaxType; + TaxAmount?: number; + LineAmount?: number; + DiscountRate?: number; + DiscountAmount?: number; + Tracking?: LineItemTracking[]; + Item?: Item; +} + +// Invoice +export interface Invoice { + InvoiceID?: InvoiceId; + Type?: InvoiceType; + InvoiceNumber?: string; + Reference?: string; + Payments?: Payment[]; + CreditNotes?: CreditNote[]; + Prepayments?: Prepayment[]; + Overpayments?: Overpayment[]; + AmountDue?: number; + AmountPaid?: number; + AmountCredited?: number; + CurrencyRate?: number; + IsDiscounted?: boolean; + HasAttachments?: boolean; + HasErrors?: boolean; + Contact: Contact; + DateString?: string; + Date?: string; + DueDateString?: string; + DueDate?: string; + Status?: InvoiceStatus; + LineAmountTypes?: LineAmountType; + LineItems?: InvoiceLineItem[]; + SubTotal?: number; + TotalTax?: number; + Total?: number; + UpdatedDateUTC?: string; + CurrencyCode?: CurrencyCode; + FullyPaidOnDate?: string; + BrandingThemeID?: BrandingThemeId; + Url?: string; + SentToContact?: boolean; + ExpectedPaymentDate?: string; + PlannedPaymentDate?: string; + RepeatingInvoiceID?: string; + Attachments?: Attachment[]; +} + +// Bill (same as Invoice but type=ACCPAY) +export type Bill = Invoice; + +// Account +export interface Account { + AccountID?: AccountId; + Code: string; + Name?: string; + Type?: AccountType; + Class?: AccountClass; + Status?: 'ACTIVE' | 'ARCHIVED' | 'DELETED'; + Description?: string; + BankAccountNumber?: string; + BankAccountType?: 'BANK' | 'CREDITCARD' | 'PAYPAL'; + CurrencyCode?: CurrencyCode; + TaxType?: TaxType; + EnablePaymentsToAccount?: boolean; + ShowInExpenseClaims?: boolean; + ReportingCode?: string; + ReportingCodeName?: string; + HasAttachments?: boolean; + UpdatedDateUTC?: string; + AddToWatchlist?: boolean; +} + +// BankTransaction +export interface BankTransaction { + BankTransactionID?: BankTransactionId; + Type?: BankTransactionType; + Contact: Contact; + LineItems: InvoiceLineItem[]; + BankAccount: Account; + IsReconciled?: boolean; + DateString?: string; + Date?: string; + Reference?: string; + CurrencyCode?: CurrencyCode; + CurrencyRate?: number; + Url?: string; + Status?: BankTransactionStatus; + LineAmountTypes?: LineAmountType; + SubTotal?: number; + TotalTax?: number; + Total?: number; + UpdatedDateUTC?: string; + HasAttachments?: boolean; + Attachments?: Attachment[]; +} + +// BankTransfer +export interface BankTransfer { + BankTransferID?: BankTransferId; + FromBankAccount: Account; + ToBankAccount: Account; + Amount: number; + Date?: string; + DateString?: string; + CurrencyRate?: number; + FromBankTransactionID?: BankTransactionId; + ToBankTransactionID?: BankTransactionId; + HasAttachments?: boolean; + CreatedDateUTC?: string; + Attachments?: Attachment[]; +} + +// Payment +export interface Payment { + PaymentID?: PaymentId; + Date?: string; + CurrencyRate?: number; + Amount?: number; + Reference?: string; + IsReconciled?: boolean; + Status?: PaymentStatus; + PaymentType?: 'ACCRECPAYMENT' | 'ACCPAYPAYMENT' | 'ARCREDITPAYMENT' | 'APCREDITPAYMENT' | 'AROVERPAYMENTPAYMENT' | 'ARPREPAYMENTPAYMENT' | 'APPREPAYMENTPAYMENT' | 'APOVERPAYMENTPAYMENT'; + UpdatedDateUTC?: string; + HasAccount?: boolean; + HasValidationErrors?: boolean; + Invoice?: Invoice; + CreditNote?: CreditNote; + Prepayment?: Prepayment; + Overpayment?: Overpayment; + Account?: Account; + BankAmount?: number; + Details?: string; +} + +// Prepayment +export interface Prepayment { + PrepaymentID?: PrepaymentId; + Type?: 'RECEIVE-PREPAYMENT' | 'SPEND-PREPAYMENT'; + Contact?: Contact; + Date?: string; + Status?: 'AUTHORISED' | 'PAID' | 'VOIDED'; + LineAmountTypes?: LineAmountType; + LineItems?: InvoiceLineItem[]; + SubTotal?: number; + TotalTax?: number; + Total?: number; + UpdatedDateUTC?: string; + CurrencyCode?: CurrencyCode; + CurrencyRate?: number; + Reference?: string; + RemainingCredit?: number; + Allocations?: { + Invoice?: Invoice; + Amount?: number; + Date?: string; + }[]; + Payments?: Payment[]; + HasAttachments?: boolean; + Attachments?: Attachment[]; +} + +// Overpayment +export interface Overpayment { + OverpaymentID?: OverpaymentId; + Type?: 'RECEIVE-OVERPAYMENT' | 'SPEND-OVERPAYMENT'; + Contact?: Contact; + Date?: string; + Status?: 'AUTHORISED' | 'PAID' | 'VOIDED'; + LineAmountTypes?: LineAmountType; + LineItems?: InvoiceLineItem[]; + SubTotal?: number; + TotalTax?: number; + Total?: number; + UpdatedDateUTC?: string; + CurrencyCode?: CurrencyCode; + CurrencyRate?: number; + RemainingCredit?: number; + Allocations?: { + Invoice?: Invoice; + Amount?: number; + Date?: string; + }[]; + Payments?: Payment[]; + HasAttachments?: boolean; + Attachments?: Attachment[]; +} + +// CreditNote +export interface CreditNote { + CreditNoteID?: CreditNoteId; + CreditNoteNumber?: string; + Type?: CreditNoteType; + Contact?: Contact; + Date?: string; + DueDate?: string; + Status?: CreditNoteStatus; + LineAmountTypes?: LineAmountType; + LineItems?: InvoiceLineItem[]; + SubTotal?: number; + TotalTax?: number; + Total?: number; + UpdatedDateUTC?: string; + CurrencyCode?: CurrencyCode; + CurrencyRate?: number; + RemainingCredit?: number; + Allocations?: { + Invoice?: Invoice; + Amount?: number; + Date?: string; + }[]; + Payments?: Payment[]; + BrandingThemeID?: BrandingThemeId; + HasAttachments?: boolean; + Attachments?: Attachment[]; + Reference?: string; + SentToContact?: boolean; +} + +// PurchaseOrder +export interface PurchaseOrder { + PurchaseOrderID?: PurchaseOrderId; + PurchaseOrderNumber?: string; + DateString?: string; + Date?: string; + DeliveryDateString?: string; + DeliveryDate?: string; + DeliveryAddress?: string; + AttentionTo?: string; + Telephone?: string; + DeliveryInstructions?: string; + ExpectedArrivalDate?: string; + Contact?: Contact; + BrandingThemeID?: BrandingThemeId; + Status?: PurchaseOrderStatus; + LineAmountTypes?: LineAmountType; + LineItems?: InvoiceLineItem[]; + SubTotal?: number; + TotalTax?: number; + Total?: number; + UpdatedDateUTC?: string; + CurrencyCode?: CurrencyCode; + CurrencyRate?: number; + HasAttachments?: boolean; + Attachments?: Attachment[]; + SentToContact?: boolean; + Reference?: string; +} + +// Quote +export interface Quote { + QuoteID?: QuoteId; + QuoteNumber?: string; + Reference?: string; + Terms?: string; + Contact?: Contact; + LineItems?: InvoiceLineItem[]; + Date?: string; + DateString?: string; + ExpiryDate?: string; + ExpiryDateString?: string; + Status?: QuoteStatus; + CurrencyCode?: CurrencyCode; + CurrencyRate?: number; + SubTotal?: number; + TotalTax?: number; + Total?: number; + TotalDiscount?: number; + Title?: string; + Summary?: string; + BrandingThemeID?: BrandingThemeId; + UpdatedDateUTC?: string; + LineAmountTypes?: LineAmountType; + Url?: string; + HasAttachments?: boolean; + Attachments?: Attachment[]; +} + +// JournalLine +export interface JournalLine { + JournalLineID?: string; + AccountID?: AccountId; + AccountCode?: string; + LineAmount?: number; + TaxType?: TaxType; + TaxAmount?: number; + Description?: string; + Tracking?: LineItemTracking[]; +} + +// ManualJournal +export interface ManualJournal { + ManualJournalID?: ManualJournalId; + Narration: string; + JournalLines?: JournalLine[]; + Date?: string; + DateString?: string; + Status?: ManualJournalStatus; + LineAmountTypes?: LineAmountType; + Url?: string; + ShowOnCashBasisReports?: boolean; + HasAttachments?: boolean; + UpdatedDateUTC?: string; + Attachments?: Attachment[]; +} + +// Item +export interface Item { + ItemID?: ItemId; + Code: string; + Name?: string; + Description?: string; + PurchaseDescription?: string; + PurchaseDetails?: { + UnitPrice?: number; + AccountCode?: string; + TaxType?: TaxType; + }; + SalesDetails?: { + UnitPrice?: number; + AccountCode?: string; + TaxType?: TaxType; + }; + IsTrackedAsInventory?: boolean; + InventoryAssetAccountCode?: string; + TotalCostPool?: number; + QuantityOnHand?: number; + UpdatedDateUTC?: string; + IsSold?: boolean; + IsPurchased?: boolean; +} + +// TaxComponent +export interface TaxComponent { + Name?: string; + Rate?: number; + IsCompound?: boolean; + IsNonRecoverable?: boolean; +} + +// TaxRate +export interface TaxRate { + TaxRateID?: TaxRateId; + Name?: string; + TaxType?: TaxType; + Status?: 'ACTIVE' | 'DELETED' | 'ARCHIVED'; + ReportTaxType?: string; + CanApplyToAssets?: boolean; + CanApplyToEquity?: boolean; + CanApplyToExpenses?: boolean; + CanApplyToLiabilities?: boolean; + CanApplyToRevenue?: boolean; + DisplayTaxRate?: number; + EffectiveRate?: number; + TaxComponents?: TaxComponent[]; +} + +// Currency +export interface Currency { + Code: CurrencyCode; + Description?: string; +} + +// Employee (basic accounting, not payroll) +export interface Employee { + EmployeeID?: EmployeeId; + Status?: 'ACTIVE' | 'DELETED'; + FirstName?: string; + LastName?: string; + ExternalLink?: { + Url?: string; + }; +} + +// BrandingTheme +export interface BrandingTheme { + BrandingThemeID?: BrandingThemeId; + Name?: string; + LogoUrl?: string; + Type?: 'STANDARD' | 'CUSTOM'; + SortOrder?: number; + CreatedDateUTC?: string; +} + +// Organisation +export interface Organisation { + OrganisationID?: OrganisationId; + APIKey?: string; + Name?: string; + LegalName?: string; + PaysTax?: boolean; + Version?: 'AU' | 'GB' | 'NZ' | 'US' | 'GLOBAL'; + OrganisationType?: 'COMPANY' | 'CHARITY' | 'CLUBSOCIETY' | 'PARTNERSHIP' | 'PRACTICE' | 'PERSON' | 'SOLETRADER' | 'TRUST'; + BaseCurrency?: CurrencyCode; + CountryCode?: string; + IsDemoCompany?: boolean; + OrganisationStatus?: string; + RegistrationNumber?: string; + TaxNumber?: string; + FinancialYearEndDay?: number; + FinancialYearEndMonth?: number; + SalesTaxBasis?: 'ACCRUALS' | 'CASH' | 'FLATRATECASH' | 'NONE'; + SalesTaxPeriod?: 'MONTHLY' | 'QUARTERLY' | 'ANNUALLY' | 'NONE'; + DefaultSalesTax?: string; + DefaultPurchasesTax?: string; + PeriodLockDate?: string; + EndOfYearLockDate?: string; + CreatedDateUTC?: string; + Timezone?: string; + OrganisationEntityType?: string; + ShortCode?: string; + LineOfBusiness?: string; + Addresses?: Address[]; + Phones?: Phone[]; + ExternalLinks?: Array<{ LinkType?: string; Url?: string }>; + PaymentTerms?: { + Bills?: { Day?: number; Type?: string }; + Sales?: { Day?: number; Type?: string }; + }; +} + +// Attachment +export interface Attachment { + AttachmentID?: AttachmentId; + FileName?: string; + Url?: string; + MimeType?: string; + ContentLength?: number; + IncludeOnline?: boolean; +} + +// Report types +export interface ReportCell { + Value?: string; + Attributes?: Array<{ Value?: string; Id?: string }>; +} + +export interface ReportRow { + RowType?: 'Header' | 'Section' | 'Row' | 'SummaryRow'; + Cells?: ReportCell[]; + Title?: string; + Rows?: ReportRow[]; +} + +export interface Report { + ReportID?: string; + ReportName?: string; + ReportType?: string; + ReportTitles?: string[]; + ReportDate?: string; + UpdatedDateUTC?: string; + Rows?: ReportRow[]; +} + +export interface BalanceSheet extends Report { + ReportType: 'BalanceSheet'; +} + +export interface ProfitAndLoss extends Report { + ReportType: 'ProfitAndLoss'; +} + +export interface TrialBalance extends Report { + ReportType: 'TrialBalance'; +} + +export interface BankSummary extends Report { + ReportType: 'BankSummary'; +} + +export interface BudgetSummary extends Report { + ReportType: 'BudgetSummary'; +} + +export interface ExecutiveSummary extends Report { + ReportType: 'ExecutiveSummary'; +} + +// Pagination +export interface PaginationParams { + page?: number; + pageSize?: number; +} + +export interface PaginatedResponse { + data: T[]; + page?: number; + pageSize?: number; + totalPages?: number; + totalRecords?: number; +} + +// API Response wrappers +export interface XeroApiResponse { + Id?: string; + Status?: string; + ProviderName?: string; + DateTimeUTC?: string; + [key: string]: T[] | T | string | undefined; +} + +export interface XeroErrorResponse { + Type?: string; + Title?: string; + Status?: number; + Detail?: string; + Instance?: string; + Elements?: Array<{ + ValidationErrors?: Array<{ + Message?: string; + }>; + }>; +} + +// Client configuration +export interface XeroClientConfig { + accessToken: string; + tenantId: string; + baseUrl?: string; + timeout?: number; + retryAttempts?: number; + retryDelayMs?: number; +} + +// Request options +export interface XeroRequestOptions { + summarizeErrors?: boolean; + ifModifiedSince?: Date; + page?: number; + pageSize?: number; + where?: string; + order?: string; + includeArchived?: boolean; +} diff --git a/servers/xero/tsconfig.json b/servers/xero/tsconfig.json new file mode 100644 index 0000000..a137695 --- /dev/null +++ b/servers/xero/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}