stimulus_plumbers_mcp 0.4.4

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 00ce340e2b2d68fcfef0406efd76e88e91c5214c653bc44cbed78d6ebf23bd4e
4
+ data.tar.gz: c4ec9ad1abdabfb7ab464b685e604d78feec3a67b10944339885adbd5f322668
5
+ SHA512:
6
+ metadata.gz: 752269bd37dc2716c2fdabf2aab3bb6cecb9bd6aaae82ff245c95414ef98c9a670ac1da896a32dafa00b9d47b8231214b5f150f3ca1efd7705faafe978eabccb
7
+ data.tar.gz: 8540984025d8870982f1d75d8a33a51dfcb3096084eb8c8d0ef0dc5cc39f62b02310f787568a0d34a8cb49ac055273b6f2fa7254b398a67703879d750dceb9f5
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ComponentControllerMap
6
+ def self.call
7
+ Components.constants
8
+ .map { |c| Components.const_get(c) }
9
+ .grep(Class)
10
+ .to_h { |klass| [component_key(klass), controllers_for(klass).uniq] }
11
+ end
12
+
13
+ # Controllers from a component class plus its nested sub-components (e.g. Combobox::Date),
14
+ # keyed to the top-level component. Skips references to sibling components.
15
+ def self.controllers_for(mod)
16
+ mod.constants(false).flat_map do |const|
17
+ value = mod.const_get(const)
18
+ if const.to_s.end_with?("CONTROLLER")
19
+ Array(value).grep(String).flat_map(&:split)
20
+ elsif nested?(mod, value)
21
+ controllers_for(value)
22
+ else
23
+ []
24
+ end
25
+ end
26
+ end
27
+
28
+ def self.nested?(mod, value)
29
+ value.is_a?(Module) && !value.name.nil? && value.name.start_with?("#{mod.name}::")
30
+ end
31
+
32
+ def self.component_key(klass)
33
+ klass.name.demodulize.underscore.to_sym
34
+ end
35
+ private_class_method :controllers_for, :nested?, :component_key
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class DocsLoader
6
+ DOCS_DIR = File.expand_path(
7
+ "../../../../../stimulus-plumbers-rails/docs/component",
8
+ __dir__
9
+ )
10
+
11
+ def self.call
12
+ Dir[File.join(DOCS_DIR, "*.md")].each_with_object({}) do |path, result|
13
+ name = File.basename(path, ".md").to_sym
14
+ content = File.read(path)
15
+ result[name] = {
16
+ content: content,
17
+ examples: extract_erb_examples(content),
18
+ signature: extract_signature(content)
19
+ }
20
+ end
21
+ end
22
+
23
+ def self.extract_erb_examples(content)
24
+ content.scan(%r{```erb\n(.*?)```}m).map(&:first).map(&:strip)
25
+ end
26
+
27
+ # Parse `| Option |` / `| Slot method |` doc tables into the helper surface.
28
+ # Options are grouped under the heading (sub-helper signature) above them.
29
+ def self.extract_signature(content)
30
+ tables = tables_with_headings(content)
31
+ { helpers: option_helpers(tables), slots: slot_methods(tables) }
32
+ end
33
+
34
+ def self.option_helpers(tables)
35
+ tables.select { |t| t[:header].first == "Option" }
36
+ .filter_map do |t|
37
+ options = t[:rows].map { |r| option_row(r) }
38
+ { signature: t[:heading], options: options } unless options.empty?
39
+ end
40
+ end
41
+
42
+ def self.slot_methods(tables)
43
+ tables.select { |t| t[:header].first == "Slot method" }
44
+ .flat_map { |t| t[:rows].map { |r| slot_row(r) } }
45
+ end
46
+
47
+ def self.option_row(cells)
48
+ { option: clean(cells[0]), default: clean(cells[1]), description: cells[2].to_s }
49
+ end
50
+
51
+ def self.slot_row(cells)
52
+ slot = clean(cells[0])
53
+ description = cells[1].to_s
54
+ { slot: slot, description: description, block: block_required?(slot, description) }
55
+ end
56
+
57
+ def self.block_required?(slot, description)
58
+ slot.include?("{") || description.match?(%r{block required}i)
59
+ end
60
+
61
+ # Tag each table with the heading above it; skip fenced code (so ```ruby
62
+ # comments aren't read as headings).
63
+ def self.tables_with_headings(content)
64
+ state = { heading: nil, fenced: false, buffer: [], tables: [] }
65
+ content.each_line { |line| scan_line(line, state) }
66
+ flush_table(state)
67
+ state[:tables]
68
+ end
69
+
70
+ def self.scan_line(line, state)
71
+ if line.start_with?("```")
72
+ flush_table(state)
73
+ state[:fenced] = !state[:fenced]
74
+ elsif state[:fenced]
75
+ nil
76
+ elsif (heading = line[%r{\A#+\s+(.+)}, 1])
77
+ flush_table(state)
78
+ state[:heading] = clean(heading)
79
+ elsif line.lstrip.start_with?("|")
80
+ state[:buffer] << line
81
+ else
82
+ flush_table(state)
83
+ end
84
+ end
85
+
86
+ def self.flush_table(state)
87
+ return if state[:buffer].empty?
88
+
89
+ state[:tables] << build_table(state[:buffer]).merge(heading: state[:heading])
90
+ state[:buffer] = []
91
+ end
92
+
93
+ def self.build_table(lines)
94
+ rows = lines.map { |l| split_row(l) }.reject { |cells| cells.all? { |c| c.match?(%r{\A:?-+:?\z}) } }
95
+ { header: rows.first, rows: rows.drop(1) }
96
+ end
97
+
98
+ # Split a markdown table row, honouring escaped pipes (`\|`) inside cells.
99
+ def self.split_row(line)
100
+ line.strip.delete_prefix("|").delete_suffix("|")
101
+ .split(%r{(?<!\\)\|})
102
+ .map { |c| c.gsub('\|', "|").strip }
103
+ end
104
+
105
+ def self.clean(cell)
106
+ cell.to_s.gsub(%r{[`*]}, "").sub(%r{:\z}, "").strip
107
+ end
108
+
109
+ private_class_method :option_helpers,
110
+ :slot_methods,
111
+ :option_row,
112
+ :slot_row,
113
+ :block_required?,
114
+ :tables_with_headings,
115
+ :scan_line,
116
+ :flush_table,
117
+ :build_table,
118
+ :split_row,
119
+ :clean
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class GuideLoader
6
+ OVERVIEW_PATH = File.expand_path("guide/overview.md", __dir__).freeze
7
+
8
+ def self.call
9
+ return "" unless File.exist?(OVERVIEW_PATH)
10
+
11
+ File.read(OVERVIEW_PATH)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class SchemaLoader
6
+ def self.call
7
+ {
8
+ components: extract_schema,
9
+ field_as: extract_field_as,
10
+ icons: extract_icons,
11
+ stimulus: ComponentControllerMap.call
12
+ }
13
+ end
14
+
15
+ def self.extract_schema
16
+ Themes::Base::SCHEMA.transform_values do |param_schema|
17
+ param_schema.transform_values do |meta|
18
+ v = meta[:validate]
19
+ { default: meta[:default], valid: v.respond_to?(:to_a) ? v.to_a : v.inspect }
20
+ end
21
+ end
22
+ end
23
+
24
+ def self.extract_field_as
25
+ {
26
+ field: Form::Fields::Renderer::FIELD.keys,
27
+ collection_field: Form::Fields::Renderer::COLLECTION.keys,
28
+ choice: Form::Fields::Renderer::CHOICE.keys
29
+ }
30
+ end
31
+
32
+ def self.extract_icons
33
+ heroicon_dir = Themes::Tailwind::Icons::Heroicon.send(:svg_dir)
34
+ custom_dir = Themes::Tailwind::Icons::Custom.send(:svg_dir)
35
+
36
+ outline = Dir[File.join(heroicon_dir, "outline", "*.svg")].map { |f| File.basename(f, ".svg") }
37
+ solid = Dir[File.join(heroicon_dir, "solid", "*.svg")].map { |f| "#{File.basename(f, ".svg")}/solid" }
38
+ customs = Dir[File.join(custom_dir, "*.svg")].map { |f| File.basename(f, ".svg") }
39
+ aliases = Themes::Tailwind::Icon::ALIASES.keys
40
+
41
+ (outline + solid + customs + aliases).uniq.sort
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class StimulusManifest
6
+ MANIFEST_PATH = File.expand_path(
7
+ File.join(__dir__, "../../../../..", "stimulus-plumbers", "dist", "controllers.manifest.json")
8
+ ).freeze
9
+
10
+ def self.call
11
+ unless File.exist?(MANIFEST_PATH)
12
+ StimulusPlumbers::Logger.warn(
13
+ "controllers.manifest.json not found at #{MANIFEST_PATH}. " \
14
+ "Run `node --run build:manifest` in stimulus-plumbers/ to generate it."
15
+ )
16
+ return {}
17
+ end
18
+
19
+ JSON.parse(File.read(MANIFEST_PATH))
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class TailwindThemeLoader
6
+ def self.call
7
+ theme = Themes::TailwindTheme.new
8
+
9
+ Themes::Base::SCHEMA.each_with_object({}) do |(key, params), result|
10
+ # Skip keys with no _classes method — calling resolve would trigger Logger.warn
11
+ next unless theme.respond_to?(:"#{key}_classes", true)
12
+
13
+ result[key] = {}
14
+ result[key][:default] = theme.resolve(key)[:classes].to_s
15
+
16
+ params.each do |param, meta|
17
+ valid = meta[:validate]
18
+ next unless valid.respond_to?(:to_a)
19
+
20
+ valid.to_a.each do |val|
21
+ classes = theme.resolve(key, param => val)[:classes].to_s
22
+ result[key]["#{param}:#{val}"] = classes
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ThemeLoader
6
+ BASE_DOC = <<~MARKDOWN
7
+ # Custom Theme Implementation Guide
8
+
9
+ A custom theme is a Ruby class that extends `StimulusPlumbers::Themes::Base` and defines
10
+ `{component_key}_classes(**args)` methods for the components you want to style.
11
+
12
+ ## Method Convention
13
+
14
+ - **Name:** `{component_key}_classes` — e.g. `button_classes`, `form_group_classes`
15
+ - **Params:** keyword arguments matching the schema params for that component
16
+ - **Return:** a Hash with a `:classes` key containing a space-separated CSS class string
17
+
18
+ ```ruby
19
+ def button_classes(type: :default, variant: :default, size: :md)
20
+ { classes: "..." }
21
+ end
22
+ ```
23
+
24
+ Components with no params still receive empty kwargs:
25
+
26
+ ```ruby
27
+ def form_group_classes
28
+ { classes: "..." }
29
+ end
30
+ ```
31
+
32
+ ## Minimal Example
33
+
34
+ ```ruby
35
+ class MyTheme < StimulusPlumbers::Themes::Base
36
+ private
37
+
38
+ def button_classes(type: :default, variant: :default, size: :md)
39
+ base = "inline-flex items-center gap-2 rounded px-3 py-2"
40
+ variant_class = { primary: "bg-blue-600 text-white", destructive: "bg-red-600 text-white" }.fetch(variant, "bg-gray-100")
41
+ { classes: [base, variant_class].join(" ") }
42
+ end
43
+
44
+ def link_classes(type: :default, variant: :default)
45
+ { classes: "underline text-blue-600 hover:text-blue-800" }
46
+ end
47
+ end
48
+ ```
49
+
50
+ ## Registration
51
+
52
+ ```ruby
53
+ StimulusPlumbers.configure do |config|
54
+ config.theme.register(:my_theme, MyTheme)
55
+ config.theme = :my_theme
56
+ end
57
+ ```
58
+
59
+ ## Component Keys
60
+
61
+ See `theme://components` for the full list of component keys that can be themed.
62
+ Use `theme://components/{name}` for the method signature and param details per component.
63
+
64
+ ## Partial Implementation
65
+
66
+ You only need to define methods for components you want to style — unimplemented keys
67
+ return an empty hash, which renders the component with no CSS classes.
68
+ MARKDOWN
69
+
70
+ def self.call
71
+ {
72
+ base_doc: BASE_DOC,
73
+ components: extract_interface
74
+ }
75
+ end
76
+
77
+ def self.extract_interface
78
+ Themes::Base::SCHEMA.each_with_object({}) do |(key, params), result|
79
+ result[key] = {
80
+ method: "#{key}_classes",
81
+ params: params.transform_values { |meta| format_param(meta) },
82
+ returns: "{ classes: String }"
83
+ }
84
+ end
85
+ end
86
+
87
+ def self.format_param(meta)
88
+ valid = meta[:validate]
89
+ entry = { default: meta[:default] }
90
+ entry[:valid] = valid.to_a if valid.respond_to?(:to_a)
91
+ entry
92
+ end
93
+
94
+ private_class_method :extract_interface, :format_param
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
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).
14
+ NotFound = Struct.new(:message)
15
+
16
+ def register_tools(_server, _store); end
17
+
18
+ def not_found(message)
19
+ NotFound.new(message)
20
+ end
21
+
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
26
+
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
31
+
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 }])
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ module Docs
7
+ extend Base
8
+
9
+ LOADER_KEY = :docs
10
+ LOADER = DocsLoader
11
+
12
+ STATIC_RESOURCES = [].freeze
13
+ DYNAMIC_RESOURCE_TEMPLATES = [
14
+ ::MCP::ResourceTemplate.new(
15
+ uri_template: "docs://components/{name}",
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: "helper://components/{name}",
22
+ name: "component-helper-signature",
23
+ description: "Full sp_ helper option surface: keyword options with defaults and slot methods",
24
+ mime_type: "application/json"
25
+ )
26
+ ].freeze
27
+
28
+ def self.read(uri, store)
29
+ docs = store[:docs]
30
+
31
+ case uri
32
+ when %r{\Adocs://components/(.+)\z}
33
+ doc = docs[Regexp.last_match(1).to_sym]
34
+ doc ? text_resource(uri, "text/markdown", doc[:content]) : missing(uri, Regexp.last_match(1))
35
+ when %r{\Ahelper://components/(.+)\z}
36
+ doc = docs[Regexp.last_match(1).to_sym]
37
+ doc ? json_resource(uri, doc[:signature]) : missing(uri, Regexp.last_match(1))
38
+ end
39
+ end
40
+
41
+ def self.missing(uri, name)
42
+ json_resource(uri, { error: "no documentation for: #{name}" })
43
+ end
44
+ private_class_method :missing
45
+
46
+ def self.register_tools(server, store)
47
+ docs = store[:docs]
48
+
49
+ text_tool(
50
+ server,
51
+ name: "list_docs",
52
+ description: "Lists components that have markdown docs (docs://components/{name}) and " \
53
+ "helper signatures (helper://components/{name})"
54
+ ) do
55
+ JSON.generate(docs.keys)
56
+ end
57
+
58
+ text_tool(
59
+ server,
60
+ name: "get_erb_examples",
61
+ description: "Returns ERB usage examples for a component from the documentation",
62
+ input_schema: { properties: { component: { type: "string" } }, required: ["component"] }
63
+ ) do |component:|
64
+ examples = docs[component.to_sym]&.dig(:examples) || []
65
+ examples.empty? ? not_found("no examples for: #{component}") : examples.join("\n\n")
66
+ end
67
+
68
+ text_tool(
69
+ server,
70
+ name: "get_helper_signature",
71
+ description: "Returns the full sp_ helper surface for a component: keyword options with " \
72
+ "defaults plus slot methods (e.g. icon_leading, card.with_action)",
73
+ input_schema: { properties: { component: { type: "string" } }, required: ["component"] }
74
+ ) do |component:|
75
+ doc = docs[component.to_sym]
76
+ doc ? JSON.generate(doc[:signature]) : not_found("no documentation for: #{component}")
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ module Guide
7
+ extend Base
8
+
9
+ LOADER_KEY = :guide
10
+ LOADER = GuideLoader
11
+
12
+ STATIC_RESOURCES = [
13
+ ::MCP::Resource.new(
14
+ uri: "guide://overview",
15
+ name: "overview",
16
+ description: "Start here — how to build views and forms with stimulus-plumbers, with pointers to every tool/resource",
17
+ mime_type: "text/markdown"
18
+ )
19
+ ].freeze
20
+
21
+ DYNAMIC_RESOURCE_TEMPLATES = [].freeze
22
+
23
+ def self.read(uri, store)
24
+ return unless uri == "guide://overview"
25
+
26
+ text_resource(uri, "text/markdown", store[:guide])
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ module Schema
7
+ extend Base
8
+
9
+ LOADER_KEY = :schema
10
+ LOADER = SchemaLoader
11
+
12
+ STATIC_RESOURCES = [
13
+ ::MCP::Resource.new(
14
+ uri: "schema://components",
15
+ name: "components-index",
16
+ description: "Index of all stimulus-plumbers component theme keys",
17
+ mime_type: "application/json"
18
+ ),
19
+ ::MCP::Resource.new(
20
+ uri: "schema://icons",
21
+ name: "icons",
22
+ description: "All available icon names from the active theme registry",
23
+ mime_type: "application/json"
24
+ ),
25
+ ::MCP::Resource.new(
26
+ uri: "schema://stimulus",
27
+ name: "stimulus-wiring",
28
+ description: "Mapping from Rails component name to the Stimulus controller identifiers it requires",
29
+ mime_type: "application/json"
30
+ )
31
+ ].freeze
32
+
33
+ DYNAMIC_RESOURCE_TEMPLATES = [
34
+ ::MCP::ResourceTemplate.new(
35
+ uri_template: "schema://components/{name}",
36
+ name: "component-schema",
37
+ description: "Params, valid values, defaults, and required controllers for a component",
38
+ mime_type: "application/json"
39
+ )
40
+ ].freeze
41
+
42
+ def self.read(uri, store)
43
+ schema = store[:schema]
44
+
45
+ case uri
46
+ when "schema://components"
47
+ json_resource(uri, schema[:components].keys)
48
+ when "schema://icons"
49
+ json_resource(uri, schema[:icons])
50
+ when "schema://stimulus"
51
+ json_resource(uri, schema[:stimulus])
52
+ when %r{\Aschema://components/(.+)\z}
53
+ key = Regexp.last_match(1).to_sym
54
+ json_resource(uri, component_data(schema, key) || { error: "unknown component: #{key}" })
55
+ end
56
+ end
57
+
58
+ def self.register_tools(server, store)
59
+ schema = store[:schema]
60
+ data_by_component = component_data_map(schema)
61
+
62
+ text_tool(server, name: "list_components", description: "Lists all stimulus-plumbers component theme keys") do
63
+ JSON.generate(schema[:components].keys)
64
+ end
65
+
66
+ text_tool(
67
+ server,
68
+ name: "get_component_schema",
69
+ description: "Returns themed params (e.g. type/variant/size) with valid values, defaults, and " \
70
+ "required Stimulus controllers. For the full helper surface (icon options, slots) " \
71
+ "use get_helper_signature",
72
+ input_schema: { properties: { component: { type: "string" } }, required: ["component"] }
73
+ ) do |component:|
74
+ data = data_by_component[component.to_sym]
75
+ data ? JSON.generate(data) : not_found("unknown component: #{component}")
76
+ end
77
+
78
+ text_tool(
79
+ server,
80
+ name: "get_field_types",
81
+ description: "Returns valid as: values for a form builder method",
82
+ input_schema: {
83
+ properties: { builder_method: { type: "string", enum: %w[field collection_field choice] } },
84
+ required: ["builder_method"]
85
+ }
86
+ ) do |builder_method:|
87
+ values = schema[:field_as][builder_method.to_sym]
88
+ values ? JSON.generate(values) : not_found("unknown builder_method: #{builder_method}")
89
+ end
90
+ end
91
+
92
+ # Resolve component_data at module scope; define_tool blocks run in another context.
93
+ def self.component_data_map(schema)
94
+ keys = (schema[:components].keys + schema[:stimulus].keys).uniq
95
+ keys.to_h { |key| [key, component_data(schema, key)] }
96
+ end
97
+
98
+ def self.component_data(schema, key)
99
+ params = schema[:components][key]
100
+ wiring = schema[:stimulus]
101
+ return nil unless params || wiring.key?(key)
102
+
103
+ (params || {}).merge(controllers: wiring[key] || [])
104
+ end
105
+
106
+ private_class_method :component_data_map, :component_data
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ module Stimulus
7
+ extend Base
8
+
9
+ LOADER_KEY = :stimulus
10
+ LOADER = StimulusManifest
11
+
12
+ STATIC_RESOURCES = [
13
+ ::MCP::Resource.new(
14
+ uri: "stimulus://controllers",
15
+ name: "stimulus-controllers-index",
16
+ description: "Index of all Stimulus controller identifiers in @stimulus-plumbers/controllers",
17
+ mime_type: "application/json"
18
+ )
19
+ ].freeze
20
+
21
+ DYNAMIC_RESOURCE_TEMPLATES = [
22
+ ::MCP::ResourceTemplate.new(
23
+ uri_template: "stimulus://controllers/{identifier}",
24
+ name: "stimulus-controller-schema",
25
+ description: "Targets, values, outlets, and classes for a Stimulus controller",
26
+ mime_type: "application/json"
27
+ )
28
+ ].freeze
29
+
30
+ def self.read(uri, store)
31
+ controllers = store[:stimulus]
32
+
33
+ case uri
34
+ when "stimulus://controllers"
35
+ json_resource(uri, controllers.keys)
36
+ when %r{\Astimulus://controllers/(.+)\z}
37
+ identifier = Regexp.last_match(1)
38
+ json_resource(uri, controllers[identifier] || { error: "unknown controller: #{identifier}" })
39
+ end
40
+ end
41
+
42
+ def self.register_tools(server, store)
43
+ controllers = store[:stimulus]
44
+
45
+ text_tool(
46
+ server,
47
+ name: "list_controllers",
48
+ description: "Lists all Stimulus controller identifiers provided by @stimulus-plumbers/controllers"
49
+ ) do
50
+ JSON.generate(controllers.keys)
51
+ end
52
+
53
+ text_tool(
54
+ server,
55
+ name: "get_controller_schema",
56
+ description: "Returns targets, values (with types and defaults), outlets, and classes for a Stimulus controller",
57
+ input_schema: { properties: { controller: { type: "string" } }, required: ["controller"] }
58
+ ) do |controller:|
59
+ data = controllers[controller]
60
+ data ? JSON.generate(data) : not_found("unknown controller: #{controller}")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ module Tailwind
7
+ extend Base
8
+
9
+ LOADER_KEY = :tailwind
10
+ LOADER = TailwindThemeLoader
11
+
12
+ STATIC_RESOURCES = [
13
+ ::MCP::Resource.new(
14
+ uri: "tailwind://components",
15
+ name: "tailwind-components-index",
16
+ description: "Index of component keys implemented by the stimulus-plumbers Tailwind theme",
17
+ mime_type: "application/json"
18
+ )
19
+ ].freeze
20
+
21
+ DYNAMIC_RESOURCE_TEMPLATES = [
22
+ ::MCP::ResourceTemplate.new(
23
+ uri_template: "tailwind://components/{name}",
24
+ name: "tailwind-component-classes",
25
+ description: "Tailwind CSS utility classes emitted per variant for a component",
26
+ mime_type: "application/json"
27
+ )
28
+ ].freeze
29
+
30
+ def self.read(uri, store)
31
+ tailwind = store[:tailwind]
32
+
33
+ case uri
34
+ when "tailwind://components"
35
+ json_resource(uri, tailwind.keys)
36
+ when %r{\Atailwind://components/(.+)\z}
37
+ key = Regexp.last_match(1).to_sym
38
+ json_resource(uri, tailwind[key] || { error: "unknown component: #{key}" })
39
+ end
40
+ end
41
+
42
+ def self.register_tools(server, store)
43
+ tailwind = store[:tailwind]
44
+
45
+ text_tool(
46
+ server,
47
+ name: "get_tailwind_classes",
48
+ description: "Returns Tailwind CSS utility classes emitted per variant for a component",
49
+ input_schema: { properties: { component: { type: "string" } }, required: ["component"] }
50
+ ) do |component:|
51
+ data = tailwind[component.to_sym]
52
+ data ? JSON.generate(data) : not_found("unknown component: #{component}")
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ module Theme
7
+ extend Base
8
+
9
+ LOADER_KEY = :theme
10
+ LOADER = ThemeLoader
11
+
12
+ STATIC_RESOURCES = [
13
+ ::MCP::Resource.new(
14
+ uri: "theme://base",
15
+ name: "theme-base",
16
+ description: "Guide for implementing a custom stimulus-plumbers theme: method convention, return format",
17
+ mime_type: "text/markdown"
18
+ ),
19
+ ::MCP::Resource.new(
20
+ uri: "theme://components",
21
+ name: "theme-components-index",
22
+ description: "Index of all component keys that can be implemented in a custom theme",
23
+ mime_type: "application/json"
24
+ )
25
+ ].freeze
26
+
27
+ DYNAMIC_RESOURCE_TEMPLATES = [
28
+ ::MCP::ResourceTemplate.new(
29
+ uri_template: "theme://components/{name}",
30
+ name: "theme-component-interface",
31
+ description: "Method name, param signature, and return contract for implementing a component in a custom theme",
32
+ mime_type: "application/json"
33
+ )
34
+ ].freeze
35
+
36
+ def self.read(uri, store)
37
+ theme = store[:theme]
38
+
39
+ case uri
40
+ when "theme://base"
41
+ text_resource(uri, "text/markdown", theme[:base_doc])
42
+ when "theme://components"
43
+ json_resource(uri, theme[:components].keys)
44
+ when %r{\Atheme://components/(.+)\z}
45
+ key = Regexp.last_match(1).to_sym
46
+ json_resource(uri, theme[:components][key] || { error: "unknown component: #{key}" })
47
+ end
48
+ end
49
+
50
+ def self.register_tools(server, store)
51
+ theme = store[:theme]
52
+
53
+ text_tool(
54
+ server,
55
+ name: "get_theme_interface",
56
+ description: "Returns the method name, param signature, and return contract for a custom theme component",
57
+ input_schema: { properties: { component: { type: "string" } }, required: ["component"] }
58
+ ) do |component:|
59
+ data = theme[:components][component.to_sym]
60
+ data ? JSON.generate(data) : not_found("unknown component: #{component}")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Server
6
+ PLUGINS = [
7
+ Plugins::Guide,
8
+ Plugins::Schema,
9
+ Plugins::Docs,
10
+ Plugins::Stimulus,
11
+ Plugins::Theme,
12
+ Plugins::Tailwind
13
+ ].freeze
14
+
15
+ INSTRUCTIONS = "Use these resources and tools for accurate API references when " \
16
+ "generating Rails/ERB view code with the stimulus-plumbers UI library. " \
17
+ "Read guide://overview first for a map of the form/view/stimulus API."
18
+
19
+ def self.build
20
+ store = PLUGINS.to_h { |plugin| [plugin::LOADER_KEY, plugin::LOADER.call] }
21
+ report_sources(store)
22
+
23
+ server = new_server
24
+ server.resources_read_handler do |params|
25
+ PLUGINS.lazy.filter_map { |plugin| plugin.read(params[:uri], store) }.first || []
26
+ end
27
+ PLUGINS.each { |plugin| plugin.register_tools(server, store) }
28
+ server
29
+ end
30
+
31
+ def self.new_server
32
+ ::MCP::Server.new(
33
+ name: "stimulus-plumbers",
34
+ version: StimulusPlumbers::MCP::VERSION,
35
+ instructions: INSTRUCTIONS,
36
+ resources: PLUGINS.flat_map { |p| p::STATIC_RESOURCES },
37
+ resource_templates: PLUGINS.flat_map { |p| p::DYNAMIC_RESOURCE_TEMPLATES }
38
+ )
39
+ end
40
+
41
+ # Loaders fail soft (sibling paths may be absent), so report what resolved
42
+ # and warn loudly on any empty source instead of starting silently wrong.
43
+ def self.report_sources(store)
44
+ summary = store.map { |key, value| "#{key}=#{source_size(value)}" }.join(" ")
45
+ StimulusPlumbers::Logger.info("sources: #{summary}")
46
+ store.each_key do |key|
47
+ StimulusPlumbers::Logger.warn("source '#{key}' is empty") if empty_source?(store[key])
48
+ end
49
+ end
50
+
51
+ def self.source_size(value)
52
+ case value
53
+ when String then value.empty? ? 0 : "ok"
54
+ when Hash then value.key?(:components) ? value[:components].size : value.size
55
+ when Enumerable then value.size
56
+ else value.nil? ? 0 : 1
57
+ end
58
+ end
59
+
60
+ def self.empty_source?(value)
61
+ content = value.is_a?(Hash) && value.key?(:components) ? value[:components] : value
62
+ content.respond_to?(:empty?) && content.empty?
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ VERSION = "0.4.4"
6
+ end
7
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "action_view/version"
5
+ require "uri" if ActionView.version < Gem::Version.new("7.3")
6
+ require "mcp"
7
+ require "action_view"
8
+ require "stimulus_plumbers"
9
+ require "stimulus_plumbers_tailwind"
10
+
11
+ require_relative "stimulus_plumbers/mcp/version"
12
+ require_relative "stimulus_plumbers/mcp/loaders/guide_loader"
13
+ require_relative "stimulus_plumbers/mcp/loaders/schema_loader"
14
+ require_relative "stimulus_plumbers/mcp/loaders/docs_loader"
15
+ require_relative "stimulus_plumbers/mcp/loaders/stimulus_manifest"
16
+ require_relative "stimulus_plumbers/mcp/loaders/component_controller_map"
17
+ require_relative "stimulus_plumbers/mcp/loaders/theme_loader"
18
+ require_relative "stimulus_plumbers/mcp/loaders/tailwind_theme_loader"
19
+ require_relative "stimulus_plumbers/mcp/plugins/base"
20
+ require_relative "stimulus_plumbers/mcp/plugins/guide"
21
+ require_relative "stimulus_plumbers/mcp/plugins/schema"
22
+ require_relative "stimulus_plumbers/mcp/plugins/docs"
23
+ require_relative "stimulus_plumbers/mcp/plugins/stimulus"
24
+ require_relative "stimulus_plumbers/mcp/plugins/theme"
25
+ require_relative "stimulus_plumbers/mcp/plugins/tailwind"
26
+ require_relative "stimulus_plumbers/mcp/server"
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: stimulus_plumbers_mcp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.4
5
+ platform: ruby
6
+ authors:
7
+ - Ryan Chang
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionview
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '6.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '6.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: mcp
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.8'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.8'
40
+ - !ruby/object:Gem::Dependency
41
+ name: stimulus_plumbers
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: stimulus_plumbers_tailwind
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ description: A local MCP server that exposes the stimulus-plumbers API schema and
69
+ documentation to LLM-powered IDEs
70
+ email:
71
+ - ryancyq@gmail.com
72
+ executables: []
73
+ extensions: []
74
+ extra_rdoc_files: []
75
+ files:
76
+ - lib/stimulus_plumbers/mcp/loaders/component_controller_map.rb
77
+ - lib/stimulus_plumbers/mcp/loaders/docs_loader.rb
78
+ - lib/stimulus_plumbers/mcp/loaders/guide_loader.rb
79
+ - lib/stimulus_plumbers/mcp/loaders/schema_loader.rb
80
+ - lib/stimulus_plumbers/mcp/loaders/stimulus_manifest.rb
81
+ - lib/stimulus_plumbers/mcp/loaders/tailwind_theme_loader.rb
82
+ - lib/stimulus_plumbers/mcp/loaders/theme_loader.rb
83
+ - lib/stimulus_plumbers/mcp/plugins/base.rb
84
+ - lib/stimulus_plumbers/mcp/plugins/docs.rb
85
+ - lib/stimulus_plumbers/mcp/plugins/guide.rb
86
+ - lib/stimulus_plumbers/mcp/plugins/schema.rb
87
+ - lib/stimulus_plumbers/mcp/plugins/stimulus.rb
88
+ - lib/stimulus_plumbers/mcp/plugins/tailwind.rb
89
+ - lib/stimulus_plumbers/mcp/plugins/theme.rb
90
+ - lib/stimulus_plumbers/mcp/server.rb
91
+ - lib/stimulus_plumbers/mcp/version.rb
92
+ - lib/stimulus_plumbers_mcp.rb
93
+ homepage: https://github.com/ryancyq/stimulus-plumbers
94
+ licenses:
95
+ - MIT
96
+ metadata: {}
97
+ rdoc_options: []
98
+ require_paths:
99
+ - lib
100
+ required_ruby_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '3.0'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ requirements: []
111
+ rubygems_version: 3.6.9
112
+ specification_version: 4
113
+ summary: MCP server for the stimulus-plumbers UI library
114
+ test_files: []