Module: DocsKit::McpTools
- Defined in:
- lib/docs_kit/mcp_tools.rb
Overview
The pure, HTTP-free core the built-in MCP server exposes — three plain-Ruby functions over the SAME registry, Markdown twins, and search index the docs already render from, so an agent queries live docs, never a stale scrape:
list_pages(config) → [{ slug, title, group, url }] authored pages only
get_page(config, slug:) → { found:, title:, url:, markdown: } | { found: false, message: }
search_docs(config, query:)→ [{ page_title, section_title, url, snippet }] ranked
Zero mcp-gem dependency and zero JSON-RPC: DocsKit::McpServer wraps these
into MCP tools, and the controller only threads the Rails view context (for
url helpers/CSRF, the same seam DocsKit::LlmsController#full uses). So the
whole consumption story is unit-testable without booting Rails or the SDK.
"Authored" means a resolvable #view_class — DocsKit::LlmsText.pages already flattens every registry to just those, so an unwritten page is never listed, fetched, or indexed (no dead links, no 404s over the protocol).
Class Method Summary collapse
-
.absolutize(href, base_url) ⇒ Object
href absolutized against base_url (no .md suffix — MCP serves the page URL, not the twin).
-
.find_page(config, slug) ⇒ Object
The authored page with this slug across every registry, or nil (an unwritten page has no resolvable view_class, so it's absent from LlmsText.pages).
-
.get_page(config, slug:, base_url: nil, view_context: nil) ⇒ Object
A single page's Markdown twin by slug.
-
.index_for(config, base_url:, view_context:) ⇒ Object
A DocsKit::SearchIndex over every authored page's twin — the same triples DocsKit::SearchController builds, so MCP search can't drift from the pages.
-
.list_pages(config, base_url: nil) ⇒ Object
The authored pages across every registry, in config/registry order, as a flat list of { slug, title, group, url } — url absolutized against base_url when given (agents fetch a portable URL), else the root-relative href.
-
.not_found(config, slug) ⇒ Object
A { found: false } result whose message names every valid slug, so an agent that guessed wrong can retry with a real one.
-
.render_markdown(page, base_url:, view_context:) ⇒ Object
A page's GFM Markdown twin, rendered through the view context so url helpers and relative-link absolutization resolve — the LlmsController#full seam.
-
.search_docs(config, query:, base_url: nil, view_context: nil) ⇒ Object
The top DocsKit::SearchIndex hits for query, as { page_title, section_title, url, snippet } — url absolutized, snippet reduced to plain text (the index's HTML highlight stripped, since MCP delivers text, not HTML).
-
.strip_html(snippet) ⇒ Object
Reduce the search index's HTML-safe snippet (term wrapped in , rest CGI-escaped) to plain text: drop the tags and unescape entities, so the MCP snippet is human/agent-readable text rather than HTML.
Class Method Details
.absolutize(href, base_url) ⇒ Object
href absolutized against base_url (no .md suffix — MCP serves the page URL, not the twin). Relative href when base_url is nil. Mirrors LlmsText.md_url.
105 106 107 108 109 |
# File 'lib/docs_kit/mcp_tools.rb', line 105 def absolutize(href, base_url) return href unless base_url "#{base_url.chomp('/')}#{href}" end |
.find_page(config, slug) ⇒ Object
The authored page with this slug across every registry, or nil (an unwritten page has no resolvable view_class, so it's absent from LlmsText.pages).
73 74 75 |
# File 'lib/docs_kit/mcp_tools.rb', line 73 def find_page(config, slug) LlmsText.pages(config).find { |page| page.slug.to_s == slug.to_s } end |
.get_page(config, slug:, base_url: nil, view_context: nil) ⇒ Object
A single page's Markdown twin by slug. Renders the page's #view_class through view_context (nil off-Rails) exactly as LlmsController#full does. An unknown or unwritten slug returns { found: false } with a message listing the valid slugs, so an agent can correct itself instead of hitting an error.
43 44 45 46 47 48 49 50 51 52 53 54 |
# File 'lib/docs_kit/mcp_tools.rb', line 43 def get_page(config, slug:, base_url: nil, view_context: nil) page = find_page(config, slug) return not_found(config, slug) unless page { found: true, slug: page.slug, title: page.title, url: absolutize(page.href, base_url), markdown: render_markdown(page, base_url:, view_context:) } end |
.index_for(config, base_url:, view_context:) ⇒ Object
A DocsKit::SearchIndex over every authored page's twin — the same triples DocsKit::SearchController builds, so MCP search can't drift from the pages.
96 97 98 99 100 101 |
# File 'lib/docs_kit/mcp_tools.rb', line 96 def index_for(config, base_url:, view_context:) triples = LlmsText.pages(config).map do |page| [page.title, page.href, render_markdown(page, base_url:, view_context:)] end SearchIndex.new(triples) end |
.list_pages(config, base_url: nil) ⇒ Object
The authored pages across every registry, in config/registry order, as a flat list of { slug, title, group, url } — url absolutized against base_url when given (agents fetch a portable URL), else the root-relative href.
28 29 30 31 32 33 34 35 36 37 |
# File 'lib/docs_kit/mcp_tools.rb', line 28 def list_pages(config, base_url: nil) LlmsText.pages(config).map do |page| { slug: page.slug, title: page.title, group: page.group, url: absolutize(page.href, base_url) } end end |
.not_found(config, slug) ⇒ Object
A { found: false } result whose message names every valid slug, so an agent that guessed wrong can retry with a real one.
79 80 81 82 83 84 85 86 |
# File 'lib/docs_kit/mcp_tools.rb', line 79 def not_found(config, slug) valid = list_pages(config).map { |page| page[:slug] } { found: false, slug: slug, message: "No page with slug #{slug.inspect}. Valid slugs: #{valid.join(', ')}." } end |
.render_markdown(page, base_url:, view_context:) ⇒ Object
A page's GFM Markdown twin, rendered through the view context so url helpers and relative-link absolutization resolve — the LlmsController#full seam.
90 91 92 |
# File 'lib/docs_kit/mcp_tools.rb', line 90 def render_markdown(page, base_url:, view_context:) MarkdownExport.new(page.view_class.new, view_context:, base_url:).to_md end |
.search_docs(config, query:, base_url: nil, view_context: nil) ⇒ Object
The top DocsKit::SearchIndex hits for query, as { page_title, section_title, url, snippet } — url absolutized, snippet reduced to plain text (the index's HTML highlight stripped, since MCP delivers text, not HTML). Blank query → []. Builds the index from the same twins search + llms-full serve.
60 61 62 63 64 65 66 67 68 69 |
# File 'lib/docs_kit/mcp_tools.rb', line 60 def search_docs(config, query:, base_url: nil, view_context: nil) index_for(config, base_url:, view_context:).search(query).map do |hit| { page_title: hit.page_title, section_title: hit.section_title, url: absolutize(hit.href, base_url), snippet: strip_html(hit.snippet) } end end |
.strip_html(snippet) ⇒ Object
Reduce the search index's HTML-safe snippet (term wrapped in , rest CGI-escaped) to plain text: drop the tags and unescape entities, so the MCP snippet is human/agent-readable text rather than HTML.
114 115 116 |
# File 'lib/docs_kit/mcp_tools.rb', line 114 def strip_html(snippet) CGI.unescapeHTML(snippet.to_s.gsub(%r{</?mark>}, "")) end |