UNPKG

@directus/api

Version:

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

280 lines (278 loc) 10 kB
import { ItemsService } from "../../services/items.js"; import "../../services/index.js"; import { ToolRegistry } from "../tools/registry.js"; import { ALL_TOOLS } from "../tools/index.js"; import { DirectusTransport } from "./transport.js"; import { MCP_ACCESS_SCOPE, buildMcpWWWAuthenticateHeader, getMcpUrls } from "./utils.js"; import { ForbiddenError, InvalidPayloadError, isDirectusError } from "@directus/errors"; import { z as z$1 } from "zod"; import { render, tokenize } from "micromustache"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { CallToolRequestSchema, ErrorCode as ErrorCode$1, GetPromptRequestSchema, InitializedNotificationSchema, JSONRPCMessageSchema, ListPromptsRequestSchema, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js"; //#region src/ai/mcp/server.ts var DirectusMCP = class { 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: {} } }); } /** * Send a 401 with WWW-Authenticate per RFC 6750 / RFC 9728. * Includes `resource_metadata` pointing to `/.well-known/oauth-protected-resource/mcp` * so clients can discover the authorization server from a 401 response. */ sendUnauthorized(res, error, status = 401) { const { metadataUrl } = getMcpUrls(); res.set("WWW-Authenticate", buildMcpWWWAuthenticateHeader(metadataUrl, error)).set("Access-Control-Expose-Headers", "WWW-Authenticate").status(status).send(); } /** * Handle an incoming MCP JSON-RPC request. * * OAuth-specific checks (when `accountability.oauth` is set): * - Transport restriction: token must be in Authorization header (RFC 6750), not cookie/query * - Scope check: must include mcp:access * - Audience check: must match the canonical MCP resource URL (PUBLIC_URL/mcp) * * Note: this function does not await lower-level logic; the actual response is an * asynchronous side effect happening after the function returns. * * @see sendUnauthorized for WWW-Authenticate format (RFC 9728 `resource_metadata` attribute) */ handleRequest(req, res) { const oauth = req.accountability?.oauth; if (!req.accountability?.user && !req.accountability?.role && req.accountability?.admin !== true) { this.sendUnauthorized(res); return; } if (oauth) { if (req.tokenSource !== "header") { this.sendUnauthorized(res, "invalid_request"); return; } if (!oauth.scopes.includes(MCP_ACCESS_SCOPE)) { this.sendUnauthorized(res, "insufficient_scope", 403); return; } const { resourceUrl } = getMcpUrls(); if (!oauth.aud.includes(resourceUrl)) { this.sendUnauthorized(res, "invalid_token"); return; } } if (!req.accepts("application/json")) { res.status(405).send(); return; } this.server.setNotificationHandler(InitializedNotificationSchema, () => { res.status(202).send(); }); 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) { const args = []; 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); } }); 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 prompt = (await service.readByQuery({ fields: [ "description", "system_prompt", "messages" ], filter: { name: { _eq: promptName } } }))[0]; if (!prompt) throw new McpError(ErrorCode$1.InvalidParams, `Invalid prompt "${promptName}"`); const messages = []; if (prompt.system_prompt) messages.push({ role: "assistant", content: { type: "text", text: render(prompt.system_prompt, args) } }); (prompt.messages || []).forEach((message) => { 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 }); }); const mountedRegistry = new ToolRegistry(ALL_TOOLS).mount({ accountability: req.accountability, allowDeletes: this.allowDeletes, isToolCallApproved: () => true, schema: req.schema, systemPrompt: this.systemPrompt, systemPromptEnabled: this.systemPromptEnabled }); const toolMode = req.query?.["tool_mode"] === "registry" ? "registry" : "legacy"; this.server.setRequestHandler(ListToolsRequestSchema, () => { return { tools: (toolMode === "registry" ? mountedRegistry.getRootTools() : mountedRegistry.tools).map((tool) => this.toMcpTool(tool, toolMode)) }; }); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { try { if (toolMode === "legacy") { const tool = ALL_TOOLS.find(({ name }) => name === request.params.name); 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" }); } const result = toolMode === "registry" ? await mountedRegistry.executeRoot(request.params.name, request.params.arguments) : await mountedRegistry.execute(request.params.name, request.params.arguments); return this.toToolResponse(result, toolMode); } 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; } } toPromptResponse(result) { const response = { messages: result.messages }; if (result.description) response.description = result.description; return response; } toMcpTool(tool, mode = "legacy") { return { name: tool.name, description: mode === "legacy" && tool.instructions ? tool.instructions : tool.description, inputSchema: z$1.toJSONSchema(tool.inputSchema), annotations: tool.annotations, ...mode === "registry" && tool.output && { outputSchema: z$1.toJSONSchema(tool.output) } }; } toToolResponse(executeResult, mode = "legacy") { if (!executeResult.ok) return this.toRegistryErrorResponse(executeResult.error, mode); return this.toResultResponse(executeResult.result, executeResult.structuredContent, mode); } toResultResponse(result, structuredContent, mode = "legacy") { const response = { content: [] }; if (mode === "registry" && structuredContent !== void 0) response.structuredContent = structuredContent; if (!result || typeof result.data === "undefined" || result.data === null) return response; if (result.type === "text") response.content.push({ type: "text", text: JSON.stringify(mode === "legacy" ? { raw: result.data, url: result.url } : { data: result.data, ...result.url && { url: result.url } }) }); else response.content.push(result); return response; } toRegistryErrorResponse(error, mode = "legacy") { const serializedError = mode === "legacy" ? { error: error.message, ...error.code !== "TOOL_EXECUTION_FAILED" && { code: error.code } } : { error: error.message, code: error.code, recoverable: error.recoverable, ...error.next && { next: error.next } }; return { isError: true, content: [{ type: "text", text: JSON.stringify([serializedError]) }] }; } 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 { let message = "An unknown error occurred."; let code; if (error instanceof Error) { message = error.message; code = "code" in error ? String(error.code) : void 0; } else if (typeof error === "object" && error !== null) { message = "message" in error ? String(error.message) : message; code = "code" in error ? String(error.code) : void 0; } else if (typeof error === "string") message = error; errors.push({ error: message, ...code && { code } }); } return { isError: true, content: [{ type: "text", text: JSON.stringify(errors) }] }; } }; //#endregion export { DirectusMCP };