Skip to content

feat(mcp): optional built-in MCP server — list/get/search docs over Streamable HTTP#36

Merged
mhenrixon merged 1 commit into
mainfrom
issue-20-mcp-server
Jul 3, 2026
Merged

feat(mcp): optional built-in MCP server — list/get/search docs over Streamable HTTP#36
mhenrixon merged 1 commit into
mainfrom
issue-20-mcp-server

Conversation

@mhenrixon

Copy link
Copy Markdown
Owner

Closes #20.

What & why

Developers increasingly read docs through agents. llms.txt covers fetch-style consumption, but MCP (Model Context Protocol) is the native protocol: a reader adds https://docs.example.com/mcp to Claude Code / Claude.ai / Cursor once and the docs become first-class tools (search, targeted page retrieval) instead of scraped text. This makes every current and future docs-kit site agent-queryable for free.

A read-only, stateless Streamable HTTP MCP endpoint built on the official mcp Ruby SDK, mounted by the host app, off unless both the gem is present and the route is drawn.

The three tools

All over the same registry the docs render from — an agent queries live docs, never a stale scrape:

Tool Returns
list_pages every authored page — slug, title, group, url
get_page(slug:) the page's Markdown twin (the same .md twin /llms.txt links), or a graceful not-found listing valid slugs
search_docs(query:) ranked DocsKit::SearchIndex hits — page_title, section_title, url, snippet (with the <mark> highlight stripped to plain text)

Architecture — mirrors the existing llms.txt/search split

  • DocsKit::McpTools — the pure, Rails-free, mcp-free core (all three functions over LlmsText.pages / MarkdownExport / SearchIndex). Fully unit-testable without HTTP or the SDK.
  • DocsKit::McpServer — wraps those into an MCP::Server via define_tool, named from c.brand, instructions from c.tagline + a /llms.txt pointer. No-ops (returns nil) when the gem is absent.
  • DocsKit::McpController — thin: POST /mcpserver.handle_json(request.body.read); owns only the Rails view context (for the twin render) + the read-only method policy. GET/DELETE405.

Optional-dependency gate

docs-kit adds mcp to no gemspec list. Configuration#mcp_enabled? gates on the c.mcp knob (default true) AND the gem being loadable. The mcp gem lives in its own bundler group, and a new CI leg installs --without mcp and runs the suite — proving the feature no-ops (the MCP specs self-skip) when the gem is absent. A site that never bundles mcp is byte-identical to before this PR.

Discovery + opt-in wiring

  • /llms.txt grows a final ## MCP line advertising the endpoint when enabled, so agents discover it.
  • The install generator draws the /mcp route commented out (opt-in — the gem is optional); config/initializers/docs_kit.rb and the docs-kit new template document the gem "mcp" + c.mcp opt-in.
  • README: a new "Add your docs to an agent (MCP)" section with the claude mcp add --transport http one-liner.

Dogfooded end-to-end

Enabled on the gem's own docs/ app (real /mcp route + gem "mcp") and verified from curl against a booted server — through the real Rails render path, not just unit tests:

  • tools/list → the three tools
  • search_docs("theme switcher") → top hit Components → ThemeSwitcher with a correct anchored absolute URL, snippet as plain text
  • get_page("installation") → the real Markdown twin; get_page("nope") → graceful not-found
  • GET/DELETE /mcp405
  • /llms.txt → the ## MCP advertisement line

Test plan

  • spec/docs_kit/mcp_tools_spec.rb — the pure core (runs without the mcp gem): authored-only listing, twin + unknown-slug, ranked hits with <mark> stripped
  • spec/docs_kit/mcp_server_spec.rb — the real SDK via handle_json: tools/list + each tools/call round-trips; .build no-ops gem-absent (self-skips without the gem)
  • spec/docs_kit/mcp_controller_spec.rb — source-wiring (the class is Rails-only), mirrors llms_controller_spec
  • configuration_spec / llms_text_spec / install_generator_spec — the knob + mcp_enabled?, the advertisement line, the commented route

Verification

  • bundle exec rspec448 examples, 0 failures, 96% line coverage
  • bundle exec rubocop — no offenses
  • Suite green without the mcp gem (bundle config set --local without mcp) — the optional-dependency gate
  • Dogfooded end-to-end against docs/ (curl → every tool answers)
  • Backwards compatible — a site without the gem/route is byte-identical

Out of scope (per the issue)

Write tools (authoring is git), auth (public docs; a host wraps the route), and MCP resources/prompts primitives (tools cover the consumption story).

## Summary

Any docs-kit site can now turn on a read-only MCP endpoint with one gem + one
route — every current and future site becomes agent-queryable for free. A reader
adds `https://docs.example.com/mcp` to Claude Code / Claude.ai / Cursor once and
the docs become first-class tools (search, page retrieval) instead of scraped
text.

Built on the official `mcp` Ruby SDK, exposed as a stateless Streamable HTTP
JSON-RPC endpoint mounted by the host app, OFF unless BOTH the gem is present and
the route is drawn. Three tools over the SAME registry the docs render from (so
an agent queries live docs, never a stale scrape):

- `list_pages` → every authored page {slug, title, group, url}
- `get_page(slug:)` → the page's Markdown twin, or a graceful not-found listing
  valid slugs
- `search_docs(query:)` → ranked SearchIndex hits {page_title, section_title,
  url, snippet}

Layering mirrors the existing llms.txt/search split — a pure, Rails-free core
(DocsKit::McpTools) wrapped into an MCP::Server (DocsKit::McpServer), threaded by
a thin controller (DocsKit::McpController) that owns only the Rails view context.

## Optional-dependency gate

docs-kit adds `mcp` to no gemspec list. `Configuration#mcp_enabled?` gates on the
`c.mcp` knob (default true) AND the gem being loadable. The `mcp` gem lives in its
own bundler group; a new CI leg installs `--without mcp` and runs the suite green,
proving the feature no-ops (the MCP specs self-skip) when the gem is absent — a
site that never bundles it is byte-identical to before.

## Also

- `/llms.txt` grows a final `## MCP` line advertising the endpoint when enabled,
  so agents discover it.
- Install generator draws the /mcp route COMMENTED (opt-in); `docs_kit.rb.erb`
  and `new_site.rb` document the `gem "mcp"` + `c.mcp` opt-in.
- Dogfooded on the gem's own docs/ app (real /mcp route + gem): verified from
  curl — tools/list, search_docs, get_page, and GET/DELETE → 405 all work
  end-to-end through the real Rails render path.
- README "Add your docs to an agent (MCP)" section with the
  `claude mcp add --transport http` one-liner.

## Test Coverage

- spec/docs_kit/mcp_tools_spec.rb — pure core: authored-only listing, markdown
  twin + graceful unknown slug, ranked hits with <mark> stripped (runs WITHOUT
  the mcp gem)
- spec/docs_kit/mcp_server_spec.rb — real SDK via handle_json: tools/list + each
  tools/call round-trips; .build no-ops when the gem is absent (self-skips
  without the gem)
- spec/docs_kit/mcp_controller_spec.rb — source-wiring (Rails-only class), mirrors
  llms_controller_spec
- configuration_spec / llms_text_spec / install_generator_spec — the knob, the
  advertisement line, the commented route

## Verification

- [x] bundle exec rspec — 448 examples, 0 failures, 96% line coverage
- [x] bundle exec rubocop — no offenses
- [x] suite green WITHOUT the mcp gem (optional-dependency gate)
- [x] dogfooded end-to-end against docs/ (curl → all tools answer)

Closes #20
@mhenrixon mhenrixon self-assigned this Jul 3, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 3, 2026
@mhenrixon mhenrixon merged commit 96f893e into main Jul 3, 2026
4 checks passed
@mhenrixon mhenrixon deleted the issue-20-mcp-server branch July 4, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(mcp): optional built-in MCP server — list/get/search docs over Streamable HTTP

1 participant