UNPKG

@directus/api

Version:

Directus is a real-time API and App dashboard for managing SQL database content

302 lines (301 loc) 12.5 kB
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) }], }; } }