#!/usr/bin/env node /** * Grav MCP server — zero-dependency stdio JSON-RPC implementation. * * Env: * GRAV_URL Base URL of the Grav site (e.g. https://example.com); /api/v1 is appended. * GRAV_API_KEY API key (Admin → user → API keys, or POST /users/{username}/api-keys) * * API docs: https://learn.getgrav.org/20/api/endpoints */ 'use strict'; const fs = require('fs'); const nodePath = require('path'); const SERVER_INFO = { name: 'grav-mcp', version: '0.1.0' }; const PROTOCOL_VERSION = '2024-11-05'; const GRAV_URL = (process.env.GRAV_URL || '').replace(/\/+$/, ''); const GRAV_API_KEY = process.env.GRAV_API_KEY || ''; const API_BASE = '/api/v1'; // --------------------------------------------------------------------------- // Grav REST client // --------------------------------------------------------------------------- function requireConfig() { if (!GRAV_URL) { throw new Error( 'GRAV_URL is not configured. Set it to your Grav site base URL (e.g. https://example.com).' ); } if (!GRAV_API_KEY) { throw new Error( 'GRAV_API_KEY is not configured. Create an API key for your user in the Grav admin ' + '(or via POST /api/v1/users/{username}/api-keys) and set it here.' ); } } function buildQuery(params) { const qs = new URLSearchParams(); for (const [key, value] of Object.entries(params || {})) { if (value === undefined || value === null || value === '') continue; if (typeof value === 'boolean') qs.set(key, value ? 'true' : 'false'); else qs.set(key, String(value)); } const s = qs.toString(); return s ? `?${s}` : ''; } // Encode a page route or file path segment-by-segment, keeping the slashes. function encPath(route) { return String(route) .replace(/^\/+|\/+$/g, '') .split('/') .map(encodeURIComponent) .join('/'); } async function grav(method, path, { query, body, multipart } = {}) { requireConfig(); const url = `${GRAV_URL}${API_BASE}${path}${buildQuery(query)}`; const headers = { 'X-API-Key': GRAV_API_KEY }; let payload; if (multipart) { headers['Content-Type'] = multipart.contentType; payload = multipart.body; } else if (body !== undefined) { headers['Content-Type'] = 'application/json'; payload = JSON.stringify(body); } const res = await fetch(url, { method, headers, body: payload }); const text = await res.text(); if (!res.ok) { let detail = text; try { const parsed = JSON.parse(text); if (parsed.error) detail = typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error); else if (parsed.message) detail = parsed.message; } catch { /* keep raw text */ } const hints = { 401: 'Authentication failed — check GRAV_API_KEY.', 403: 'Forbidden — the API key owner lacks the required api.* permission for this action.', 404: 'Not found — check the route/name/id (or the REST API plugin may be disabled).', 409: 'Conflict — the resource changed since it was read (stale ETag) or already exists.', 422: 'Validation failed.', 429: 'Rate limited — retry later.', }; const hint = hints[res.status] ? ` ${hints[res.status]}` : ''; throw new Error(`Grav API ${res.status} ${res.statusText} for ${method} ${path}.${hint}${detail ? ` Details: ${detail.slice(0, 2000)}` : ''}`); } if (!text) return { success: true, status: res.status }; try { return JSON.parse(text); } catch { return { success: true, status: res.status, raw: text.slice(0, 4000) }; } } // Strip undefined values so we send clean payloads. function compact(obj) { const out = {}; for (const [k, v] of Object.entries(obj)) { if (v !== undefined && v !== null) out[k] = v; } return out; } const MIME_TYPES = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', pdf: 'application/pdf', zip: 'application/zip', json: 'application/json', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', yaml: 'application/x-yaml', yml: 'application/x-yaml', }; function multipartFile(fieldName, filePath, extraFields) { const data = fs.readFileSync(filePath); const filename = nodePath.basename(filePath); const ext = filename.split('.').pop().toLowerCase(); const mime = MIME_TYPES[ext] || 'application/octet-stream'; const boundary = '----grav-mcp-' + Date.now().toString(16) + Math.random().toString(16).slice(2); const parts = []; for (const [k, v] of Object.entries(extraFields || {})) { if (v === undefined || v === null) continue; parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`)); } parts.push(Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\n` + `Content-Type: ${mime}\r\n\r\n` )); parts.push(data, Buffer.from(`\r\n--${boundary}--\r\n`)); return { body: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` }; } // --------------------------------------------------------------------------- // Tool definitions // --------------------------------------------------------------------------- const PAGINATION = { page: { type: 'integer', description: 'Page number for pagination (default 1).' }, per_page: { type: 'integer', description: 'Results per page (default 20, max 100).' }, }; const HEADER_FIELD = { type: 'object', description: 'Page frontmatter/header values as an object, e.g. {"taxonomy": {"tag": ["news"]}}.', }; const TOOLS = [ // --- Pages ---------------------------------------------------------------- { name: 'grav_list_pages', description: 'List/search pages with filters and pagination. Needs api.pages.read.', inputSchema: { type: 'object', properties: { search: { type: 'string', description: 'Full-text search across indexed fields.' }, template: { type: 'string', description: 'Filter by page template name (e.g. "blog", "default").' }, parent: { type: 'string', description: 'All descendants under this parent route.' }, children_of: { type: 'string', description: 'Direct children of this route only.' }, published: { type: 'boolean', description: 'Filter by published state.' }, visible: { type: 'boolean', description: 'Filter by visible state.' }, routable: { type: 'boolean', description: 'Filter by routable state.' }, sort: { type: 'string', description: 'Sort field: date, title, slug, modified, order, default.' }, order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction.' }, translations: { type: 'boolean', description: 'Include translation metadata.' }, ...PAGINATION, }, }, handler: (a) => grav('GET', '/pages', { query: compact(a) }), }, { name: 'grav_get_page', description: 'Get a single page by route, with its markdown content, header and media.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Page route, e.g. "/blog/my-post".' }, render: { type: 'boolean', description: 'Return rendered HTML instead of raw markdown.' }, summary: { type: 'boolean', description: 'Include the page summary.' }, children: { type: 'boolean', description: 'Include child pages.' }, translations: { type: 'boolean', description: 'Include translation metadata.' }, lang: { type: 'string', description: 'Return the page in a specific language (e.g. "hu").' }, }, required: ['route'], }, handler: (a) => { const { route, ...rest } = a; return grav('GET', `/pages/${encPath(route)}`, { query: compact(rest) }); }, }, { name: 'grav_create_page', description: 'Create a new page. Needs api.pages.write.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Route for the new page, e.g. "/blog/my-post".' }, title: { type: 'string', description: 'Page title.' }, template: { type: 'string', description: 'Page template (default: "default").' }, content: { type: 'string', description: 'Markdown content.' }, header: HEADER_FIELD, order: { type: 'integer', description: 'Numeric ordering prefix.' }, lang: { type: 'string', description: 'Language code for multilingual sites.' }, }, required: ['route', 'title'], }, handler: (a) => grav('POST', '/pages', { body: compact(a) }), }, { name: 'grav_update_page', description: 'Update a page: title, content, header (merged), template, published/visible state.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Page route.' }, title: { type: 'string' }, content: { type: 'string', description: 'New markdown content (replaces existing).' }, header: HEADER_FIELD, template: { type: 'string', description: 'Change the page template.' }, published: { type: 'boolean' }, visible: { type: 'boolean' }, }, required: ['route'], }, handler: (a) => { const { route, ...fields } = a; return grav('PATCH', `/pages/${encPath(route)}`, { body: compact(fields) }); }, }, { name: 'grav_delete_page', description: 'Delete a page (by default including its children). Irreversible.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Page route to delete.' }, children: { type: 'boolean', description: 'Also delete child pages (default true).' }, lang: { type: 'string', description: 'Delete only this language version.' }, }, required: ['route'], }, handler: (a) => { const { route, ...rest } = a; return grav('DELETE', `/pages/${encPath(route)}`, { query: compact(rest) }); }, }, { name: 'grav_move_page', description: 'Move a page under a new parent, optionally renaming its slug.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Current page route.' }, parent: { type: 'string', description: 'Target parent route (e.g. "/" or "/blog").' }, slug: { type: 'string', description: 'New slug at the target location.' }, order: { type: 'integer', description: 'Numeric ordering prefix at the new location.' }, }, required: ['route', 'parent'], }, handler: (a) => { const { route, ...body } = a; return grav('POST', `/pages/${encPath(route)}/move`, { body: compact(body) }); }, }, { name: 'grav_copy_page', description: 'Copy a page to a new route.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Source page route.' }, destination: { type: 'string', description: 'Destination route for the copy.' }, }, required: ['route', 'destination'], }, handler: (a) => grav('POST', `/pages/${encPath(a.route)}/copy`, { body: { route: a.destination } }), }, { name: 'grav_reorder_pages', description: 'Reorder the children of a page by listing their slugs in the desired order.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Parent page route.' }, order: { type: 'array', items: { type: 'string' }, description: 'Child slugs in the desired order.' }, }, required: ['route', 'order'], }, handler: (a) => grav('POST', `/pages/${encPath(a.route)}/reorder`, { body: { order: a.order } }), }, { name: 'grav_translate_page', description: 'Create a translation of a page in a target language (multilingual sites).', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Source page route.' }, lang: { type: 'string', description: 'Target language code, e.g. "en".' }, title: { type: 'string', description: 'Translated title (defaults to source).' }, content: { type: 'string', description: 'Translated markdown content (defaults to source).' }, header: HEADER_FIELD, }, required: ['route', 'lang'], }, handler: (a) => { const { route, ...body } = a; return grav('POST', `/pages/${encPath(route)}/translate`, { body: compact(body) }); }, }, { name: 'grav_list_taxonomy', description: 'List all taxonomy types and their values used across the site (e.g. categories, tags).', inputSchema: { type: 'object', properties: {} }, handler: () => grav('GET', '/taxonomy'), }, // --- Media ------------------------------------------------------------------ { name: 'grav_list_media', description: 'List media files: site-level media (user/media) by default, or a page\'s media when route is given.', inputSchema: { type: 'object', properties: { route: { type: 'string', description: 'Page route — if set, lists that page\'s media instead of site media.' }, path: { type: 'string', description: 'Site media subfolder path (site media only).' }, search: { type: 'string', description: 'Recursive filename search (site media only).' }, type: { type: 'string', enum: ['image', 'video', 'audio', 'document'], description: 'Filter by media type (site media only).' }, ...PAGINATION, }, }, handler: (a) => { if (a.route) return grav('GET', `/pages/${encPath(a.route)}/media`); const { route, ...rest } = a; return grav('GET', '/media', { query: compact(rest) }); }, }, { name: 'grav_upload_media', description: 'Upload a local file as media, either to a page (route) or to site-level media (optional subfolder path).', inputSchema: { type: 'object', properties: { file_path: { type: 'string', description: 'Absolute local path of the file to upload.' }, route: { type: 'string', description: 'Page route to attach the media to. Omit for site-level media.' }, path: { type: 'string', description: 'Site media subfolder (site-level uploads only), e.g. "blog/2026".' }, }, required: ['file_path'], }, handler: (a) => { const target = a.route ? `/pages/${encPath(a.route)}/media` : '/media'; const extra = !a.route && a.path ? { path: a.path } : undefined; return grav('POST', target, { multipart: multipartFile('file', a.file_path, extra) }); }, }, { name: 'grav_delete_media', description: 'Delete a media file from a page (route) or from site-level media.', inputSchema: { type: 'object', properties: { filename: { type: 'string', description: 'Filename to delete (site media supports subfolder paths, e.g. "blog/hero.jpg").' }, route: { type: 'string', description: 'Page route the media belongs to. Omit for site-level media.' }, }, required: ['filename'], }, handler: (a) => a.route ? grav('DELETE', `/pages/${encPath(a.route)}/media/${encPath(a.filename)}`) : grav('DELETE', `/media/${encPath(a.filename)}`), }, { name: 'grav_manage_media', description: 'Site media housekeeping: rename a file, or create/delete/rename a folder.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['rename_file', 'create_folder', 'delete_folder', 'rename_folder'], description: 'Operation to perform.', }, path: { type: 'string', description: 'Folder path — for create_folder and delete_folder.' }, from: { type: 'string', description: 'Current file/folder path — for rename actions.' }, to: { type: 'string', description: 'New file/folder path — for rename actions.' }, }, required: ['action'], }, handler: (a) => { switch (a.action) { case 'rename_file': if (!a.from || !a.to) throw new Error('rename_file requires "from" and "to".'); return grav('POST', '/media/rename', { body: { from: a.from, to: a.to } }); case 'create_folder': if (!a.path) throw new Error('create_folder requires "path".'); return grav('POST', '/media/folders', { body: { path: a.path } }); case 'delete_folder': if (!a.path) throw new Error('delete_folder requires "path".'); return grav('DELETE', `/media/folders/${encPath(a.path)}`); case 'rename_folder': if (!a.from || !a.to) throw new Error('rename_folder requires "from" and "to".'); return grav('POST', '/media/folders/rename', { body: { from: a.from, to: a.to } }); default: throw new Error(`Unknown action: ${a.action}`); } }, }, // --- Configuration ---------------------------------------------------------- { name: 'grav_get_config', description: 'Get a configuration scope (system, site, media, security, plugins/{name}, themes/{name}); without scope, lists available scopes. Needs api.config.read.', inputSchema: { type: 'object', properties: { scope: { type: 'string', description: 'e.g. "system", "site", "plugins/sitemap", "themes/quark". Omit to list scopes.' }, }, }, handler: (a) => (a.scope ? grav('GET', `/config/${encPath(a.scope)}`) : grav('GET', '/config')), }, { name: 'grav_update_config', description: 'Update a configuration scope — values are deep-merged into the existing config. Needs api.config.write.', inputSchema: { type: 'object', properties: { scope: { type: 'string', description: 'e.g. "site", "system", "plugins/sitemap", "themes/quark".' }, values: { type: 'object', description: 'Values to merge, e.g. {"title": "New Site Title"}.' }, }, required: ['scope', 'values'], }, handler: (a) => grav('PATCH', `/config/${encPath(a.scope)}`, { body: a.values }), }, // --- Users -------------------------------------------------------------------- { name: 'grav_list_users', description: 'List user accounts, filterable by search text, permission scope or group. Needs api.users.read.', inputSchema: { type: 'object', properties: { search: { type: 'string', description: 'Matches username, email or fullname.' }, access: { type: 'string', description: 'Filter by permission, e.g. "admin.login" or "api.super".' }, group: { type: 'string', description: 'Only members of this group.' }, ...PAGINATION, }, }, handler: (a) => grav('GET', '/users', { query: compact(a) }), }, { name: 'grav_get_user', description: 'Get a user account by username, or the API key owner with username="me".', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username, or "me" for the authenticated account.' }, }, required: ['username'], }, handler: (a) => (a.username === 'me' ? grav('GET', '/me') : grav('GET', `/users/${encodeURIComponent(a.username)}`)), }, { name: 'grav_create_user', description: 'Create a new user account. Needs api.users.write.', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Unique username.' }, password: { type: 'string', description: 'Account password.' }, email: { type: 'string', description: 'Email address.' }, fullname: { type: 'string', description: 'Display name.' }, state: { type: 'string', description: 'Account state, e.g. "enabled" or "disabled".' }, access: { type: 'object', description: 'Permission configuration, e.g. {"admin": {"login": true}}.' }, }, required: ['username', 'password', 'email'], }, handler: (a) => grav('POST', '/users', { body: compact(a) }), }, { name: 'grav_update_user', description: 'Update a user account (email, fullname, state, access, password).', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Target username.' }, email: { type: 'string' }, fullname: { type: 'string' }, state: { type: 'string', description: 'e.g. "enabled" or "disabled".' }, access: { type: 'object', description: 'New permission configuration.' }, password: { type: 'string', description: 'New password.' }, }, required: ['username'], }, handler: (a) => { const { username, ...fields } = a; return grav('PATCH', `/users/${encodeURIComponent(username)}`, { body: compact(fields) }); }, }, { name: 'grav_delete_user', description: 'Delete a user account. Irreversible.', inputSchema: { type: 'object', properties: { username: { type: 'string', description: 'Username to delete.' } }, required: ['username'], }, handler: (a) => grav('DELETE', `/users/${encodeURIComponent(a.username)}`), }, // --- System --------------------------------------------------------------------- { name: 'grav_system_info', description: 'Get system information: Grav/PHP versions, extensions, environment, installed plugins and themes. Needs api.system.read.', inputSchema: { type: 'object', properties: {} }, handler: () => grav('GET', '/system/info'), }, { name: 'grav_clear_cache', description: 'Clear the Grav cache. Needs api.system.write.', inputSchema: { type: 'object', properties: { scope: { type: 'string', enum: ['all', 'standard', 'images', 'assets', 'tmp'], description: 'Cache scope to clear (default "standard").', }, }, }, handler: (a) => grav('DELETE', '/cache', { query: compact({ scope: a.scope }) }), }, { name: 'grav_get_logs', description: 'Read Grav log entries, filterable by level.', inputSchema: { type: 'object', properties: { level: { type: 'string', enum: ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], description: 'Filter by log level.' }, ...PAGINATION, }, }, handler: (a) => grav('GET', '/system/logs', { query: compact(a) }), }, { name: 'grav_manage_backups', description: 'Site backups: list existing backups, create a new one, or delete one.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'create', 'delete'], description: 'Backup operation.' }, filename: { type: 'string', description: 'Backup filename (bare basename ending in .zip) — required for delete.' }, }, required: ['action'], }, handler: (a) => { switch (a.action) { case 'list': return grav('GET', '/system/backups'); case 'create': return grav('POST', '/system/backup'); case 'delete': if (!a.filename) throw new Error('delete requires "filename".'); return grav('DELETE', `/system/backups/${encodeURIComponent(a.filename)}`); default: throw new Error(`Unknown action: ${a.action}`); } }, }, // --- Blueprints ---------------------------------------------------------------- { name: 'grav_get_blueprint', description: 'Get blueprint schemas (field definitions): page templates, user accounts, permissions, config scopes, plugins, themes or flex directories. ' + 'kind=pages without a name lists available page types.', inputSchema: { type: 'object', properties: { kind: { type: 'string', enum: ['pages', 'users', 'permissions', 'config', 'plugins', 'themes', 'flex-objects'], description: 'Blueprint category.', }, name: { type: 'string', description: 'Template/scope/slug/type name. Required for config, plugins, themes and flex-objects; optional for pages (omit to list page types).', }, }, required: ['kind'], }, handler: (a) => { switch (a.kind) { case 'pages': return a.name ? grav('GET', `/blueprints/pages/${encodeURIComponent(a.name)}`) : grav('GET', '/blueprints/pages'); case 'users': return grav('GET', '/blueprints/users'); case 'permissions': return grav('GET', '/blueprints/users/permissions'); case 'config': case 'plugins': case 'themes': case 'flex-objects': if (!a.name) throw new Error(`kind=${a.kind} requires "name".`); return grav('GET', `/blueprints/${a.kind}/${encodeURIComponent(a.name)}`); default: throw new Error(`Unknown kind: ${a.kind}`); } }, }, // --- Package manager (GPM) ------------------------------------------------------- { name: 'grav_list_packages', description: 'List plugins or themes — installed ones by default, or search the getgrav.org repository with source="repository". ' + 'Give a slug to fetch a single package. Needs api.gpm.read.', inputSchema: { type: 'object', properties: { type: { type: 'string', enum: ['plugins', 'themes'], description: 'Package type.' }, source: { type: 'string', enum: ['installed', 'repository'], description: 'Where to list from (default installed).' }, slug: { type: 'string', description: 'Fetch a single package by slug instead of listing.' }, q: { type: 'string', description: 'Search term (repository only).' }, ...PAGINATION, }, required: ['type'], }, handler: (a) => { const repo = a.source === 'repository'; if (a.slug) { return repo ? grav('GET', `/gpm/repository/${encodeURIComponent(a.slug)}`) : grav('GET', `/gpm/${a.type}/${encodeURIComponent(a.slug)}`); } return repo ? grav('GET', `/gpm/repository/${a.type}`, { query: compact({ q: a.q, page: a.page, per_page: a.per_page }) }) : grav('GET', `/gpm/${a.type}`); }, }, { name: 'grav_check_updates', description: 'Check for available updates to Grav core, plugins and themes.', inputSchema: { type: 'object', properties: { flush: { type: 'boolean', description: 'Bypass the GPM cache and refetch.' }, }, }, handler: (a) => grav('GET', '/gpm/updates', { query: compact({ flush: a.flush }) }), }, { name: 'grav_manage_package', description: 'Install, remove or update plugins/themes, or upgrade Grav core. Changes the live site — use deliberately. Needs api.gpm.write.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['install', 'remove', 'update', 'update_all', 'upgrade_core'], description: 'Operation. upgrade_core upgrades the Grav installation itself.', }, package: { type: 'string', description: 'Package slug — required for install/remove/update.' }, type: { type: 'string', enum: ['plugin', 'theme'], description: 'Package type — required for install.' }, license: { type: 'string', description: 'License key for premium packages (install only).' }, }, required: ['action'], }, handler: (a) => { switch (a.action) { case 'install': if (!a.package || !a.type) throw new Error('install requires "package" and "type".'); return grav('POST', '/gpm/install', { body: compact({ package: a.package, type: a.type, license: a.license }) }); case 'remove': if (!a.package) throw new Error('remove requires "package".'); return grav('POST', '/gpm/remove', { body: { package: a.package } }); case 'update': if (!a.package) throw new Error('update requires "package".'); return grav('POST', '/gpm/update', { body: { package: a.package } }); case 'update_all': return grav('POST', '/gpm/update-all'); case 'upgrade_core': return grav('POST', '/gpm/upgrade'); default: throw new Error(`Unknown action: ${a.action}`); } }, }, // --- Scheduler, dashboard, webhooks --------------------------------------------- { name: 'grav_scheduler', description: 'Scheduler (cron) management: list jobs, check status, view run history, or trigger a run.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['jobs', 'status', 'history', 'run'], description: 'Scheduler operation.' }, force: { type: 'boolean', description: 'Run all jobs regardless of schedule (run only).' }, ...PAGINATION, }, required: ['action'], }, handler: (a) => { switch (a.action) { case 'jobs': return grav('GET', '/scheduler/jobs'); case 'status': return grav('GET', '/scheduler/status'); case 'history': return grav('GET', '/scheduler/history', { query: compact({ page: a.page, per_page: a.per_page }) }); case 'run': return grav('POST', '/scheduler/run', { body: compact({ force: a.force }) }); default: throw new Error(`Unknown action: ${a.action}`); } }, }, { name: 'grav_dashboard', description: 'Dashboard data: site stats (pages/users/plugins/versions), page view popularity, or notifications.', inputSchema: { type: 'object', properties: { view: { type: 'string', enum: ['stats', 'popularity', 'notifications'], description: 'Which dashboard data to fetch.' }, }, required: ['view'], }, handler: (a) => grav('GET', `/dashboard/${a.view}`), }, { name: 'grav_manage_webhooks', description: 'Outgoing webhook management: list/get/create/update/delete webhooks, view the delivery log, or send a test delivery. ' + 'Events: page.created/updated/deleted/moved, media.uploaded/deleted, user.*, config.updated, gpm.*, "*" for all.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'get', 'create', 'update', 'delete', 'test', 'deliveries'], description: 'Webhook operation.' }, id: { type: 'string', description: 'Webhook id — required for everything except list and create.' }, url: { type: 'string', description: 'Target HTTP(S) URL (create/update).' }, events: { type: 'array', items: { type: 'string' }, description: 'Event filter (create/update); defaults to all.' }, secret: { type: 'string', description: 'Shared secret for HMAC-SHA256 signing (create/update); auto-generated if omitted.' }, enabled: { type: 'boolean', description: 'Enable/disable the webhook (create/update).' }, ...PAGINATION, }, required: ['action'], }, handler: (a) => { const needId = ['get', 'update', 'delete', 'test', 'deliveries']; if (needId.includes(a.action) && !a.id) throw new Error(`${a.action} requires "id".`); switch (a.action) { case 'list': return grav('GET', '/webhooks'); case 'get': return grav('GET', `/webhooks/${encodeURIComponent(a.id)}`); case 'create': if (!a.url) throw new Error('create requires "url".'); return grav('POST', '/webhooks', { body: compact({ url: a.url, events: a.events, secret: a.secret, enabled: a.enabled }) }); case 'update': return grav('PATCH', `/webhooks/${encodeURIComponent(a.id)}`, { body: compact({ url: a.url, events: a.events, secret: a.secret, enabled: a.enabled }), }); case 'delete': return grav('DELETE', `/webhooks/${encodeURIComponent(a.id)}`); case 'test': return grav('POST', `/webhooks/${encodeURIComponent(a.id)}/test`); case 'deliveries': return grav('GET', `/webhooks/${encodeURIComponent(a.id)}/deliveries`, { query: compact({ page: a.page, per_page: a.per_page }), }); default: throw new Error(`Unknown action: ${a.action}`); } }, }, // --- Flex objects ------------------------------------------------------------------ { name: 'grav_list_flex', description: 'Flex Objects: without type, lists available flex directories; with type, lists that directory\'s objects (searchable, sortable). ' + 'Use metadata=true to get a directory\'s configuration instead.', inputSchema: { type: 'object', properties: { type: { type: 'string', description: 'Flex directory type (e.g. "contacts"). Omit to list directories.' }, search: { type: 'string', description: 'Search term (object listing only).' }, sort: { type: 'string', description: 'Field to sort by.' }, order: { type: 'string', enum: ['asc', 'desc'], description: 'Sort direction.' }, metadata: { type: 'boolean', description: 'Return the directory\'s metadata/config instead of its objects.' }, ...PAGINATION, }, }, handler: (a) => { if (!a.type) return grav('GET', '/flex-objects'); if (a.metadata) return grav('GET', `/flex-objects/${encodeURIComponent(a.type)}/metadata`); const { type, metadata, ...rest } = a; return grav('GET', `/flex-objects/${encodeURIComponent(type)}`, { query: compact(rest) }); }, }, { name: 'grav_get_flex_object', description: 'Get a single flex object by directory type and key.', inputSchema: { type: 'object', properties: { type: { type: 'string', description: 'Flex directory type.' }, key: { type: 'string', description: 'Object key.' }, }, required: ['type', 'key'], }, handler: (a) => grav('GET', `/flex-objects/${encodeURIComponent(a.type)}/${encodeURIComponent(a.key)}`), }, { name: 'grav_save_flex_object', description: 'Create a flex object (omit key) or update an existing one (with key; fields are merged).', inputSchema: { type: 'object', properties: { type: { type: 'string', description: 'Flex directory type.' }, key: { type: 'string', description: 'Object key — omit to create a new object.' }, data: { type: 'object', description: 'Field values, e.g. {"name": "John", "email": "john@example.com"}.' }, }, required: ['type', 'data'], }, handler: (a) => a.key ? grav('PATCH', `/flex-objects/${encodeURIComponent(a.type)}/${encodeURIComponent(a.key)}`, { body: a.data }) : grav('POST', `/flex-objects/${encodeURIComponent(a.type)}`, { body: a.data }), }, { name: 'grav_delete_flex_object', description: 'Delete a flex object. Irreversible.', inputSchema: { type: 'object', properties: { type: { type: 'string', description: 'Flex directory type.' }, key: { type: 'string', description: 'Object key.' }, }, required: ['type', 'key'], }, handler: (a) => grav('DELETE', `/flex-objects/${encodeURIComponent(a.type)}/${encodeURIComponent(a.key)}`), }, ]; const TOOL_MAP = new Map(TOOLS.map((t) => [t.name, t])); // --------------------------------------------------------------------------- // MCP stdio transport (newline-delimited JSON-RPC 2.0) // --------------------------------------------------------------------------- function send(message) { process.stdout.write(JSON.stringify(message) + '\n'); } function sendResult(id, result) { send({ jsonrpc: '2.0', id, result }); } function sendError(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); } async function handleRequest(msg) { const { id, method, params } = msg; switch (method) { case 'initialize': sendResult(id, { protocolVersion: params?.protocolVersion || PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO, }); return; case 'ping': sendResult(id, {}); return; case 'tools/list': sendResult(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })), }); return; case 'tools/call': { const tool = TOOL_MAP.get(params?.name); if (!tool) { sendError(id, -32602, `Unknown tool: ${params?.name}`); return; } try { const result = await tool.handler(params.arguments || {}); sendResult(id, { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], isError: false, }); } catch (err) { sendResult(id, { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true, }); } return; } default: if (id !== undefined && id !== null) { sendError(id, -32601, `Method not found: ${method}`); } // Notifications (notifications/initialized, notifications/cancelled, ...) are ignored. } } let buffer = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { buffer += chunk; let newline; while ((newline = buffer.indexOf('\n')) !== -1) { const line = buffer.slice(0, newline).trim(); buffer = buffer.slice(newline + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { sendError(null, -32700, 'Parse error'); continue; } handleRequest(msg).catch((err) => { if (msg.id !== undefined && msg.id !== null) { sendError(msg.id, -32603, `Internal error: ${err.message}`); } }); } }); process.stdin.on('end', () => process.exit(0));