Module: DocsKit::McpServer

Defined in:
lib/docs_kit/mcp_server.rb

Overview

Builds the read-only MCP::Server a docs-kit site exposes at POST /mcp — a stateless JSON-RPC skin over DocsKit::McpTools, so an agent (Claude Code, Claude.ai, Cursor) adds one URL and the docs become first-class tools:

server = DocsKit::McpServer.build(DocsKit.configuration, base_url:, view_context:)
server.handle_json(request.body.read)   # → the JSON-RPC response string

The mcp gem is OPTIONAL and runtime-detected (docs-kit depends on it in no gemspec list). .build returns nil when the gem is absent, and the controller only reaches here when DocsKit.configuration#mcp_enabled? — so a site without the gem, or with c.mcp = false, is byte-identical to before this feature.

base_url + view_context ride in the server_context (the SDK threads it to every tool block), so the tools render each page's Markdown twin through the Rails view context and absolutize URLs — the same seam LlmsController#full uses. The three tools mirror DocsKit::McpTools one-to-one.

Class Method Summary collapse

Class Method Details

.build(config, base_url: nil, view_context: nil) ⇒ Object

The MCP::Server for this config, or nil when the mcp gem isn't loadable. base_url/view_context flow to the tools via server_context.



27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/docs_kit/mcp_server.rb', line 27

def build(config, base_url: nil, view_context: nil)
  return unless mcp_available?

  server = MCP::Server.new(
    name: config.brand.to_s,
    version: DocsKit::VERSION,
    instructions: instructions_for(config, base_url),
    server_context: { config:, base_url:, view_context: }
  )
  define_tools(server)
  server
end

.define_get_page(server) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/docs_kit/mcp_server.rb', line 84

def define_get_page(server)
  server.define_tool(
    name: "get_page",
    description: "Fetch one documentation page as Markdown, by its slug (see list_pages). " \
                 "Returns the page's full Markdown twin.",
    input_schema: {
      type: "object",
      properties: { slug: { type: "string", description: "The page slug, e.g. \"installation\"." } },
      required: ["slug"]
    }
  ) do |slug:, server_context:|
    cfg = server_context[:config]
    McpServer.json_response(
      McpTools.get_page(cfg, slug:, base_url: server_context[:base_url],
                             view_context: server_context[:view_context])
    )
  end
end

.define_list_pages(server) ⇒ Object



72
73
74
75
76
77
78
79
80
81
82
# File 'lib/docs_kit/mcp_server.rb', line 72

def define_list_pages(server)
  server.define_tool(
    name: "list_pages",
    description: "List every documentation page: its slug, title, group, and URL. " \
                 "Use the slug with get_page.",
    input_schema: { type: "object", properties: {}, required: [] }
  ) do |server_context:|
    cfg = server_context[:config]
    McpServer.json_response(McpTools.list_pages(cfg, base_url: server_context[:base_url]))
  end
end

.define_search_docs(server) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'lib/docs_kit/mcp_server.rb', line 103

def define_search_docs(server)
  server.define_tool(
    name: "search_docs",
    description: "Full-text search across all documentation pages. " \
                 "Returns ranked hits with the page, section, URL, and a snippet.",
    input_schema: {
      type: "object",
      properties: { query: { type: "string", description: "The search terms." } },
      required: ["query"]
    }
  ) do |query:, server_context:|
    cfg = server_context[:config]
    McpServer.json_response(
      McpTools.search_docs(cfg, query:, base_url: server_context[:base_url],
                                view_context: server_context[:view_context])
    )
  end
end

.define_tools(server) ⇒ Object

Register the three read-only tools. Each block pulls config + render context from server_context, calls the matching DocsKit::McpTools function, and returns the result as pretty JSON text (agents consume structured data).



66
67
68
69
70
# File 'lib/docs_kit/mcp_server.rb', line 66

def define_tools(server)
  define_list_pages(server)
  define_get_page(server)
  define_search_docs(server)
end

.instructions_for(config, base_url) ⇒ Object

Server instructions: the site's tagline (when set) plus a pointer to /llms.txt, so an agent knows what these docs cover and where the full index lives. base_url absolutizes the /llms.txt hint when available.



52
53
54
55
56
57
58
59
60
61
# File 'lib/docs_kit/mcp_server.rb', line 52

def instructions_for(config, base_url)
  llms = base_url ? "#{base_url.chomp('/')}/llms.txt" : "/llms.txt"
  lines = []
  tagline = config.tagline
  lines << tagline.to_s if tagline && !tagline.to_s.empty?
  lines << "Read-only documentation tools for #{config.brand}. " \
           "Use search_docs to find sections, get_page to read a page's Markdown, " \
           "and list_pages to enumerate the docs. Full index: #{llms}."
  lines.join(" ")
end

.json_response(payload) ⇒ Object

Wrap a Ruby data payload as a single-text MCP tool response, the data as pretty JSON so an agent parses structured fields (not prose).



124
125
126
# File 'lib/docs_kit/mcp_server.rb', line 124

def json_response(payload)
  MCP::Tool::Response.new([{ type: "text", text: JSON.pretty_generate(payload) }])
end

.mcp_available?Boolean

Whether the official MCP SDK is loadable — the runtime-detection gate. Kept as a seam so specs can force the gem-absent branch without unloading it.

Returns:

  • (Boolean)


42
43
44
45
46
47
# File 'lib/docs_kit/mcp_server.rb', line 42

def mcp_available?
  require "mcp"
  defined?(::MCP::Server) ? true : false
rescue LoadError
  false
end