Skip to main content

MCP Server

7 min readStableAdvanced

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.

Request
Bash
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"}}}'

Adding Options

Five methods, two tool arguments beyond query, and a bearer token cover the rest of the surface.

  • initialize negotiates 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. ping returns an empty result.
  • server/discover reports the protocol versions the route accepts, its capabilities, and its server info.
  • tools/list returns both tools with their JSON Schemas, which is how a client learns the argument names without reading this page.
  • limit widens or narrows a search_docs result set. The route floors the number and clamps it between 1 and 20, so limit: 50 still returns 20 and a non-numeric value falls back to 5.
  • url tells fetch_page which page to read. A leading slash is optional, trailing slashes are trimmed, backslashes normalize to /, an empty path resolves to app/index.mdx, and a directory path falls back to its index.mdx.
  • Authorization: Bearer <token> is compared against MCP_BEARER_TOKEN in constant time. A missing or wrong token returns 401 with a WWW-Authenticate: Bearer realm="owndocs-mcp" header and a JSON body naming the reason.
server/discover response
JSON
{
  "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"
  }
}

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_page decodes the URL first, then walks it segment by segment. Any .., ., or null byte throws -32602 with the message Path traversal rejected, and a percent-encoded segment such as %2e%2e is caught after decoding. A URL that decodeURIComponent can't parse returns -32602 with Malformed URL parameter, and both resolved candidates are re-checked against the app/ root before either file is opened.
  • Two failures answer as ordinary text results carrying isError: true instead of an error object: a query that is empty or absent, and a fetch_page path that matches no file (Page not found: /<path>). Calling a tool name that isn't search_docs or fetch_page behaves the same way.
  • A missing url argument returns -32602. A body that isn't a valid JSON-RPC request, wrong jsonrpc value, missing method, returns -32600 when it carries an id.
  • A request without an id key is a notification: the route answers 202 with an empty body and no payload at all.
  • An unknown method returns -32601 with HTTP 404. A JSON array body returns -32600 with Batch requests are not supported, and unparseable JSON returns -32700. An unexpected server-side failure returns -32603 with a flat Internal error message, 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; a MCP-Protocol-Version the route doesn't implement returns -32022 with the supported and requested versions in error.data. If the body carries params._meta["io.modelcontextprotocol/protocolVersion"], it has to match the header when one is sent.
  • Only POST does work. GET answers 405, OPTIONS answers 204 with Allow: POST, OPTIONS, and both return 404 while 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-For address otherwise. Past the cap the route returns 429 with {"error":"Rate limit exceeded"}.
Traversal attempt
JSON
{
  "jsonrpc": "2.0",
  "id": 5,
  "method": "tools/call",
  "params": {
    "name": "fetch_page",
    "arguments": { "url": "/%2e%2e/%2e%2e/etc/passwd" }
  }
}

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: false

Enables the route in private mode, where it also needs MCP_BEARER_TOKEN. Ignored in public mode, which always serves the route.

MCP_BEARER_TOKENenv

The 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-Versionheader

Optional. When sent, must be one of the supported spec versions (2024-11-05 through 2026-07-28); anything else returns -32022.

Mcp-Methodheader

Optional. When sent, must repeat the body's method value exactly, or the route returns -32020.

Mcp-Nameheader

Optional, tools/call only. When sent, must repeat params.name.

Authorizationheader

Bearer <token>, matched against MCP_BEARER_TOKEN in constant time. Also the rate-limit key when present.

querystringrequired

search_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: 5

Maximum search_docs results, floored and clamped between 1 and 20.

urlstringrequired

fetch_page page path, with or without a leading slash. Resolves to app/<path>.mdx, then to app/<path>/index.mdx.

resultTypestring

Always complete — the route never streams a partial result.

contentarray

One entry of { type: "text", text: string }. Tool payloads are JSON encoded inside that string.

isErrorboolean

Present only on a tool-level failure: a blank query, a missing page, or an unknown tool name.

_metaobject

Carries io.modelcontextprotocol/serverInfo with the server name (owndocs) and version on server/discover and tools/list.

supportedVersionsarray

server/discover only. The protocol versions the route accepts.

capabilitiesobject

server/discover only. { tools: { listChanged: false } } — the tool list is static.

toolsarray

tools/list only. Each entry has name, description, and inputSchema.

ttlMsnumber

How long a client may cache the discovery or tool-list response, in milliseconds. Both send 60000.

cacheScopestring

public on both cacheable responses, so a shared client cache may hold them.

Was this page helpful?