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