@directus/api
Version:
Directus is a real-time API and App dashboard for managing SQL database content
302 lines (301 loc) • 12.5 kB
JavaScript
import { useEnv } from '@directus/env';
import { ForbiddenError, InvalidPayloadError, isDirectusError } from '@directus/errors';
import { isObject, parseJSON, toArray } from '@directus/utils';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { CallToolRequestSchema, GetPromptRequestSchema, InitializedNotificationSchema, ErrorCode as JSONRPCErrorCode, JSONRPCMessageSchema, ListPromptsRequestSchema, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
import { render, tokenize } from 'micromustache';
import { z } from 'zod';
import { fromZodError } from 'zod-validation-error';
import { ItemsService } from '../../services/index.js';
import { Url } from '../../utils/url.js';
import { findMcpTool, getAllMcpTools } from '../tools/index.js';
import { DirectusTransport } from './transport.js';
export class DirectusMCP {
promptsCollection;
systemPrompt;
systemPromptEnabled;
server;
allowDeletes;
constructor(options = {}) {
this.promptsCollection = options.promptsCollection ?? null;
this.systemPromptEnabled = options.systemPromptEnabled ?? true;
this.systemPrompt = options.systemPrompt ?? null;
this.allowDeletes = options.allowDeletes ?? false;
this.server = new Server({
name: 'directus-mcp',
version: '0.1.0',
}, {
capabilities: {
tools: {},
prompts: {},
},
});
}
/**
* This handleRequest function is not awaiting lower level logic resulting in the actual
* response being an asynchronous side effect happening after the function has returned
*/
handleRequest(req, res) {
if (!req.accountability?.user && !req.accountability?.role && req.accountability?.admin !== true) {
throw new ForbiddenError();
}
if (!req.accepts('application/json')) {
// we currently dont support "text/event-stream" requests
res.status(405).send();
return;
}
this.server.setNotificationHandler(InitializedNotificationSchema, () => {
res.status(202).send();
});
// list prompts
this.server.setRequestHandler(ListPromptsRequestSchema, async () => {
const prompts = [];
if (!this.promptsCollection) {
throw new McpError(1001, `A prompts collection must be set in settings`);
}
const service = new ItemsService(this.promptsCollection, {
accountability: req.accountability,
schema: req.schema,
});
try {
const promptList = await service.readByQuery({
fields: ['name', 'description', 'system_prompt', 'messages'],
});
for (const prompt of promptList) {
// builds args
const args = [];
// Add system prompt as the first assistant message if it exists
if (prompt.system_prompt) {
for (const varName of tokenize(prompt.system_prompt).varNames) {
args.push({
name: varName,
description: `Value for ${varName}`,
required: false,
});
}
}
for (const message of prompt.messages || []) {
for (const varName of tokenize(message.text).varNames) {
args.push({
name: varName,
description: `Value for ${varName}`,
required: false,
});
}
}
prompts.push({
name: prompt.name,
description: prompt.description,
arguments: args,
});
}
return { prompts };
}
catch (error) {
return this.toExecutionError(error);
}
});
// get prompt
this.server.setRequestHandler(GetPromptRequestSchema, async (request) => {
if (!this.promptsCollection) {
throw new McpError(1001, `A prompts collection must be set in settings`);
}
const service = new ItemsService(this.promptsCollection, {
accountability: req.accountability,
schema: req.schema,
});
const { name: promptName, arguments: args } = request.params;
const promptCommand = await service.readByQuery({
fields: ['description', 'system_prompt', 'messages'],
filter: {
name: {
_eq: promptName,
},
},
});
const prompt = promptCommand[0];
if (!prompt) {
throw new McpError(JSONRPCErrorCode.InvalidParams, `Invalid prompt "${promptName}"`);
}
const messages = [];
// Add system prompt as the first assistant message if it exists
if (prompt.system_prompt) {
messages.push({
role: 'assistant',
content: {
type: 'text',
text: render(prompt.system_prompt, args),
},
});
}
// render any provided args
(prompt.messages || []).forEach((message) => {
// skip invalid prompts
if (!message.role || !message.text)
return;
messages.push({
role: message.role,
content: {
type: 'text',
text: render(message.text, args),
},
});
});
return this.toPromptResponse({
messages,
description: prompt.description,
});
});
// listing tools
this.server.setRequestHandler(ListToolsRequestSchema, () => {
const tools = [];
for (const tool of getAllMcpTools()) {
if (req.accountability?.admin !== true && tool.admin === true)
continue;
if (tool.name === 'system-prompt' && this.systemPromptEnabled === false)
continue;
tools.push({
name: tool.name,
description: tool.description,
inputSchema: z.toJSONSchema(tool.inputSchema),
annotations: tool.annotations,
});
}
return { tools };
});
// calling tools
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const tool = findMcpTool(request.params.name);
try {
if (!tool || (tool.name === 'system-prompt' && this.systemPromptEnabled === false)) {
throw new InvalidPayloadError({ reason: `"${request.params.name}" doesn't exist in the toolset` });
}
if (req.accountability?.admin !== true && tool.admin === true) {
throw new ForbiddenError({ reason: 'You must be an admin to access this tool' });
}
if (tool.name === 'system-prompt') {
request.params.arguments = { promptOverride: this.systemPrompt };
}
// ensure json expected fields are not stringified
if (request.params.arguments) {
for (const field of ['data', 'keys', 'query']) {
const arg = request.params.arguments[field];
if (typeof arg === 'string') {
request.params.arguments[field] = parseJSON(arg);
}
}
}
const { error, data: args } = tool.validateSchema?.safeParse(request.params.arguments) ?? {
data: request.params.arguments,
};
if (error) {
throw new InvalidPayloadError({ reason: fromZodError(error).message });
}
if (!isObject(args)) {
throw new InvalidPayloadError({ reason: '"arguments" must be an object' });
}
if (this.allowDeletes === false && 'action' in args && args['action'] === 'delete') {
throw new InvalidPayloadError({ reason: 'Delete actions are disabled' });
}
const result = await tool.handler({
args,
schema: req.schema,
accountability: req.accountability,
});
// if single item and create/read/update/import add url
const data = toArray(result?.data);
if ('action' in args &&
['create', 'update', 'read', 'import'].includes(args['action']) &&
result?.data &&
data.length === 1) {
result.url = this.buildURL(tool, args, data[0]);
}
return this.toToolResponse(result);
}
catch (error) {
return this.toExecutionError(error);
}
});
const transport = new DirectusTransport(res);
this.server.connect(transport);
try {
const parsedMessage = JSONRPCMessageSchema.parse(req.body);
transport.onmessage?.(parsedMessage);
}
catch (error) {
transport.onerror?.(error);
throw error;
}
}
buildURL(tool, input, data) {
const env = useEnv();
const publicURL = env['PUBLIC_URL'];
if (!publicURL)
return;
if (!tool.endpoint)
return;
const path = tool.endpoint({ input, data });
if (!path)
return;
return new Url(env['PUBLIC_URL']).addPath('admin', ...path).toString();
}
toPromptResponse(result) {
const response = {
messages: result.messages,
};
if (result.description) {
response.description = result.description;
}
return response;
}
toToolResponse(result) {
const response = {
content: [],
};
if (!result || typeof result.data === 'undefined' || result.data === null)
return response;
if (result.type === 'text') {
response.content.push({
type: 'text',
text: JSON.stringify({ raw: result.data, url: result.url }),
});
}
else {
response.content.push(result);
}
return response;
}
toExecutionError(err) {
const errors = [];
const receivedErrors = Array.isArray(err) ? err : [err];
for (const error of receivedErrors) {
if (isDirectusError(error)) {
errors.push({
error: error.message || 'Unknown error',
code: error.code,
});
}
else {
// Handle generic errors
let message = 'An unknown error occurred.';
let code;
if (error instanceof Error) {
message = error.message;
code = 'code' in error ? String(error.code) : undefined;
}
else if (typeof error === 'object' && error !== null) {
message = 'message' in error ? String(error.message) : message;
code = 'code' in error ? String(error.code) : undefined;
}
else if (typeof error === 'string') {
message = error;
}
errors.push({ error: message, ...(code && { code }) });
}
}
return {
isError: true,
content: [{ type: 'text', text: JSON.stringify(errors) }],
};
}
}