Repository navigation
Module: MCP Server
The MCP module lets AI agents (Claude Code, Cursor, VS Code, Codex and any other client of the Model Context Protocol) read site content: they can search, list and read pages, media, tags and categories through typed tools.
This first release is read-only. Every tool is marked read-only, and a drift test fails if a tool without that mark is registered.
-
Streamable HTTP transport at
POST /api/mcp. It runs stateless with JSON responses, so it works behind reverse proxies, needs no session storage and fits multi-instance deployments. -
Built on the official Go SDK
(
modelcontextprotocol/go-sdkv1.8.0). It speaks protocol versions2026-07-28,2025-11-25,2025-06-18,2025-03-26and2024-11-05. - 10 read-only tools that wrap the REST v2 services. REST and MCP share one set of visibility rules, validation and caching, and the tool input schemas come from the same struct tags as the REST parameters.
-
API key authentication. Keys need the
mcp:accesspermission and are subject to every API key policy, including CIDR allowlists, expiry, maximum lifetime and IP-change revocation. -
Drafts are hidden by default. An agent can see a draft only if an admin
turns the drafts policy on and the agent's key holds
pages:read. -
Admin page at
/admin/mcp. It shows the endpoint and ready-made client configuration, and holds the drafts policy, instructions for agents, the tool catalog and the keys that have MCP access. -
Server card.
/.well-known/mcp/server-card.jsonadvertises the endpoint while the module is active. - Structured logs: one line per dispatched tool call, including calls the SDK rejects after dispatch: invalid arguments, unknown tools and invalid output. Keys and arguments are never logged.
The module is opt-in. When it is first registered it starts inactive, so an upgrade never opens a new remote endpoint on its own.
- Go to Admin → Modules and turn on MCP Server. Until you do,
/api/mcpreturns 404. - Go to Admin → API Keys and create a key with the MCP permission
(
mcp:access). That alone lets agents read published content. Add Pages → Read pages, including unpublished drafts (pages:read) only if agents should see drafts (see below). - Configure the client (see Connecting clients). The
admin page at Admin → Modules → MCP Server (
/admin/mcp) shows ready-made snippets for the endpoint.
Set the Site URL (OCMS_SITE_URL or Admin → Config) to an absolute
http(s) URL. Without a usable one, the admin page shows the endpoint as a bare
path and tool results leave out page URLs. The admin page tells a missing value
apart from an unusable one, such as example.com without a scheme.
MCP requests go through the same middleware.APIKeyAuth as REST v2. In
production, each of these defaults to on:
| Variable | Effect on MCP clients |
|---|---|
OCMS_REQUIRE_API_ALLOWED_CIDRS |
OCMS_API_ALLOWED_CIDRS must list the networks agents connect from |
OCMS_REQUIRE_API_KEY_EXPIRY |
The key needs an expiration date |
OCMS_REQUIRE_API_KEY_SOURCE_CIDRS |
The key needs its own source CIDRs |
OCMS_REVOKE_API_KEY_ON_SOURCE_IP_CHANGE |
A key without source CIDRs is deactivated when its source IP changes |
OCMS_API_KEY_MAX_TTL_DAYS |
The key's lifetime is capped (90 days by default) |
An agent on a laptop whose address changes therefore needs a key whose source
CIDRs cover every network it uses. Behind a reverse proxy, also set
OCMS_TRUSTED_PROXIES so the client IP is resolved correctly (see
Reverse Proxy).
For Claude's remote connectors, requests originate from Anthropic's cloud
rather than the desktop computer. Anthropic currently publishes
160.79.104.0/21 as its outbound IPv4 range; check the
official IP addresses
before configuring it. Add this range to the key's Source CIDR allowlist
and to OCMS_API_ALLOWED_CIDRS when the global policy requires it.
For local clients, such as Claude Code or a desktop HTTP bridge, add the
client's public IP as a /32 entry. A key used by both kinds of client can
have one CIDR per line, for example:
160.79.104.0/21
203.0.113.42/32
Replace the documentation address 203.0.113.42 with your current public
IPv4 address, and update it if your ISP or VPN changes that address. For an
individual IPv6 address, use /128.
If an IP change has already revoked the key, edit it in Admin → API Keys:
save the source CIDRs while Active remains unchecked, then edit it again
and check Active. This order prevents a retry from revoking the key between
activation and saving its CIDRs. The token stays the same and no server
restart is needed. Once source CIDRs are configured, changes between allowed
IPs may still log an observed warning, but they do not revoke the key.
Requests from outside the allowlist are rejected.
The endpoint takes a static bearer token, which is the API key. Every client that supports remote HTTP MCP servers with custom headers can connect.
Claude Code:
claude mcp add --transport http ocms https://example.com/api/mcp \
--header "Authorization: Bearer <YOUR_API_KEY>"Cursor, VS Code and other clients that read an mcpServers JSON file:
{
"mcpServers": {
"ocms": {
"type": "http",
"url": "https://example.com/api/mcp",
"headers": {
"Authorization": "Bearer <YOUR_API_KEY>"
}
}
}
}Keep keys out of configuration files that are shared or committed. Most clients can read headers from environment variables.
Claude Desktop and claude.ai remote connectors:
Request header authentication is a limited beta. If Request headers does not appear in the connector dialog, your organization does not yet have access. See Anthropic's custom connector instructions.
- Open Customize → Connectors → Add custom connector. For an organization connector, an owner uses Organization settings → Connectors.
- Enter the public HTTPS endpoint, such as
https://example.com/api/mcp. - Choose No sign-in for authentication.
- Under Request headers, add a required
Authorizationheader with the complete valueBearer <YOUR_API_KEY>, including theBearerprefix. - Save the connector and enable it through + → Connectors in the chat.
Configure the source CIDRs described above before connecting. OAuth is not
implemented by this release; accounts without request header access need a
local stdio-to-HTTP bridge in Claude Desktop or another client that supports
bearer headers. OAuth connections reserve the Authorization header, so use
No sign-in for this API key setup.
| Tool | Arguments | REST v2 counterpart | Returns |
|---|---|---|---|
get_site_info |
— | GET /api/v2/auth |
Site name, description, URL and default language; active languages; the key's name, prefix, permissions and expiry; whether drafts are visible; server details |
search_pages |
query (1–200 chars), page, per_page (≤ 50) |
— (MCP only) | Pages matching the words, each with a plain-text excerpt |
list_pages |
status, category_id, tag_id, page, per_page (≤ 100), include_taxonomy
|
GET /pages |
Page summaries without bodies, newest first |
get_page |
id or slug, body_format (html | markdown) |
GET /pages/{id}, GET /pages/slug/{slug}
|
Page with body, SEO metadata, author name, categories and tags |
list_media |
type, folder_id, search, page, per_page (≤ 100) |
GET /media |
Media items, newest first, without variants |
get_media |
id |
GET /media/{id} |
One media item with variants, folder and translations |
list_tags |
page, per_page (≤ 100) |
GET /tags |
Tags with usage counts |
get_tag |
id |
GET /tags/{id} |
One tag |
list_categories |
flat |
GET /categories |
Category tree, or a flat list |
get_category |
id |
GET /categories/{id} |
One category with its direct children |
Notes:
-
Search:
search_pagesuses the site's FTS5 index, which covers published pages, the same index that powers the public site search. When drafts are visible to the key, it matches titles and bodies across all pages instead, as the admin search does. -
Markdown bodies:
body_format: "markdown"converts the stored HTML with the same converter as Markdown for Agents (2 MB input cap). The result is compact, but embeds and styling are dropped. -
URLs: page results carry a public
urlfor published pages once the site URL is set, with a language prefix for non-default languages. Media URLs are site-relative (/uploads/...). - Privacy: author email addresses are never returned, only the author's id and name.
-
Pagination: list results include
total,page,per_pageandtotal_pages. -
Tool annotations: every tool is marked
readOnlyHint: true,idempotentHint: trueandopenWorldHint: false. Results the SDK builds itself, such astools/listand discovery, carrycacheScope: private, because each response is produced for one API key.
REST operations without a tool are listed in restOperationsNotExposed
(modules/mcpserver/catalog.go), each with its reason: write operations are
deferred to a later release, and status is covered by MCP discovery.
TestEveryRESTOperationHasMCPDecision fails when REST gains an operation that
has neither a tool nor an entry there.
Tool failures come back as tool results with isError: true. Their text is
the REST v2 error envelope, so an agent can correct its own call:
{"error":{"code":"validation_error","message":"Validation failed","details":{"id":"Give either id or slug, not both"}}}Codes match REST v2: not_found, validation_error, forbidden, conflict,
unauthorized and internal_error. One code is MCP-only: timeout, for a
call cut short because the client went away or the call hit the 25-second
limit. Internal failures never expose details; the cause is logged on the
server.
Arguments that break the input schema, such as an unknown property or a value
out of range, are rejected before the tool runs. They use the same envelope,
with the SDK's validation message as the arguments detail:
{"error":{"code":"validation_error","message":"Validation failed","details":{"arguments":"validating \"arguments\": validating root: validating /properties/per_page: maximum: 500/1 is greater than 100.000000"}}}Protocol-level failures are JSON-RPC errors instead. An unknown tool is one.
So is a tool result that fails its own output schema, which is a server bug:
that one returns a generic internal error, never the SDK's message, because
the message can quote server data.
| Content | Visible over MCP |
|---|---|
| Published pages | Always, to any key with mcp:access
|
| Drafts (and scheduled, unpublished pages) | Only if Expose drafts to AI agents is on and the key holds pages:read
|
| Media, tags, categories | Always; they are public reads in REST v2 too |
| Users, forms, submissions, settings, API keys | Never; no tool exposes them |
When drafts are not visible, the server removes pages:read from the key's
effective permissions before calling the page services. The services then apply
their published-only rule, so a draft is reported as "not found", and search
and listings skip it. A list_pages request with status: "draft" is refused
with forbidden. Tag and category page counts still include drafts (see
Known limitations).
get_site_info reports drafts_visible, so agents know which rule applies.
Admin → Modules → MCP Server (/admin/mcp) is for admins only, and saving
it is blocked in demo mode.
| Setting | Default | Description |
|---|---|---|
| Expose drafts to AI agents | Off | Lets keys that hold pages:read read unpublished drafts |
| Instructions for AI agents | Empty | Up to 4000 characters of guidance sent to every client in the initialize / server/discover response, after the built-in orientation |
Saving rebuilds the MCP server first, stores the settings second and swaps the
new server in last, one save at a time. A failure therefore never leaves the
stored settings and the running server out of step. Every change is logged and
recorded in the event log as MCP settings updated.
The page always shows the stored settings. Each authenticated MCP request also reloads the settings and checks module activation in the shared database before dispatch. Saving settings or disabling MCP on one instance therefore applies to subsequent requests on other running instances. Requests already admitted may finish with their earlier settings. Enable the module on every instance, or restart instances after enabling it, so their routes are registered.
If the stored settings cannot be read, the page says so and disables the form, so nothing is saved over values it could not read. MCP requests return HTTP 503 if activation or settings cannot be read, or the server cannot apply updated settings; they retry on the next request. A stored inactive module returns HTTP 404. This prevents a replica from serving drafts under a stale policy.
The page also shows:
- the endpoint URL;
- the supported protocol versions;
- snippets for Claude Code and JSON configuration, with a placeholder instead of a key;
- the tool catalog;
- every API key that holds
mcp:access, with name, prefix, permissions, last use, expiry and status. Key material is never shown.
/.well-known/mcp/server-card.json follows the SEP-1649 draft
while the module is active. It carries:
{
"$schema": "https://static.modelcontextprotocol.io/schemas/mcp-server-card/v1.json",
"version": "1.0",
"protocolVersion": "2026-07-28",
"serverInfo": {"name": "ocms", "title": "oCMS", "version": "1.0.0"},
"transport": {"type": "streamable-http", "endpoint": "https://example.com/api/mcp"},
"capabilities": {"tools": {}, "rest": {"openapi": "https://example.com/api/v2/openapi.json"}}
}serverInfo is exactly what the endpoint reports at initialize;
TestServerCardMatchesInitialize keeps the two in step. protocolVersion
advertises the endpoint's preferred supported revision; version is the
card format version. The card does not claim SEP-2127 compatibility.
While the module is inactive, the card omits the schema and protocol fields
and falls back to "transport": null with only the REST
capability, labelled with the mcp_server_version config key (0.0.0 when
empty). That key does not apply to a live card.
The card is served with Cache-Control: no-cache, because toggling the module
changes it.
The endpoint runs this chain, outermost first. The global middleware chain (trusted-proxy client IP, Sentinel bans, request logging, 30-second timeout, security headers) runs before all of it.
| Step | Rejects with |
|---|---|
Cache-Control: no-store on every response |
— |
| Method check (POST only) | 405 + Allow: POST
|
| Per-IP rate limit, applied before key verification (Argon2) | 429 |
| In-flight cap | 503 + Retry-After
|
Cross-origin browser check (Sec-Fetch-Site / Origin) |
403 |
API key authentication (middleware.APIKeyAuth) |
401 + WWW-Authenticate: Bearer realm="oCMS MCP" / 403 / 429 |
mcp:access permission |
403 |
| Per-key rate limit | 429 |
| Request body cap | 413 |
| JSON-RPC batch | 400 batch_not_supported
|
| Limit | Value |
|---|---|
| Request body | 256 KiB |
| Concurrent requests | 32 |
| Per-IP rate | 20 requests/s, burst 40 |
| Per-key rate | 10 requests/s, burst 30 |
| Tool call time | 25 s per call. A client disconnect or the 30 s global timeout also cancels the call, and cancellation reaches its database queries on every protocol version. |
Other controls:
-
DNS rebinding: the SDK's own localhost guard is disabled. It rejects a
same-host reverse proxy that forwards
Host, which is the setup in Reverse Proxy. It is replaced by the controls the MCP specification requires: Origin validation, through Go'shttp.CrossOriginProtection, and mandatory authentication on every request. Non-browser clients send neitherOriginnorSec-Fetch-Siteand pass; a web page cannot reach the endpoint. -
JSON-RPC batches: rejected. For requests that declare protocol
2025-03-26or older, or no protocol, go-sdk runs every call of a batch concurrently. One request could then carry thousands of tool calls past the per-key rate limit and the in-flight cap, which both count requests. Protocol2025-06-18and later forbid batches, and mainstream clients do not send them. This deliberately deviates from2025-03-26, which says servers must accept batches. -
Cancellation: go-sdk gives handlers of pre-
2026-07-28requests a context that never reports cancellation. The module re-attaches the HTTP request's cancellation and adds the 25-second cap, so abandoned calls stop their database work. - Panics: the SDK has no panic recovery and runs handlers on its own goroutines, which neither chi's recoverer nor the global timeout middleware's recovery reaches. The module therefore recovers panics itself at three levels: the endpoint, every MCP method and every tool. A panic becomes an internal error, in JSON rather than the timeout middleware's plain-text 500, and is logged with its stack; it does not crash the process.
-
"arguments": null: in go-sdk v1.8.0, atools/callwhose arguments are JSONnullpanics while applying schema defaults. The module turns that into absent arguments before validation. A regression test covers it. - Prompt injection: pages are written by site authors, not by the person driving the agent. A compromised author account could plant instructions in page bodies. The built-in server instructions tell agents that everything the tools return is website data, not instructions. Agents should still be run with the usual care for untrusted input, and drafts should stay hidden unless you need them.
- Read-only scope: no tool writes data. Write tools will come with their own policy layer.
The module logs through slog with module=mcp:
| Event | Level | Fields |
|---|---|---|
Tool call, one line per dispatched tools/call
|
Info; Warn for cancelled; Error for internal_error
|
tool, outcome, duration_ms, api_key_id, api_key_prefix, client_name, client_version, protocol_version, error_code, plus error (the cause) for internal and cancelled calls |
| Panic in the endpoint, an MCP method or a tool | Error |
panic, stack, plus tool/method/ip
|
Key without mcp:access, cross-origin request, in-flight cap reached |
Warn |
api_key_id/api_key_prefix or ip, origin
|
| Unusable site URL (once until the value changes) | Warn | site_url |
| Settings saved | Info |
user_id, allow_drafts, previous_allow_drafts, instructions_length (also recorded in the event log) |
| Settings reloaded from the database | Info |
allow_drafts, instructions_length
|
| Settings unreadable at startup | Error | error |
| Module initialized | Info | endpoint, tool count, drafts policy, protocol versions, limits |
| go-sdk messages | Warn and above only |
component (mcp-server, mcp-transport) |
Tool call outcomes:
| Outcome | Meaning |
|---|---|
ok |
The call succeeded. |
tool_error |
A domain error, such as not found or forbidden. |
invalid_arguments |
The arguments failed the input schema, so the tool never ran. |
rejected |
A JSON-RPC error, such as an unknown tool. |
cancelled |
The client disconnected, the request timed out, or the call hit the 25-second cap. |
internal_error |
A server-side failure. |
unauthenticated |
The call had no authenticated key. |
Some requests never reach the tool-call log:
- requests the SDK rejects before dispatching them, such as undecodable parameters or a mismatched protocol version or header;
- JSON-RPC batches, which are answered with a 400.
Client-supplied values (an unknown tool's name, the client's name and version) are cut to 128 bytes.
API keys and tool arguments are never logged, and neither is returned content, with one exception: an internal failure's cause is logged as-is. When the SDK rejects a tool's own output (a server bug), that message can quote the offending value.
KEY='<YOUR_API_KEY>' # an API key holding mcp:access
# Method and authentication checks
curl -si http://localhost:8080/api/mcp | head -1 # 405
curl -si -X POST http://localhost:8080/api/mcp | grep -i '^www-authenticate'
# List the tools
curl -s http://localhost:8080/api/mcp \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | jq '.result.tools[].name'
# Call a tool
curl -s http://localhost:8080/api/mcp \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2025-11-25' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_page","arguments":{"slug":"about","body_format":"markdown"}}}' \
| jq -r '.result.content[0].text'The Accept header must list both application/json and
text/event-stream, as the Streamable HTTP transport requires. Responses are
always plain JSON.
-
Package:
modules/mcpserver; module namemcp; translation prefixmcp. -
Database table:
mcp_settings(single row,id = 1) -
Endpoint:
/api/mcp. It sits under the reservedapiprefix, so it can never shadow a page slug. -
Admin URL:
/admin/mcp -
Permission:
mcp:access(model.PermissionMCPAccess) -
Opt-in: the module implements
module.ActivationDefaulter(ActiveByDefault() == false). -
Schemas: tool input and output schemas are generated by huma's schema
registry from the same struct tags REST v2 uses, then converted to
jsonschema-goschemas for the SDK. Recursive types such as the category tree use$ref/$defs.TestToolInputConstraintsMatchRESTfails when an MCP argument and its REST parameter disagree on type, bounds or enum values.
Drift tests in modules/mcpserver/drift_test.go:
| Test | Fails when |
|---|---|
TestEveryRESTOperationHasMCPDecision |
A REST v2 operation has neither a tool nor a documented reason |
TestToolInputConstraintsMatchREST |
MCP argument constraints diverge from the REST parameters |
TestEveryRegisteredToolIsReadOnly |
A registered tool lacks the read-only annotations |
TestCatalogSpecsWellFormed |
A catalog entry has an invalid or duplicate name, lacks a title or description, or declares both or neither of a REST mapping and an MCP-only reason |
TestTranslationsCompleteAndUsedKeysExist |
en/ru keys diverge, or code uses an mcp.* key that does not exist |
TestServerCardMatchesInitialize |
The server card's serverInfo differs from what initialize reports |
internal/api/v2/drift_test.go adds TestInternalErrorsKeepTheirCause, which
fails when a v2 service builds an internal error without its cause. Without the
cause, the MCP log could not explain internal failures or recognize
cancellations.
These come from the REST v2 services the tools share and will be fixed there, for REST and MCP together:
-
Tag and category page counts include drafts.
page_countinlist_tags,get_tag,list_categoriesandget_category(and REST v2) counts unpublished pages too, which reveals that drafts exist. Their content is not revealed. -
Draft status with category or tag.
list_pagesrejectsstatus: "draft"combined withcategory_idortag_id; these combinations are unsupported.
- Write tools for pages, taxonomy and media upload, with an editorial policy
layer (drafts-only and no-deletes defaults) and
transport=mcpaudit metadata. - OAuth 2.1 authorization for individual user sign-in and remote connectors without request header access. The bearer-token verifier is the seam it plugs into.
- MCP resources and prompts.
- A language filter for list and search tools (needs v2 service support).
Getting Started
Core Features
- Content Management
- Media Library
- Taxonomy
- Menu Builder
- Forms
- Multi-Language
- SEO
- Feeds
- Video Embedding
- Agent-Ready
Administration
API & Integrations
Security
Theme Development
Module Development
Built-in Modules
- Module: Sentinel
- Module: Developer
- Module: DB Manager
- Module: Migrator
- Module: Informer
- Module: Demo Mode
- Module: Internal Analytics
- Module: External Analytics
- Module: Privacy
- Module: Embed
- Module: MCP Server
Deployment