#!/usr/bin/env node
import 'dotenv/config';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListPromptsRequestSchema,
GetPromptRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import WebSocket from 'ws';
import FormData from 'form-data';
import fs from 'fs';
import path from 'path';
// ===== TYPE DEFINITIONS =====
interface DirectusConfig {
url: string;
token: string;
timeout?: number;
retries?: number;
websocket?: boolean;
}
interface DirectusCollection {
collection: string;
meta?: {
collection: string;
icon?: string;
note?: string;
display_template?: string;
hidden?: boolean;
singleton?: boolean;
translations?: Record<string, string>;
color?: string;
sort_field?: string;
archive_field?: string;
archive_app_filter?: boolean;
archive_value?: string;
unarchive_value?: string;
accountability?: string;
item_duplication_fields?: string[];
sort?: number;
group?: string;
collapse?: string;
preview_url?: string;
versioning?: boolean;
};
schema?: {
name: string;
comment?: string;
};
}
interface DirectusField {
collection: string;
field: string;
type: FieldType;
meta?: FieldMeta;
schema?: FieldSchema;
}
type FieldType =
| 'string' | 'text' | 'boolean' | 'integer' | 'bigInteger'
| 'float' | 'decimal' | 'date' | 'dateTime' | 'time'
| 'timestamp' | 'json' | 'csv' | 'uuid' | 'hash' | 'geometry';
interface FieldMeta {
id?: number;
collection: string;
field: string;
special?: string[];
interface?: string;
options?: Record<string, any>;
display?: string;
display_options?: Record<string, any>;
readonly?: boolean;
hidden?: boolean;
sort?: number;
width?: string;
translations?: Record<string, string>;
note?: string;
conditions?: any[];
required?: boolean;
group?: string;
validation?: Record<string, any>;
validation_message?: string;
}
interface FieldSchema {
name: string;
table: string;
data_type: string;
default_value?: any;
max_length?: number;
numeric_precision?: number;
numeric_scale?: number;
is_generated?: boolean;
generation_expression?: string;
is_nullable?: boolean;
is_unique?: boolean;
is_primary_key?: boolean;
has_auto_increment?: boolean;
foreign_key_column?: string;
foreign_key_table?: string;
comment?: string;
}
interface DirectusRelation {
collection: string;
field: string;
related_collection: string;
schema?: {
table: string;
column: string;
foreign_key_table: string;
foreign_key_column: string;
constraint_name?: string;
on_update?: string;
on_delete?: string;
};
meta?: {
id?: number;
many_collection: string;
many_field: string;
one_collection?: string;
one_field?: string;
one_collection_field?: string;
one_allowed_collections?: string[];
junction_field?: string;
sort_field?: string;
one_deselect_action?: string;
};
}
interface DirectusUser {
id: string;
first_name?: string;
last_name?: string;
email: string;
password?: string;
location?: string;
title?: string;
description?: string;
tags?: string[];
avatar?: string;
language?: string;
tfa_secret?: string;
status?: 'invited' | 'draft' | 'active' | 'suspended' | 'deleted';
role?: string;
token?: string;
last_access?: string;
last_page?: string;
provider?: string;
external_identifier?: string;
auth_data?: Record<string, any>;
email_notifications?: boolean;
appearance?: string;
theme_dark?: string;
theme_light?: string;
theme_light_overrides?: Record<string, any>;
theme_dark_overrides?: Record<string, any>;
}
interface DirectusRole {
id: string;
name: string;
icon?: string;
description?: string;
ip_access?: string[];
enforce_tfa?: boolean;
admin_access?: boolean;
app_access?: boolean;
users?: string[];
}
interface DirectusFile {
id: string;
storage: string;
filename_disk: string;
filename_download: string;
title?: string;
type?: string;
folder?: string;
uploaded_by?: string;
uploaded_on?: string;
modified_by?: string;
modified_on?: string;
charset?: string;
filesize?: number;
width?: number;
height?: number;
duration?: number;
embed?: string;
description?: string;
location?: string;
tags?: string[];
metadata?: Record<string, any>;
}
interface WebSocketMessage {
type: 'auth' | 'subscribe' | 'unsubscribe' | 'message';
event?: string;
data?: any;
uid?: string;
}
interface LogContext {
operation?: string;
collection?: string;
field?: string;
duration?: number;
error?: Error;
[key: string]: any;
}
interface QueryOptions {
filter?: Record<string, any>;
sort?: string[];
limit?: number;
offset?: number;
fields?: string[];
search?: string;
deep?: Record<string, any>;
aggregate?: Record<string, any>;
}
// ===== CONFIGURATION =====
const config: DirectusConfig = {
url: process.env.DIRECTUS_URL || 'https://local-mcp-server.dev',
token: process.env.DIRECTUS_TOKEN || '',
timeout: parseInt(process.env.DIRECTUS_TIMEOUT || '30000'),
retries: parseInt(process.env.DIRECTUS_RETRIES || '3'),
websocket: process.env.DIRECTUS_WEBSOCKET !== 'false'
};
if (!config.token) {
console.error('DIRECTUS_TOKEN environment variable is required');
process.exit(1);
}
// ===== EXTERNAL MODULES =====
import { logger } from './src/utils/logger.js';
import { DirectusWebSocketClient } from './src/websocket/websocket-client.js';
// ===== DIRECTUS API CLIENT =====
class DirectusAPIClient {
private axios: AxiosInstance;
private config: DirectusConfig;
constructor(config: DirectusConfig) {
this.config = config;
this.axios = axios.create({
baseURL: config.url,
timeout: config.timeout || 30000,
headers: {
'Authorization': `Bearer ${config.token}`,
'Content-Type': 'application/json'
}
});
this.setupInterceptors();
}
private setupInterceptors(): void {
// Request interceptor
this.axios.interceptors.request.use(
(config) => {
logger.debug(`API Request: ${config.method?.toUpperCase()} ${config.url}`, {
url: config.url,
method: config.method
});
return config;
},
(error) => {
logger.error('Request interceptor error', { error });
return Promise.reject(error);
}
);
// Response interceptor
this.axios.interceptors.response.use(
(response: AxiosResponse) => {
logger.debug(`API Response: ${response.status} ${response.config.url}`, {
status: response.status,
url: response.config.url,
dataSize: JSON.stringify(response.data).length
});
return response;
},
(error) => {
const parsedError = this.parseDirectusError(error);
logger.error('API Error', {
url: error.config?.url,
method: error.config?.method,
status: error.response?.status,
code: parsedError.code,
message: parsedError.message
});
return Promise.reject(error);
}
);
}
private parseDirectusError(error: any): { code: string; message: string; details?: any } {
if (error?.response?.data?.errors) {
const err = error.response.data.errors[0];
return {
code: err.extensions?.code || 'DIRECTUS_ERROR',
message: err.message,
details: err.extensions
};
}
if (error?.response?.data?.message) {
return {
code: error?.response?.status?.toString() || 'HTTP_ERROR',
message: error.response.data.message
};
}
return {
code: 'UNKNOWN_ERROR',
message: error?.message || 'An unknown error occurred'
};
}
private async retry<T>(fn: () => Promise<T>, retries: number = this.config.retries || 3): Promise<T> {
let lastError: Error;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error as Error;
if (attempt === retries) break;
const backoffDelay = 1000 * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, backoffDelay));
}
}
throw lastError!;
}
// Collections API
async getCollections(): Promise<DirectusCollection[]> {
logger.startTimer('getCollections');
try {
const response = await this.retry(() => this.axios.get('/collections'));
logger.endTimer('getCollections');
return response.data.data;
} catch (error) {
logger.endTimer('getCollections');
throw error;
}
}
async createCollection(collection: DirectusCollection): Promise<DirectusCollection> {
const operationId = `createCollection.${collection.collection}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() => this.axios.post('/collections', collection));
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async updateCollection(name: string, updates: Partial<DirectusCollection>): Promise<DirectusCollection> {
const operationId = `updateCollection.${name}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() => this.axios.patch(`/collections/${name}`, updates));
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async deleteCollection(name: string): Promise<void> {
const operationId = `deleteCollection.${name}`;
logger.startTimer(operationId);
try {
await this.retry(() => this.axios.delete(`/collections/${name}`));
logger.endTimer(operationId);
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
// Fields API
async getFields(collection: string): Promise<DirectusField[]> {
const operationId = `getFields.${collection}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() => this.axios.get(`/fields/${collection}`));
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async createField(field: DirectusField): Promise<DirectusField> {
const operationId = `createField.${field.collection}.${field.field}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() =>
this.axios.post(`/fields/${field.collection}`, field)
);
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async updateField(collection: string, field: string, updates: Partial<DirectusField>): Promise<DirectusField> {
const operationId = `updateField.${collection}.${field}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() =>
this.axios.patch(`/fields/${collection}/${field}`, updates)
);
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async deleteField(collection: string, field: string): Promise<void> {
const operationId = `deleteField.${collection}.${field}`;
logger.startTimer(operationId);
try {
await this.retry(() => this.axios.delete(`/fields/${collection}/${field}`));
logger.endTimer(operationId);
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
// Relations API
async getRelations(): Promise<DirectusRelation[]> {
logger.startTimer('getRelations');
try {
const response = await this.retry(() => this.axios.get('/relations'));
logger.endTimer('getRelations');
return response.data.data;
} catch (error) {
logger.endTimer('getRelations');
throw error;
}
}
async createRelation(relation: DirectusRelation): Promise<DirectusRelation> {
const operationId = `createRelation.${relation.collection}.${relation.field}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() => this.axios.post('/relations', relation));
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async deleteRelation(collection: string, field: string): Promise<void> {
const operationId = `deleteRelation.${collection}.${field}`;
logger.startTimer(operationId);
try {
await this.retry(() => this.axios.delete(`/relations/${collection}/${field}`));
logger.endTimer(operationId);
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
// Items API - Generic CRUD operations
async getItems<T = any>(collection: string, options?: QueryOptions): Promise<T[]> {
const operationId = `getItems.${collection}`;
logger.startTimer(operationId);
try {
const params = new URLSearchParams();
if (options?.limit) params.append('limit', options.limit.toString());
if (options?.offset) params.append('offset', options.offset.toString());
if (options?.fields) params.append('fields', options.fields.join(','));
if (options?.search) params.append('search', options.search);
if (options?.filter) params.append('filter', JSON.stringify(options.filter));
if (options?.sort) params.append('sort', options.sort.join(','));
if (options?.deep) params.append('deep', JSON.stringify(options.deep));
if (options?.aggregate) params.append('aggregate', JSON.stringify(options.aggregate));
const response = await this.retry(() =>
this.axios.get(`/items/${collection}?${params.toString()}`)
);
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async createItem<T = any>(collection: string, data: Partial<T>): Promise<T> {
const operationId = `createItem.${collection}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() =>
this.axios.post(`/items/${collection}`, data)
);
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async createItems<T = any>(collection: string, data: Partial<T>[]): Promise<T[]> {
const operationId = `createItems.${collection}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() =>
this.axios.post(`/items/${collection}`, data)
);
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async updateItem<T = any>(collection: string, id: string, data: Partial<T>): Promise<T> {
const operationId = `updateItem.${collection}.${id}`;
logger.startTimer(operationId);
try {
const response = await this.retry(() =>
this.axios.patch(`/items/${collection}/${id}`, data)
);
logger.endTimer(operationId);
return response.data.data;
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
async deleteItem(collection: string, id: string): Promise<void> {
const operationId = `deleteItem.${collection}.${id}`;
logger.startTimer(operationId);
try {
await this.retry(() => this.axios.delete(`/items/${collection}/${id}`));
logger.endTimer(operationId);
} catch (error) {
logger.endTimer(operationId);
throw error;
}
}
// Users API
async getUsers(): Promise<DirectusUser[]> {
return (await this.retry(() => this.axios.get('/users'))).data.data;
}
async createUser(user: Partial<DirectusUser>): Promise<DirectusUser> {
return (await this.retry(() => this.axios.post('/users', user))).data.data;
}
// Roles API
async getRoles(): Promise<DirectusRole[]> {
return (await this.retry(() => this.axios.get('/roles'))).data.data;
}
// Files API
async getFiles(): Promise<DirectusFile[]> {
return (await this.retry(() => this.axios.get('/files'))).data.data;
}
async uploadFile(formData: FormData): Promise<DirectusFile> {
const response = await this.axios.post('/files', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data.data;
}
async deleteFile(id: string): Promise<void> {
await this.axios.delete(`/files/${id}`);
}
// Schema API
async getSchema(): Promise<any> {
return (await this.retry(() => this.axios.get('/schema/snapshot'))).data;
}
// Server info
async getServerInfo(): Promise<any> {
return (await this.retry(() => this.axios.get('/server/info'))).data.data;
}
}
// ===== UTILITY FUNCTIONS =====
function getInterfaceForType(type: FieldType): string {
const interfaceMap: Record<string, string> = {
'string': 'input',
'text': 'input-multiline',
'boolean': 'boolean',
'integer': 'input',
'bigInteger': 'input',
'float': 'input',
'decimal': 'input',
'date': 'date',
'dateTime': 'datetime',
'time': 'time',
'timestamp': 'datetime',
'json': 'input-code',
'csv': 'tags',
'uuid': 'input',
'hash': 'input',
'geometry': 'map'
};
return interfaceMap[type] || 'input';
}
function extractVariables(text: string): string[] {
if (!text) return [];
const matches = text.match(/\{\{([^}]+)\}\}/g);
if (!matches) return [];
return [...new Set(matches.map(match => match.slice(2, -2).trim()))];
}
// ===== MCP SERVER IMPLEMENTATION =====
class DirectusMCPServer {
private server: Server;
private directus: DirectusAPIClient;
private websocket: DirectusWebSocketClient | null = null;
constructor() {
this.server = new Server(
{ name: 'directus-enhanced-mcp', version: '4.0.0' },
{ capabilities: { tools: {}, prompts: {} } }
);
this.directus = new DirectusAPIClient(config);
if (config.websocket) {
this.websocket = new DirectusWebSocketClient(config);
this.initializeWebSocket();
}
this.registerHandlers();
this.setupErrorHandlers();
}
private initializeWebSocket(): void {
if (this.websocket) {
logger.info('WebSocket client initialized. It will connect on first subscription.');
// The new client connects automatically when the first subscription is made.
// We can add a listener to log connection status for clarity.
// This requires adding an event emitter to the client, which is a future improvement.
}
}
private registerHandlers(): void {
// Prompts handlers
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
const prompts = await this.getPrompts();
return {
prompts: prompts.map(prompt => {
const systemVars = extractVariables(prompt.system_prompt);
const messageVars = prompt.messages ? extractVariables(JSON.stringify(prompt.messages)) : [];
const allVars = [...new Set([...systemVars, ...messageVars])];
return {
name: prompt.name,
description: prompt.description || `AI prompt: ${prompt.name}`,
arguments: allVars.map(variable => ({
name: variable,
description: `Value for ${variable}`,
required: false,
})),
};
}),
};
});
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
const { name, arguments: args = {} } = request.params;
const prompt = await this.getPromptByName(name);
if (!prompt) {
throw new Error(`Prompt not found: ${name}`);
}
let systemPrompt = prompt.system_prompt || '';
let messages: any[] = [];
if (prompt.messages) {
try {
const parsedMessages = typeof prompt.messages === 'string'
? JSON.parse(prompt.messages)
: prompt.messages;
if (Array.isArray(parsedMessages)) {
messages = parsedMessages;
}
} catch (error) {
logger.error('Error parsing messages:', { error });
}
}
for (const [key, value] of Object.entries(args)) {
const placeholder = `{{${key}}}`;
const regex = new RegExp(placeholder, 'g');
systemPrompt = systemPrompt.replace(regex, value as string);
messages = messages.map(msg => ({
...msg,
content: msg.content ? msg.content.replace(regex, value as string) : msg.content,
text: msg.text ? msg.text.replace(regex, value as string) : msg.text,
}));
}
return {
description: prompt.description || `AI prompt: ${name}`,
messages: [
...(systemPrompt ? [{ role: 'system', content: { type: 'text', text: systemPrompt } }] : []),
...messages.map(msg => ({
role: msg.role || 'user',
content: { type: 'text', text: msg.content || msg.text || '' }
}))
]
};
});
// Tools handlers
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: this.getToolDefinitions()
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
logger.info(`Tool called: ${name}`, { tool: name, args });
try {
return await this.handleToolCall(name, args);
} catch (error) {
logger.error(`Tool execution failed: ${name}`, { error, tool: name });
return {
content: [{
type: 'text',
text: `Error executing tool '${name}': ${(error as Error).message}`
}]
};
}
});
}
private async handleToolCall(name: string, args: any): Promise<any> {
switch (name) {
// Collection management
case 'list_collections':
return this.handleListCollections();
case 'create_collection':
return this.handleCreateCollection(args);
case 'update_collection':
return this.handleUpdateCollection(args);
case 'delete_collection':
return this.handleDeleteCollection(args);
// Field management
case 'create_field':
return this.handleCreateField(args);
case 'get_fields':
return this.handleGetFields(args);
case 'update_field':
return this.handleUpdateField(args);
case 'delete_field':
return this.handleDeleteField(args);
// Relation management
case 'create_relation':
return this.handleCreateRelation(args);
case 'get_relations':
return this.handleGetRelations();
case 'delete_relation':
return this.handleDeleteRelation(args);
// Content operations
case 'get_collection_items':
return this.handleGetCollectionItems(args);
case 'create_item':
return this.handleCreateItem(args);
case 'create_batch_items':
return this.handleCreateBatchItems(args);
case 'update_item':
return this.handleUpdateItem(args);
case 'delete_item':
return this.handleDeleteItem(args);
case 'query_items':
return this.handleQueryItems(args);
// User management
case 'get_users':
return this.handleGetUsers(args);
case 'create_user':
return this.handleCreateUser(args);
case 'get_roles':
return this.handleGetRoles(args);
// File management
case 'get_files':
return this.handleGetFiles();
case 'upload_from_url':
return this.handleUploadFromUrl(args);
case 'upload_from_path':
return this.handleUploadFromPath(args);
case 'delete_file':
return this.handleDeleteFile(args);
// Real-time features
case 'subscribe_realtime':
return this.handleSubscribeRealtime(args);
case 'unsubscribe_realtime':
return this.handleUnsubscribeRealtime(args);
// Schema management
case 'get_schema':
return this.handleGetSchema();
case 'get_server_info':
return this.handleGetServerInfo();
// E-commerce specific tools
case 'get_products':
return this.handleGetProducts(args);
case 'create_product':
return this.handleCreateProduct(args);
case 'update_product':
return this.handleUpdateProduct(args);
case 'get_orders':
return this.handleGetOrders(args);
case 'create_order':
return this.handleCreateOrder(args);
case 'get_customers':
return this.handleGetCustomers(args);
case 'create_customer':
return this.handleCreateCustomer(args);
case 'get_brands':
return this.handleGetBrands(args);
case 'get_categories':
return this.handleGetCategories(args);
case 'get_product_images':
return this.handleGetProductImages(args);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
// Tool handlers implementation
private async handleListCollections(): Promise<any> {
try {
const collections = await this.directus.getCollections();
const nonSystemCollections = collections.filter((c: any) => !c.collection.startsWith('directus_'));
return {
content: [{
type: 'text',
text: `Available collections (${nonSystemCollections.length}):\n\n${nonSystemCollections.map((c: any) =>
`• ${c.collection}: ${c.meta?.note || 'No description'}`
).join('\n')}`
}]
};
} catch (error: any) {
logger.error('Error listing collections', { error: error.message });
return {
success: false,
error: error.message || 'Failed to list collections',
details: error.response?.data || null
};
}
}
private async handleUpdateCollection(args: any): Promise<any> {
try {
const { collection, meta, schema } = args;
if (!collection) {
throw new Error('Missing required parameter: collection name is required');
}
const updateData: any = {};
if (meta) updateData.meta = meta;
if (schema) updateData.schema = schema;
if (Object.keys(updateData).length === 0) {
throw new Error('No update data provided. Please provide meta or schema to update.');
}
const result = await this.directus.updateCollection(collection, updateData);
return {
success: true,
data: result,
message: `Collection '${collection}' updated successfully`
};
} catch (error: any) {
logger.error('Error updating collection', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to update collection',
details: error.response?.data || null
};
}
}
private async handleCreateCollection(args: any): Promise<any> {
try {
const { collection, meta, schema } = args;
if (!collection || !meta || !schema) {
throw new Error('Missing required parameters: collection, meta, and schema are required');
}
const collectionData = {
collection,
meta,
schema
};
const result = await this.directus.createCollection(collectionData);
return { success: true, data: result };
} catch (error: any) {
logger.error('Error creating collection', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to create collection',
details: error.response?.data || null
};
}
}
private async handleDeleteCollection(args: any): Promise<any> {
try {
const { collection } = args;
if (!collection) {
throw new Error('Missing required parameter: collection name is required');
}
await this.directus.deleteCollection(collection);
return {
success: true,
message: `Collection '${collection}' deleted successfully`
};
} catch (error: any) {
logger.error('Error deleting collection', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to delete collection',
details: error.response?.data || null
};
}
}
private async handleGetFields(args: any): Promise<any> {
try {
const { collection } = args;
if (!collection) {
throw new Error('Missing required parameter: collection name is required');
}
const fields = await this.directus.getFields(collection);
return {
success: true,
data: fields
};
} catch (error: any) {
logger.error('Error getting fields', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to get fields',
details: error.response?.data || null
};
}
}
private async handleUpdateField(args: any): Promise<any> {
const { collection, field, meta = {}, schema = {} } = args;
try {
if (!collection || !field) {
throw new Error('Missing required parameters: collection and field are required');
}
const updateData: any = {};
if (Object.keys(meta).length > 0) updateData.meta = meta;
if (Object.keys(schema).length > 0) updateData.schema = schema;
if (Object.keys(updateData).length === 0) {
throw new Error('No update data provided. Please provide meta or schema to update.');
}
const result = await this.directus.updateField(collection, field, updateData);
return {
success: true,
data: result,
message: `Field '${field}' in collection '${collection}' updated successfully`
};
} catch (error: any) {
logger.error('Error updating field', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to update field',
details: error.response?.data || null
};
}
}
private async handleGetRelations(args: any = {}): Promise<any> {
try {
const { collection, field } = args;
// Get all relations and filter based on parameters
const allRelations = await this.directus.getRelations();
let filteredRelations = allRelations;
if (collection) {
filteredRelations = filteredRelations.filter((r: any) =>
r.collection === collection || r.related_collection === collection
);
if (field) {
filteredRelations = filteredRelations.filter((r: any) => r.field === field);
}
}
return {
success: true,
data: filteredRelations,
count: filteredRelations.length
};
} catch (error: any) {
logger.error('Error getting relations', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to get relations',
details: error.response?.data || null
};
}
}
private async handleGetCollectionItems(args: any): Promise<any> {
try {
const {
collection,
fields = ['*'],
filter = {},
sort = [],
limit = 100,
page = 1,
search
} = args;
if (!collection) {
throw new Error('Missing required parameter: collection');
}
const query: any = {
fields,
filter,
sort,
limit: Math.min(limit, 1000), // Enforce a reasonable limit
page,
meta: ['total_count', 'filter_count']
};
if (search) {
query.search = search;
}
const response = await this.directus.getItems(collection, query);
// Handle array response (non-paginated)
if (Array.isArray(response)) {
return {
success: true,
data: response,
meta: {
total: response.length,
filtered: response.length,
page: 1,
limit: response.length
}
};
}
// Handle object response (paginated or other format)
if (response && typeof response === 'object') {
// Check if it's a paginated response with data and meta
const responseObj = response as Record<string, any>;
const data = Array.isArray(responseObj.data) ? responseObj.data : [];
const meta = responseObj.meta || {};
return {
success: true,
data,
meta: {
total: meta.total_count || data.length,
filtered: meta.filter_count || data.length,
page: page,
limit: query.limit
}
};
}
// Fallback for unexpected response format
return {
success: true,
data: [],
meta: {
total: 0,
filtered: 0,
page: 1,
limit: 0
}
};
} catch (error: any) {
logger.error('Error getting collection items', {
error: error.message,
collection: args.collection,
details: error.response?.data || null
});
return {
success: false,
error: error.message || 'Failed to get collection items',
details: error.response?.data || null
};
}
}
private async handleCreateBatchItems(args: any): Promise<any> {
try {
const { collection, items } = args;
if (!collection || !items) {
throw new Error('Missing required parameters: collection and items are required');
}
if (!Array.isArray(items) || items.length === 0) {
throw new Error('Items must be a non-empty array');
}
// Add status if not provided in any item
const itemsWithStatus = items.map(item => ({
...item,
status: 'status' in item ? item.status : 'published'
}));
const results: any[] = [];
// Process items in chunks to avoid overwhelming the API
const chunkSize = 25; // Process 25 items at a time
for (let i = 0; i < itemsWithStatus.length; i += chunkSize) {
const chunk = itemsWithStatus.slice(i, i + chunkSize);
const chunkResults = await Promise.all(
chunk.map(item =>
this.directus.createItem(collection, item).catch((error: any) => ({
success: false,
error: error.message,
data: item,
details: error.response?.data || null
}))
)
);
results.push(...chunkResults);
}
// Check for any failures
const failedItems = results.filter((result: any) => result && result.success === false);
const successCount = results.length - failedItems.length;
if (failedItems.length > 0) {
logger.warn('Some items failed to create', {
total: results.length,
success: successCount,
failed: failedItems.length,
failedItems: failedItems.map((item: any, index: number) => ({
index,
error: item.error,
details: item.details
}))
});
return {
success: true, // Still success as some items were created
data: results,
meta: {
total: results.length,
success: successCount,
failed: failedItems.length
},
warnings: [
`Successfully created ${successCount} items, but ${failedItems.length} items failed.`,
'Check the response data for details on failed items.'
]
};
}
return {
success: true,
data: results,
meta: {
total: results.length,
success: results.length,
failed: 0
},
message: `Successfully created ${results.length} items`
};
} catch (error: any) {
logger.error('Error creating batch items', {
error: error.message,
collection: args.collection,
details: error.response?.data || null
});
return {
success: false,
error: error.message || 'Failed to create batch items',
details: error.response?.data || null
};
}
}
private async handleQueryItems(args: any): Promise<any> {
try {
const {
collection,
fields = ['*'],
filter = {},
search,
sort = [],
limit = 100,
page = 1,
offset,
aggregate,
groupBy,
deep = {}
} = args;
if (!collection) {
throw new Error('Missing required parameter: collection');
}
// Build the query object
const query: Record<string, any> = {
fields,
filter,
sort,
limit: Math.min(limit, 1000), // Enforce a reasonable limit
page,
meta: ['total_count', 'filter_count'],
...(offset !== undefined && { offset }),
...(aggregate && { aggregate }),
...(groupBy && { groupBy }),
...(Object.keys(deep).length > 0 && { deep })
};
// Add search if provided
if (search) {
query.search = search;
}
// Execute the query
const response = await this.directus.getItems(collection, query);
// Handle both array and paginated responses
if (Array.isArray(response)) {
return {
success: true,
data: response,
meta: {
total: response.length,
filtered: response.length,
page: 1,
limit: response.length
}
};
}
// Handle paginated response
const responseObj = response as Record<string, any>;
const data = Array.isArray(responseObj.data) ? responseObj.data : [];
const meta = responseObj.meta || {};
return {
success: true,
data,
meta: {
total: meta.total_count || data.length,
filtered: meta.filter_count || data.length,
page: page,
limit: query.limit,
...(offset !== undefined && { offset })
}
};
} catch (error: any) {
logger.error('Error querying items', {
error: error.message,
collection: args.collection,
details: error.response?.data || null
});
return {
success: false,
error: error.message || 'Failed to query items',
details: error.response?.data || null
};
}
}
private async handleDeleteItem(args: any): Promise<any> {
try {
const { collection, id } = args;
if (!collection || id === undefined) {
throw new Error('Missing required parameters: collection and id are required');
}
await this.directus.deleteItem(collection, id);
return {
success: true,
message: 'Item deleted successfully'
};
} catch (error: any) {
logger.error('Error deleting item', {
error: error.message,
collection: args.collection,
id: args.id,
details: error.response?.data || null
});
// Handle 404 Not Found specifically
if (error.response?.status === 404) {
return {
success: false,
error: 'Item not found',
details: { collection: args.collection, id: args.id }
};
}
return {
success: false,
error: error.message || 'Failed to delete item',
details: error.response?.data || null
};
}
}
private async handleUpdateItem(args: any): Promise<any> {
try {
const { collection, id, data } = args;
if (!collection || id === undefined || !data) {
throw new Error('Missing required parameters: collection, id, and data are required');
}
if (typeof data !== 'object' || data === null) {
throw new Error('Data must be an object');
}
const result = await this.directus.updateItem(collection, id, data);
return {
success: true,
data: result,
message: 'Item updated successfully'
};
} catch (error: any) {
logger.error('Error updating item', {
error: error.message,
collection: args.collection,
id: args.id,
details: error.response?.data || null
});
return {
success: false,
error: error.message || 'Failed to update item',
details: error.response?.data || null
};
}
}
private async handleCreateItem(args: any): Promise<any> {
try {
const { collection, data } = args;
if (!collection || !data) {
throw new Error('Missing required parameters: collection and data are required');
}
if (typeof data !== 'object' || data === null) {
throw new Error('Data must be an object');
}
// Add status if not provided
const itemData = { ...data };
if (!('status' in itemData)) {
itemData.status = 'published';
}
const result = await this.directus.createItem(collection, itemData);
return {
success: true,
data: result,
message: 'Item created successfully'
};
} catch (error: any) {
logger.error('Error creating item', {
error: error.message,
collection: args.collection,
details: error.response?.data || null
});
return {
success: false,
error: error.message || 'Failed to create item',
details: error.response?.data || null
};
}
}
private async handleDeleteRelation(args: any): Promise<any> {
try {
const { collection, field } = args;
if (!collection || !field) {
throw new Error('Missing required parameters: collection and field are required');
}
await this.directus.deleteRelation(collection, field);
return {
success: true,
message: `Relation '${field}' deleted from collection '${collection}'`
};
} catch (error: any) {
logger.error('Error deleting relation', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to delete relation',
details: error.response?.data || null
};
}
}
private async handleCreateRelation(args: any): Promise<any> {
try {
const {
collection,
field,
related_collection,
meta = {},
schema = {},
type = 'many-to-one'
} = args;
if (!collection || !field || !related_collection) {
throw new Error('Missing required parameters: collection, field, and related_collection are required');
}
const relationData: any = {
collection,
field,
related_collection,
meta: {
...meta,
special: meta.special || [],
interface: meta.interface || 'select-dropdown-m2o',
display: meta.display || 'related-values',
display_options: meta.display_options || {
template: '{{ name }}',
visible_options: 15,
enableCreate: true,
enableSelect: true
}
},
schema: {
...schema,
on_delete: schema.on_delete || 'SET NULL',
on_update: schema.on_update || 'CASCADE'
}
};
// Set relation type specific configurations
if (type === 'many-to-many') {
relationData.meta.special = [...(relationData.meta.special || []), 'm2m'];
relationData.schema = {
...relationData.schema,
junction_field: schema.junction_field || `${collection}_id`,
related_junction_field: schema.related_junction_field || `${related_collection}_id`
};
} else if (type === 'one-to-many') {
relationData.meta.special = [...(relationData.meta.special || []), 'o2m'];
relationData.schema = {
...relationData.schema,
one_field: schema.one_field || field.replace(/_id$/, '')
};
} else {
// many-to-one
relationData.meta.special = [...(relationData.meta.special || []), 'm2o'];
}
const result = await this.directus.createRelation(relationData);
return {
success: true,
data: result,
message: `Created ${type} relation '${field}' from '${collection}' to '${related_collection}'`
};
} catch (error: any) {
logger.error('Error creating relation', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to create relation',
details: error.response?.data || null
};
}
}
private async handleDeleteFile(args: any): Promise<any> {
try {
const { id } = args;
if (!id) {
throw new Error('Missing required parameter: id');
}
await this.directus.deleteFile(id);
return {
success: true,
message: `File with ID ${id} deleted successfully`
};
} catch (error: any) {
logger.error('Error deleting file', { error: error.message });
return {
success: false,
error: error.message || 'Failed to delete file',
details: error.response?.data || null
};
}
}
private async handleDeleteField(args: any): Promise<any> {
const { collection, field } = args;
try {
if (!collection || !field) {
throw new Error('Missing required parameters: collection and field are required');
}
await this.directus.deleteField(collection, field);
return {
success: true,
message: `Field '${field}' deleted from collection '${collection}'`
};
} catch (error: any) {
logger.error('Error deleting field', { error: error.message, args });
return {
success: false,
error: error.message || 'Failed to delete field',
details: error.response?.data || null
};
}
}
private async handleCreateField(args: any): Promise<any> {
const { collection, field, type = 'string', meta = {}, schema = {} } = args;
const fieldData: DirectusField = {
collection,
field,
type,
meta: {
collection,
field,
interface: getInterfaceForType(type),
display: 'raw',
readonly: false,
hidden: false,
width: 'full',
required: false,
...meta
}
};
if (Object.keys(schema).length > 0) {
fieldData.schema = {
name: field,
table: collection,
data_type: type,
is_nullable: true,
is_unique: false,
is_primary_key: false,
has_auto_increment: false,
default_value: null,
...schema
};
}
const result = await this.directus.createField(fieldData);
return {
content: [{
type: 'text',
text: `Field '${field}' created in collection '${collection}'\n\n${JSON.stringify(result, null, 2)}`
}]
};
}
private async handleUploadFromPath(args: any): Promise<any> {
try {
const { path: filePath, title, folder } = args;
if (!filePath) {
throw new Error('Missing required parameter: path');
}
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
// Read file from path
const fileBuffer = fs.readFileSync(filePath);
const fileName = title || path.basename(filePath);
// Create form data for upload
const formData = new FormData();
formData.append('file', fileBuffer, { filename: fileName });
if (folder) {
formData.append('folder', folder);
}
if (title) {
formData.append('title', title);
}
const file = await this.directus.uploadFile(formData);
return {
success: true,
data: file,
message: 'File uploaded successfully'
};
} catch (error: any) {
logger.error('Error uploading file from path', { error: error.message });
return {
success: false,
error: error.message || 'Failed to upload file from path',
details: error.response?.data || null
};
}
}
private async handleSubscribeRealtime(args: any): Promise<any> {
const { event, collection } = args;
if (this.websocket) {
const subscriptionId = await this.websocket.subscribe(collection, (data) => {
logger.info('Real-time update received', { collection, event, data });
// Here you could potentially forward the update to the MCP client if needed
}, undefined, event);
return {
content: [{
type: 'text',
text: `Subscribed to real-time updates for ${collection}.${event}. Subscription ID: ${subscriptionId}`
}]
};
} else {
return {
content: [{
type: 'text',
text: 'WebSocket not enabled. Real-time subscriptions are not available.'
}]
};
}
}
private async handleUnsubscribeRealtime(args: any): Promise<any> {
const { subscriptionId } = args;
if (this.websocket) {
await this.websocket.unsubscribe(subscriptionId);
return {
content: [{
type: 'text',
text: `Unsubscribed from real-time updates for subscription ID: ${subscriptionId}`
}]
};
} else {
return {
content: [{
type: 'text',
text: 'WebSocket not enabled. Cannot unsubscribe from real-time updates.'
}]
};
}
}
private async handleGetSchema(): Promise<any> {
try {
const schema = await this.directus.getSchema();
return {
success: true,
data: schema
};
} catch (error: any) {
logger.error('Error getting schema', { error: error.message });
return {
success: false,
error: error.message || 'Failed to get schema',
details: error.response?.data || null
};
}
}
private async handleGetServerInfo(): Promise<any> {
try {
const serverInfo = await this.directus.getServerInfo();
return {
success: true,
data: serverInfo
};
} catch (error: any) {
logger.error('Error getting server info', { error: error.message });
return {
success: false,
error: error.message || 'Failed to get server info',
details: error.response?.data || null
};
}
}
private async handleGetProducts(args: any): Promise<any> {
const { limit = 25, offset = 0, filter = {}, search } = args;
const products = await this.directus.getItems('products', {
limit,
offset,
filter: { ...filter, status: { _eq: 'published' } },
search,
fields: ['id', 'reference', 'name', 'name_en', 'description', 'description_en', 'price_without_discount', 'price_with_discount', 'stock', 'brand_id', 'primary_image_file']
});
return {
content: [{
type: 'text',
text: `Found ${products.length} products:\n\n${products.map(p =>
`• ${p.name} (${p.reference}) - $${p.price_with_discount || p.price_without_discount} - Stock: ${p.stock}`
).join('\n')}`
}]
};
}
private async handleCreateProduct(args: any): Promise<any> {
const { name, description, price_without_discount, stock = 0, reference, brand_id } = args;
const product = await this.directus.createItem('products', {
name,
description,
price_without_discount,
stock,
reference,
brand_id,
status: 'draft'
});
return {
content: [{
type: 'text',
text: `Product "${name}" created successfully with ID: ${product.id}`
}]
};
}
private async handleUpdateProduct(args: any): Promise<any> {
const { id, ...updates } = args;
const product = await this.directus.updateItem('products', id, updates);
return {
content: [{
type: 'text',
text: `Product "${product.name}" updated successfully`
}]
};
}
private async handleGetOrders(args: any): Promise<any> {
const { limit = 25, offset = 0, filter = {}, status } = args;
const orders = await this.directus.getItems('orders', {
limit,
offset,
filter: status ? { ...filter, status: { _eq: status } } : filter,
fields: ['id', 'order_number', 'status', 'total', 'customer_id', 'created_at']
});
return {
content: [{
type: 'text',
text: `Found ${orders.length} orders:\n\n${orders.map(o =>
`• Order #${o.order_number} - ${o.status} - $${o.total} - Customer: ${o.customer_id}`
).join('\n')}`
}]
};
}
private async handleCreateOrder(args: any): Promise<any> {
const { customer_id, order_items, total } = args;
// Generate order number
const orderNumber = `ORD-${Date.now()}`;
const order = await this.directus.createItem('orders', {
order_number: orderNumber,
customer_id,
total,
status: 'pending'
});
// Create order items
if (order_items && Array.isArray(order_items)) {
for (const item of order_items) {
await this.directus.createItem('order_items', {
order_id: order.id,
product_id: item.product_id,
quantity: item.quantity,
price: item.price
});
}
}
return {
content: [{
type: 'text',
text: `Order #${orderNumber} created successfully with ${order_items?.length || 0} items`
}]
};
}
private async handleGetUsers(args: any): Promise<any> {
try {
const users = await this.directus.getUsers();
return {
success: true,
data: users,
count: users.length
};
} catch (error: any) {
logger.error('Error getting users', { error: error.message });
return {
success: false,
error: error.message || 'Failed to get users',
details: error.response?.data || null
};
}
}
private async handleGetCustomers(args: any): Promise<any> {
const { limit = 25, offset = 0, filter = {}, search } = args;
const customers = await this.directus.getItems('customers', {
limit,
offset,
filter: { ...filter, status: { _eq: 'active' } },
search,
fields: ['id', 'first_name', 'last_name', 'email', 'status']
});
return {
content: [{
type: 'text',
text: `Found ${customers.length} customers:\n\n${customers.map(c =>
`• ${c.first_name} ${c.last_name} - ${c.email}`
).join('\n')}`
}]
};
}
private async handleCreateUser(args: any): Promise<any> {
try {
if (!args.email || !args.password) {
throw new Error('Missing required parameters: email and password are required');
}
const user = await this.directus.createUser(args);
return {
success: true,
data: user,
message: 'User created successfully'
};
} catch (error: any) {
logger.error('Error creating user', { error: error.message });
return {
success: false,
error: error.message || 'Failed to create user',
details: error.response?.data || null
};
}
}
private async handleCreateCustomer(args: any): Promise<any> {
const { first_name, last_name, email } = args;
const customer = await this.directus.createItem('customers', {
first_name,
last_name,
email,
status: 'active'
});
return {
content: [{
type: 'text',
text: `Customer "${first_name} ${last_name}" created successfully with ID: ${customer.id}`
}]
};
}
private async handleGetRoles(args: any): Promise<any> {
try {
const roles = await this.directus.getRoles();
return {
success: true,
data: roles,
count: roles.length
};
} catch (error: any) {
logger.error('Error getting roles', { error: error.message });
return {
success: false,
error: error.message || 'Failed to get roles',
details: error.response?.data || null
};
}
}
private async handleGetBrands(args: any): Promise<any> {
const { limit = 25, offset = 0, filter = {} } = args;
const brands = await this.directus.getItems('brands', {
limit,
offset,
filter: { ...filter, status: { _eq: 'active' } },
fields: ['id', 'name', 'status']
});
return {
content: [{
type: 'text',
text: `Found ${brands.length} brands:\n\n${brands.map(b =>
`• ${b.name}`
).join('\n')}`
}]
};
}
private async handleGetFiles(): Promise<any> {
try {
const files = await this.directus.getFiles();
return {
success: true,
data: files,
count: files.length
};
} catch (error: any) {
this.logger.error('Error getting files', { error: error.message });
return {
success: false,
error: error.message || 'Failed to get files',
details: error.response?.data || null
};
}
}
private async handleGetCategories(args: any): Promise<any> {
const { limit = 25, offset = 0, filter = {} } = args;
const categories = await this.directus.getItems('categories', {
limit,
offset,
filter: { ...filter, status: { _eq: 'published' } },
fields: ['id', 'name', 'name_en', 'parent_id']
});
return {
content: [{
type: 'text',
text: `Found ${categories.length} categories:\n\n${categories.map(c =>
`• ${c.name} (${c.name_en || 'No translation'})`
).join('\n')}`
}]
};
}
private async handleUploadFromUrl(args: any): Promise<any> {
try {
const { url, title, folder } = args;
if (!url) {
throw new Error('Missing required parameter: url');
}
// Download the file from URL
const response = await axios.get(url, { responseType: 'arraybuffer' });
const buffer = Buffer.from(response.data);
// Create form data for upload
const formData = new FormData();
formData.append('file', buffer, { filename: title || url.split('/').pop() });
if (folder) {
formData.append('folder', folder);
}
if (title) {
formData.append('title', title);
}
const file = await this.directus.uploadFile(formData);
return {
success: true,
data: file,
message: 'File uploaded successfully'
};
} catch (error: any) {
this.logger.error('Error uploading file from URL', { error: error.message });
return {
success: false,
error: error.message || 'Failed to upload file from URL',
details: error.response?.data || null
};
}
}
private async handleGetProductImages(args: any): Promise<any> {
const { product_id, limit = 50 } = args;
const images = await this.directus.getItems('product_images', {
limit,
filter: product_id ? { product_id: { _eq: product_id } } : {},
fields: ['id', 'title', 'description', 'sort', 'product_id']
});
return {
content: [{
type: 'text',
text: `Found ${images.length} product images:\n\n${images.map(img =>
`• ${img.title || 'Untitled'} - Product: ${img.product_id}`
).join('\n')}`
}]
};
}
private async getPrompts(): Promise<any[]> {
try {
return await this.directus.getItems('prompts', {
filter: { status: { _eq: 'published' } }
});
} catch (error) {
this.logger.error('Error fetching prompts:', { error });
return [];
}
}
private async getPromptByName(name: string): Promise<any> {
try {
const results = await this.directus.getItems('prompts', {
filter: { name: { _eq: name } },
limit: 1
});
return results[0] || null;
} catch (error) {
this.logger.error('Error fetching prompt:', { error });
return null;
}
}
private getToolDefinitions(): any[] {
return [];
}
private setupErrorHandlers(): void {
const shutdown = async (signal: string) => {
logger.info(`Received ${signal}, shutting down gracefully...`);
try {
if (this.websocket) {
this.websocket.disconnect();
}
await this.server.close();
process.exit(0);
} catch (error) {
logger.error('Error during shutdown', { error });
process.exit(1);
}
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('uncaughtException', (error) => {
logger.error('Uncaught Exception', { error });
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection', { reason, promise: promise.toString() });
process.exit(1);
});
}
async start(): Promise<void> {
try {
logger.info('Starting Enhanced Directus MCP Server', {
version: '4.0.0',
directusUrl: config.url,
websocketEnabled: config.websocket
});
const transport = new StdioServerTransport();
await this.server.connect(transport);
logger.info('Directus MCP Server started successfully');
} catch (error) {
logger.error('Failed to start MCP server', { error });
process.exit(1);
}
}
}
// ===== MAIN =====
async function main() {
const mcpServer = new DirectusMCPServer();
await mcpServer.start();
}
main().catch((error) => {
logger.error('Fatal error:', { error });
process.exit(1);
});