Serves a Model Context Protocol endpoint directly from your Grav site over Streamable HTTP. Point your MCP client or LLM/AI agent at https://yoursite.com/mcp.
Works with hosted connectors (built-in OAuth 2.1 authorization server with dynamic client registration) and CLI or desktop clients (OAuth or a static API key header).
- grav-plugin-api — REST API + API key management. Required: this plugin is a thin MCP translation layer over it — Bearer auth reuses its
grav_...API keys, OAuth access tokens are minted through itsApiKeyManager, and tools reuse its services rather than duplicating them. - grav-mcp — the local-process MCP server that bridges stdio → Grav REST API, for local clients like Claude Code and Cursor on your own machine. This plugin complements it as the hosted alternative: MCP served from the site itself, for hosted connectors like claude.ai and ChatGPT where a local process isn't an option. The tool surface tracks the API plugin directly (see DECISIONS.md).
- Grav 2.1.5+ (what API plugin 1.0.44 requires)
- PHP 8.3+ (Grav 2 core's own floor)
- API plugin 1.0.44 or newer, installed and enabled, with at least one API key:
bin/plugin api keys:generate --user=admin --name="MCP"
Tools map 1:1 onto API plugin endpoints, so an older API plugin 404s on tools backed by
newer endpoints. GPM enforces the version floor; a git clone doesn't — the plugin then
logs a warning at client handshake, and site_info reports api_plugin_version. A Grav 2.0
site can install API plugin 1.0.30 at most, so it stays on mcp-server 1.3.3.
Install with GPM — the preferred path, since GPM also installs the required API plugin and enforces its version floor (directory listing):
bin/gpm install mcp-serverFor development, clone into your site's plugin folder instead; the directory name must
be mcp-server:
cd user/plugins
git clone https://github.com/sandymac/grav-plugin-mcp-server mcp-serveruser/config/plugins/mcp-server.yaml:
enabled: true
route: /mcp
require_auth: true # never disable on a public site
oauth:
enabled: true # required for hosted connectors (interactive sign-in)
access_token_days: 7
refresh_token_days: 90
require_permission: api.access # permission needed to approve a connection ("API Access" in account permissions)
allowed_redirect_hosts: # localhost always allowed; empty = any https host
- claude.ai
- claude.comYour web server must pass unmatched /.well-known/* paths through to Grav's index.php (the standard try_files $uri /index.php?$args nginx setup already does).
In the client's connector settings, add a custom connector with URL https://grav.example.com/mcp. Leave any OAuth client ID/secret fields empty — the client registers itself via dynamic client registration. When prompted, sign in with your Grav credentials on the consent screen and approve.
Registration is refused when any redirect URI the client sends is off oauth.allowed_redirect_hosts. The defaults cover claude.ai, ChatGPT, Gemini (all three oauth-redirect*.googleusercontent.com hosts it registers at once), Mistral, and the VS Code / Cursor / Windsurf sign-in hosts; a client whose registration is rejected leaves a registration rejected line in grav.log naming the offending URI, so a customized list can be fixed from there.
Behind the scenes: the client discovers /.well-known/oauth-protected-resource/mcp → registers at /mcp/oauth/register → authorization-code + PKCE flow at /mcp/oauth/authorize → tokens from /mcp/oauth/token. The access token is a real grav_ API key (visible in bin/plugin api keys:list); revoke it at /mcp/oauth/revoke (revoking either the refresh token or access key revokes both). Deleting the key any other way — from the user's page in Admin2 or with bin/plugin api keys:revoke — ends the connection too: its next refresh is refused.
A client may request a scope of api.* permissions (advertised as scopes_supported in the discovery metadata); the granted scopes cap the minted key and are shown on the consent screen. A request that limits nothing — no scope, *, or the whole advertised list, which is what today's connectors send — mints an unscoped key, and the consent screen says so: effective access is always the account's live permissions intersected with the key's scopes, so a dedicated bot account remains the best way to manage what a connector can do over time.
Every permission on the consent screen is a checkbox. Untick any to exclude it from the grant: the minted key is capped at what stayed ticked, and the token response reports the narrower scope (RFC 6749 §3.3). Some clients never read that echo and assume they hold what they requested — a narrowed connection then sees fewer tools and gets permission errors on the excluded ones, which is the intended effect. The selection is frozen at approval time — a later upgrade that adds permissions does not widen it — so whoami reports the key's scopes and lists hidden tools by cause (hidden_by_key_scope versus hidden_by_account_permission). To change the selection, revoke and reconnect, or manage the key with bin/plugin api keys:*.
Every client needs the same two pieces: the endpoint URL, and — to skip the browser flow — an Authorization: Bearer grav_... header. For example, with Claude Code:
claude mcp add --transport http grav https://grav.example.com/mcp --header "Authorization: Bearer grav_your_key_here"(omit --header to use the OAuth sign-in instead). Or in an .mcp.json:
{
"mcpServers": {
"grav": {
"type": "http",
"url": "https://grav.example.com/mcp",
"headers": {
"Authorization": "Bearer grav_your_key_here"
}
}
}
}Other installed plugins can publish their own REST routes as MCP tools — an mcp.yaml
manifest at the plugin root, or the onApiMcpTools event — served by the API plugin's
GET /mcp/tools and offered here alongside the built-in tools. If you're authoring a
plugin, follow the manifest reference in grav-plugin-api's README ("MCP tool manifests"
section): https://github.com/getgrav/grav-plugin-api. A manifest that names a body
argument (manifest version 2, as Flex Objects 1.4.13 ships for its flex_* tools) is only
served by API plugin 1.0.28 or later; an older API plugin skips the whole manifest with a
warning, so those tools do not appear until the API plugin is updated.
Controlled by the plugin_tools config toggle (on by default; set to false to serve
only the built-in tools). Like every tool here, visibility follows the caller's own Grav
permissions — a plugin tool only shows up for accounts that hold whatever permission it
declares.
This is also why the OAuth consent screen mints an unscoped key for a "full account access" grant: a key capped to this plugin's own advertised scopes could never reach a permission a plugin declares on its own. The consent screen explains this and offers a checkbox to freeze the grant to the listed vocabulary instead, for connectors that should be limited to the built-in tools — plugin-published tools, installed now or added later, need permissions outside that list and are excluded by a frozen grant. Unticking any listed permission has the same freezing effect: the grant becomes exactly what stayed ticked.
curl -s https://grav.example.com/mcp \
-H "Authorization: Bearer grav_your_key_here" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'63 tools across 12 domains (pages, multilingual, translations, media, config, users, GPM, system, dashboard, webhooks, blueprints, plugins) plus site_info, 5 resources, and 7 prompts, tracking the API plugin's REST surface. Every tool call dispatches in-process through the API plugin's own router, so its permission scopes, page ACLs, ETag conflict handling, audit trail, and rate limiting all apply unchanged. A connected client only sees the tools its key scopes and its account's permissions allow — a limited bot account advertises a correspondingly small tool list. Validated end-to-end on a live deployment as both a claude.ai custom connector and a Claude Code HTTP server.
Protocol-level smoke test (no Grav install needed):
php tests/smoke.php