stimulus_plumbers_mcp 0.4.5 → 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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +34 -24
  3. data/lib/stimulus_plumbers/mcp/loaders/aria_loader.rb +31 -0
  4. data/lib/stimulus_plumbers/mcp/loaders/component_docs_loader.rb +38 -0
  5. data/lib/stimulus_plumbers/mcp/loaders/component_requirements.rb +41 -0
  6. data/lib/stimulus_plumbers/mcp/loaders/component_schema_loader.rb +49 -0
  7. data/lib/stimulus_plumbers/mcp/loaders/component_theme_loader.rb +41 -0
  8. data/lib/stimulus_plumbers/mcp/loaders/controller_docs_loader.rb +31 -0
  9. data/lib/stimulus_plumbers/mcp/loaders/controller_schema_loader.rb +49 -0
  10. data/lib/stimulus_plumbers/mcp/loaders/guide.md +48 -0
  11. data/lib/stimulus_plumbers/mcp/loaders/guide_loader.rb +40 -4
  12. data/lib/stimulus_plumbers/mcp/loaders/icons_loader.rb +34 -0
  13. data/lib/stimulus_plumbers/mcp/loaders/support/docs_table_parser.rb +112 -0
  14. data/lib/stimulus_plumbers/mcp/loaders/support/gem_vendor_path.rb +18 -0
  15. data/lib/stimulus_plumbers/mcp/loaders/tailwind_loader.rb +35 -0
  16. data/lib/stimulus_plumbers/mcp/loaders/versions_loader.rb +110 -0
  17. data/lib/stimulus_plumbers/mcp/plugins/aria.rb +34 -0
  18. data/lib/stimulus_plumbers/mcp/plugins/base.rb +40 -31
  19. data/lib/stimulus_plumbers/mcp/plugins/component_docs.rb +96 -0
  20. data/lib/stimulus_plumbers/mcp/plugins/component_schema.rb +133 -0
  21. data/lib/stimulus_plumbers/mcp/plugins/component_theme.rb +90 -0
  22. data/lib/stimulus_plumbers/mcp/plugins/controller_docs.rb +91 -0
  23. data/lib/stimulus_plumbers/mcp/plugins/controller_schema.rb +81 -0
  24. data/lib/stimulus_plumbers/mcp/plugins/guide.rb +70 -16
  25. data/lib/stimulus_plumbers/mcp/plugins/icons.rb +42 -0
  26. data/lib/stimulus_plumbers/mcp/plugins/tailwind.rb +69 -45
  27. data/lib/stimulus_plumbers/mcp/plugins/versions.rb +44 -0
  28. data/lib/stimulus_plumbers/mcp/server.rb +26 -9
  29. data/lib/stimulus_plumbers/mcp/version.rb +1 -1
  30. data/lib/stimulus_plumbers_mcp.rb +31 -11
  31. metadata +22 -12
  32. data/lib/stimulus_plumbers/mcp/loaders/component_controller_map.rb +0 -38
  33. data/lib/stimulus_plumbers/mcp/loaders/docs_loader.rb +0 -129
  34. data/lib/stimulus_plumbers/mcp/loaders/guide/overview.md +0 -48
  35. data/lib/stimulus_plumbers/mcp/loaders/schema_loader.rb +0 -45
  36. data/lib/stimulus_plumbers/mcp/loaders/stimulus_manifest.rb +0 -47
  37. data/lib/stimulus_plumbers/mcp/loaders/tailwind_theme_loader.rb +0 -29
  38. data/lib/stimulus_plumbers/mcp/loaders/theme_loader.rb +0 -97
  39. data/lib/stimulus_plumbers/mcp/plugins/docs.rb +0 -82
  40. data/lib/stimulus_plumbers/mcp/plugins/schema.rb +0 -110
  41. data/lib/stimulus_plumbers/mcp/plugins/stimulus.rb +0 -66
  42. data/lib/stimulus_plumbers/mcp/plugins/theme.rb +0 -66
@@ -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
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ class ComponentSchema < Base
7
+ class << self
8
+ def loader_key = :component_schema
9
+
10
+ def loader = ComponentSchemaLoader
11
+
12
+ def static_resources
13
+ [
14
+ ::MCP::Resource.new(
15
+ uri: "component://index",
16
+ name: "components-index",
17
+ description: "Index of all stimulus-plumbers component theme keys",
18
+ mime_type: "application/json"
19
+ ),
20
+ ::MCP::Resource.new(
21
+ uri: "component://integration",
22
+ name: "component-integration",
23
+ description: "Mapping from Rails component name to the Stimulus controller identifiers it requires",
24
+ mime_type: "application/json"
25
+ )
26
+ ].freeze
27
+ end
28
+
29
+ def dynamic_resource_templates
30
+ [
31
+ ::MCP::ResourceTemplate.new(
32
+ uri_template: "component://{name}/schema",
33
+ name: "component-schema",
34
+ description: "Params, valid values, defaults, and required controllers for a component",
35
+ mime_type: "application/json"
36
+ )
37
+ ].freeze
38
+ end
39
+
40
+ def read(uri, store)
41
+ schema = store[:component_schema]
42
+
43
+ case uri
44
+ when "component://index"
45
+ json_resource(uri, schema[:components].keys)
46
+ when "component://integration"
47
+ json_resource(uri, schema[:controllers])
48
+ when %r{\Acomponent://([^/]+)/schema\z}
49
+ key = Regexp.last_match(1).to_sym
50
+ json_resource(uri, component_data(schema, key) || { error: "unknown component: #{key}" })
51
+ end
52
+ end
53
+
54
+ def register_tools(server, store)
55
+ schema = store[:component_schema]
56
+ data_by_component = component_data_map(schema)
57
+
58
+ register_list_components(server, schema)
59
+ register_get_component_schema(server, data_by_component)
60
+ register_get_field_as_values(server, schema)
61
+ register_get_field_as_controller(server, schema)
62
+ end
63
+
64
+ private
65
+
66
+ def register_list_components(server, schema)
67
+ text_tool(server, name: "list_components", description: "Lists all stimulus-plumbers component theme keys") do
68
+ JSON.generate(schema[:components].keys)
69
+ end
70
+ end
71
+
72
+ def register_get_component_schema(server, data_by_component)
73
+ text_tool(
74
+ server,
75
+ name: "get_component_schema",
76
+ description: "Returns themed params (e.g. type/variant/size) with valid values, defaults, and " \
77
+ "required Stimulus controllers. For the full helper surface (icon options, slots) " \
78
+ "use get_component_helper. Keys are renderer-level (e.g. combobox_listbox), not " \
79
+ "f.field(as:) values — for the as: value's backing controller use get_field_as_controller",
80
+ input_schema: { properties: { name: { type: "string" } }, required: ["name"] }
81
+ ) do |name:|
82
+ data = data_by_component[name.to_sym]
83
+ data ? JSON.generate(data) : not_found("unknown component: #{name}")
84
+ end
85
+ end
86
+
87
+ def register_get_field_as_values(server, schema)
88
+ text_tool(
89
+ server,
90
+ name: "get_field_as_values",
91
+ description: "Returns valid as: values for a form builder method",
92
+ input_schema: {
93
+ properties: { builder_method: { type: "string", enum: %w[field collection_field choice] } },
94
+ required: ["builder_method"]
95
+ }
96
+ ) do |builder_method:|
97
+ values = schema[:field_as][builder_method.to_sym]
98
+ values ? JSON.generate(values) : not_found("unknown builder_method: #{builder_method}")
99
+ end
100
+ end
101
+
102
+ def register_get_field_as_controller(server, schema)
103
+ text_tool(
104
+ server,
105
+ name: "get_field_as_controller",
106
+ description: "Returns the Stimulus controller identifier backing an f.field/f.collection_field " \
107
+ "as: value (e.g. \"select\" -> \"combobox-dropdown\"), for as: values whose picker " \
108
+ "is controller-backed. Plain input as: values (text, email, file, ...) return not-found",
109
+ input_schema: { properties: { as: { type: "string" } }, required: ["as"] }
110
+ ) do |as:|
111
+ controller = schema[:field_as_controllers][as.to_sym]
112
+ controller ? JSON.generate(controller: controller) : not_found("no dedicated controller for as: #{as}")
113
+ end
114
+ end
115
+
116
+ # Resolve component_data at module scope; define_tool blocks run in another context.
117
+ def component_data_map(schema)
118
+ keys = (schema[:components].keys + schema[:controllers].keys).uniq
119
+ keys.to_h { |key| [key, component_data(schema, key)] }
120
+ end
121
+
122
+ def component_data(schema, key)
123
+ params = schema[:components][key]
124
+ required = schema[:controllers]
125
+ return nil unless params || required.key?(key)
126
+
127
+ (params || {}).merge(controllers: required[key] || [])
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ class ComponentTheme < Base
7
+ class << self
8
+ def loader_key = :component_theme
9
+
10
+ def loader = ComponentThemeLoader
11
+
12
+ def static_resources
13
+ [
14
+ ::MCP::Resource.new(
15
+ uri: "component://theme/base",
16
+ name: "theme-base",
17
+ description: "Guide for implementing a custom stimulus-plumbers theme: method convention, return format",
18
+ mime_type: "text/markdown"
19
+ ),
20
+ ::MCP::Resource.new(
21
+ uri: "component://theme",
22
+ name: "component-theme-index",
23
+ description: "Index of all component keys that can be implemented in a custom theme",
24
+ mime_type: "application/json"
25
+ )
26
+ ].freeze
27
+ end
28
+
29
+ def dynamic_resource_templates
30
+ [
31
+ ::MCP::ResourceTemplate.new(
32
+ uri_template: "component://{name}/theme",
33
+ name: "component-theme-interface",
34
+ description: "Method name, param signature, and return contract for implementing a component in a custom theme",
35
+ mime_type: "application/json"
36
+ )
37
+ ].freeze
38
+ end
39
+
40
+ def read(uri, store)
41
+ theme = store[:component_theme]
42
+
43
+ case uri
44
+ when "component://theme/base"
45
+ text_resource(uri, "text/markdown", theme[:base_doc])
46
+ when "component://theme"
47
+ json_resource(uri, theme[:components].keys)
48
+ when %r{\Acomponent://([^/]+)/theme\z}
49
+ key = Regexp.last_match(1).to_sym
50
+ json_resource(uri, theme[:components][key] || { error: "unknown component: #{key}" })
51
+ end
52
+ end
53
+
54
+ def register_tools(server, store)
55
+ theme = store[:component_theme]
56
+
57
+ register_list_component_themes(server, theme)
58
+ register_get_component_theme(server, theme)
59
+ end
60
+
61
+ private
62
+
63
+ def register_list_component_themes(server, theme)
64
+ text_tool(
65
+ server,
66
+ name: "list_component_themes",
67
+ description: "Lists all component keys that can be implemented in a custom theme"
68
+ ) do
69
+ JSON.generate(theme[:components].keys)
70
+ end
71
+ end
72
+
73
+ def register_get_component_theme(server, theme)
74
+ text_tool(
75
+ server,
76
+ name: "get_component_theme",
77
+ description: "Returns the method name, param signature, and return contract for implementing " \
78
+ "a component in a custom theme. For the built-in Tailwind theme's output use " \
79
+ "get_component_tailwind; for themed params use get_component_schema",
80
+ input_schema: { properties: { name: { type: "string" } }, required: ["name"] }
81
+ ) do |name:|
82
+ data = theme[:components][name.to_sym]
83
+ data ? JSON.generate(data) : not_found("unknown component: #{name}")
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ module Plugins
6
+ # Shares the controller:// scheme with Plugins::ControllerSchema, distinguished by the
7
+ # docs/ path segment (ControllerSchema is per-identifier schema data; docs/ is
8
+ # narrative markdown, grouped by controller family, not individual identifier).
9
+ class ControllerDocs < Base
10
+ class << self
11
+ def loader_key = :controller_docs
12
+
13
+ def loader = ControllerDocsLoader
14
+
15
+ def static_resources
16
+ [
17
+ ::MCP::Resource.new(
18
+ uri: "controller://docs",
19
+ name: "controller-docs-index",
20
+ description: "Index of JS controller narrative docs, grouped by controller family " \
21
+ "(e.g. :calendar covers calendar-month/-year/-decade)",
22
+ mime_type: "application/json"
23
+ )
24
+ ].freeze
25
+ end
26
+
27
+ def dynamic_resource_templates
28
+ [
29
+ ::MCP::ResourceTemplate.new(
30
+ uri_template: "controller://docs/{name}",
31
+ name: "controller-doc",
32
+ description: "Narrative usage doc for a controller family, for plain-JS/Hotwire consumers " \
33
+ "without Rails helpers",
34
+ mime_type: "text/markdown"
35
+ )
36
+ ].freeze
37
+ end
38
+
39
+ def read(uri, store)
40
+ docs = store[:controller_docs]
41
+
42
+ case uri
43
+ when "controller://docs"
44
+ json_resource(uri, docs.keys)
45
+ when %r{\Acontroller://docs/(.+)\z}
46
+ key = Regexp.last_match(1).to_sym
47
+ docs[key] ? text_resource(uri, "text/markdown", docs[key]) : missing(uri, key)
48
+ end
49
+ end
50
+
51
+ def register_tools(server, store)
52
+ docs = store[:controller_docs]
53
+
54
+ register_list_controller_docs(server, docs)
55
+ register_get_controller_docs(server, docs)
56
+ end
57
+
58
+ private
59
+
60
+ def missing(uri, key)
61
+ json_resource(uri, { error: "no controller docs for: #{key}" })
62
+ end
63
+
64
+ def register_list_controller_docs(server, docs)
65
+ text_tool(
66
+ server,
67
+ name: "list_controller_docs",
68
+ description: "Lists controller doc families available at controller://docs/{name} — " \
69
+ "grouped by family, not individual controller identifier"
70
+ ) do
71
+ JSON.generate(docs.keys)
72
+ end
73
+ end
74
+
75
+ def register_get_controller_docs(server, docs)
76
+ text_tool(
77
+ server,
78
+ name: "get_controller_docs",
79
+ description: "Returns the narrative usage doc for a controller family (e.g. \"calendar\", " \
80
+ "\"combobox\") — for individual controller targets/values/outlets use get_controller_schema",
81
+ input_schema: { properties: { name: { type: "string" } }, required: ["name"] }
82
+ ) do |name:|
83
+ doc = docs[name.to_sym]
84
+ doc || not_found("no controller docs for: #{name}")
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end