This commit is contained in:
2026-07-23 19:58:45 +02:00
parent 32c33156a8
commit 5e8e753b03
4 changed files with 1016 additions and 13 deletions
@@ -0,0 +1,33 @@
{
"name": "grav-mcp",
"displayName": "Grav MCP",
"version": "0.1.0",
"description": "Grav CMS MCP szerver — oldalak, média, konfiguráció, felhasználók, csomagkezelő, scheduler, webhookok és flex objektumok a Grav REST API-n keresztül.",
"author": {
"name": "TTG",
"email": "rastasi@gmail.com"
},
"mcpServers": {
"grav": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/server/index.js"],
"env": {
"GRAV_URL": "${user_config.grav_url}",
"GRAV_API_KEY": "${user_config.grav_api_key}"
}
}
},
"userConfig": {
"grav_url": {
"type": "string",
"title": "Grav URL",
"description": "A Grav site alap URL-je, pl. https://example.com (a /api/v1 automatikusan hozzáfűződik)"
},
"grav_api_key": {
"type": "string",
"title": "Grav API kulcs",
"description": "API kulcs a Grav adminban a felhasználónál (vagy POST /api/v1/users/{username}/api-keys)",
"sensitive": true
}
}
}
+932
View File
@@ -0,0 +1,932 @@
#!/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));