Enables the route in private mode, where it also needs MCP_BEARER_TOKEN.
Ignored in public mode, which always serves the route.
MCP Server
OwnDocs answers Model Context Protocol calls at /api/mcp with two tools.
search_docs runs a free-text query over the same index that powers Cmd+K, and
fetch_page hands back the raw MDX behind a page path. The route speaks
JSON-RPC 2.0 over an HTTP POST and holds no session, so every call carries
everything it needs.
Public mode leaves the route on. Private mode keeps it dark until both
MCP_ENABLED=true and MCP_BEARER_TOKEN are set — until then, every method
returns 404. The auth proxy whitelists /api/mcp, so a client never needs the
site's session cookie; the bearer token is the only credential.
Quick Start
The smallest useful call is a tools/call for search_docs carrying only
query; limit falls back to 5.
Three optional headers can travel with it: MCP-Protocol-Version set to a
supported spec version, Mcp-Method repeating the body's method, and for
tools/call only, Mcp-Name repeating params.name. Any header that is
present but disagrees with the body comes back as error -32020 with HTTP
400. Standard MCP clients that complete the initialize handshake may omit
the OwnDocs headers entirely. The tool result arrives as a text block whose
text is a JSON string of the matched pages, each with a title, a URL, and a
240-character snippet.
curl -sS https://docs.example.com/api/mcp \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: search_docs' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search_docs","arguments":{"query":"mermaid"}}}'{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "{\n \"query\": \"mermaid\",\n \"results\": [\n {\n \"title\": \"Mermaid Diagrams\",\n \"url\": \"/features/diagrams/mermaid\",\n \"snippet\": \"Flowcharts, sequence diagrams, and state charts.\"\n }\n ]\n}"
}
]
}
}Adding Options
Five methods, two tool arguments beyond query, and a bearer token cover the
rest of the surface.
initializenegotiates the protocol version: the route echoes the client's requested version when it supports it and answers with the newest supported one otherwise, alongside capabilities and server info.pingreturns an empty result.server/discoverreports the protocol versions the route accepts, its capabilities, and its server info.tools/listreturns both tools with their JSON Schemas, which is how a client learns the argument names without reading this page.limitwidens or narrows asearch_docsresult set. The route floors the number and clamps it between 1 and 20, solimit: 50still returns 20 and a non-numeric value falls back to 5.urltellsfetch_pagewhich page to read. A leading slash is optional, trailing slashes are trimmed, backslashes normalize to/, an empty path resolves toapp/index.mdx, and a directory path falls back to itsindex.mdx.Authorization: Bearer <token>is compared againstMCP_BEARER_TOKENin constant time. A missing or wrong token returns401with aWWW-Authenticate: Bearer realm="owndocs-mcp"header and a JSON body naming the reason.
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "owndocs",
"version": "1.0.0"
}
},
"resultType": "complete",
"supportedVersions": [
"2024-11-05",
"2025-03-26",
"2025-06-18",
"2025-11-25",
"2026-07-28"
],
"capabilities": { "tools": { "listChanged": false } },
"ttlMs": 60000,
"cacheScope": "public"
}
}{
"jsonrpc": "2.0",
"id": 2,
"result": {
"_meta": {
"io.modelcontextprotocol/serverInfo": {
"name": "owndocs",
"version": "1.0.0"
}
},
"resultType": "complete",
"tools": [
{
"name": "search_docs",
"description": "Search OwnDocs documentation by free-text query. Returns matching pages with title, URL, and snippet.",
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Free-text search query."
},
"limit": {
"type": "number",
"description": "Maximum number of results to return.",
"default": 5
}
},
"required": ["query"]
}
},
{
"name": "fetch_page",
"description": "Fetch the raw MDX content of a documentation page by URL path (for example, \"/getting-started/introduction\").",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Page path starting with \"/\" or relative."
}
},
"required": ["url"]
}
}
],
"ttlMs": 60000,
"cacheScope": "public"
}
}{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "search_docs",
"arguments": { "query": "openapi embed", "limit": 10 }
}
}{
"jsonrpc": "2.0",
"id": 4,
"method": "tools/call",
"params": {
"name": "fetch_page",
"arguments": { "url": "/getting-started/introduction" }
}
}{
"jsonrpc": "2.0",
"id": 4,
"result": {
"resultType": "complete",
"content": [
{
"type": "text",
"text": "{\n \"url\": \"/getting-started/introduction\",\n \"content\": \"---\\ntitle: Introduction\\n---\\n\\n# Introduction\\n\"\n}"
}
]
}
}fetch_page returns the file exactly as it sits on disk, frontmatter included,
which is what makes it useful for an agent that wants to quote or rewrite a
page.
Advanced
Guard rails surface as ordinary JSON-RPC errors rather than stack traces, so a client can branch on the code.
fetch_pagedecodes the URL first, then walks it segment by segment. Any..,., or null byte throws-32602with the messagePath traversal rejected, and a percent-encoded segment such as%2e%2eis caught after decoding. A URL thatdecodeURIComponentcan't parse returns-32602withMalformed URL parameter, and both resolved candidates are re-checked against theapp/root before either file is opened.- Two failures answer as ordinary text results carrying
isError: trueinstead of an error object: aquerythat is empty or absent, and afetch_pagepath that matches no file (Page not found: /<path>). Calling a tool name that isn'tsearch_docsorfetch_pagebehaves the same way. - A missing
urlargument returns-32602. A body that isn't a valid JSON-RPC request, wrongjsonrpcvalue, missingmethod, returns-32600when it carries anid. - A request without an
idkey is a notification: the route answers202with an empty body and no payload at all. - An unknown method returns
-32601with HTTP404. A JSON array body returns-32600withBatch requests are not supported, and unparseable JSON returns-32700. An unexpected server-side failure returns-32603with a flatInternal errormessage, never a stack trace. - Header checks run before dispatch and only apply to headers that are present.
Any header that disagrees with the body returns
-32020; aMCP-Protocol-Versionthe route doesn't implement returns-32022with the supported and requested versions inerror.data. If the body carriesparams._meta["io.modelcontextprotocol/protocolVersion"], it has to match the header when one is sent. - Only
POSTdoes work.GETanswers405,OPTIONSanswers204withAllow: POST, OPTIONS, and both return404while the route is disabled. - Traffic is capped at 60 requests per minute in a per-instance in-memory
counter, keyed on the bearer token when one is present and on the first
X-Forwarded-Foraddress otherwise. Past the cap the route returns429with{"error":"Rate limit exceeded"}.
{
"jsonrpc": "2.0",
"id": 5,
"method": "tools/call",
"params": {
"name": "fetch_page",
"arguments": { "url": "/%2e%2e/%2e%2e/etc/passwd" }
}
}{
"jsonrpc": "2.0",
"id": 5,
"error": {
"code": -32602,
"message": "Path traversal rejected"
}
}{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32022,
"message": "Unsupported protocol version",
"data": {
"supported": [
"2024-11-05",
"2025-03-26",
"2025-06-18",
"2025-11-25",
"2026-07-28"
],
"requested": "1999-01-01"
}
}
}{
"jsonrpc": "2.0",
"id": 6,
"result": {
"resultType": "complete",
"content": [{ "type": "text", "text": "Page not found: /nope/missing" }],
"isError": true
}
}Scoring is worth knowing if you tune it. search_docs lowercases the query and
splits it on whitespace, then for each token adds 4 when the page title contains
it and 1 more when the title, body, or URL contains it. Pages scoring zero drop
out, the rest sort high to low, and the surviving entries are cut to limit. A
two-token query therefore tops out at 10 points for a page that carries both
tokens in its title. Both weights live in handleSearchDocs in
app/api/mcp/route.ts, and the tool list itself is the TOOLS array in the
same file.
Options
MCP_ENABLEDenvDefault: falseMCP_BEARER_TOKENenvThe shared secret clients send as Authorization: Bearer <token>. Required in
private mode; optional in public mode, but enforced there once set. Whitespace
around the value is trimmed on both sides before comparison.
MCP-Protocol-VersionheaderOptional. When sent, must be one of the supported spec versions (2024-11-05
through 2026-07-28); anything else returns -32022.
Mcp-MethodheaderOptional. When sent, must repeat the body's method value exactly, or the
route returns -32020.
Mcp-NameheaderOptional, tools/call only. When sent, must repeat params.name.
AuthorizationheaderBearer <token>, matched against MCP_BEARER_TOKEN in constant time. Also
the rate-limit key when present.
querystringrequiredsearch_docs free-text query. A page scores 4 for a title hit and 1 for a hit
anywhere in its title, content, or URL, per token.
limitnumberDefault: 5Maximum search_docs results, floored and clamped between 1 and 20.
urlstringrequiredfetch_page page path, with or without a leading slash. Resolves to
app/<path>.mdx, then to app/<path>/index.mdx.
resultTypestringAlways complete — the route never streams a partial result.
contentarrayOne entry of { type: "text", text: string }. Tool payloads are JSON encoded
inside that string.
isErrorbooleanPresent only on a tool-level failure: a blank query, a missing page, or an unknown tool name.
_metaobjectCarries io.modelcontextprotocol/serverInfo with the server name
(owndocs) and version on server/discover and tools/list.
supportedVersionsarrayserver/discover only. The protocol versions the route accepts.
capabilitiesobjectserver/discover only. { tools: { listChanged: false } } — the tool list is
static.
toolsarraytools/list only. Each entry has name, description, and inputSchema.
ttlMsnumberHow long a client may cache the discovery or tool-list response, in milliseconds. Both send 60000.
cacheScopestringpublic on both cacheable responses, so a shared client cache may hold them.