├── .gitignore ├── tsconfig.json ├── eslint.config.js ├── .editorconfig ├── src ├── utils │ ├── to-mpc-tools.ts │ ├── define.ts │ ├── fetch-current-user-permissions.ts │ ├── is-directus-error.ts │ ├── check-collection.ts │ ├── get-primary-key.ts │ ├── strip-null-undefined.ts │ ├── links.ts │ ├── response.ts │ ├── prompt-helpers.ts │ └── fetch-schema.ts ├── types │ ├── permissions.ts │ ├── prompt.ts │ ├── tool.ts │ ├── schema.ts │ ├── files.ts │ ├── common.ts │ ├── fields.ts │ └── query.ts ├── tools │ ├── schema.ts │ ├── users.ts │ ├── markdown.ts │ ├── prompts.ts │ ├── index.ts │ ├── comments.ts │ ├── fields.ts │ ├── flows.ts │ ├── items.ts │ └── files.ts ├── prompts │ ├── handlers.ts │ └── index.ts ├── directus.ts ├── constants │ └── default-prompts.ts ├── config.ts └── index.ts ├── LICENSE ├── package.json └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .env 4 | mcp.json 5 | .docs 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@directus/tsconfig/node22", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import directusConfig from '@directus/eslint-config'; 2 | 3 | export default [ 4 | ...directusConfig, 5 | { 6 | rules: { 7 | 'n/prefer-global/process': 'off', 8 | }, 9 | }, 10 | ]; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root=true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | 9 | [*.{mjs,cjs,js,mts,cts,ts,json,vue,html,scss,css,toml,md}] 10 | indent_style = tab 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.{yml,yaml}] 16 | indent_style = space -------------------------------------------------------------------------------- /src/utils/to-mpc-tools.ts: -------------------------------------------------------------------------------- 1 | import type { ToolDefinition } from '../types/tool.js'; 2 | import { z } from 'zod'; 3 | 4 | export const toMpcTools = (defs: ToolDefinition[]) => { 5 | return defs.map((def) => ({ 6 | name: def.name, 7 | description: def.description, 8 | inputSchema: z.toJSONSchema(def.inputSchema, { reused: 'ref' }), 9 | annotations: def.annotations, 10 | })); 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/define.ts: -------------------------------------------------------------------------------- 1 | import type { PromptDefinition } from '../types/prompt.js'; 2 | import type { ToolDefinition } from '../types/tool.js'; 3 | 4 | export const defineTool = ( 5 | name: string, 6 | tool: Omit, 7 | ) => ({ name, ...tool }); 8 | 9 | export const definePrompt = ( 10 | name: string, 11 | prompt: Omit, 12 | ) => ({ name, ...prompt }); 13 | -------------------------------------------------------------------------------- /src/types/permissions.ts: -------------------------------------------------------------------------------- 1 | export interface ActionPermission { 2 | access: 'none' | 'partial' | 'full'; 3 | fields?: string[]; 4 | presets?: Record; 5 | } 6 | 7 | export interface CollectionPermissions { 8 | create: ActionPermission; 9 | read: ActionPermission; 10 | update: ActionPermission; 11 | delete: ActionPermission; 12 | share: ActionPermission; 13 | } 14 | 15 | export type UserPermissionsData = Record; 16 | -------------------------------------------------------------------------------- /src/types/prompt.ts: -------------------------------------------------------------------------------- 1 | import type { GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; 2 | import type { ZodType } from 'zod'; 3 | import type { Directus } from '../directus.js'; 4 | import type { Schema } from '../types/schema.js'; 5 | 6 | export interface PromptDefinition { 7 | name: string; 8 | description: string; 9 | inputSchema: ZodType; 10 | annotations?: Record; 11 | handler: ( 12 | directus: Directus, 13 | args: Params, 14 | ctx: { schema: Schema }, 15 | ) => Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/types/tool.ts: -------------------------------------------------------------------------------- 1 | import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; 2 | import type { ZodType } from 'zod'; 3 | import type { Directus } from '../directus.js'; 4 | import type { Schema } from '../types/schema.js'; 5 | 6 | export interface ToolDefinition { 7 | name: string; 8 | description: string; 9 | inputSchema: ZodType; 10 | annotations?: Record; 11 | handler: ( 12 | directus: Directus, 13 | args: Params, 14 | ctx: { schema: Schema; baseUrl?: string }, 15 | ) => Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/tools/schema.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod'; 2 | import { defineTool } from '../utils/define.js'; 3 | 4 | export default defineTool('read-collections', { 5 | description: 6 | 'Retrieve the schema of the connected Directus instance. This is a short hand schema to reduce the tokens used for the LLM. The fields will not match the return type of the fields from the Directus API. If you need the exact fields definitions from the Directus API, use the read-fields tool.', 7 | inputSchema: z.object({}), 8 | handler: async (_directus, _args, { schema }) => { 9 | return { content: [{ type: 'text', text: JSON.stringify(schema) }] }; 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/utils/fetch-current-user-permissions.ts: -------------------------------------------------------------------------------- 1 | import type { Directus } from '../directus.js'; 2 | 3 | import type { UserPermissionsData } from '../types/permissions.js'; 4 | import { readUserPermissions } from '@directus/sdk'; 5 | 6 | /** 7 | * Fetches the current user's permissions from the Directus API. 8 | * @param directus - The Directus instance. 9 | * @returns The current user's permissions. 10 | */ 11 | export async function fetchCurrentUserPermissions(directus: Directus): Promise { 12 | const permissions = await directus.request( 13 | readUserPermissions(), 14 | ); 15 | 16 | return permissions; 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/is-directus-error.ts: -------------------------------------------------------------------------------- 1 | export interface DirectusApiError { 2 | message: string; 3 | extensions: { 4 | code: string; 5 | [key: string]: any; 6 | }; 7 | } 8 | 9 | export interface DirectusError { 10 | errors: DirectusApiError[]; 11 | response: Response; 12 | } 13 | 14 | /** 15 | * A type guard to check if an error is a Directus API error 16 | */ 17 | export function isDirectusError(error: unknown): error is DirectusError { 18 | return ( 19 | typeof error === 'object' 20 | && error !== null 21 | && 'errors' in error 22 | && Array.isArray(error.errors) 23 | && 'message' in error.errors[0] 24 | && 'extensions' in error.errors[0] 25 | && 'code' in error.errors[0].extensions 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/check-collection.ts: -------------------------------------------------------------------------------- 1 | import type { Schema } from '../types/schema.js'; 2 | import { formatErrorResponse } from './response.js'; 3 | 4 | /** 5 | * Check if a collection exists in the context schema. 6 | * @param collection - The collection to check. 7 | * @param contextSchema - The context schema. 8 | * @returns The collection if it exists, otherwise an error. 9 | */ 10 | export function checkCollection(collection: string, contextSchema: Schema) { 11 | if (collection && !contextSchema[collection]) { 12 | return formatErrorResponse( 13 | new Error( 14 | `Collection "${collection}" not found. Use read-collections tool first.`, 15 | ), 16 | ); 17 | } 18 | 19 | return contextSchema[collection]; 20 | } 21 | -------------------------------------------------------------------------------- /src/types/schema.ts: -------------------------------------------------------------------------------- 1 | export type CollectionName = string; 2 | export type FieldName = string; 3 | export type Schema = Record>; 4 | 5 | export interface Field { 6 | type: string | null; 7 | interface?: string | null | undefined; 8 | note?: string | null | undefined; 9 | primary_key?: boolean | null | undefined; 10 | required?: boolean | null | undefined; 11 | choices?: Record[] | null | undefined; 12 | // Relationship details 13 | relation_type?: 'm2o' | 'o2m' | 'm2m' | 'm2a' | 'file' | 'files' | null | undefined; 14 | relation_collection?: string | string[] | null | undefined; // Collection(s) on the other side 15 | relation_meta?: Record | null | undefined; // Stores the corresponding relation.meta object 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/get-primary-key.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Gets the primary key field name for a specified collection 3 | * @param collection The collection name to get the primary key for 4 | * @param schema The collections schema object containing field definitions 5 | * @returns The name of the primary key field or null if not found 6 | */ 7 | export function getPrimaryKeyField( 8 | collection: string, 9 | schema: Record>, 10 | ): string | null { 11 | // Check if collection exists in schema 12 | if (!schema[collection]) { 13 | return null; 14 | } 15 | 16 | // Find the field that has primary_key: true 17 | for (const [fieldName, fieldConfig] of Object.entries(schema[collection])) { 18 | if (fieldConfig.primary_key === true) { 19 | return fieldName; 20 | } 21 | } 22 | 23 | return null; 24 | } 25 | -------------------------------------------------------------------------------- /src/types/files.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { nullableNumber, nullableString } from './common.js'; 3 | 4 | export const FileSchema = z.object({ 5 | id: z.string().describe('The ID of the file to update.'), 6 | title: nullableString().describe('The name of the file.'), 7 | folder: nullableString().describe('The ID of the folder the file is in.'), 8 | description: nullableString().describe( 9 | 'The description of the file. Often used as the alt text on a website', 10 | ), 11 | location: nullableString().describe( 12 | 'The location of where the photo was taken.', 13 | ), 14 | tags: z.array(z.string()).optional().describe('The tags of the file.'), 15 | focal_point_x: nullableNumber().describe( 16 | 'The x coordinate of the focal point of the file in pixels.', 17 | ), 18 | focal_point_y: nullableNumber().describe( 19 | 'The y coordinate of the focal point of the file.', 20 | ), 21 | filename_download: nullableString().describe( 22 | 'The filename of the file when users download it.', 23 | ), 24 | }); 25 | export type FileSchemaType = z.infer; 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Directus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@directus/content-mcp", 3 | "type": "module", 4 | "version": "0.1.0", 5 | "packageManager": "pnpm@10.4.1", 6 | "description": "Model Context Protocol server for Directus projects.", 7 | "contributors": [ 8 | "Rijk van Zanten ", 9 | "Bryant Gillespie" 10 | ], 11 | "license": "MIT", 12 | "keywords": [ 13 | "ai", 14 | "directus", 15 | "mcp", 16 | "modelcontextprotocol" 17 | ], 18 | "main": "dist/index.js", 19 | "bin": { 20 | "directus-mcp-server": "./dist/index.js" 21 | }, 22 | "scripts": { 23 | "prepare": "pnpm build", 24 | "build": "tsc --project tsconfig.json", 25 | "dev": "tsc --project tsconfig.json --watch & node --watch dist/index.js", 26 | "start": "node dist/index.js", 27 | "lint": "eslint .", 28 | "lint:fix": "eslint --fix ." 29 | }, 30 | 31 | "dependencies": { 32 | "@directus/sdk": "19.1.0", 33 | "@modelcontextprotocol/sdk": "1.10.2", 34 | "dotenv": "16.5.0", 35 | "isomorphic-dompurify": "^2.24.0", 36 | "marked": "^15.0.11", 37 | "zod": "4.0.0-beta.20250424T163858" 38 | }, 39 | "devDependencies": { 40 | "@directus/eslint-config": "^0.1.0", 41 | "@directus/tsconfig": "3.0.0", 42 | "@directus/types": "^13.1.0", 43 | "@types/node": "22.15.3", 44 | "eslint": "^9.25.1", 45 | "typescript": "5.8.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/tools/users.ts: -------------------------------------------------------------------------------- 1 | import { readMe, readUsers } from '@directus/sdk'; 2 | import * as z from 'zod'; 3 | import { itemQuerySchema } from '../types/query.js'; 4 | import { defineTool } from '../utils/define.js'; 5 | import { formatErrorResponse, formatSuccessResponse } from '../utils/response.js'; 6 | 7 | export const usersMeTool = defineTool('users-me', { 8 | description: 'Retrieve information about the current user', 9 | inputSchema: z.object({ 10 | fields: z.array(z.string()), 11 | }), 12 | handler: async (directus, { fields }) => { 13 | const me = await directus.request(readMe({ fields })); 14 | 15 | return { content: [{ type: 'text', text: JSON.stringify(me) }] }; 16 | }, 17 | }); 18 | 19 | export const readUsersTool = defineTool('read-users', { 20 | description: 'Retrieve information about users.', 21 | inputSchema: z.object({ 22 | query: itemQuerySchema.describe( 23 | 'Directus query parameters (filter, sort, fields, limit, deep, etc. You can use the read-collections tool to get the schema of the collection first.)', 24 | ), 25 | }), 26 | handler: async (directus, { query }) => { 27 | try { 28 | const users = await directus.request(readUsers(query)); 29 | return formatSuccessResponse(users); 30 | } 31 | catch (error) { 32 | return formatErrorResponse(error); 33 | } 34 | }, 35 | }); 36 | -------------------------------------------------------------------------------- /src/utils/strip-null-undefined.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Recursively removes properties with null or undefined values from an object or array. 3 | * @param obj The input object or array. 4 | * @returns A new object or array with null/undefined properties/elements removed, or the original value if not an object/array. 5 | */ 6 | export function stripNullUndefined(obj: T): T | undefined { 7 | if (obj === null || obj === undefined) { 8 | return undefined; 9 | } 10 | 11 | // Handle arrays 12 | if (Array.isArray(obj)) { 13 | const newArr = obj.reduce((acc, item) => { 14 | const processed = stripNullUndefined(item); 15 | 16 | if (processed !== undefined) { 17 | acc.push(processed); 18 | } 19 | 20 | return acc; 21 | }, []); 22 | 23 | return newArr.length > 0 ? newArr as T : undefined; 24 | } 25 | 26 | // Handle objects (excluding Date and other special objects) 27 | if (typeof obj === 'object' && !(obj instanceof Date) 28 | && !(obj instanceof RegExp) && !(obj instanceof Map) 29 | && !(obj instanceof Set) && !(obj instanceof Error)) { 30 | const newObj: Record = {}; 31 | let hasKeys = false; 32 | 33 | for (const key of Object.keys(obj)) { 34 | const value = stripNullUndefined(obj[key as keyof T]); 35 | 36 | if (value !== undefined) { 37 | newObj[key] = value; 38 | hasKeys = true; 39 | } 40 | } 41 | 42 | return hasKeys ? newObj as T : undefined; 43 | } 44 | 45 | return obj; 46 | } 47 | -------------------------------------------------------------------------------- /src/prompts/handlers.ts: -------------------------------------------------------------------------------- 1 | import type { GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; 2 | import type { Config } from '../config.js'; 3 | import type { Directus } from '../directus.js'; 4 | import type { McpPrompt } from './index.js'; 5 | import { createPromptResult } from '../utils/prompt-helpers.js'; 6 | import { fetchPromptByName } from './index.js'; 7 | 8 | /** 9 | * Handler for get-prompt requests. Fetches the prompt from Directus and processes it. 10 | * @param directus - The Directus client 11 | * @param config - The application configuration 12 | * @param promptName - The name of the prompt to fetch 13 | * @param args - The arguments to apply to the prompt template 14 | * @returns The processed prompt result 15 | */ 16 | export async function handleGetPrompt( 17 | directus: Directus, 18 | config: Config, 19 | promptName: string, 20 | args: Record = {}, 21 | ): Promise { 22 | const promptItem = await fetchPromptByName(directus, config, promptName); 23 | 24 | if (!promptItem) { 25 | throw new Error(`Prompt not found: ${promptName}`); 26 | } 27 | 28 | // Ensure messages is an array, even if empty 29 | const messages = promptItem.messages || []; 30 | return createPromptResult(messages, args, promptItem.systemPrompt); 31 | } 32 | 33 | /** 34 | * Returns all available prompts from Directus 35 | * @param prompts - Record of prompt name to prompt definition 36 | * @returns An array of MCP prompt definitions 37 | */ 38 | export function getAvailablePrompts(prompts: Record): McpPrompt[] { 39 | return Object.values(prompts); 40 | } 41 | -------------------------------------------------------------------------------- /src/tools/markdown.ts: -------------------------------------------------------------------------------- 1 | import * as DOMPurify from 'isomorphic-dompurify'; 2 | import { marked } from 'marked'; 3 | import * as z from 'zod'; 4 | import { defineTool } from '../utils/define.js'; 5 | import { formatErrorResponse } from '../utils/response.js'; 6 | 7 | export function markdownToHtml(markdown: string) { 8 | return DOMPurify.sanitize(marked.parse(markdown)); 9 | } 10 | 11 | export function htmlToMarkdown(html: string) { 12 | return marked.parse(html); 13 | } 14 | 15 | export const markdownTool = defineTool('markdown-tool', { 16 | description: 17 | 'Convert HTML to Markdown or Markdown to HTML.', 18 | annotations: { 19 | title: 'Markdown Tool', 20 | readOnlyHint: true, 21 | }, 22 | inputSchema: z.object({ 23 | html: z.string().optional().describe('HTML string to convert to Markdown'), 24 | markdown: z.string().optional().describe('Markdown string to convert to HTML'), 25 | }).refine((data) => data.html || data.markdown, { 26 | message: 'Either html or markdown must be provided', 27 | path: ['html', 'markdown'], 28 | }), 29 | // @ts-expect-error - We're not using the directus client here 30 | handler: async (_directus, query) => { 31 | try { 32 | if (query.html) { 33 | return { 34 | content: [{ type: 'text', text: htmlToMarkdown(query.html) }], 35 | }; 36 | } 37 | 38 | if (query.markdown) { 39 | return { 40 | content: [{ type: 'text', text: markdownToHtml(query.markdown) }], 41 | }; 42 | } 43 | 44 | return formatErrorResponse(new Error('No input provided')); 45 | } 46 | catch (error) { 47 | return formatErrorResponse(error); 48 | } 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const nullableString = () => 4 | z.string().nullable().optional(); 5 | 6 | export const optionalString = () => 7 | z.string().optional(); 8 | 9 | export const nullableNumber = () => 10 | z.number().nullable().optional(); 11 | 12 | export const optionalNumber = () => 13 | z.number().optional(); 14 | 15 | export const optionalBoolean = () => 16 | z.boolean().optional(); 17 | 18 | export const nullableBoolean = () => 19 | z.boolean().nullable().optional(); 20 | 21 | export const nullableRecord = () => 22 | z.record(z.string(), z.any()).nullable().optional(); 23 | 24 | export const optionalStringArray = () => 25 | z.array(z.string()).optional(); 26 | 27 | export const nullableStringArray = () => 28 | z.array(z.string()).nullable().optional(); 29 | 30 | export const stringOrNumber = z.union([z.string(), z.number()]); 31 | export const nullableStringOrNumber = stringOrNumber.nullable().optional(); 32 | 33 | export const stringNumberOrBoolean = z.union([z.string(), z.number(), z.boolean()]); 34 | export const nullableStringNumberOrBoolean = stringNumberOrBoolean.nullable().optional(); 35 | 36 | export const fieldsParamSchema = nullableStringArray().describe('Specify fields to return. Supports dot notation and wildcards (*).'); 37 | export const sortParamSchema = nullableStringArray().describe('Fields to sort by (prefix with - for descending).'); 38 | export const limitParamSchema = nullableNumber().describe('Maximum number of items to return.'); 39 | export const offsetParamSchema = nullableNumber().describe('Number of items to skip.'); 40 | export const pageParamSchema = nullableNumber().describe('Page number for pagination.'); 41 | export const searchParamSchema = nullableString().describe('Global search term for root level text fields.'); 42 | -------------------------------------------------------------------------------- /src/tools/prompts.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '../config.js'; 2 | import { readItems } from '@directus/sdk'; 3 | import * as z from 'zod'; 4 | import { itemQuerySchema } from '../types/query.js'; 5 | import { defineTool } from '../utils/define.js'; 6 | import { formatErrorResponse, formatSuccessResponse } from '../utils/response.js'; 7 | 8 | export function createSystemPrompt(config: Config) { 9 | return defineTool('system-prompt', { 10 | description: 11 | 'IMPORTANT! Call this tool first. It will retrieve important information about your role.', 12 | inputSchema: z.object({}), 13 | handler: async (_directus, _args) => { 14 | return { 15 | content: [{ type: 'text', text: config.MCP_SYSTEM_PROMPT as string }], 16 | }; 17 | }, 18 | }); 19 | } 20 | 21 | export function getPromptsTool(config: Config) { 22 | return defineTool('get-prompts', { 23 | description: 'Retrieve the list of prompts available to the user.', 24 | inputSchema: z.object({ 25 | query: itemQuerySchema.describe( 26 | 'Directus query parameters (filter, sort, fields, limit, deep, etc. You can use the read-collections tool to get the schema of the collection first.)', 27 | ), 28 | }), 29 | handler: async (directus, query) => { 30 | try { 31 | if (config.DIRECTUS_PROMPTS_COLLECTION_ENABLED === 'false') { 32 | throw new Error('DIRECTUS_PROMPTS_COLLECTION_ENABLED is false'); 33 | } 34 | 35 | if (!config.DIRECTUS_PROMPTS_COLLECTION) { 36 | throw new Error('DIRECTUS_PROMPTS_COLLECTION is not set'); 37 | } 38 | 39 | const result = await directus.request(readItems(config.DIRECTUS_PROMPTS_COLLECTION as unknown as never, query)); 40 | return formatSuccessResponse(result); 41 | } 42 | catch (error) { 43 | return formatErrorResponse(error); 44 | } 45 | }, 46 | }); 47 | } 48 | -------------------------------------------------------------------------------- /src/directus.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AuthenticationClient, 3 | DirectusClient, 4 | RestClient, 5 | } from '@directus/sdk'; 6 | import type { Config } from './config.js'; 7 | import type { Schema } from './types/schema.js'; 8 | import { 9 | authentication, 10 | createDirectus as createSdk, 11 | rest, 12 | } from '@directus/sdk'; 13 | 14 | export type Directus = DirectusClient & 15 | RestClient & 16 | AuthenticationClient; 17 | 18 | /** 19 | * Create a Directus client. 20 | * @param config - The configuration. 21 | * @returns The Directus client. 22 | */ 23 | export const createDirectus = (config: Config) => 24 | createSdk(config.DIRECTUS_URL).with(authentication()).with(rest()); 25 | 26 | /** 27 | * Authenticate the Directus client. 28 | * @param directus - The Directus client. 29 | * @param config - The configuration. 30 | */ 31 | export async function authenticateDirectus(directus: Directus, config: Config) { 32 | if (!directus || !config) { 33 | throw new Error('Directus or config is not defined'); 34 | } 35 | 36 | // Token-based authentication 37 | if (config.DIRECTUS_TOKEN) { 38 | directus.setToken(config.DIRECTUS_TOKEN); 39 | return; 40 | } 41 | 42 | // Credentials-based authentication 43 | if (config.DIRECTUS_USER_EMAIL && config.DIRECTUS_USER_PASSWORD) { 44 | try { 45 | await directus.login( 46 | config.DIRECTUS_USER_EMAIL, 47 | config.DIRECTUS_USER_PASSWORD, 48 | ); 49 | 50 | return; 51 | } 52 | catch (error) { 53 | const errorMessage = 54 | error instanceof Error ? error.message : String(error); 55 | throw new Error( 56 | `Failed to authenticate with credentials: ${errorMessage}`, 57 | ); 58 | } 59 | } 60 | 61 | // No valid authentication method 62 | throw new Error( 63 | 'No valid authentication method provided (requires either DIRECTUS_TOKEN or both DIRECTUS_USER_EMAIL and DIRECTUS_USER_PASSWORD)', 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/utils/links.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Types of resources that can be linked to in the CMS 3 | */ 4 | export type CmsResourceType = 'item' | 'collection' | 'file' | 'user' | 'visual'; 5 | 6 | /** 7 | * Configuration for generating CMS deep links 8 | */ 9 | interface LinkConfig { 10 | baseUrl: string; 11 | type: CmsResourceType; 12 | collection?: string; 13 | id?: string; 14 | file?: string; 15 | user?: string; 16 | url?: string; 17 | } 18 | 19 | /** 20 | * Base function to generate CMS deep links based on resource type 21 | * @param config - Configuration for the link 22 | * @returns A deep link or undefined if required parameters are missing 23 | */ 24 | export const generateCmsLink = (config: Partial): string | undefined => { 25 | const { baseUrl, type, collection, id, file, user, url } = config; 26 | 27 | if (!baseUrl) { 28 | return undefined; 29 | } 30 | 31 | switch (type) { 32 | case 'item': 33 | if (!collection || !id) return undefined; 34 | return `${baseUrl}/admin/content/${collection}/${id}`; 35 | 36 | case 'collection': 37 | if (!collection) return undefined; 38 | return `${baseUrl}/admin/content/${collection}`; 39 | 40 | case 'file': 41 | if (!file) return undefined; 42 | return `${baseUrl}/admin/content/files/${file}`; 43 | 44 | case 'user': 45 | if (!user) return undefined; 46 | return `${baseUrl}/admin/content/users/${user}`; 47 | 48 | case 'visual': 49 | if (!url) return undefined; 50 | 51 | try { 52 | // Ensure url is a valid URL and not a relative path 53 | const urlObj = new URL(url); 54 | // Set visual-editing=true in query params while keeping other params 55 | urlObj.searchParams.set('visual-editing', 'true'); 56 | return `${baseUrl}/admin/visual/${urlObj.toString()}`; 57 | } 58 | catch { 59 | // Handle invalid URLs 60 | return undefined; 61 | } 62 | 63 | default: 64 | return undefined; 65 | } 66 | }; 67 | -------------------------------------------------------------------------------- /src/tools/index.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '../config.js'; 2 | import type { ToolDefinition } from '../types/tool.js'; 3 | import { readCommentsTool, upsertCommentTool } from './comments.js'; 4 | import { 5 | createFieldTool, 6 | readFieldsTool, 7 | readFieldTool, 8 | updateFieldTool, 9 | } from './fields.js'; 10 | import { 11 | importFileTool, 12 | readFilesTool, 13 | readFoldersTool, 14 | updateFilesTool, 15 | } from './files.js'; 16 | import { 17 | readFlowsTool, 18 | triggerFlowTool, 19 | } from './flows.js'; 20 | import { 21 | createItemTool, 22 | deleteItemTool, 23 | readItemsTool, 24 | updateItemTool, 25 | } from './items.js'; 26 | import { markdownTool } from './markdown.js'; 27 | import { createSystemPrompt } from './prompts.js'; 28 | import schemaTool from './schema.js'; 29 | import { readUsersTool, usersMeTool } from './users.js'; 30 | 31 | export const getTools = (config: Config) => { 32 | const toolList: ToolDefinition[] = [ 33 | // Users 34 | usersMeTool, 35 | readUsersTool, 36 | // Schema 37 | schemaTool, 38 | // Items 39 | readItemsTool, 40 | createItemTool, 41 | updateItemTool, 42 | deleteItemTool, 43 | // Flows 44 | readFlowsTool, 45 | triggerFlowTool, 46 | // Files 47 | readFoldersTool, 48 | readFilesTool, 49 | importFileTool, 50 | updateFilesTool, 51 | // Fields 52 | readFieldsTool, 53 | readFieldTool, 54 | createFieldTool, 55 | updateFieldTool, 56 | // Comments 57 | readCommentsTool, 58 | upsertCommentTool, 59 | // Markdown 60 | markdownTool, 61 | ]; 62 | 63 | // If system propmt is enabled and exists, add the system prompt tool 64 | if (config.MCP_SYSTEM_PROMPT_ENABLED === 'true' && config.MCP_SYSTEM_PROMPT) { 65 | toolList.push(createSystemPrompt(config)); 66 | } 67 | 68 | // Filter the list of available tools based on cof 69 | const availableTools = toolList.filter( 70 | (tool) => !config.DISABLE_TOOLS.includes(tool.name), 71 | ); 72 | 73 | return availableTools; 74 | }; 75 | -------------------------------------------------------------------------------- /src/constants/default-prompts.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_SYSTEM_PROMPT = ` 2 | ## Core Identity 3 | - You are Directus Assistant, a powerful AI assistant and world-class content editor specialized in working with Directus CMS. 4 | 5 | # Instructions 6 | You are always up-to-date with the latest content management practices and technical copywriting techniques. 7 | Your responses should be precise, helpful, and tailored to technical audiences while maintaining clarity. 8 | You'll be given details about a Directus instance and its schema to help users manage their content effectively. 9 | 10 | # Capabilities 11 | 12 | You have access to a Directus instance and can analyze the schema and content within it. Here are your key capabilities: 13 | 14 | - Understanding and working with Directus schema structures 15 | - Content editing and optimization for technical audiences 16 | - HTML/WYSIWYG field editing 17 | - Expert copywriting for technical documentation 18 | 19 | ## Working with Directus 20 | 21 | You'll analyze prompts containing: 22 | - "system prompt" (your guidelines) 23 | - "user" (the input query) 24 | - "assistant" (expected output, which may often be blank) 25 | 26 | ## Content Editing Rules 27 | 28 | 1. When editing HTML/WYSIWYG fields: 29 | - Use ONLY standard HTML elements 30 | - NEVER add extra styling, classes, or markup outside standard HTML elements 31 | - Focus on semantic, clean markup 32 | 33 | 2. Field Value Certainty: 34 | - IMPORTANT. IF you're less than 99% certain about values for specific fields, STOP AND ASK THE USER 35 | - Prioritize ACCURACY over ASSUMPTION 36 | 37 | 3. Content Deletion: 38 | - Before deleting any content, confirm with the user 39 | - Require explicit "DELETE" confirmation via text 40 | - Always explain potential impacts of deletion 41 | 42 | ## Technical Communication 43 | 44 | 1. Craft messaging that resonates with technical audiences: 45 | - Use appropriate terminology for Directus and CMS concepts 46 | - Balance technical precision with clarity and readability 47 | - Prioritize accuracy in all communications 48 | 49 | 2. Response Format: 50 | - Provide clear, concise responses focused on the task at hand 51 | - Ask clarifying questions when needed to ensure accuracy 52 | - Confirm understanding before making significant changes 53 | 54 | # Important Restrictions 55 | 56 | - NEVER add styling beyond standard HTML elements 57 | - ALWAYS confirm before deleting anything 58 | - ALWAYS ask for clarification when uncertain 59 | - NEVER modify schema or system fields without explicit permission 60 | - NEVER suggest changes that would compromise data integrity 61 | `; 62 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import * as z from 'zod'; 3 | import { DEFAULT_SYSTEM_PROMPT } from './constants/default-prompts.js'; 4 | import { nullableString } from './types/common.js'; 5 | 6 | const configSchema = z 7 | .object({ 8 | DIRECTUS_URL: z.string().describe('The URL of the Directus instance.'), 9 | DIRECTUS_TOKEN: nullableString().describe( 10 | 'The user token for the Directus instance.', 11 | ), 12 | DIRECTUS_USER_EMAIL: nullableString().describe( 13 | 'The email of the user to login with.', 14 | ), 15 | DIRECTUS_USER_PASSWORD: nullableString().describe( 16 | 'The password of the user to login with.', 17 | ), 18 | DISABLE_TOOLS: z 19 | .array(z.string()) 20 | .default(['delete-item']) 21 | .describe("Disable specific tools by name. Defaults to ['delete-item']"), 22 | MCP_SYSTEM_PROMPT_ENABLED: z 23 | .string() 24 | .optional() 25 | .default('true') 26 | .describe('Enable a tailored system prompt for the MCP server.'), 27 | MCP_SYSTEM_PROMPT: nullableString().describe( 28 | 'Overwrite the default system prompt for the MCP server. Will load into the context before you use any other Directus tools.', 29 | ).default(DEFAULT_SYSTEM_PROMPT), 30 | DIRECTUS_PROMPTS_COLLECTION_ENABLED: z 31 | .string() 32 | .optional() 33 | .default('false') 34 | .describe('Enable MCP prompts.'), 35 | DIRECTUS_PROMPTS_COLLECTION: nullableString().describe( 36 | 'The Directus collection to store LLM prompts in.', 37 | ), 38 | DIRECTUS_PROMPTS_NAME_FIELD: nullableString().default('name').describe( 39 | 'The name of the field that contains the prompt name.', 40 | ), 41 | DIRECTUS_PROMPTS_DESCRIPTION_FIELD: nullableString().default('description').describe( 42 | 'The name of the field that contains the prompt description.', 43 | ), 44 | DIRECTUS_PROMPTS_SYSTEM_PROMPT_FIELD: nullableString().default('system_prompt').describe( 45 | 'The name of the field that contains the prompt.', 46 | ), 47 | DIRECTUS_PROMPTS_MESSAGES_FIELD: nullableString().default('messages').describe( 48 | 'The name of the field that contains the prompt messages.', 49 | ), 50 | }) 51 | .refine( 52 | (data) => { 53 | return ( 54 | !!data.DIRECTUS_TOKEN 55 | || (!!data.DIRECTUS_USER_EMAIL && !!data.DIRECTUS_USER_PASSWORD) 56 | ); 57 | }, 58 | { 59 | message: 60 | 'Either DIRECTUS_TOKEN or both DIRECTUS_USER_EMAIL and DIRECTUS_USER_PASSWORD must be provided.', 61 | path: ['DIRECTUS_TOKEN'], 62 | }, 63 | ); 64 | 65 | export const createConfig = () => { 66 | dotenv.config(); 67 | 68 | return configSchema.parse(process.env); 69 | }; 70 | 71 | export type Config = z.infer; 72 | -------------------------------------------------------------------------------- /src/tools/comments.ts: -------------------------------------------------------------------------------- 1 | import { createComment, readComments, updateComment } from '@directus/sdk'; 2 | 3 | import * as z from 'zod'; 4 | import { itemQuerySchema } from '../types/query.js'; 5 | import { defineTool } from '../utils/define.js'; 6 | import { formatErrorResponse, formatSuccessResponse } from '../utils/response.js'; 7 | 8 | export const readCommentsTool = defineTool('read-comments', { 9 | description: 'Fetch comments from any Directus collection item.', 10 | annotations: { 11 | title: 'Read Comments', 12 | readOnlyHint: true, 13 | }, 14 | inputSchema: z.object({ 15 | query: itemQuerySchema.describe( 16 | 'Directus query parameters (filter, sort, fields, limit, etc.)', 17 | ), 18 | }), 19 | handler: async (directus, query) => { 20 | try { 21 | const { query: queryParams } = query; 22 | const result = await directus.request( 23 | readComments(queryParams), 24 | ); 25 | return formatSuccessResponse(result); 26 | } 27 | catch (error) { 28 | return formatErrorResponse(error); 29 | } 30 | }, 31 | }); 32 | 33 | export const upsertCommentTool = defineTool('upsert-comment', { 34 | description: `Create or update a comment on a collection item. When mentioning users: 35 | 1. First use the read-users tool to retrieve the user's ID 36 | 2. Then format the mention using the exact syntax: @[user-uuid] 37 | 3. Include this formatted mention within your comment text 38 | Example: "Hey @8cc67ebc-3c52-475a-9ae6-fba26963a9ad, can you take a look at this?" 39 | IMPORTANT: User mentions MUST use the exact pattern @uuid without any additional characters or formatting. Keep all comments brief and to the point - no more than 2-3 sentences when possible. Focus on essential information only.`, 40 | annotations: { 41 | title: 'Create or Update Comment', 42 | readOnlyHint: true, 43 | }, 44 | inputSchema: z.object({ 45 | id: z.string().optional().describe('The id of the comment to update. If not provided, a new comment will be created.'), 46 | collection: z.string().describe('The name of the collection the item belongs to'), 47 | item: z.union([z.string(), z.number()]).describe('The primary key of the item to comment on.'), 48 | comment: z.string().describe('The comment text.'), 49 | }), 50 | handler: async (directus, input) => { 51 | try { 52 | const { collection, item, comment, id } = input; 53 | 54 | const result = await (id 55 | ? directus.request( 56 | updateComment(id, { 57 | collection, 58 | item, 59 | comment, 60 | }), 61 | ) 62 | : directus.request( 63 | createComment({ 64 | collection, 65 | item, 66 | comment, 67 | }), 68 | )); 69 | 70 | return formatSuccessResponse(result); 71 | } 72 | catch (error) { 73 | return formatErrorResponse(error); 74 | } 75 | }, 76 | }); 77 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import type { CallToolRequest } from '@modelcontextprotocol/sdk/types.js'; 3 | import { Server } from '@modelcontextprotocol/sdk/server/index.js'; 4 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; 5 | import { 6 | CallToolRequestSchema, 7 | GetPromptRequestSchema, 8 | ListPromptsRequestSchema, 9 | ListToolsRequestSchema, 10 | } from '@modelcontextprotocol/sdk/types.js'; 11 | import { createConfig } from './config.js'; 12 | import { authenticateDirectus, createDirectus } from './directus.js'; 13 | import { getAvailablePrompts, handleGetPrompt } from './prompts/handlers.js'; 14 | import { fetchPrompts } from './prompts/index.js'; 15 | import { getTools } from './tools/index.js'; 16 | import { fetchSchema } from './utils/fetch-schema.js'; 17 | import { toMpcTools } from './utils/to-mpc-tools.js'; 18 | 19 | async function main() { 20 | const config = createConfig(); 21 | const directus = createDirectus(config); 22 | await authenticateDirectus(directus, config); 23 | const schema = await fetchSchema(directus); 24 | const prompts = await fetchPrompts(directus, config, schema); 25 | const availableTools = getTools(config); 26 | 27 | const server = new Server( 28 | { 29 | name: 'Directus MCP Server', 30 | version: '0.0.1', 31 | }, 32 | { 33 | capabilities: { 34 | tools: {}, 35 | resources: {}, 36 | prompts: {}, 37 | }, 38 | }, 39 | ); 40 | 41 | // Manage prompts 42 | server.setRequestHandler(ListPromptsRequestSchema, async () => { 43 | return { 44 | prompts: getAvailablePrompts(prompts), 45 | }; 46 | }); 47 | 48 | // Get specific prompt 49 | server.setRequestHandler(GetPromptRequestSchema, async (request) => { 50 | const promptName = request.params.name; 51 | const args = request.params.arguments || {}; 52 | 53 | return await handleGetPrompt( 54 | directus, 55 | config, 56 | promptName, 57 | args, 58 | ); 59 | }); 60 | 61 | // Manage tool requests 62 | server.setRequestHandler( 63 | CallToolRequestSchema, 64 | async (request: CallToolRequest) => { 65 | try { 66 | // Find the tool definition among ALL tools 67 | const tool = availableTools.find((definition) => { 68 | return definition.name === request.params.name; 69 | }); 70 | 71 | if (!tool) { 72 | throw new Error(`Unknown tool: ${request.params.name}`); 73 | } 74 | 75 | // Proceed with execution if permission check passes 76 | const { inputSchema, handler } = tool; 77 | const args = inputSchema.parse(request.params.arguments); 78 | return await handler(directus, args, { schema, baseUrl: config.DIRECTUS_URL }); 79 | } 80 | catch (error) { 81 | console.error('Error executing tool:', error); 82 | const errorMessage = 83 | error instanceof Error ? error.message : JSON.stringify(error); 84 | 85 | return { 86 | content: [ 87 | { 88 | type: 'text', 89 | text: errorMessage, 90 | }, 91 | ], 92 | isError: true, 93 | }; 94 | } 95 | }, 96 | ); 97 | 98 | // Return the pre-filtered list for listing purposes 99 | server.setRequestHandler(ListToolsRequestSchema, async () => { 100 | return { tools: toMpcTools(availableTools) }; 101 | }); 102 | 103 | const transport = new StdioServerTransport(); 104 | 105 | await server.connect(transport); 106 | } 107 | 108 | try { 109 | await main(); 110 | } 111 | catch (error) { 112 | console.error('Fatal error in main():', error); 113 | process.exit(1); 114 | } 115 | -------------------------------------------------------------------------------- /src/tools/fields.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createField, 3 | readField, 4 | readFields, 5 | readFieldsByCollection, 6 | updateField, 7 | } from '@directus/sdk'; 8 | import * as z from 'zod'; 9 | import { 10 | CreateFieldDataSchema, 11 | UpdateFieldDataSchema, 12 | } from '../types/fields.js'; 13 | import { defineTool } from '../utils/define.js'; 14 | import { 15 | formatErrorResponse, 16 | formatSuccessResponse, 17 | } from '../utils/response.js'; 18 | 19 | export const readFieldsTool = defineTool('read-fields', { 20 | description: 21 | 'Retrieve the field definitions for all collections or a specific collection. Note: This is lots of data and should be used sparingly. Use only if you cannot find the field information you need and you absolutely need to have the raw field definition.', 22 | inputSchema: z.object({ 23 | collection: z 24 | .string() 25 | .optional() 26 | .describe( 27 | 'Optional: The name (ID) of the collection to retrieve fields for. If omitted, fields for all collections are returned.', 28 | ), 29 | }), 30 | handler: async (directus, { collection }) => { 31 | try { 32 | const fields = collection 33 | ? await directus.request(readFieldsByCollection(collection)) 34 | : await directus.request(readFields()); 35 | return formatSuccessResponse(fields); 36 | } 37 | catch (error) { 38 | return formatErrorResponse(error); 39 | } 40 | }, 41 | }); 42 | 43 | export const readFieldTool = defineTool('read-field', { 44 | description: 45 | 'Retrieve the definition of a specific field within a collection.', 46 | inputSchema: z.object({ 47 | collection: z 48 | .string() 49 | .describe('The name (ID) of the collection the field belongs to.'), 50 | field: z.string().describe('The name (ID) of the field to retrieve.'), 51 | }), 52 | handler: async (directus, { collection, field }) => { 53 | try { 54 | const fieldData = await directus.request(readField(collection, field)); 55 | return formatSuccessResponse(fieldData); 56 | } 57 | catch (error) { 58 | return formatErrorResponse(error); 59 | } 60 | }, 61 | }); 62 | 63 | export const createFieldTool = defineTool('create-field', { 64 | description: 'Create a new field in a specified collection.', 65 | 66 | inputSchema: z.object({ 67 | collection: z 68 | .string() 69 | .describe('The name (ID) of the collection to add the field to.'), 70 | data: CreateFieldDataSchema.describe( 71 | 'The data for the new field (field name, type, optional schema/meta).', 72 | ), 73 | }), 74 | handler: async (directus, { collection, data }) => { 75 | try { 76 | const result = await directus.request(createField(collection, data)); 77 | return formatSuccessResponse(result); 78 | } 79 | catch (error) { 80 | return formatErrorResponse(error); 81 | } 82 | }, 83 | }); 84 | 85 | export const updateFieldTool = defineTool('update-field', { 86 | description: 'Update an existing field in a specified collection.', 87 | inputSchema: z.object({ 88 | collection: z 89 | .string() 90 | .describe('The name (ID) of the collection containing the field.'), 91 | field: z.string().describe('The name (ID) of the field to update.'), 92 | data: UpdateFieldDataSchema.describe( 93 | 'The partial data to update the field with (type, schema, meta).', 94 | ), 95 | }), 96 | handler: async (directus, { collection, field, data }) => { 97 | try { 98 | const result = await directus.request(updateField(collection, field, data)); 99 | return formatSuccessResponse(result); 100 | } 101 | catch (error) { 102 | return formatErrorResponse(error); 103 | } 104 | }, 105 | }); 106 | -------------------------------------------------------------------------------- /src/utils/response.ts: -------------------------------------------------------------------------------- 1 | import type { DirectusApiError } from './is-directus-error.js'; 2 | import { isDirectusError } from './is-directus-error.js'; 3 | 4 | /** 5 | * Format a success response for the MCP server. 6 | * @param data - The data to format. 7 | * @param message - The message to send to the user. 8 | * @returns The formatted success response. 9 | */ 10 | export const formatSuccessResponse = (data: unknown, message?: string) => { 11 | if (message) { 12 | const formatted = `\n${JSON.stringify(data, null, 2)}\n\n\n${message}\n`; 13 | return { 14 | content: [{ type: 'text' as const, text: `${formatted}` }], 15 | }; 16 | } 17 | 18 | return { 19 | content: [ 20 | { type: 'text' as const, text: `${JSON.stringify(data, null, 2)}` }, 21 | ], 22 | }; 23 | }; 24 | 25 | /** 26 | * Format an error response for the MCP server. 27 | * @param error - The error to format. 28 | */ 29 | export const formatErrorResponse = (error: unknown) => { 30 | let errorPayload: Record; 31 | 32 | if (isDirectusError(error)) { 33 | // Handle Directus API errors 34 | errorPayload = { 35 | directusApiErrors: error.errors.map((e: DirectusApiError) => ({ 36 | message: e.message || 'Unknown error', 37 | code: e.extensions?.code, 38 | })), 39 | }; 40 | } 41 | else { 42 | // Handle generic errors 43 | let message = 'An unknown error occurred.'; 44 | let code: string | undefined; 45 | 46 | if (error instanceof Error) { 47 | message = error.message; 48 | code = 'code' in error ? String(error.code) : undefined; 49 | } 50 | else if (typeof error === 'object' && error !== null) { 51 | message = 'message' in error ? String(error.message) : message; 52 | code = 'code' in error ? String(error.code) : undefined; 53 | } 54 | else if (typeof error === 'string') { 55 | message = error; 56 | } 57 | 58 | errorPayload = { error: message, ...(code && { code }) }; 59 | } 60 | 61 | return { 62 | isError: true, 63 | content: [{ type: 'text' as const, text: JSON.stringify(errorPayload) }], 64 | }; 65 | }; 66 | 67 | /** 68 | * Format a response containing a single embedded resource for the MCP server, 69 | * handling both text and binary content according to the MCP specification. 70 | * 71 | * @param uri - The full URI for the resource (e.g., "resource://my-data/123", "file:///path/to/file.txt"). 72 | * @param mimeType - The MIME type of the resource content (e.g., "application/json", "text/plain", "image/png"). 73 | * @param contentString - The resource content. For text resources, this is the plain text. For binary resources, this MUST be the Base64 encoded string. 74 | * @param isBinary - Set to true if the contentString is Base64 encoded binary data, false or undefined for text. 75 | * @param size - Optional size of the original resource in bytes. 76 | * @returns The formatted response object suitable for returning from an MCP tool or resource handler. 77 | */ 78 | export const formatResourceResponse = ( 79 | uri: string, 80 | mimeType: string, 81 | contentString: string, 82 | isBinary?: boolean, 83 | size?: number, 84 | ) => { 85 | const resourceBase = { 86 | uri, 87 | mimeType, 88 | ...(size !== undefined && { size }), // Conditionally add size if provided 89 | }; 90 | 91 | const resource = isBinary 92 | ? { 93 | ...resourceBase, 94 | blob: contentString, // Use 'blob' field for base64 binary data 95 | } 96 | : { 97 | ...resourceBase, 98 | text: contentString, // Use 'text' field for textual data 99 | }; 100 | 101 | return { 102 | content: [ 103 | { 104 | type: 'resource' as const, 105 | resource, 106 | }, 107 | ], 108 | }; 109 | }; 110 | -------------------------------------------------------------------------------- /src/utils/prompt-helpers.ts: -------------------------------------------------------------------------------- 1 | import type { GetPromptResult } from '@modelcontextprotocol/sdk/types.js'; 2 | 3 | type MessageRole = 'user' | 'assistant'; 4 | 5 | interface PromptMessage { 6 | role: MessageRole; 7 | text: string; 8 | } 9 | 10 | /** 11 | * Extracts mustache variables ({{ variable_name }}) from a string 12 | * @param text - The text to extract variables from 13 | * @returns An array of unique variable names 14 | */ 15 | export function extractMustacheVariables(text: string): string[] { 16 | const regex = /\{\{([^{}]*)\}\}/g; 17 | const variables = new Set(); 18 | let match; 19 | 20 | while ((match = regex.exec(text)) !== null) { 21 | if (match[1]) { 22 | variables.add(match[1].trim()); 23 | } 24 | } 25 | 26 | return [...variables]; 27 | } 28 | 29 | /** 30 | * Process a prompt message by replacing mustache variables with values 31 | * @param message - The message object with role and text 32 | * @param args - The arguments map with values for variables 33 | * @returns The processed message with variables replaced 34 | */ 35 | export function processPromptMessage( 36 | message: PromptMessage, 37 | args: Record, 38 | ): { role: MessageRole; content: { type: 'text'; text: string } } { 39 | let processedText = message.text; 40 | 41 | // Replace all mustache variables 42 | for (const [key, value] of Object.entries(args)) { 43 | const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); 44 | const regex = new RegExp(`\\{\\{\\s*${escapedKey}\\s*\\}\\}`, 'g'); 45 | processedText = processedText.replace(regex, String(value)); 46 | } 47 | 48 | return { 49 | role: message.role, 50 | content: { 51 | type: 'text', 52 | text: processedText, 53 | }, 54 | }; 55 | } 56 | 57 | /** 58 | * Process a system prompt by replacing mustache variables with values 59 | * @param systemPrompt - The system prompt text 60 | * @param args - The arguments map with values for variables 61 | * @returns The processed system prompt with variables replaced 62 | */ 63 | export function processSystemPrompt( 64 | systemPrompt: string, 65 | args: Record, 66 | ): string { 67 | let processedText = systemPrompt; 68 | 69 | // Replace all mustache variables 70 | for (const [key, value] of Object.entries(args)) { 71 | const escapedKey = key.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`); 72 | const regex = new RegExp(`\\{\\{\\s*${escapedKey}\\s*\\}\\}`, 'g'); 73 | processedText = processedText.replace(regex, String(value)); 74 | } 75 | 76 | return processedText; 77 | } 78 | 79 | /** 80 | * Creates a GetPromptResult by processing system prompt and all messages 81 | * @param messages - Array of message objects with role and text 82 | * @param args - Arguments to apply to the prompt templates 83 | * @param systemPrompt - Optional system prompt text 84 | * @returns A formatted GetPromptResult for the MCP server 85 | */ 86 | export function createPromptResult( 87 | messages: PromptMessage[] | null | undefined, 88 | args: Record, 89 | systemPrompt?: string, 90 | ): GetPromptResult { 91 | const processedMessages = []; 92 | 93 | // Add system prompt as the first assistant message if it exists 94 | if (systemPrompt) { 95 | processedMessages.push({ 96 | role: 'assistant', 97 | content: { 98 | type: 'text', 99 | text: processSystemPrompt(systemPrompt, args), 100 | }, 101 | }); 102 | } 103 | 104 | // Add and process regular messages if they exist 105 | if (Array.isArray(messages) && messages.length > 0) { 106 | processedMessages.push(...messages.map((message) => processPromptMessage(message, args))); 107 | } 108 | 109 | return { 110 | messages: processedMessages as GetPromptResult['messages'], 111 | }; 112 | } 113 | -------------------------------------------------------------------------------- /src/tools/flows.ts: -------------------------------------------------------------------------------- 1 | import { readFlows, triggerFlow } from '@directus/sdk'; 2 | 3 | import * as z from 'zod'; 4 | import { defineTool } from '../utils/define.js'; 5 | import { 6 | formatErrorResponse, 7 | formatSuccessResponse, 8 | } from '../utils/response.js'; 9 | 10 | export const readFlowsTool = defineTool('read-flows', { 11 | description: 'Fetch manually triggerable flows from Directus. Flows allow users to automate tasks inside Directus.', 12 | annotations: { 13 | title: 'Read Flows', 14 | readOnlyHint: true, 15 | }, 16 | inputSchema: z.object({}), 17 | handler: async (directus, _query) => { 18 | try { 19 | const result = await directus.request( 20 | readFlows({ 21 | filter: { 22 | trigger: { 23 | // @ts-expect-error - _prefixed operators not working 24 | _eq: 'manual', 25 | }, 26 | }, 27 | }), 28 | ); 29 | 30 | return formatSuccessResponse(result); 31 | } 32 | catch (error) { 33 | return formatErrorResponse(error); 34 | } 35 | }, 36 | }); 37 | 38 | export const triggerFlowTool = defineTool('trigger-flow', { 39 | description: `Trigger a flow by ID. Rules: 40 | - Always call read-flows first and include the FULL flow definition in your reasoning 41 | - Always explicitly check if the flow requires selection (options.requireSelection !== false) 42 | - Always verify the collection is in the flow's collections list 43 | - Always provide a complete data object with all required fields 44 | - NEVER skip providing keys when requireSelection is true or undefined`, 45 | annotations: { 46 | title: 'Trigger Flow', 47 | }, 48 | 49 | inputSchema: z.object({ 50 | flowDefinition: z 51 | .record(z.string(), z.any()) 52 | .describe('The full flow definition from the read-flows call.'), 53 | flowId: z.string().describe('The ID of the flow to trigger'), 54 | collection: z 55 | .string() 56 | .describe('The collection of the items to trigger the flow on.'), 57 | keys: z 58 | .array(z.string()) 59 | .describe( 60 | 'The primary keys of the items to trigger the flow on. If the flow requireSelection field is true, you must provide the keys.', 61 | ), 62 | data: z 63 | .record(z.string(), z.any()) 64 | .optional() 65 | .describe( 66 | 'The data to pass to the flow. Should be an object with keys that match the flow *options.fields.fields* property', 67 | ), 68 | }), 69 | 70 | handler: async (directus, input) => { 71 | try { 72 | const { flowDefinition, flowId, collection, keys, data } = input; 73 | 74 | // Validate flow existence 75 | if (!flowDefinition) { 76 | throw new Error('Flow definition must be provided'); 77 | } 78 | 79 | // Validate flow ID matches 80 | if (flowDefinition.id !== flowId) { 81 | throw new Error( 82 | `Flow ID mismatch: provided ${flowId} but definition has ${flowDefinition.id}`, 83 | ); 84 | } 85 | 86 | // Validate collection is valid for this flow 87 | if (!flowDefinition.options.collections.includes(collection)) { 88 | throw new Error( 89 | `Invalid collection "${collection}". This flow only supports: ${flowDefinition.options.collections.join(', ')}`, 90 | ); 91 | } 92 | 93 | // Check if selection is required 94 | const requiresSelection = 95 | flowDefinition.options.requireSelection !== false; 96 | 97 | if (requiresSelection && (!keys || keys.length === 0)) { 98 | throw new Error( 99 | 'This flow requires selecting at least one item, but no keys were provided', 100 | ); 101 | } 102 | 103 | // Validate required fields 104 | if (flowDefinition.options.fields) { 105 | const requiredFields = flowDefinition.options.fields 106 | .filter((field: any) => field.meta?.required) 107 | .map((field: any) => field.field); 108 | 109 | for (const fieldName of requiredFields) { 110 | if (!data || !(fieldName in data)) { 111 | throw new Error(`Missing required field: ${fieldName}`); 112 | } 113 | } 114 | } 115 | 116 | // All validations passed, trigger the flow 117 | const result = await directus.request( 118 | triggerFlow('POST', flowId, { ...data, collection, keys }), 119 | ); 120 | return formatSuccessResponse(result); 121 | } 122 | catch (error) { 123 | return formatErrorResponse(error); 124 | } 125 | }, 126 | }); 127 | -------------------------------------------------------------------------------- /src/types/fields.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | import { 3 | nullableNumber, 4 | nullableRecord, 5 | nullableString, 6 | optionalBoolean, 7 | optionalNumber, 8 | optionalString, 9 | } from './common.js'; 10 | 11 | export const FieldSchemaInfoSchema = z.object({ 12 | name: optionalString().describe('The name of the field (often inferred).'), 13 | table: optionalString().describe('The collection of the field (often inferred).'), 14 | data_type: optionalString().describe('Database specific data type for the field.'), 15 | default_value: z.any().nullable().optional().describe('The default value of the field.'), 16 | max_length: nullableNumber().describe('The max length of the field.'), 17 | is_nullable: optionalBoolean().describe('If the field is nullable.'), 18 | is_primary_key: optionalBoolean().describe('If the field is primary key.'), 19 | has_auto_increment: optionalBoolean().describe('If the field has auto increment.'), 20 | foreign_key_table: nullableString().describe('Related table from the foreign key constraint.'), 21 | foreign_key_column: nullableString().describe('Related column from the foreign key constraint.'), 22 | comment: nullableString().describe('Comment as saved in the database.'), 23 | }).partial().describe('Database level schema information for a field (subset for creation/update).'); 24 | 25 | export const FieldMetaSchema = z.object({ 26 | collection: optionalString().describe('Collection name (often inferred).'), 27 | field: optionalString().describe('Field name (often inferred).'), 28 | interface: nullableString().describe('What interface is used.'), 29 | options: nullableRecord().describe('Options for the interface.'), 30 | display: nullableString().describe('What display is used.'), 31 | display_options: nullableRecord().describe('Options for the display.'), 32 | readonly: optionalBoolean().describe('If the field is read-only.'), 33 | hidden: optionalBoolean().describe('If the field should be hidden.'), 34 | sort: nullableNumber().describe('Sort order.'), 35 | width: z.enum(['half', 'half-left', 'half-right', 'full', 'fill']).nullable().optional().describe('Width of the field.'), 36 | translations: nullableRecord().describe("Translations for the field's name."), 37 | note: nullableString().describe('A note for the field.'), 38 | required: optionalBoolean().describe('If the field is required.'), 39 | group: nullableString().describe('Field group (references another field name).'), 40 | validation: nullableRecord().describe('Validation rule object.'), 41 | validation_message: nullableString().describe('Validation error message.'), 42 | conditions: z.array(z.record(z.string(), z.any())).describe('Conditions to display the field.').nullable().optional(), 43 | }).partial().describe('Directus specific metadata and configuration for a field (subset for creation/update).'); 44 | 45 | export const CreateFieldDataSchema = z.object({ 46 | field: z.string().describe('Unique name of the field. Required.'), 47 | type: z.string().describe('Directus specific data type. Required.'), 48 | schema: FieldSchemaInfoSchema.optional().describe('Optional: Database schema details.'), 49 | meta: FieldMetaSchema.optional().describe('Optional: Directus metadata and configuration.'), 50 | }).describe('Data payload for creating a new field.'); 51 | 52 | export const UpdateFieldDataSchema = z.object({ 53 | type: optionalString().describe('Optional: New Directus data type.'), 54 | schema: FieldSchemaInfoSchema.optional().describe('Optional: Database schema details to update.'), 55 | meta: FieldMetaSchema.optional().describe('Optional: Directus metadata and configuration to update.'), 56 | }).describe('Data payload for updating an existing field. All properties are optional.'); 57 | 58 | export const FieldSchema = z.object({ 59 | collection: z.string(), 60 | field: z.string(), 61 | type: nullableString().describe('Directus data type of the field.'), 62 | schema: FieldSchemaInfoSchema.nullable(), 63 | meta: FieldMetaSchema.extend({ 64 | id: optionalNumber().describe('Internal ID of the meta entry.'), 65 | interface: nullableString().describe('Interface used in Directus UI.'), 66 | display: nullableString().describe('Display used in Directus UI.'), 67 | readonly: optionalBoolean().describe('Whether the field is read-only.'), 68 | hidden: optionalBoolean().describe('Whether the field is hidden.'), 69 | sort: nullableNumber().describe('Sort order in the UI.'), 70 | width: z.enum(['half', 'half-left', 'half-right', 'full', 'fill']).nullable().optional().describe('UI width.'), 71 | }).nullable().describe('Field metadata from Directus.'), 72 | }).describe('Represents a Directus Field as typically returned by the API.'); 73 | 74 | export type DirectusField = z.infer; 75 | export type DirectusCreateFieldData = z.infer; 76 | export type DirectusUpdateFieldData = z.infer; 77 | -------------------------------------------------------------------------------- /src/utils/fetch-schema.ts: -------------------------------------------------------------------------------- 1 | import type { Field as DirectusField, Relation as DirectusRelation } from '@directus/types'; 2 | import type { Directus } from '../directus.js'; 3 | 4 | import type { Field, Schema } from '../types/schema.js'; 5 | import { readFields, readRelations } from '@directus/sdk'; 6 | import { stripNullUndefined } from './strip-null-undefined.js'; 7 | 8 | /** 9 | * Fetches the fields and relations from the Directus API and returns an unofficial short-hand schema to reduce the tokens used for the LLM. 10 | * @param directus - The Directus instance. 11 | * @returns The schema. 12 | */ 13 | export async function fetchSchema(directus: Directus): Promise { 14 | const fields = await directus.request(readFields()) as unknown as DirectusField[]; 15 | const relations = await directus.request(readRelations()) as unknown as DirectusRelation[]; 16 | const schema: Schema = {}; 17 | 18 | // Create a lookup for relations by collection and field for faster access 19 | const relationsLookup: Record> = {}; 20 | 21 | for (const relation of relations) { 22 | if (!relationsLookup[relation.collection]) { 23 | relationsLookup[relation.collection] = {}; 24 | } 25 | 26 | relationsLookup[relation.collection]![relation.field] = relation; 27 | } 28 | 29 | for (const field of fields) { 30 | // Skip system fields/collections, but allow relations targeting directus_files or directus_users 31 | if (field.meta?.system === true) { 32 | const fieldRelation = relationsLookup[field.collection]?.[field.field]; 33 | const isFileOrUserRelation = fieldRelation?.related_collection === 'directus_files' || fieldRelation?.related_collection === 'directus_users'; 34 | 35 | if (!isFileOrUserRelation) { 36 | // If it's a system field AND not a file/user relation, skip it 37 | continue; 38 | } 39 | } 40 | 41 | // Also skip directus internal collections themselves, unless it's files or users 42 | if (field.collection.startsWith('directus_') && field.collection !== 'directus_files' && field.collection !== 'directus_users') { 43 | continue; 44 | } 45 | 46 | // Skip UI-only fields 47 | if (field.type === 'alias' && field.meta?.special?.includes('no-data')) 48 | continue; 49 | 50 | if (!schema[field.collection]) { 51 | schema[field.collection] = {}; 52 | } 53 | 54 | const schemaField: Field = { 55 | type: field.type, 56 | interface: field.meta?.interface ?? undefined, 57 | primary_key: field.schema?.is_primary_key === true ? true : undefined, 58 | required: field.meta?.required === true ? true : undefined, 59 | note: field.meta?.note ?? undefined, 60 | }; 61 | 62 | // If there are choices from the interface, add them to the schema 63 | if (Array.isArray(field.meta?.options?.['choices']) && field.meta?.options?.['choices'].length > 0) { 64 | schemaField.choices = field.meta?.options?.['choices'].map((choice: { text: string; value: string }) => ({ 65 | text: choice.text, 66 | value: choice.value, 67 | })); 68 | } 69 | 70 | // Process relationships using both field meta and relations data 71 | const fieldRelation = relationsLookup[field.collection]?.[field.field]; 72 | 73 | if (field.meta?.special) { 74 | if (field.meta.special.includes('m2o') || field.meta.special.includes('file')) { 75 | schemaField.relation_type = field.meta.special.includes('file') ? 'file' : 'm2o'; 76 | 77 | if (fieldRelation) { 78 | schemaField.relation_collection = fieldRelation.related_collection; 79 | schemaField.relation_meta = fieldRelation.meta; 80 | } 81 | } 82 | else if (field.meta.special.includes('o2m')) { 83 | schemaField.relation_type = 'o2m'; 84 | 85 | if (fieldRelation) { 86 | schemaField.relation_collection = fieldRelation.related_collection; 87 | schemaField.relation_meta = fieldRelation.meta; 88 | } 89 | } 90 | else if (field.meta.special.includes('m2m') || field.meta.special.includes('files')) { 91 | schemaField.relation_type = field.meta.special.includes('files') ? 'files' : 'm2m'; 92 | 93 | if (fieldRelation) { 94 | schemaField.relation_collection = fieldRelation.related_collection; 95 | schemaField.relation_meta = fieldRelation.meta; 96 | } 97 | } 98 | else if (field.meta.special.includes('m2a')) { 99 | schemaField.relation_type = 'm2a'; 100 | 101 | if (fieldRelation) { 102 | // For M2A, related_collection is null, but one_allowed_collections has the list 103 | schemaField.relation_collection = stripNullUndefined(fieldRelation.meta?.one_allowed_collections); 104 | schemaField.relation_meta = stripNullUndefined(fieldRelation.meta); 105 | } 106 | } 107 | } 108 | 109 | // Add the field to the schema 110 | schema[field.collection]![field.field] = schemaField; 111 | } 112 | 113 | return schema; 114 | } 115 | -------------------------------------------------------------------------------- /src/tools/items.ts: -------------------------------------------------------------------------------- 1 | import { createItem, deleteItem, readItems, updateItem } from '@directus/sdk'; 2 | 3 | import * as z from 'zod'; 4 | import { itemQuerySchema } from '../types/query.js'; 5 | import { checkCollection } from '../utils/check-collection.js'; 6 | import { defineTool } from '../utils/define.js'; 7 | import { getPrimaryKeyField } from '../utils/get-primary-key.js'; 8 | import { generateCmsLink } from '../utils/links.js'; 9 | import { 10 | formatErrorResponse, 11 | formatSuccessResponse, 12 | } from '../utils/response.js'; 13 | 14 | export const readItemsTool = defineTool('read-items', { 15 | description: `Fetch items from any Directus collection. 16 | - Use the *fields* param with dot notation to fetch related fields. 17 | For example – fields: ['title','slug','author.first_name','author.last_name'] 18 | `, 19 | annotations: { 20 | title: 'Read Items', 21 | readOnlyHint: true, 22 | }, 23 | inputSchema: z.object({ 24 | collection: z.string().describe('The name of the collection to read from'), 25 | query: itemQuerySchema.describe( 26 | 'Directus query parameters (filter, sort, fields, limit, deep, etc. You can use the read-collections tool to get the schema of the collection first.)', 27 | ), 28 | }), 29 | handler: async (directus, query, { schema: contextSchema }) => { 30 | try { 31 | const { collection, query: queryParams } = query; 32 | checkCollection(collection, contextSchema); 33 | 34 | const result = await directus.request( 35 | readItems(collection as unknown as never, queryParams), 36 | ); 37 | return formatSuccessResponse(result); 38 | } 39 | catch (error) { 40 | return formatErrorResponse(error); 41 | } 42 | }, 43 | }); 44 | 45 | export const createItemTool = defineTool('create-item', { 46 | description: 47 | 'Create an item in a collection. Will return a link to the created item. You should show the link to the user.', 48 | annotations: { 49 | title: 'Create Item', 50 | }, 51 | 52 | inputSchema: z.object({ 53 | collection: z.string().describe('The name of the collection to create in'), 54 | item: z.record(z.string(), z.unknown()).describe('The item data to create'), 55 | query: itemQuerySchema 56 | .pick({ fields: true, meta: true }) 57 | .optional() 58 | .describe( 59 | 'Optional query parameters for the created item (e.g., fields)', 60 | ), 61 | }), 62 | handler: async (directus, input, { schema: contextSchema, baseUrl }) => { 63 | try { 64 | const { collection, item, query } = input; 65 | checkCollection(collection, contextSchema); 66 | 67 | const primaryKeyField = getPrimaryKeyField(collection, contextSchema); 68 | 69 | const result = await directus.request( 70 | createItem(collection, item, query), 71 | ); 72 | 73 | const id = result[primaryKeyField as any]; 74 | 75 | return formatSuccessResponse( 76 | result, 77 | `Item created: ${generateCmsLink({ baseUrl: baseUrl as string, type: 'item', collection, id: id ?? '' })}`, 78 | ); 79 | } 80 | catch (error) { 81 | return formatErrorResponse(error); 82 | } 83 | }, 84 | }); 85 | 86 | export const updateItemTool = defineTool('update-item', { 87 | description: 88 | 'Update an existing item in a collection. Will return a link to the created item. You should show the link to the user.', 89 | annotations: { 90 | title: 'Update Item', 91 | destructiveHint: true, 92 | }, 93 | 94 | inputSchema: z.object({ 95 | collection: z.string().describe('The name of the collection to update in'), 96 | id: z 97 | .union([z.string(), z.number()]) 98 | .describe('The primary key of the item to update'), 99 | data: z 100 | .record(z.string(), z.unknown()) 101 | .describe('The partial item data to update'), 102 | query: itemQuerySchema 103 | .pick({ fields: true, meta: true }) 104 | .optional() 105 | .describe( 106 | 'Optional query parameters for the updated item (e.g., fields)', 107 | ), 108 | }), 109 | handler: async (directus, input, { schema: contextSchema, baseUrl }) => { 110 | try { 111 | const { collection, id, data, query } = input; 112 | checkCollection(collection, contextSchema); 113 | const primaryKeyField = getPrimaryKeyField(collection, contextSchema); 114 | const result = await directus.request( 115 | updateItem(collection, id, data, query), 116 | ); 117 | return formatSuccessResponse( 118 | result, 119 | `Item updated: ${generateCmsLink({ baseUrl: baseUrl ?? '', type: 'item', collection, id: result[primaryKeyField as any] ?? '' })}`, 120 | ); 121 | } 122 | catch (error) { 123 | return formatErrorResponse(error); 124 | } 125 | }, 126 | }); 127 | 128 | export const deleteItemTool = defineTool('delete-item', { 129 | description: 130 | 'Delete a single item from a collection. Please confirm with the user before deleting.', 131 | annotations: { 132 | title: 'Delete Item', 133 | destructiveHint: true, 134 | }, 135 | inputSchema: z.object({ 136 | collection: z 137 | .string() 138 | .describe('The name of the collection to delete from'), 139 | id: z 140 | .union([z.string(), z.number()]) 141 | .describe('The primary key of the item to delete'), 142 | }), 143 | handler: async (directus, input, { schema: contextSchema }) => { 144 | try { 145 | const { collection, id } = input; 146 | checkCollection(collection, contextSchema); 147 | const result = await directus.request(deleteItem(collection, id)); 148 | return formatSuccessResponse(result); 149 | } 150 | catch (error) { 151 | return formatErrorResponse(error); 152 | } 153 | }, 154 | }); 155 | -------------------------------------------------------------------------------- /src/prompts/index.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '../config.js'; 2 | import type { Directus } from '../directus.js'; 3 | import type { Schema } from '../types/schema.js'; 4 | import { readItems } from '@directus/sdk'; 5 | import { extractMustacheVariables } from '../utils/prompt-helpers.js'; 6 | 7 | export interface PromptItem { 8 | id: string; 9 | name: string; 10 | description: string; 11 | systemPrompt: string; 12 | messages: Array<{ 13 | role: 'user' | 'assistant'; 14 | text: string; 15 | }>; 16 | } 17 | 18 | export interface McpPrompt { 19 | name: string; 20 | description: string; 21 | arguments: Array<{ 22 | name: string; 23 | description: string; 24 | required: boolean; 25 | }>; 26 | } 27 | 28 | /** 29 | * Fetches prompts from the Directus collection specified in the config 30 | * @param directus - The Directus client 31 | * @param config - The application config 32 | * @returns A map of prompt name to MCP prompt definition 33 | */ 34 | export async function fetchPrompts( 35 | directus: Directus, 36 | config: Config, 37 | schema: Schema, 38 | ): Promise> { 39 | // If no prompts collection is configured, return an empty object 40 | if (!config.DIRECTUS_PROMPTS_COLLECTION) { 41 | console.error('No prompts collection configured, skipping prompt fetching'); 42 | return {}; 43 | } 44 | 45 | // If the prompts collection isn't in the schema, then throw an error because there's a permissions issue or it doesn't exist 46 | if (!schema[config.DIRECTUS_PROMPTS_COLLECTION]) { 47 | throw new Error( 48 | 'Prompts collection not found in the schema. Use the read-collections tool first. If you have already used the read-collections tool, then check the permissions of the user you are using to fetch prompts.', 49 | ); 50 | } 51 | 52 | console.error('config', config); 53 | 54 | // Check the prompts collection has the required fields 55 | const requiredFields = [ 56 | config.DIRECTUS_PROMPTS_NAME_FIELD, 57 | config.DIRECTUS_PROMPTS_DESCRIPTION_FIELD, 58 | config.DIRECTUS_PROMPTS_SYSTEM_PROMPT_FIELD, 59 | config.DIRECTUS_PROMPTS_MESSAGES_FIELD, 60 | ]; 61 | 62 | for (const field of requiredFields) { 63 | if (!schema[config.DIRECTUS_PROMPTS_COLLECTION]?.[field as keyof typeof schema[string]]) { 64 | throw new Error(`Prompt field ${field} not found in the prompts collection.`); 65 | } 66 | } 67 | 68 | try { 69 | type DirectusCollection = any; 70 | 71 | const response = (await directus.request( 72 | // @ts-expect-error - We're using a dynamic collection name 73 | readItems(config.DIRECTUS_PROMPTS_COLLECTION), 74 | )) as DirectusCollection[]; 75 | 76 | const prompts: Record = {}; 77 | 78 | for (const item of response) { 79 | // Still include prompts even if messages are empty or null 80 | const variables = new Set(); 81 | const messagesField = config.DIRECTUS_PROMPTS_MESSAGES_FIELD as string; 82 | const systemPromptField = config.DIRECTUS_PROMPTS_SYSTEM_PROMPT_FIELD as string; 83 | 84 | // Extract variables from system prompt if it exists 85 | if (item[systemPromptField]) { 86 | for (const variable of extractMustacheVariables(item[systemPromptField])) { 87 | variables.add(variable); 88 | } 89 | } 90 | 91 | // Extract variables from messages if they exist 92 | if (item[messagesField] && Array.isArray(item[messagesField])) { 93 | for (const message of item[messagesField]) { 94 | if (message && message.text) { 95 | for (const variable of extractMustacheVariables(message.text)) { 96 | variables.add(variable); 97 | } 98 | } 99 | } 100 | } 101 | 102 | prompts[item.name] = { 103 | name: item.name, 104 | description: item.description || `Prompt: ${item.name}`, 105 | arguments: [...variables].map((name) => ({ 106 | name, 107 | description: `Value for ${name}`, 108 | required: false, 109 | })), 110 | }; 111 | } 112 | 113 | return prompts; 114 | } 115 | catch (error) { 116 | console.error('Error fetching prompts:', error); 117 | return {}; 118 | } 119 | } 120 | 121 | /** 122 | * Fetches a specific prompt by name from Directus 123 | * @param directus - The Directus client 124 | * @param config - The application config 125 | * @param promptName - The name of the prompt to fetch 126 | * @returns The prompt item if found, undefined otherwise 127 | */ 128 | export async function fetchPromptByName( 129 | directus: Directus, 130 | config: Config, 131 | promptName: string, 132 | ): Promise { 133 | if (!config.DIRECTUS_PROMPTS_COLLECTION) return undefined; 134 | 135 | try { 136 | type DirectusCollection = any; 137 | 138 | const response = (await directus.request( 139 | // @ts-expect-error - We're using a dynamic collection name 140 | readItems(config.DIRECTUS_PROMPTS_COLLECTION, { 141 | filter: { 142 | [config.DIRECTUS_PROMPTS_NAME_FIELD as string]: { 143 | _eq: promptName, 144 | }, 145 | }, 146 | limit: 1, 147 | }), 148 | )) as DirectusCollection[]; 149 | 150 | if (!response || response.length === 0) return undefined; 151 | 152 | // Map Directus item to our expected format 153 | const item = response[0]; 154 | const nameField = config.DIRECTUS_PROMPTS_NAME_FIELD as string; 155 | const descriptionField = config.DIRECTUS_PROMPTS_DESCRIPTION_FIELD as string; 156 | const systemPromptField = config.DIRECTUS_PROMPTS_SYSTEM_PROMPT_FIELD as string; 157 | const messagesField = config.DIRECTUS_PROMPTS_MESSAGES_FIELD as string; 158 | 159 | return { 160 | id: item.id, 161 | name: item[nameField], 162 | description: item[descriptionField], 163 | systemPrompt: item[systemPromptField] || '', 164 | messages: item[messagesField] || [], 165 | }; 166 | } 167 | catch (error) { 168 | console.error(`Error fetching prompt ${promptName}:`, error); 169 | return undefined; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/tools/files.ts: -------------------------------------------------------------------------------- 1 | import { Buffer } from 'node:buffer'; 2 | import { 3 | importFile, 4 | readAssetArrayBuffer, 5 | readFile, 6 | readFiles, 7 | readFolders, 8 | updateFilesBatch, 9 | } from '@directus/sdk'; 10 | import { z } from 'zod'; 11 | import { FileSchema } from '../types/files.js'; 12 | 13 | import { itemQuerySchema } from '../types/query.js'; 14 | import { defineTool } from '../utils/define.js'; 15 | import { 16 | formatErrorResponse, 17 | formatResourceResponse, 18 | formatSuccessResponse, 19 | } from '../utils/response.js'; 20 | 21 | export const readFilesTool = defineTool('read-files', { 22 | description: 23 | "Read file (asset) metadata. Provide a query to list multiple files' metadata. Provide 'id' to get a single file's metadata. Provide 'id' and 'raw: true' to get a single file's raw content (Base64 encoded).", 24 | annotations: { 25 | title: 'Read Files', 26 | readOnlyHint: true, 27 | }, 28 | inputSchema: z.object({ 29 | query: itemQuerySchema 30 | .optional() 31 | .describe( 32 | 'Directus query parameters (filter, sort, fields, limit, deep, etc.) for file metadata.', 33 | ), 34 | id: z 35 | .string() 36 | .optional() 37 | .describe( 38 | 'The ID of the specific file. Omit to retrieve all files.', 39 | ), 40 | raw: z 41 | .boolean() 42 | .optional() 43 | .describe( 44 | "If true, fetch raw file content (requires 'id'). Content will be Base64 encoded and returned in the 'blob' field with the correct MIME type.", 45 | ), 46 | }), 47 | 48 | handler: async (directus, input) => { 49 | try { 50 | // Case 1: Get a single file (with or without raw content) 51 | if (input.id) { 52 | // Default to all fields if raw to ensure we get type and filename 53 | const fieldsForMetadata = input.raw ? ['*'] : input.query?.fields; 54 | 55 | const query = fieldsForMetadata 56 | ? { fields: fieldsForMetadata } 57 | : undefined; 58 | 59 | const metadata = await directus.request(readFile(input.id, query)); 60 | 61 | if (!metadata) { 62 | return formatErrorResponse(`File with ID ${input.id} not found.`); 63 | } 64 | 65 | // If raw content requested, get base64 (usually for image analysis or vision tool use) 66 | if (input.raw) { 67 | // Check if this is an image and if we have dimensions 68 | const isImage = metadata['type']?.toString().startsWith('image/'); 69 | const width = Number(metadata['width']) || 0; 70 | const height = Number(metadata['height']) || 0; 71 | 72 | // If image exceeds 1200px in any dimension, apply resize parameter 73 | let assetRequest; 74 | 75 | if (isImage && (width > 1200 || height > 1200)) { 76 | // Calculate which dimension to constrain 77 | const transforms = width > height 78 | ? [['resize', { width: 800, fit: 'contain' }]] 79 | : [['resize', { height: 800, fit: 'contain' }]]; 80 | 81 | assetRequest = readAssetArrayBuffer(input.id, { 82 | transforms: transforms as [string, ...any[]][], 83 | }); 84 | } 85 | else { 86 | assetRequest = readAssetArrayBuffer(input.id); 87 | } 88 | 89 | const fileData = await directus.request(assetRequest); 90 | const fileBuffer = Buffer.from(fileData); 91 | const base64Content = fileBuffer.toString('base64'); 92 | const sizeInBytes = fileBuffer.byteLength; 93 | 94 | // The fallback here is janky and certain to fail if the asset is missing a type and it's not an image. Should we just throw an errror? 95 | const mimeType = metadata['type'] || 'image/jpeg'; 96 | 97 | return formatResourceResponse( 98 | `directus://files/${input.id}/raw`, 99 | mimeType as string, 100 | base64Content, 101 | true, 102 | sizeInBytes, 103 | ); 104 | } 105 | 106 | return formatSuccessResponse(metadata); 107 | } 108 | 109 | // Case 2: Query all files 110 | const files = await directus.request( 111 | input.query ? readFiles(input.query) : readFiles(), 112 | ); 113 | return formatSuccessResponse(files); 114 | } 115 | catch (error) { 116 | return formatErrorResponse(error); 117 | } 118 | }, 119 | }); 120 | 121 | export const updateFilesTool = defineTool('update-files', { 122 | description: 'Update the metadata of existing file(s) in Directus.', 123 | annotations: { 124 | title: 'Update Files', 125 | }, 126 | inputSchema: z.object({ 127 | data: z 128 | .array(FileSchema) 129 | .describe( 130 | 'An array of objects containing the id and fields to update (e.g., title, description, tags, folder).', 131 | ), 132 | }), 133 | handler: async (directus, input) => { 134 | try { 135 | if (Object.keys(input.data).length === 0) { 136 | return formatErrorResponse( 137 | "The 'data' object cannot be empty. Provide at least one field to update.", 138 | ); 139 | } 140 | 141 | const result = await directus.request(updateFilesBatch(input.data)); 142 | return formatSuccessResponse(result); 143 | } 144 | catch (error) { 145 | return formatErrorResponse(error); 146 | } 147 | }, 148 | }); 149 | 150 | export const importFileTool = defineTool('import-file', { 151 | description: 152 | "Import a file to Directus from a web URL. Optionally include 'data' for file metadata (title, folder, etc.).", 153 | annotations: { 154 | title: 'Import File', 155 | }, 156 | inputSchema: z.object({ 157 | url: z.string().describe('URL of the file to import.'), 158 | data: FileSchema, 159 | }), 160 | handler: async (directus, input) => { 161 | try { 162 | const result = await directus.request( 163 | importFile(input.url, input.data || {}), 164 | ); 165 | return formatSuccessResponse(result); 166 | } 167 | catch (error) { 168 | return formatErrorResponse(error); 169 | } 170 | }, 171 | }); 172 | 173 | export const readFoldersTool = defineTool('read-folders', { 174 | description: 'Read the metadata of existing folders in Directus.', 175 | annotations: { 176 | title: 'Read Folders', 177 | }, 178 | inputSchema: z.object({ 179 | query: itemQuerySchema.optional(), 180 | }), 181 | handler: async (directus, input) => { 182 | try { 183 | const result = await directus.request(readFolders(input.query)); 184 | return formatSuccessResponse(result); 185 | } 186 | catch (error) { 187 | return formatErrorResponse(error); 188 | } 189 | }, 190 | }); 191 | -------------------------------------------------------------------------------- /src/types/query.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | import { 4 | fieldsParamSchema, 5 | limitParamSchema, 6 | nullableBoolean, 7 | nullableNumber, 8 | nullableString, 9 | nullableStringArray, 10 | nullableStringNumberOrBoolean, 11 | offsetParamSchema, 12 | optionalBoolean, 13 | optionalString, 14 | optionalStringArray, 15 | pageParamSchema, 16 | searchParamSchema, 17 | sortParamSchema, 18 | stringOrNumber, 19 | } from './common.js'; 20 | 21 | export const aggregateSchema = z.object({ 22 | avg: optionalStringArray().describe('Average of fields.'), 23 | avgDistinct: optionalStringArray().describe('Average of distinct fields.'), 24 | count: optionalStringArray().describe('Count of fields.'), 25 | countDistinct: optionalStringArray().describe('Count of distinct fields.'), 26 | sum: optionalStringArray().describe('Sum of fields.'), 27 | sumDistinct: optionalStringArray().describe('Sum of distinct fields.'), 28 | min: optionalStringArray().describe('Minimum value of fields.'), 29 | max: optionalStringArray().describe('Maximum value of fields.'), 30 | }).describe('Aggregation operations to perform.'); 31 | 32 | export const fieldFilterOperatorSchema = z.object({ 33 | _eq: nullableStringNumberOrBoolean.describe('Equals'), 34 | _neq: nullableStringNumberOrBoolean.describe('Not equals'), 35 | _lt: stringOrNumber.optional().describe('Less than'), 36 | _lte: stringOrNumber.optional().describe('Less than or equal to'), 37 | _gt: stringOrNumber.optional().describe('Greater than'), 38 | _gte: stringOrNumber.optional().describe('Greater than or equal to'), 39 | _in: z.array(stringOrNumber).optional().describe('In array'), 40 | _nin: z.array(stringOrNumber).optional().describe('Not in array'), 41 | _null: optionalBoolean().describe('Is null'), 42 | _nnull: optionalBoolean().describe('Is not null'), 43 | _contains: optionalString().describe('Contains substring (case-sensitive)'), 44 | _ncontains: optionalString().describe('Does not contain substring (case-sensitive)'), 45 | _icontains: optionalString().describe('Contains substring (case-insensitive)'), 46 | _starts_with: optionalString().describe('Starts with (case-sensitive)'), 47 | _nstarts_with: optionalString().describe('Does not start with (case-sensitive)'), 48 | _istarts_with: optionalString().describe('Starts with (case-insensitive)'), 49 | _nistarts_with: optionalString().describe('Does not start with (case-insensitive)'), 50 | _ends_with: optionalString().describe('Ends with (case-sensitive)'), 51 | _nends_with: optionalString().describe('Does not end with (case-sensitive)'), 52 | _iends_with: optionalString().describe('Ends with (case-insensitive)'), 53 | _niends_with: optionalString().describe('Does not end with (case-insensitive)'), 54 | _between: z.array(stringOrNumber).optional().describe('Between two values'), 55 | _nbetween: z.array(stringOrNumber).optional().describe('Not between two values'), 56 | _empty: optionalBoolean().describe('Is empty'), 57 | _nempty: optionalBoolean().describe('Is not empty'), 58 | _intersects: optionalString().describe('Geometry intersects (GeoJSON)'), 59 | _nintersects: optionalString().describe('Geometry does not intersect (GeoJSON)'), 60 | _intersects_bbox: optionalString().describe('Geometry intersects bounding box (GeoJSON)'), 61 | _nintersects_bbox: optionalString().describe('Geometry does not intersect bounding box (GeoJSON)'), 62 | }).partial().describe('Operators for field filtering.'); 63 | 64 | export const fieldValidationOperatorSchema = z.object({ 65 | _submitted: optionalBoolean().describe('Is submitted (relevant for validation)'), 66 | _regex: optionalString().describe('Matches regex pattern'), 67 | }).partial().describe('Operators for field validation.'); 68 | 69 | export const fieldFilterSchema = z.record( 70 | z.string(), 71 | z.union([fieldFilterOperatorSchema, fieldValidationOperatorSchema]), 72 | ).describe('Record of field filters.'); 73 | 74 | export const filterSchema = z.union([ 75 | z.interface({ 76 | get _or() { 77 | return z.array(filterSchema).describe('Logical OR condition.'); 78 | }, 79 | }), 80 | z.interface({ 81 | get _and() { 82 | return z.array(filterSchema).describe('Logical AND condition.'); 83 | }, 84 | }), 85 | fieldFilterSchema, 86 | ]).describe('Recursive filter structure (including logical AND/OR).'); 87 | 88 | export const deepQuerySchema = z.object({ 89 | _fields: nullableStringArray().describe('Fields for deep query.'), 90 | _sort: nullableStringArray().describe('Sort order for deep query.'), 91 | _filter: filterSchema.optional().nullable().describe('Filter for deep query.'), 92 | _limit: nullableNumber().describe('Limit for deep query.'), 93 | _offset: nullableNumber().describe('Offset for deep query.'), 94 | _page: nullableNumber().describe('Page for deep query.'), 95 | _search: nullableString().describe('Search term for deep query.'), 96 | _group: nullableStringArray().describe('Grouping fields for deep query.'), 97 | _aggregate: aggregateSchema.optional().nullable().describe('Aggregation for deep query.'), 98 | }).describe('Schema for a deep query on a relation.'); 99 | 100 | export const nestedDeepQuerySchema = z.record(z.string(), deepQuerySchema).optional().nullable().describe('Nested deep query for relational fields.'); 101 | 102 | export const filterParamSchema = filterSchema.optional().nullable().describe('Filter conditions for the query. Respects all standard Directus filter syntax.'); 103 | 104 | export const versionParamSchema = nullableString().describe('Content version to retrieve.'); 105 | 106 | export const versionRawParamSchema = nullableBoolean().describe('Whether to return raw version data.'); 107 | 108 | export const exportParamSchema = z.union([z.literal('json'), z.literal('csv'), z.literal('xml'), z.literal('yaml')]).optional().nullable().describe('Format to export the data.'); 109 | 110 | export const groupByParamSchema = nullableStringArray().describe('Fields to group results by.'); 111 | 112 | export const aggregateParamSchema = aggregateSchema.optional().nullable().describe('Aggregation operations to perform on the main query.'); 113 | 114 | export const aliasParamSchema = z.record(z.string(), z.string()).optional().nullable().describe('Field aliases for the response.'); 115 | 116 | export const metaParamSchema = z.union([z.literal('total_count'), z.literal('filter_count')]).optional().nullable().describe('Include collection counts in the response.'); 117 | 118 | export const itemQuerySchema = z.object({ 119 | fields: fieldsParamSchema, 120 | sort: sortParamSchema, 121 | filter: filterParamSchema, 122 | limit: limitParamSchema, 123 | offset: offsetParamSchema, 124 | page: pageParamSchema, 125 | search: searchParamSchema, 126 | version: versionParamSchema, 127 | export: exportParamSchema, 128 | groupBy: groupByParamSchema, 129 | aggregate: aggregateParamSchema, 130 | deep: nestedDeepQuerySchema, 131 | alias: aliasParamSchema, 132 | meta: metaParamSchema, 133 | }).describe('Main Directus item query parameters.'); 134 | 135 | export type FieldFilterOperator = z.infer; 136 | export type FieldValidationOperator = z.infer; 137 | export interface FieldFilter { 138 | [field: string]: FieldFilterOperator | FieldValidationOperator; 139 | } 140 | export interface LogicalFilterOR { 141 | _or: z.infer[]; 142 | } 143 | export interface LogicalFilterAND { 144 | _and: z.infer[]; 145 | } 146 | export type LogicalFilter = LogicalFilterOR | LogicalFilterAND; 147 | export type Filter = LogicalFilter | FieldFilter; 148 | 149 | export { fieldsParamSchema, limitParamSchema, offsetParamSchema, pageParamSchema, searchParamSchema, sortParamSchema } from './common.js'; 150 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Directus Content MCP Server 2 | 3 | The [Model Context Protocol](https://modelcontextprotocol.io/introduction) (MCP) is a standard for helping AI tools and 4 | LLMs talk to applications and services like Directus. 5 | 6 | The Directus Content MCP Server is an interface for Directus users to interact with their data in LLMs. Some good use 7 | cases are: 8 | 9 | - **Content Editors**: build custom pages, write blog posts, update content, organize assets and more inside your 10 | Directus project. 11 | - **Data Analysts**: query collections, generate reports, analyze trends, and extract insights from your Directus data 12 | using natural language. 13 | 14 | It intentionally limits destructive actions that would result in really bad outcomes like data loss from deleting fields 15 | or deleting collections. 16 | 17 | We plan to provide more tools for developers who are working with local / development Directus projects in future 18 | releases and potentially as a separate package to help further prevent data loss. 19 | 20 | ## Installation 21 | 22 | ### Prerequisites 23 | 24 | - An existing Directus project 25 | 26 | If you don't have an existing Directus project, you can get started with a free trial on 27 | [Directus Cloud](https://directus.cloud/register) at https://directus.cloud/register 28 | 29 | OR 30 | 31 | You can spin up a sample Directus instance locally with the following terminal command. 32 | 33 | ``` 34 | npx directus-template-cli@latest init 35 | ``` 36 | 37 | ### Step 1. Get Directus Credentials 38 | 39 | You can use email and password or generate a static token to connect the MCP to your Directus instance. 40 | 41 | To get a static access token: 42 | 43 | 1. Login to your Directus instnace 44 | 2. Go to the User Directory and choose your own user profile 45 | 3. Scroll down to the Token field 46 | 4. Generate token and copy it 47 | 5. Save the user (do NOT forget to save because you’ll get an error that shows Invalid token!) 48 | 49 | ### Step 2. Configure the MCP in your AI Tool 50 | 51 | #### Claude Desktop 52 | 53 | 1. Open [Claude Desktop](https://claude.ai/download) and navigate to Settings. 54 | 55 | 2. Under the Developer tab, click Edit Config to open the configuration file. 56 | 57 | 3. Add the following configuration: 58 | 59 | ```json 60 | { 61 | "mcpServers": { 62 | "directus": { 63 | "command": "npx", 64 | "args": ["@directus/content-mcp@latest"], 65 | "env": { 66 | "DIRECTUS_URL": "https://your-directus-url.com", 67 | "DIRECTUS_TOKEN": "your-directus-token>" 68 | } 69 | } 70 | } 71 | } 72 | ``` 73 | 74 | or if you're using email and password 75 | 76 | ```json 77 | { 78 | "mcpServers": { 79 | "directus": { 80 | "command": "npx", 81 | "args": ["@directus/content-mcp@latest"], 82 | "env": { 83 | "DIRECTUS_URL": "https://your-directus-url.com", 84 | "DIRECTUS_USER_EMAIL": "john@example.com", 85 | "DIRECTUS_USER_PASSWORD": "your-password" 86 | } 87 | } 88 | } 89 | } 90 | ``` 91 | Make sure you replace the placeholder values with your URL and credentials. 92 | 93 | 4. Save the configuration file and restart Claude desktop. 94 | 95 | 5. From the new chat screen, you should see an icon appear with the Directus MCP server. 96 | 97 | #### Cursor 98 | 99 | 1. Open [Cursor](https://docs.cursor.com/context/model-context-protocol) and create a .cursor directory in your project 100 | root if it doesn't exist. 101 | 102 | 2. Create a `.cursor/mcp.json` file if it doesn't exist and open it. 103 | 104 | 3. Add the following configuration: 105 | 106 | ```json 107 | { 108 | "mcpServers": { 109 | "directus": { 110 | "command": "npx", 111 | "args": ["@directus/content-mcp@latest"], 112 | "env": { 113 | "DIRECTUS_URL": "https://your-directus-url.com", 114 | "DIRECTUS_TOKEN": "your-directus-token>" 115 | } 116 | } 117 | } 118 | } 119 | ``` 120 | 121 | or if you're using email and password 122 | 123 | ```json 124 | { 125 | "mcpServers": { 126 | "directus": { 127 | "command": "npx", 128 | "args": ["@directus/content-mcp@latest"], 129 | "env": { 130 | "DIRECTUS_URL": "https://your-directus-url.com", 131 | "DIRECTUS_USER_EMAIL": "john@example.com", 132 | "DIRECTUS_USER_PASSWORD": "your-password" 133 | } 134 | } 135 | } 136 | } 137 | ``` 138 | 139 | Make sure you replace the placeholder values with your URL and credentials. 140 | 141 | 4. Save the configuration file. 142 | 143 | 5. Open Cursor and navigate to Settings/MCP. You should see a green active status after the server is successfully 144 | connected. 145 | 146 | ## Tools 147 | 148 | The MCP Server provides the following tools to interact with your Directus instance: 149 | 150 | | Tool | Description | Use Cases | 151 | | -------------------- | ---------------------------------------------------- | -------------------------------------------------------------- | 152 | | **system-prompt** | Provides context to the LLM assistant about its role | Start of a session to understand the system context | 153 | | **users-me** | Get current user information | Understanding permissions, personalizing responses | 154 | | **read-collections** | Retrieve the schema of all collections | Exploring database structure, understanding relationships | 155 | | **read-items** | Fetch items from any collection | Retrieving content, searching for data, displaying information | 156 | | **create-item** | Create new items in collections | Adding new content, records, or entries | 157 | | **update-item** | Modify existing items | Editing content, updating statuses, correcting information | 158 | | **delete-item** | Remove items from collections | Cleaning up outdated content | 159 | | **read-files** | Access file metadata or raw content | Finding images, documents, or media assets | 160 | | **import-file** | Import files from URLs | Adding external media to your Directus instance | 161 | | **update-files** | Update file metadata | Organizing media, adding descriptions, tagging | 162 | | **read-fields** | Get field definitions for collections | Understanding data structure, field types and validation | 163 | | **read-field** | Get specific field information | Detailed field configuration | 164 | | **create-field** | Add new fields to collections | Extending data models | 165 | | **update-field** | Modify existing fields | Changing field configuration, interface options | 166 | | **read-flows** | List available automation flows | Finding automation opportunities | 167 | | **trigger-flow** | Execute automation flows | Bulk operations, publishing, status changes | 168 | | **read-comments** | View comments on items | Retrieving feedback, viewing discussion threads | 169 | | **upsert-comment** | Add or update comments | Providing feedback, documenting decisions | 170 | | **markdown-tool** | Convert between markdown and HTML | Content formatting for WYSIWYG fields | 171 | | **get-prompts** | List available prompts | Discovering pre-configured prompt templates | 172 | | **get-prompt** | Execute a stored prompt | Using prompt templates for consistent AI interactions | 173 | 174 | ### System Prompt 175 | 176 | The MCP server comes with a system prompt that helps encourage the right tool use and provides guiderails for the LLM. 177 | You can overwrite the default system prompt in your env configuration by setting the `MCP_SYSTEM_PROMPT` variable. 178 | 179 | You can also disable the system prompt entirely if desired. 180 | 181 | Just set `MCP_SYSTEM_PROMPT_ENABLED` to `false` 182 | 183 | ### Prompt Configuration 184 | 185 | The MCP server supports dynamic prompts stored in a Directus collection. Prompts are not widely supported across MCP 186 | Clients, but Claude Desktop does have support for them. 187 | 188 | You can configure the following: 189 | 190 | - `DIRECTUS_PROMPTS_COLLECTION_ENABLED`: Set to "true" to enable prompt functionality 191 | - `DIRECTUS_PROMPTS_COLLECTION`: The name of the collection containing prompts 192 | - `DIRECTUS_PROMPTS_NAME_FIELD`: Field name for the prompt name (default: "name") 193 | - `DIRECTUS_PROMPTS_DESCRIPTION_FIELD`: Field name for the prompt description (default: "description") 194 | - `DIRECTUS_PROMPTS_SYSTEM_PROMPT_FIELD`: Field name for the system prompt text (default: "system_prompt") 195 | - `DIRECTUS_PROMPTS_MESSAGES_FIELD`: Field name for the messages array (default: "messages") 196 | 197 | ### Mustache Templating 198 | 199 | Both system prompts and message content support mustache templating using the `{{ variable_name }}` syntax: 200 | 201 | 1. Define variables in your prompts using double curly braces: `Hello, {{ name }}!` 202 | 2. When calling a prompt, provide values for the variables in the `arguments` parameter 203 | 3. The MCP server will automatically replace all variables with their provided values 204 | 205 | ## Local / Dev Installation 206 | 207 | 1. Clone the repo 208 | 2. `pnpm install && pnpm build` to build the server 209 | 3. Configure Claude Desktop or Cursor like above, but pointing it to the `dist` file instead: 210 | 4. Use `pnpm dev` to watch for changes and rebuild the server 211 | 212 | ```json 213 | { 214 | "mcpServers": { 215 | "directus": { 216 | "command": "node", 217 | "args": ["/path/to/directus-mcp-server/dist/index.js"] 218 | } 219 | } 220 | } 221 | ``` 222 | 223 | Sample Claude Desktop Config for local dev with full settings 224 | 225 | ```json 226 | { 227 | "mcpServers": { 228 | "directus": { 229 | "command": "node", 230 | "args": [ 231 | "/path/to/directus-mcp-server/dist/index.js" 232 | ], 233 | "env": { 234 | "DIRECTUS_URL": "DIRECTUS_URL", 235 | "DIRECTUS_TOKEN": "DIRECTUS_TOKEN", 236 | "MCP_SYSTEM_PROMPT_ENABLED": "true", 237 | "MCP_SYSTEM_PROMPT": "You're a content editor working at Directus.\nYou're a master at copywriting and creating messaging that resonates with technical audiences.\nYou'll be given details about a Directus instance and the schema within it. You'll be asked to update content and other helpful tasks. **Rules** \n - If you're updating HTML / WYSWIG fields inside the CMS - DO NOT ADD extra styling, classes, or markup outside the standard HTML elements. If you're not 95% sure what values should go into a certain field, stop and ask the user. Before deleting anything, confirm with the user and prompt them for an explicit DELETE confirmation via text.", 238 | "DIRECTUS_PROMPTS_COLLECTION_ENABLED": "true", 239 | "DIRECTUS_PROMPTS_COLLECTION": "ai_prompts" 240 | } 241 | } 242 | } 243 | } 244 | ``` 245 | 246 | ### Example Configurations 247 | 248 | #### Basic Configuration with Token 249 | 250 | ```json 251 | { 252 | "mcpServers": { 253 | "directus": { 254 | "command": "npx", 255 | "args": ["@directus/content-mcp@latest"], 256 | "env": { 257 | "DIRECTUS_URL": "https://your-directus-instance.com", 258 | "DIRECTUS_TOKEN": "your_directus_token" 259 | } 260 | } 261 | } 262 | } 263 | ``` 264 | 265 | #### Configuration with Email/Password Authentication 266 | 267 | ```json 268 | { 269 | "mcpServers": { 270 | "directus": { 271 | "command": "npx", 272 | "args": ["@directus/content-mcp@latest"], 273 | "env": { 274 | "DIRECTUS_URL": "https://your-directus-instance.com", 275 | "DIRECTUS_USER_EMAIL": "user@example.com", 276 | "DIRECTUS_USER_PASSWORD": "your_password" 277 | } 278 | } 279 | } 280 | } 281 | ``` 282 | 283 | #### Advanced Configuration with Custom System Prompt and Tool Restrictions 284 | 285 | ```json 286 | { 287 | "mcpServers": { 288 | "directus": { 289 | "command": "npx", 290 | "args": ["@directus/content-mcp@latest"], 291 | "env": { 292 | "DIRECTUS_URL": "https://your-directus-instance.com", 293 | "DIRECTUS_TOKEN": "your_directus_token", 294 | "DISABLE_TOOLS": ["delete-item", "update-field"], 295 | "MCP_SYSTEM_PROMPT_ENABLED": "true", 296 | "MCP_SYSTEM_PROMPT": "You are an assistant specialized in managing content for our marketing website.", 297 | "DIRECTUS_PROMPTS_COLLECTION_ENABLED": "true", 298 | "DIRECTUS_PROMPTS_COLLECTION": "ai_prompts", 299 | "DIRECTUS_PROMPTS_NAME_FIELD": "name", 300 | "DIRECTUS_PROMPTS_DESCRIPTION_FIELD": "description", 301 | "DIRECTUS_PROMPTS_SYSTEM_PROMPT_FIELD": "system_prompt", 302 | "DIRECTUS_PROMPTS_MESSAGES_FIELD": "messages" 303 | } 304 | } 305 | } 306 | } 307 | ``` 308 | 309 | # ❤️ Contributing 310 | 311 | We love to see community contributions, but please open an issue first to discuss proposed changes before submitting any 312 | PRs. 313 | 314 | ## 🙏 Thanks To 315 | 316 | This started as an experiment by the dude, the legend, the [@rijkvanzanten](https://github.com/rijkvanzanten) 🙌 317 | 318 | ## License 319 | 320 | MIT 321 | --------------------------------------------------------------------------------