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.
- checksums.yaml +4 -4
- data/README.md +120 -0
- data/bin/bump-version +26 -0
- data/bin/mcp-query +107 -0
- data/bin/server +8 -0
- data/bin/stimulus-plumbers-mcp +7 -0
- data/lib/stimulus_plumbers/mcp/cli.rb +13 -0
- data/lib/stimulus_plumbers/mcp/loaders/aria_loader.rb +31 -0
- data/lib/stimulus_plumbers/mcp/loaders/component_docs_loader.rb +38 -0
- data/lib/stimulus_plumbers/mcp/loaders/component_requirements.rb +41 -0
- data/lib/stimulus_plumbers/mcp/loaders/component_schema_loader.rb +49 -0
- data/lib/stimulus_plumbers/mcp/loaders/component_theme_loader.rb +41 -0
- data/lib/stimulus_plumbers/mcp/loaders/controller_docs_loader.rb +31 -0
- data/lib/stimulus_plumbers/mcp/loaders/controller_schema_loader.rb +49 -0
- data/lib/stimulus_plumbers/mcp/loaders/guide.md +48 -0
- data/lib/stimulus_plumbers/mcp/loaders/guide_loader.rb +40 -4
- data/lib/stimulus_plumbers/mcp/loaders/icons_loader.rb +34 -0
- data/lib/stimulus_plumbers/mcp/loaders/support/docs_table_parser.rb +112 -0
- data/lib/stimulus_plumbers/mcp/loaders/support/gem_vendor_path.rb +18 -0
- data/lib/stimulus_plumbers/mcp/loaders/tailwind_loader.rb +35 -0
- data/lib/stimulus_plumbers/mcp/loaders/versions_loader.rb +110 -0
- data/lib/stimulus_plumbers/mcp/plugins/aria.rb +34 -0
- data/lib/stimulus_plumbers/mcp/plugins/base.rb +40 -31
- data/lib/stimulus_plumbers/mcp/plugins/component_docs.rb +96 -0
- data/lib/stimulus_plumbers/mcp/plugins/component_schema.rb +133 -0
- data/lib/stimulus_plumbers/mcp/plugins/component_theme.rb +90 -0
- data/lib/stimulus_plumbers/mcp/plugins/controller_docs.rb +91 -0
- data/lib/stimulus_plumbers/mcp/plugins/controller_schema.rb +81 -0
- data/lib/stimulus_plumbers/mcp/plugins/guide.rb +70 -16
- data/lib/stimulus_plumbers/mcp/plugins/icons.rb +42 -0
- data/lib/stimulus_plumbers/mcp/plugins/tailwind.rb +69 -45
- data/lib/stimulus_plumbers/mcp/plugins/versions.rb +44 -0
- data/lib/stimulus_plumbers/mcp/server.rb +26 -9
- data/lib/stimulus_plumbers/mcp/version.rb +1 -1
- data/lib/stimulus_plumbers_mcp.rb +32 -11
- metadata +36 -13
- data/lib/stimulus_plumbers/mcp/loaders/component_controller_map.rb +0 -38
- data/lib/stimulus_plumbers/mcp/loaders/docs_loader.rb +0 -122
- data/lib/stimulus_plumbers/mcp/loaders/schema_loader.rb +0 -45
- data/lib/stimulus_plumbers/mcp/loaders/stimulus_manifest.rb +0 -23
- data/lib/stimulus_plumbers/mcp/loaders/tailwind_theme_loader.rb +0 -29
- data/lib/stimulus_plumbers/mcp/loaders/theme_loader.rb +0 -97
- data/lib/stimulus_plumbers/mcp/plugins/docs.rb +0 -82
- data/lib/stimulus_plumbers/mcp/plugins/schema.rb +0 -110
- data/lib/stimulus_plumbers/mcp/plugins/stimulus.rb +0 -66
- 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
|
|
6
|
+
OVERVIEW_PATH = File.expand_path("guide.md", __dir__).freeze
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
+
class << self
|
|
12
|
+
def loader_key
|
|
13
|
+
raise NotImplementedError, "#{name} must define .loader_key"
|
|
14
|
+
end
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
def loader
|
|
17
|
+
raise NotImplementedError, "#{name} must define .loader"
|
|
18
|
+
end
|
|
21
19
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
end
|
|
20
|
+
def read(_uri, _store)
|
|
21
|
+
raise NotImplementedError, "#{name} must define .read"
|
|
22
|
+
end
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|