grav mcp
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
Reference in New Issue
Block a user