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

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