Module: McpLogs::Docs

Defined in:
app/models/mcp_logs/docs.rb

Overview

Renders a server's tool contract as reference documentation for a person, as opposed to the server itself, which serves it to an agent.

Reads MCP::Tool.to_h rather than the tool classes' own DSL, because to_h is the exact payload tools/list returns — so the page cannot drift from what a connected client actually receives.

Defined Under Namespace

Classes: Param, Section, Tool

Constant Summary collapse

UNGROUPED =
"Ungrouped".freeze

Class Method Summary collapse

Class Method Details

.params(schema) ⇒ Object

Property keys arrive as Symbols but the required array holds Strings, so membership has to be tested on strings. Comparing the Symbol key directly against required returns false for every parameter, silently rendering the whole contract as optional.



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'app/models/mcp_logs/docs.rb', line 56

def params(schema)
  required = Array(schema[:required] || schema["required"]).map(&:to_s)
  properties = schema[:properties] || schema["properties"] || {}

  properties.map do |name, spec|
    spec = spec.symbolize_keys if spec.respond_to?(:symbolize_keys)

    Param.new(
      name: name.to_s,
      type: type_label(spec),
      required: required.include?(name.to_s),
      description: spec[:description].to_s
    )
  end
end

.sections(server, sections = McpLogs.tool_sections) ⇒ Object

sections is { "Title" => ["tool_name", ...] }, in the order the host wants them rendered. A tool missing from every section still renders, under UNGROUPED, so an omission is visible rather than silent.



20
21
22
23
24
25
26
27
28
29
30
31
32
33
# File 'app/models/mcp_logs/docs.rb', line 20

def sections(server, sections = McpLogs.tool_sections)
  return [] if server.nil?

  by_name = tools_by_name(server)

  grouped = sections.map do |title, names|
    Section.new(title: title, tools: names.filter_map { |name| by_name[name.to_s] }.map { |klass| tool(klass) })
  end

  leftover = by_name.except(*sections.values.flatten.map(&:to_s))
  grouped << Section.new(title: UNGROUPED, tools: leftover.values.map { |klass| tool(klass) }) if leftover.any?

  grouped.reject { |section| section.tools.empty? }
end

.tool(klass) ⇒ Object



41
42
43
44
45
46
47
48
49
50
# File 'app/models/mcp_logs/docs.rb', line 41

def tool(klass)
  contract = klass.to_h

  Tool.new(
    name: contract[:name],
    title: contract[:title],
    description: contract[:description].to_s.strip,
    params: params(contract[:inputSchema] || {})
  )
end

.tools_by_name(server) ⇒ Object

MCP::Server#tools is already { name => tool class }; going through it rather than the class list means the page shows exactly what the server serves.



37
38
39
# File 'app/models/mcp_logs/docs.rb', line 37

def tools_by_name(server)
  server.tools.transform_keys(&:to_s)
end

.type_label(spec) ⇒ Object

Folds enum and array item type into the type label rather than the view, so a schema's accepted values are visible on the page instead of only in the tool's source file.



75
76
77
78
79
# File 'app/models/mcp_logs/docs.rb', line 75

def type_label(spec)
  base = spec[:type].to_s
  base = "#{base} of #{spec.dig(:items, :type)}" if base == "array" && spec.dig(:items, :type)
  spec[:enum] ? "#{base} (#{spec[:enum].join(" | ")})" : base
end