stimulus_plumbers_mcp 0.4.4 → 0.4.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -0
  3. data/bin/bump-version +26 -0
  4. data/bin/mcp-query +107 -0
  5. data/bin/server +8 -0
  6. data/bin/stimulus-plumbers-mcp +7 -0
  7. data/lib/stimulus_plumbers/mcp/cli.rb +13 -0
  8. data/lib/stimulus_plumbers/mcp/loaders/aria_loader.rb +31 -0
  9. data/lib/stimulus_plumbers/mcp/loaders/component_docs_loader.rb +38 -0
  10. data/lib/stimulus_plumbers/mcp/loaders/component_requirements.rb +41 -0
  11. data/lib/stimulus_plumbers/mcp/loaders/component_schema_loader.rb +49 -0
  12. data/lib/stimulus_plumbers/mcp/loaders/component_theme_loader.rb +41 -0
  13. data/lib/stimulus_plumbers/mcp/loaders/controller_docs_loader.rb +31 -0
  14. data/lib/stimulus_plumbers/mcp/loaders/controller_schema_loader.rb +49 -0
  15. data/lib/stimulus_plumbers/mcp/loaders/guide.md +48 -0
  16. data/lib/stimulus_plumbers/mcp/loaders/guide_loader.rb +40 -4
  17. data/lib/stimulus_plumbers/mcp/loaders/icons_loader.rb +34 -0
  18. data/lib/stimulus_plumbers/mcp/loaders/support/docs_table_parser.rb +112 -0
  19. data/lib/stimulus_plumbers/mcp/loaders/support/gem_vendor_path.rb +18 -0
  20. data/lib/stimulus_plumbers/mcp/loaders/tailwind_loader.rb +35 -0
  21. data/lib/stimulus_plumbers/mcp/loaders/versions_loader.rb +110 -0
  22. data/lib/stimulus_plumbers/mcp/plugins/aria.rb +34 -0
  23. data/lib/stimulus_plumbers/mcp/plugins/base.rb +40 -31
  24. data/lib/stimulus_plumbers/mcp/plugins/component_docs.rb +96 -0
  25. data/lib/stimulus_plumbers/mcp/plugins/component_schema.rb +133 -0
  26. data/lib/stimulus_plumbers/mcp/plugins/component_theme.rb +90 -0
  27. data/lib/stimulus_plumbers/mcp/plugins/controller_docs.rb +91 -0
  28. data/lib/stimulus_plumbers/mcp/plugins/controller_schema.rb +81 -0
  29. data/lib/stimulus_plumbers/mcp/plugins/guide.rb +70 -16
  30. data/lib/stimulus_plumbers/mcp/plugins/icons.rb +42 -0
  31. data/lib/stimulus_plumbers/mcp/plugins/tailwind.rb +69 -45
  32. data/lib/stimulus_plumbers/mcp/plugins/versions.rb +44 -0
  33. data/lib/stimulus_plumbers/mcp/server.rb +26 -9
  34. data/lib/stimulus_plumbers/mcp/version.rb +1 -1
  35. data/lib/stimulus_plumbers_mcp.rb +32 -11
  36. metadata +36 -13
  37. data/lib/stimulus_plumbers/mcp/loaders/component_controller_map.rb +0 -38
  38. data/lib/stimulus_plumbers/mcp/loaders/docs_loader.rb +0 -122
  39. data/lib/stimulus_plumbers/mcp/loaders/schema_loader.rb +0 -45
  40. data/lib/stimulus_plumbers/mcp/loaders/stimulus_manifest.rb +0 -23
  41. data/lib/stimulus_plumbers/mcp/loaders/tailwind_theme_loader.rb +0 -29
  42. data/lib/stimulus_plumbers/mcp/loaders/theme_loader.rb +0 -97
  43. data/lib/stimulus_plumbers/mcp/plugins/docs.rb +0 -82
  44. data/lib/stimulus_plumbers/mcp/plugins/schema.rb +0 -110
  45. data/lib/stimulus_plumbers/mcp/plugins/stimulus.rb +0 -66
  46. data/lib/stimulus_plumbers/mcp/plugins/theme.rb +0 -66
@@ -3,12 +3,48 @@
3
3
  module StimulusPlumbers
4
4
  module MCP
5
5
  class GuideLoader
6
- OVERVIEW_PATH = File.expand_path("guide/overview.md", __dir__).freeze
6
+ OVERVIEW_PATH = File.expand_path("guide.md", __dir__).freeze
7
7
 
8
- def self.call
9
- return "" unless File.exist?(OVERVIEW_PATH)
8
+ class << self
9
+ def call
10
+ {
11
+ overview: read_file(OVERVIEW_PATH),
12
+ component: read_file(component_guide_path),
13
+ controller: read_file(controller_guide_path),
14
+ tailwind: read_file(tailwind_guide_path),
15
+ theme: read_file(File.join(ComponentDocsLoader.docs_dir, "theme.md"))
16
+ }
17
+ end
10
18
 
11
- File.read(OVERVIEW_PATH)
19
+ # Reused by VersionsLoader to report which fallback location resolved.
20
+ def controller_guide_path
21
+ # 1. Monorepo dev checkout — the JS package's own docs are freshest while working locally.
22
+ dev_path = File.expand_path(File.join(__dir__, "../../../../..", "stimulus-plumbers", "docs", "guide.md"))
23
+ return dev_path if File.exist?(dev_path)
24
+
25
+ # 2. gem exec — vendored into the rails gem at release time, under vendor/controller/guide.md.
26
+ GemVendorPath.resolve("controller", "guide.md")
27
+ end
28
+
29
+ private
30
+
31
+ def read_file(path)
32
+ path && File.exist?(path) ? File.read(path) : ""
33
+ end
34
+
35
+ def component_guide_path
36
+ gem_dir = Gem::Specification.find_by_name("stimulus_plumbers").gem_dir
37
+ File.join(gem_dir, "docs/guide.md")
38
+ rescue Gem::MissingSpecError
39
+ File.expand_path("../../../../../stimulus-plumbers-rails/docs/guide.md", __dir__)
40
+ end
41
+
42
+ def tailwind_guide_path
43
+ gem_dir = Gem::Specification.find_by_name("stimulus_plumbers_tailwind").gem_dir
44
+ File.join(gem_dir, "docs/guide.md")
45
+ rescue Gem::MissingSpecError
46
+ File.expand_path("../../../../../stimulus-plumbers-tailwind/docs/guide.md", __dir__)
47
+ end
12
48
  end
13
49
  end
14
50
  end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class IconsLoader
6
+ class << self
7
+ def call
8
+ heroicon_dir = Themes::Tailwind::Icons::Heroicon.send(:svg_dir)
9
+ custom_dir = Themes::Tailwind::Icons::Custom.send(:svg_dir)
10
+
11
+ (outline_names(heroicon_dir) + solid_names(heroicon_dir) + custom_names(custom_dir) + alias_names).uniq.sort
12
+ end
13
+
14
+ private
15
+
16
+ def outline_names(heroicon_dir)
17
+ Dir[File.join(heroicon_dir, "outline", "*.svg")].map { |f| File.basename(f, ".svg") }
18
+ end
19
+
20
+ def solid_names(heroicon_dir)
21
+ Dir[File.join(heroicon_dir, "solid", "*.svg")].map { |f| "#{File.basename(f, ".svg")}/solid" }
22
+ end
23
+
24
+ def custom_names(custom_dir)
25
+ Dir[File.join(custom_dir, "*.svg")].map { |f| File.basename(f, ".svg") }
26
+ end
27
+
28
+ def alias_names
29
+ Themes::Tailwind::Icon::ALIASES.keys
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class DocsTableParser
6
+ class << self
7
+ def call(content)
8
+ tables = tables_with_headings(content)
9
+ { helpers: option_helpers(tables), slots: slot_methods(tables) }
10
+ end
11
+
12
+ # A standalone bold label (e.g. `**Time**`) between `#` headings is treated as a
13
+ # sub-heading, since some docs use one to introduce a table scoped to part of a
14
+ # section. It only labels the single table immediately following it — a fenced
15
+ # code block or a second table both clear it, so it can't leak onto later tables.
16
+ def tables_with_headings(content)
17
+ state = { heading: nil, subheading: nil, fenced: false, buffer: [], tables: [] }
18
+ content.each_line { |line| scan_line(line, state) }
19
+ flush_table(state)
20
+ state[:tables]
21
+ end
22
+
23
+ private
24
+
25
+ def option_helpers(tables)
26
+ tables.select { |t| t[:header].first == "Option" }
27
+ .filter_map do |t|
28
+ options = t[:rows].map { |r| option_row(r) }
29
+ { signature: t[:heading], options: options } unless options.empty?
30
+ end
31
+ end
32
+
33
+ def slot_methods(tables)
34
+ tables.select { |t| t[:header].first == "Slot method" }
35
+ .flat_map { |t| t[:rows].map { |r| slot_row(r) } }
36
+ end
37
+
38
+ def option_row(cells)
39
+ { option: clean(cells[0]), default: clean(cells[1]), description: cells[2].to_s }
40
+ end
41
+
42
+ def slot_row(cells)
43
+ slot = clean(cells[0])
44
+ description = cells[1].to_s
45
+ { slot: slot, description: description, block: block_required?(slot, description) }
46
+ end
47
+
48
+ def block_required?(slot, description)
49
+ slot.include?("{") || description.match?(%r{block required}i)
50
+ end
51
+
52
+ def scan_line(line, state)
53
+ if line.start_with?("```")
54
+ toggle_fence(state)
55
+ elsif state[:fenced]
56
+ nil
57
+ elsif (heading = line[%r{\A#+\s+(.+)}, 1])
58
+ set_heading(state, heading)
59
+ elsif (subheading = line[%r{\A\*\*([^*]+)\*\*}, 1])
60
+ set_subheading(state, subheading)
61
+ elsif line.lstrip.start_with?("|")
62
+ state[:buffer] << line
63
+ else
64
+ flush_table(state)
65
+ end
66
+ end
67
+
68
+ def toggle_fence(state)
69
+ flush_table(state)
70
+ state[:subheading] = nil
71
+ state[:fenced] = !state[:fenced]
72
+ end
73
+
74
+ def set_heading(state, heading)
75
+ flush_table(state)
76
+ state[:heading] = clean(heading)
77
+ state[:subheading] = nil
78
+ end
79
+
80
+ def set_subheading(state, subheading)
81
+ flush_table(state)
82
+ state[:subheading] = clean(subheading)
83
+ end
84
+
85
+ def flush_table(state)
86
+ return if state[:buffer].empty?
87
+
88
+ heading = [state[:heading], state[:subheading]].compact.join(" — ")
89
+ state[:tables] << build_table(state[:buffer]).merge(heading: heading)
90
+ state[:buffer] = []
91
+ state[:subheading] = nil
92
+ end
93
+
94
+ def build_table(lines)
95
+ rows = lines.map { |l| split_row(l) }.reject { |cells| cells.all? { |c| c.match?(%r{\A:?-+:?\z}) } }
96
+ { header: rows.first, rows: rows.drop(1) }
97
+ end
98
+
99
+ # Split a markdown table row, honouring escaped pipes (`\|`) inside cells.
100
+ def split_row(line)
101
+ line.strip.delete_prefix("|").delete_suffix("|")
102
+ .split(%r{(?<!\\)\|})
103
+ .map { |c| c.gsub('\|', "|").strip }
104
+ end
105
+
106
+ def clean(cell)
107
+ cell.to_s.gsub(%r{[`*]}, "").sub(%r{:\z}, "").strip
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ # Path under the stimulus_plumbers gem's vendor/ dir (populated by bin/release),
6
+ # used by loaders as their `gem exec` fallback when there's no monorepo checkout.
7
+ module GemVendorPath
8
+ GEM_NAME = "stimulus_plumbers"
9
+
10
+ def self.resolve(*relative)
11
+ gem_dir = Gem::Specification.find_by_name(GEM_NAME).gem_dir
12
+ File.join(gem_dir, "vendor", *relative)
13
+ rescue Gem::MissingSpecError
14
+ nil
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class TailwindLoader
6
+ class << self
7
+ def call
8
+ theme = Themes::TailwindTheme.new
9
+
10
+ Themes::Base::SCHEMA.each_with_object({}) do |(key, params), result|
11
+ # Skip keys with no _classes method — calling resolve would trigger Logger.warn
12
+ next unless theme.respond_to?(:"#{key}_classes", true)
13
+
14
+ result[key] = component_classes(theme, key, params)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def component_classes(theme, key, params)
21
+ classes = { default: theme.resolve(key)[:classes].to_s }
22
+
23
+ params.each do |param, meta|
24
+ valid = meta[:validate]
25
+ next unless valid.respond_to?(:to_a)
26
+
27
+ valid.to_a.each { |val| classes["#{param}:#{val}"] = theme.resolve(key, param => val)[:classes].to_s }
28
+ end
29
+
30
+ classes
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class VersionsLoader
6
+ class << self
7
+ def call
8
+ {
9
+ component_docs: component_docs_source,
10
+ component_guide: component_guide_source,
11
+ component_schema: component_schema_source,
12
+ component_theme: component_theme_source,
13
+
14
+ controller_docs: controller_docs_source,
15
+ controller_guide: controller_guide_source,
16
+ controller_schema: controller_schema_source,
17
+
18
+ icons: icons_source,
19
+ tailwind: tailwind_source,
20
+ tailwind_guide: tailwind_guide_source
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def gem_version(gem_name)
27
+ Gem::Specification.find_by_name(gem_name).version.to_s
28
+ rescue Gem::MissingSpecError
29
+ nil
30
+ end
31
+
32
+ def component_docs_source
33
+ { version: gem_version("stimulus_plumbers") }
34
+ end
35
+
36
+ def component_guide_source
37
+ { version: gem_version("stimulus_plumbers") }
38
+ end
39
+
40
+ def component_schema_source
41
+ { version: gem_version("stimulus_plumbers") }
42
+ end
43
+
44
+ def component_theme_source
45
+ { version: gem_version("stimulus_plumbers") }
46
+ end
47
+
48
+ def controller_docs_source
49
+ dir = ControllerDocsLoader.docs_dir
50
+ return { version: nil, resolved_from: nil } unless dir && Dir.exist?(dir)
51
+
52
+ { version: npm_package_version(File.join(dir, "..", "..")), resolved_from: npm_docs_resolved_from(dir) }
53
+ end
54
+
55
+ def controller_guide_source
56
+ path = GuideLoader.controller_guide_path
57
+ return { version: nil, resolved_from: nil } unless path && File.exist?(path)
58
+
59
+ { version: npm_package_version(File.join(File.dirname(path), "..")),
60
+ resolved_from: npm_docs_resolved_from(path)
61
+ }
62
+ end
63
+
64
+ def controller_schema_source
65
+ path = ControllerSchemaLoader.resolved_path
66
+ return { version: nil, resolved_from: nil } unless path
67
+
68
+ { version: npm_package_version(File.join(File.dirname(path), "..")),
69
+ resolved_from: controller_schema_resolved_from(path)
70
+ }
71
+ end
72
+
73
+ def controller_schema_resolved_from(path)
74
+ case path
75
+ when %r{node_modules} then "node_modules"
76
+ when %r{vendor} then "stimulus_plumbers gem vendor"
77
+ else "monorepo sibling dist/"
78
+ end
79
+ end
80
+
81
+ # Shared by controller_docs_source and controller_guide_source — both read from the same
82
+ # npm package's docs/ tree, dev sibling checkout vs vendored into the rails gem.
83
+ def npm_docs_resolved_from(path)
84
+ path.include?("vendor") ? "stimulus_plumbers gem vendor" : "monorepo sibling stimulus-plumbers/docs"
85
+ end
86
+
87
+ # package_root is the npm package's own directory — no package.json there means a vendored
88
+ # copy that didn't carry one along, so fall back to the wrapping gem's version.
89
+ def npm_package_version(package_root)
90
+ package_json = File.join(package_root, "package.json")
91
+ return gem_version("stimulus_plumbers") unless File.exist?(package_json)
92
+
93
+ JSON.parse(File.read(package_json))["version"]
94
+ end
95
+
96
+ def icons_source
97
+ { version: gem_version("stimulus_plumbers_tailwind") }
98
+ end
99
+
100
+ def tailwind_source
101
+ { version: gem_version("stimulus_plumbers_tailwind") }
102
+ end
103
+
104
+ def tailwind_guide_source
105
+ { version: gem_version("stimulus_plumbers_tailwind") }
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ class Aria < Base
7
+ class << self
8
+ def loader_key = :aria
9
+
10
+ def loader = AriaLoader
11
+
12
+ def static_resources
13
+ [
14
+ ::MCP::Resource.new(
15
+ uri: "aria://reference",
16
+ name: "aria-reference",
17
+ description: "WCAG 2.1 AA criteria, JS keyboard navigation patterns, and per-component ARIA " \
18
+ "patterns for this library. For generic ARIA role/WCAG technique reference not " \
19
+ "specific to this library, use the MDN MCP server (https://developer.mozilla.org/en-US/mcp)",
20
+ mime_type: "text/markdown"
21
+ )
22
+ ].freeze
23
+ end
24
+
25
+ def read(uri, store)
26
+ return unless uri == "aria://reference"
27
+
28
+ text_resource(uri, "text/markdown", store[:aria])
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -3,43 +3,52 @@
3
3
  module StimulusPlumbers
4
4
  module MCP
5
5
  module Plugins
6
- # Shared contract + content helpers for plugins, which `extend` this.
7
- #
8
- # Each plugin defines: LOADER_KEY, LOADER, STATIC_RESOURCES,
9
- # DYNAMIC_RESOURCE_TEMPLATES, and read(uri, store). Tools are optional —
10
- # plugins with tools override register_tools, the rest inherit the no-op.
11
- module Base
12
- # Returned by a tool block to signal "not found" — rendered as an MCP
13
- # error response with a structured { error: } payload (see text_tool).
6
+ # Plugin contract: required members raise NotImplementedError; optional members have defaults.
7
+ class Base
8
+ # Returned by a tool block to signal not-found (see text_tool).
14
9
  NotFound = Struct.new(:message)
15
10
 
16
- def register_tools(_server, _store); end
11
+ class << self
12
+ def loader_key
13
+ raise NotImplementedError, "#{name} must define .loader_key"
14
+ end
17
15
 
18
- def not_found(message)
19
- NotFound.new(message)
20
- end
16
+ def loader
17
+ raise NotImplementedError, "#{name} must define .loader"
18
+ end
21
19
 
22
- # resources/read content for a JSON payload.
23
- def json_resource(uri, data)
24
- [{ uri: uri, mimeType: "application/json", text: JSON.generate(data) }]
25
- end
20
+ def read(_uri, _store)
21
+ raise NotImplementedError, "#{name} must define .read"
22
+ end
26
23
 
27
- # resources/read content for raw text (e.g. markdown).
28
- def text_resource(uri, mime_type, text)
29
- [{ uri: uri, mimeType: mime_type, text: text }]
30
- end
24
+ def static_resources = []
25
+
26
+ def dynamic_resource_templates = []
27
+
28
+ def register_tools(_server, _store); end
29
+
30
+ def not_found(message)
31
+ NotFound.new(message)
32
+ end
33
+
34
+ def json_resource(uri, data)
35
+ [{ uri: uri, mimeType: "application/json", text: JSON.generate(data) }]
36
+ end
37
+
38
+ def text_resource(uri, mime_type, text)
39
+ [{ uri: uri, mimeType: mime_type, text: text }]
40
+ end
31
41
 
32
- # Define a tool whose block returns the response text, or `not_found(msg)`
33
- # for a uniform error (isError + { error: } JSON). Declaring `**args` makes
34
- # the MCP gem inject :server_context, which tool blocks don't want — drop it.
35
- def text_tool(server, name:, description:, input_schema: nil, &block)
36
- server.define_tool(name: name, description: description, input_schema: input_schema) do |**args|
37
- args.delete(:server_context)
38
- result = block.call(**args)
39
- if result.is_a?(NotFound)
40
- ::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(error: result.message) }], error: true)
41
- else
42
- ::MCP::Tool::Response.new([{ type: "text", text: result }])
42
+ # Declaring **args makes the MCP gem inject :server_context, which tool blocks don't want — drop it.
43
+ def text_tool(server, name:, description:, input_schema: nil, &block)
44
+ server.define_tool(name: name, description: description, input_schema: input_schema) do |**args|
45
+ args.delete(:server_context)
46
+ result = block.call(**args)
47
+ if result.is_a?(NotFound)
48
+ ::MCP::Tool::Response.new([{ type: "text", text: JSON.generate(error: result.message) }], error: true)
49
+ else
50
+ ::MCP::Tool::Response.new([{ type: "text", text: result }])
51
+ end
43
52
  end
44
53
  end
45
54
  end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ class ComponentDocs < Base
7
+ class << self
8
+ def loader_key = :component_docs
9
+
10
+ def loader = ComponentDocsLoader
11
+
12
+ def dynamic_resource_templates
13
+ [
14
+ ::MCP::ResourceTemplate.new(
15
+ uri_template: "component://{name}/docs",
16
+ name: "component-docs",
17
+ description: "Full markdown documentation and ERB examples for a component",
18
+ mime_type: "text/markdown"
19
+ ),
20
+ ::MCP::ResourceTemplate.new(
21
+ uri_template: "component://{name}/helper",
22
+ name: "component-helper",
23
+ description: "Full sp_ helper option surface: keyword options with defaults and slot methods",
24
+ mime_type: "application/json"
25
+ )
26
+ ].freeze
27
+ end
28
+
29
+ def read(uri, store)
30
+ docs = store[:component_docs]
31
+
32
+ case uri
33
+ when %r{\Acomponent://([^/]+)/docs\z}
34
+ doc = docs[Regexp.last_match(1).to_sym]
35
+ doc ? text_resource(uri, "text/markdown", doc[:content]) : missing(uri, Regexp.last_match(1))
36
+ when %r{\Acomponent://([^/]+)/helper\z}
37
+ doc = docs[Regexp.last_match(1).to_sym]
38
+ doc ? json_resource(uri, doc[:signature]) : missing(uri, Regexp.last_match(1))
39
+ end
40
+ end
41
+
42
+ def register_tools(server, store)
43
+ docs = store[:component_docs]
44
+
45
+ register_list_component_docs(server, docs)
46
+ register_get_component_examples(server, docs)
47
+ register_get_component_helper(server, docs)
48
+ end
49
+
50
+ private
51
+
52
+ def missing(uri, name)
53
+ json_resource(uri, { error: "no documentation for: #{name}" })
54
+ end
55
+
56
+ def register_list_component_docs(server, docs)
57
+ text_tool(
58
+ server,
59
+ name: "list_component_docs",
60
+ description: "Lists components that have markdown docs (component://{name}/docs) and " \
61
+ "helper signatures (component://{name}/helper)"
62
+ ) do
63
+ JSON.generate(docs.keys)
64
+ end
65
+ end
66
+
67
+ def register_get_component_examples(server, docs)
68
+ text_tool(
69
+ server,
70
+ name: "get_component_examples",
71
+ description: "Returns ERB usage examples for a component from the documentation",
72
+ input_schema: { properties: { name: { type: "string" } }, required: ["name"] }
73
+ ) do |name:|
74
+ examples = docs[name.to_sym]&.dig(:examples) || []
75
+ examples.empty? ? not_found("no examples for: #{name}") : examples.join("\n\n")
76
+ end
77
+ end
78
+
79
+ def register_get_component_helper(server, docs)
80
+ text_tool(
81
+ server,
82
+ name: "get_component_helper",
83
+ description: "Returns the full sp_ helper surface for a component: keyword options with " \
84
+ "defaults plus slot methods (e.g. icon_leading, card.with_action). For themed " \
85
+ "params/controllers use get_component_schema",
86
+ input_schema: { properties: { name: { type: "string" } }, required: ["name"] }
87
+ ) do |name:|
88
+ doc = docs[name.to_sym]
89
+ doc ? JSON.generate(doc[:signature]) : not_found("no documentation for: #{name}")
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end