diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f96c081..4bac3f1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -14,6 +14,15 @@ "author": { "name": "TTG" } + }, + { + "name": "grav-mcp", + "source": "./plugins/grav-mcp", + "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.", + "version": "0.1.0", + "author": { + "name": "TTG" + } } ] } diff --git a/README.md b/README.md index 0616706..faaa877 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,30 @@ -# TTG Marketplace — Redmine MCP +# TTG Marketplace -Claude Code plugin marketplace, egyetlen pluginnal: **redmine-mcp**, egy függőségmentes -Node.js MCP szerver a [Redmine REST API](https://www.redmine.org/projects/redmine/wiki/rest_api)-hoz. +Claude Code plugin marketplace, két pluginnal: + +- **redmine-mcp** — függőségmentes Node.js MCP szerver a [Redmine REST API](https://www.redmine.org/projects/redmine/wiki/rest_api)-hoz +- **grav-mcp** — függőségmentes Node.js MCP szerver a [Grav CMS REST API](https://learn.getgrav.org/20/api/endpoints)-hoz ## Telepítés ``` -/plugin marketplace add /Users/tasi/Work/TTG/redmine-mcp +/plugin marketplace add /Users/tasi/Work/TTG/ttg-marketplace /plugin install redmine-mcp@ttg-marketplace +/plugin install grav-mcp@ttg-marketplace ``` (Git repóból: `/plugin marketplace add `.) -Telepítéskor a plugin két beállítást kér: +Telepítés után: `/reload-plugins` vagy új session. + +## redmine-mcp + +Telepítéskor kért beállítások: | Beállítás | Leírás | |---|---| | `redmine_url` | A Redmine példány alap URL-je, pl. `https://redmine.example.com` | -| `redmine_api_key` | Saját fiók → jobb oldalt „API hozzáférési kulcs” (az adminnak engedélyeznie kell: Adminisztráció → Beállítások → API → REST szolgáltatás engedélyezése) | - -Telepítés után: `/reload-plugins` vagy új session. - -## Eszközök +| `redmine_api_key` | Saját fiók → jobb oldalt „API hozzáférési kulcs" (az adminnak engedélyeznie kell: Adminisztráció → Beállítások → API → REST szolgáltatás engedélyezése) | **Issue-k:** `redmine_list_issues`, `redmine_get_issue` (journals/attachments/relations include-dal), `redmine_create_issue`, `redmine_update_issue` (mezők + komment a `notes`-szal), `redmine_delete_issue` @@ -35,13 +38,39 @@ Telepítés után: `/reload-plugins` vagy új session. **Wiki és keresés:** `redmine_list_wiki_pages`, `redmine_get_wiki_page`, `redmine_update_wiki_page` (upsert), `redmine_search` +## grav-mcp + +Telepítéskor kért beállítások: + +| Beállítás | Leírás | +|---|---| +| `grav_url` | A Grav site alap URL-je, pl. `https://example.com` (a `/api/v1` automatikusan hozzáfűződik) | +| `grav_api_key` | API kulcs — a Grav adminban a felhasználó fiókjánál hozható létre, `X-API-Key` headerként megy | + +**Oldalak:** `grav_list_pages` (keresés, template/parent/published szűrők), `grav_get_page` (markdown vagy renderelt HTML, gyerekek, fordítás-metaadatok), `grav_create_page`, `grav_update_page`, `grav_delete_page`, `grav_move_page`, `grav_copy_page`, `grav_reorder_pages`, `grav_translate_page`, `grav_list_taxonomy` + +**Média:** `grav_list_media` (site- vagy oldal-szintű), `grav_upload_media` (helyi fájl feltöltése multipart-tal), `grav_delete_media`, `grav_manage_media` (fájl átnevezés, mappa létrehozás/törlés/átnevezés) + +**Konfiguráció:** `grav_get_config`, `grav_update_config` (deep-merge; system, site, plugins/{name}, themes/{name} scope-ok) + +**Felhasználók:** `grav_list_users`, `grav_get_user` (`username="me"` = saját fiók), `grav_create_user`, `grav_update_user`, `grav_delete_user` + +**Rendszer:** `grav_system_info`, `grav_clear_cache`, `grav_get_logs`, `grav_manage_backups` + +**Blueprintek:** `grav_get_blueprint` — page template-ek, user, permissions, config, plugin, téma és flex sémák + +**Csomagkezelő (GPM):** `grav_list_packages` (telepített vagy repository), `grav_check_updates`, `grav_manage_package` (install/remove/update/update-all/core upgrade) + +**Egyéb:** `grav_scheduler` (jobok, státusz, history, futtatás), `grav_dashboard` (statisztikák, népszerűség, értesítések), `grav_manage_webhooks`, flex objektumok: `grav_list_flex`, `grav_get_flex_object`, `grav_save_flex_object`, `grav_delete_flex_object` + ## Megjegyzések -- A szervernek nincs npm függősége (Node 18+ beépített `fetch`), build lépés sincs. -- Hitelesítés: `X-Redmine-API-Key` header. -- Lapozás: `offset`/`limit` (max. 100), a válaszban `total_count`. +- Egyik szervernek sincs npm függősége (Node 18+ beépített `fetch`), build lépés sincs. - Kézi próba plugin nélkül: ```sh REDMINE_URL=https://redmine.example.com REDMINE_API_KEY=xxx \ node plugins/redmine-mcp/server/index.js + + GRAV_URL=https://example.com GRAV_API_KEY=xxx \ + node plugins/grav-mcp/server/index.js ``` diff --git a/plugins/grav-mcp/.claude-plugin/plugin.json b/plugins/grav-mcp/.claude-plugin/plugin.json new file mode 100644 index 0000000..1c79c6b --- /dev/null +++ b/plugins/grav-mcp/.claude-plugin/plugin.json @@ -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 + } + } +} diff --git a/plugins/grav-mcp/server/index.js b/plugins/grav-mcp/server/index.js new file mode 100644 index 0000000..c964c0b --- /dev/null +++ b/plugins/grav-mcp/server/index.js @@ -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));