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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c888ecb5ce465abc47875df8b2924ed833930400af77b9ebc546207173adf4d3
4
- data.tar.gz: 9f2278aa3c144f18e82a3070894bbfd4aeb1a30870b0ca1611c137252aedcaac
3
+ metadata.gz: 33ad1eb09fa4ed8fa1780dffe068f475fc04f3b3e1f179d6cbda211190155dc9
4
+ data.tar.gz: 29fecb05f19a17c3b398d8eb9189167e87cfe2f6f13d2b511472884ed9ebc935
5
5
  SHA512:
6
- metadata.gz: e62c6f7a43d006f158e2bf309f7a044ddff0bc74534667d2c736ac9f7c8dd5f977f0daf1b00ba063b4a5d6dc7c0d7043949cc89ca40e0a203847ba0e25cb87ad
7
- data.tar.gz: 8d7dafd931f3db501d06b48436ab5d028d5e4fb57d49b1a8ed4cc20c5ffad7ccdb6bebf45cc166438cafb55cf637ced44bb48e78339998189f2ebd7629c13878
6
+ metadata.gz: cd757ac9c1c357f3b71c1683816ab497718215ac181e2773af6d02bca45f95114c725e501a3218a71aefa11a4e090d233efdaa085a0a01e7af399745f4082284
7
+ data.tar.gz: 8481e2f13c22dfccbfa0e089442bdec11fe9475c408e4d189567ea4b60b2557aa1f959698c5bc511d428b079f1fbcc5038b327729b4e43b7eb78da8f2261720c
data/README.md CHANGED
@@ -18,34 +18,44 @@ gem install stimulus_plumbers_mcp
18
18
  | URI | Content |
19
19
  |-----|---------|
20
20
  | `guide://overview` | **Start here** — map of the form/view/stimulus API with pointers to every tool/resource |
21
- | `schema://components` | Index of all component theme keys |
22
- | `schema://components/{name}` | Params, valid values, defaults + required controllers |
23
- | `schema://icons` | All available icon names |
24
- | `schema://stimulus` | Map from component key → required Stimulus controller identifiers |
25
- | `docs://components/{name}` | Full markdown doc with ERB examples |
26
- | `helper://components/{name}` | Full `sp_` helper surface: keyword options + defaults (grouped by helper signature) + slot methods (with block-required flag) |
27
- | `stimulus://controllers` | Index of Stimulus controller identifiers |
28
- | `stimulus://controllers/{identifier}` | Targets, values, outlets, classes for a controller |
29
- | `theme://base` | Custom theme authoring guide |
30
- | `theme://components` | Index of theme-implementable component keys |
31
- | `theme://components/{name}` | Method name, param signature, return contract |
32
- | `tailwind://components` | Index of Tailwind theme component keys |
33
- | `tailwind://components/{name}` | Tailwind CSS classes per variant |
21
+ | `guide://{name}` | Per-package usage guide: `component` (Rails forms/views), `controller` (plain-JS/non-Rails setup), `tailwind` (Tailwind install/theming), `theme` (custom theme integration contract) |
22
+ | `aria://reference` | WCAG 2.1 AA criteria, keyboard navigation patterns, and per-component ARIA patterns |
23
+ | `component://index` | Index of all component theme keys |
24
+ | `component://icons` | All available icon names |
25
+ | `component://integration` | Map from component key required Stimulus controller identifiers |
26
+ | `component://{name}/schema` | Params, valid values, defaults + required controllers |
27
+ | `component://{name}/docs` | Full markdown doc with ERB examples |
28
+ | `component://{name}/helper` | Full `sp_` helper surface: keyword options + defaults (grouped by helper signature) + slot methods |
29
+ | `component://theme` | Index of theme-implementable component keys |
30
+ | `component://{name}/theme` | Method name, param signature, return contract |
31
+ | `component://theme/base` | Custom theme authoring guide |
32
+ | `component://tailwind` | Index of Tailwind theme component keys |
33
+ | `component://{name}/tailwind` | Tailwind CSS classes per variant |
34
+ | `controller://index` | Index of Stimulus controller identifiers |
35
+ | `controller://{name}/schema` | Targets, values, outlets, classes for a controller |
36
+ | `controller://docs` | Index of JS controller narrative docs, grouped by controller family |
37
+ | `controller://docs/{name}` | Narrative usage doc for a controller family (plain-JS/Hotwire, no Rails helpers) |
38
+ | `versions://sources` | Resolved version (and resolution path) of each source gem/package |
34
39
 
35
40
  ## Tools
36
41
 
37
42
  | Tool | Input | Output |
38
43
  |------|-------|--------|
39
44
  | `list_components` | — | All component theme keys |
40
- | `list_docs` | — | Components with markdown docs + helper signatures (the `docs://`/`helper://` `{name}` set) |
41
- | `get_component_schema` | `component` | Themed params (type/variant/size) + defaults + required controllers |
42
- | `get_helper_signature` | `component` | Full `sp_` helper surface — keyword options grouped by helper signature (incl. `icon_leading`) + slot methods |
43
- | `get_erb_examples` | `component` | ERB code fences from docs |
44
- | `get_field_types` | `builder_method` (`field`/`collection_field`/`choice`) | Valid `as:` values |
45
+ | `list_component_docs` | — | Components with markdown docs + helper signatures (the `component://{name}/docs`+`/helper` set) |
46
+ | `get_component_schema` | `name` | Themed params (type/variant/size) + defaults + required controllers |
47
+ | `get_component_helper` | `name` | Full `sp_` helper surface — keyword options grouped by helper signature (incl. `icon_leading`) + slot methods |
48
+ | `get_component_examples` | `name` | ERB code fences from docs |
49
+ | `get_field_as_values` | `builder_method` (`field`/`collection_field`/`choice`) | Valid `as:` values |
50
+ | `get_component_theme` | `name` | Method signature + return contract for custom theme |
51
+ | `get_component_tailwind` | `name` | Tailwind CSS utility classes per variant |
45
52
  | `list_controllers` | — | All Stimulus controller identifiers |
46
- | `get_controller_schema` | `controller` | Targets, values, outlets, classes |
47
- | `get_theme_interface` | `component` | Method signature + return contract for custom theme |
48
- | `get_tailwind_classes` | `component` | Tailwind CSS utility classes per variant |
53
+ | `get_controller_schema` | `name` | Targets, values, outlets, classes |
54
+ | `list_controller_docs` | | Controller doc families available at `controller://docs/{name}` |
55
+ | `get_controller_docs` | `name` | Narrative usage doc for a controller family |
56
+ | `get_source_versions` | — | Resolved version (and resolution path) of each source |
57
+ | `list_guides` | — | Available per-package guide names (`component`/`controller`/`tailwind`/`theme`) |
58
+ | `get_guide` | `name` | Per-package usage guide content |
49
59
 
50
60
  ## IDE Setup
51
61
 
@@ -64,7 +74,7 @@ Requires Ruby >= 3.3. No `cwd` required. Pin a specific version by adding it to
64
74
 
65
75
  ## Stimulus Manifest
66
76
 
67
- `stimulus://` resources require the controller manifest. The server resolves it in order:
77
+ `controller://` resources require the controller manifest. The server resolves it in order:
68
78
 
69
79
  1. `node_modules/@stimulus-plumbers/controllers/dist/controllers.manifest.json` — npm/yarn/bun projects
70
80
  2. `vendor/controllers.manifest.json` inside the installed `stimulus_plumbers` gem — importmaps fallback
@@ -94,9 +104,9 @@ bundle exec ruby bin/server
94
104
  bundle exec ruby bin/mcp-query tools # list tools
95
105
  bundle exec ruby bin/mcp-query resources # list resources
96
106
  bundle exec ruby bin/mcp-query tool list_components # call a tool (no args)
97
- bundle exec ruby bin/mcp-query tool get_component_schema '{"component":"button"}'
107
+ bundle exec ruby bin/mcp-query tool get_component_schema '{"name":"button"}'
98
108
  bundle exec ruby bin/mcp-query read guide://overview
99
- bundle exec ruby bin/mcp-query read docs://components/form
109
+ bundle exec ruby bin/mcp-query read component://form/docs
100
110
  ```
101
111
 
102
112
  ```bash
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class AriaLoader
6
+ FILENAME = "ARIA.md"
7
+
8
+ class << self
9
+ def call
10
+ path = resolved_path
11
+ path ? File.read(path) : ""
12
+ end
13
+
14
+ private
15
+
16
+ def resolved_path
17
+ aria_paths.find { |p| File.exist?(p) }
18
+ end
19
+
20
+ def aria_paths
21
+ [
22
+ # 1. Monorepo dev checkout — the root file is the freshest copy while working locally.
23
+ File.expand_path(File.join(__dir__, "../../../../..", FILENAME)),
24
+ # 2. gem exec — vendored into the rails gem at release time (bin/release).
25
+ GemVendorPath.resolve(FILENAME)
26
+ ].compact
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ComponentDocsLoader
6
+ class << self
7
+ def docs_dir
8
+ @docs_dir ||= resolve_docs_dir
9
+ end
10
+
11
+ def 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: DocsTableParser.call(content)
19
+ }
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def resolve_docs_dir
26
+ gem_dir = Gem::Specification.find_by_name("stimulus_plumbers").gem_dir
27
+ File.join(gem_dir, "docs/component")
28
+ rescue Gem::MissingSpecError
29
+ File.expand_path("../../../../../stimulus-plumbers-rails/docs/component", __dir__)
30
+ end
31
+
32
+ def extract_erb_examples(content)
33
+ content.scan(%r{```erb\n(.*?)```}m).map(&:first).map(&:strip)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ComponentRequirements
6
+ class << self
7
+ def call
8
+ Components.constants
9
+ .map { |c| Components.const_get(c) }
10
+ .grep(Class)
11
+ .to_h { |klass| [component_key(klass), controllers_for(klass).uniq] }
12
+ end
13
+
14
+ private
15
+
16
+ # Controllers from a component class plus its nested sub-components (e.g. Combobox::Date),
17
+ # keyed to the top-level component. Skips references to sibling components.
18
+ def controllers_for(mod)
19
+ mod.constants(false).flat_map do |const|
20
+ value = mod.const_get(const)
21
+ if const.to_s.end_with?("CONTROLLER")
22
+ Array(value).grep(String).flat_map(&:split)
23
+ elsif nested?(mod, value)
24
+ controllers_for(value)
25
+ else
26
+ []
27
+ end
28
+ end
29
+ end
30
+
31
+ def nested?(mod, value)
32
+ value.is_a?(Module) && !value.name.nil? && value.name.start_with?("#{mod.name}::")
33
+ end
34
+
35
+ def component_key(klass)
36
+ klass.name.demodulize.underscore.to_sym
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ComponentSchemaLoader
6
+ # Not derivable from Form::Fields::Renderer's method names (`search:` renders via
7
+ # `render_combobox_typeahead`, but wires up "combobox-dropdown", not
8
+ # "combobox-typeahead") — hand-maintained; keep in sync with form.md's "backed by" notes.
9
+ FIELD_AS_CONTROLLERS = {
10
+ date: "combobox-date",
11
+ time: "combobox-time",
12
+ select: "combobox-dropdown",
13
+ search: "combobox-dropdown",
14
+ collection_select: "combobox-dropdown",
15
+ grouped_collection_select: "combobox-dropdown"
16
+ }.freeze
17
+
18
+ class << self
19
+ def call
20
+ {
21
+ components: extract_schema,
22
+ field_as: extract_field_as,
23
+ field_as_controllers: FIELD_AS_CONTROLLERS,
24
+ controllers: ComponentRequirements.call
25
+ }
26
+ end
27
+
28
+ private
29
+
30
+ def extract_schema
31
+ Themes::Base::SCHEMA.transform_values do |param_schema|
32
+ param_schema.transform_values do |meta|
33
+ v = meta[:validate]
34
+ { default: meta[:default], valid: v.respond_to?(:to_a) ? v.to_a : v.inspect }
35
+ end
36
+ end
37
+ end
38
+
39
+ def extract_field_as
40
+ {
41
+ field: Form::Fields::Renderer::FIELD.keys,
42
+ collection_field: Form::Fields::Renderer::COLLECTION.keys,
43
+ choice: Form::Fields::Renderer::CHOICE.keys
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ComponentThemeLoader
6
+ class << self
7
+ def call
8
+ {
9
+ base_doc: base_doc,
10
+ components: extract_interface
11
+ }
12
+ end
13
+
14
+ private
15
+
16
+ # Same file ComponentDocsLoader already serves at component://theme/docs — avoids a duplicate heredoc.
17
+ def base_doc
18
+ path = File.join(ComponentDocsLoader.docs_dir, "theme.md")
19
+ File.exist?(path) ? File.read(path) : ""
20
+ end
21
+
22
+ def extract_interface
23
+ Themes::Base::SCHEMA.each_with_object({}) do |(key, params), result|
24
+ result[key] = {
25
+ method: "#{key}_classes",
26
+ params: params.transform_values { |meta| format_param(meta) },
27
+ returns: "{ classes: String }"
28
+ }
29
+ end
30
+ end
31
+
32
+ def format_param(meta)
33
+ valid = meta[:validate]
34
+ entry = { default: meta[:default] }
35
+ entry[:valid] = valid.to_a if valid.respond_to?(:to_a)
36
+ entry
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ControllerDocsLoader
6
+ class << self
7
+ def docs_dir
8
+ @docs_dir ||= resolve_docs_dir
9
+ end
10
+
11
+ def call
12
+ Dir[File.join(docs_dir, "*.md")].to_h do |path|
13
+ [File.basename(path, ".md").to_sym, File.read(path)]
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def resolve_docs_dir
20
+ # 1. Monorepo dev checkout — the JS package's own docs are freshest while working locally.
21
+ dev_path = File.expand_path(File.join(__dir__, "../../../../..", "stimulus-plumbers", "docs", "component"))
22
+ return dev_path if Dir.exist?(dev_path)
23
+
24
+ # 2. gem exec — vendored into the rails gem at release time, under vendor/controller/docs/
25
+ # (mirrors the MCP server's controller:// resource namespace).
26
+ GemVendorPath.resolve("controller", "docs") || dev_path
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StimulusPlumbers
4
+ module MCP
5
+ class ControllerSchemaLoader
6
+ MANIFEST_FILENAME = "controllers.manifest.json"
7
+ # Vendored as manifest.json — the "controllers" prefix is redundant once nested
8
+ # under vendor/controller/ (mirrors the MCP server's controller:// namespace).
9
+ VENDOR_FILENAME = "manifest.json"
10
+
11
+ class << self
12
+ def call
13
+ path = resolved_path
14
+
15
+ unless path
16
+ StimulusPlumbers::Logger.warn(
17
+ "controller manifest not found. Tried:\n" \
18
+ "#{manifest_paths.map { |p| " - #{p}" }.join("\n")}\n" \
19
+ "Run `node --run build:manifest` in stimulus-plumbers/ to generate it."
20
+ )
21
+ return {}
22
+ end
23
+
24
+ JSON.parse(File.read(path))
25
+ end
26
+
27
+ # Reused by VersionsLoader to report which fallback location resolved.
28
+ def resolved_path
29
+ manifest_paths.find { |p| File.exist?(p) }
30
+ end
31
+
32
+ private
33
+
34
+ def manifest_paths
35
+ [
36
+ # 1. npm/yarn/bun project — version matches project lockfile
37
+ File.join(Dir.pwd, "node_modules/@stimulus-plumbers/controllers/dist", MANIFEST_FILENAME),
38
+ # 2. importmaps users — vendored in Rails gem at release time
39
+ GemVendorPath.resolve("controller", VENDOR_FILENAME),
40
+ # 3. Monorepo dev fallback
41
+ File.expand_path(
42
+ File.join(__dir__, "../../../../..", "stimulus-plumbers", "dist", MANIFEST_FILENAME)
43
+ )
44
+ ].compact
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,48 @@
1
+ # stimulus-plumbers — overview
2
+
3
+ Accessible Rails view components plus a themed form builder, backed by Stimulus controllers.
4
+ Start here, then drill into the per-package guides and per-component resources/tools below.
5
+
6
+ ## Per-package guides
7
+ - `guide://component` — building forms (`f.field`/`f.collection_field`/`f.choice`) and views
8
+ (`sp_*` helpers) with the Rails gem
9
+ - `guide://controller` — plain-JS / non-Rails setup for `@stimulus-plumbers/controllers`
10
+ - `guide://tailwind` — Tailwind theme install + icons
11
+ - `guide://theme` — implementing a custom theme (subclassing `Themes::Base`)
12
+
13
+ Tools: `list_guides`, `get_guide(name:)`.
14
+
15
+ ## Building forms and views
16
+ Valid `as:` values: `get_field_as_values(builder_method:)`. Combobox-backed `as:` values' controller
17
+ identifier: `get_field_as_controller(as:)` — `component://{name}/schema` keys are renderer-level, not
18
+ `as:` values. Component helper surface: `get_component_helper(name)`; themed params:
19
+ `get_component_schema(name)`; ERB examples: `get_component_examples(name)`. List everything with
20
+ `list_components`; `list_component_docs` shows which components have full docs
21
+ (`component://{name}/docs`) and helper signatures (`component://{name}/helper`). Icon options take a
22
+ name from `component://icons` (or `list_icons`). All 77 schema components are queryable via
23
+ `get_component_schema`; only a subset have full docs and ERB examples — use `list_component_docs` to
24
+ see what's covered. Full form builder reference: `component://form/docs`.
25
+
26
+ ## Stimulus integration
27
+ Most display components are pure markup; interactive ones (combobox, popover, calendar) emit their
28
+ `data-controller` attributes automatically. Component → required controllers: `component://integration`.
29
+ Controller identifiers and details (targets/values/outlets/classes): `controller://index`,
30
+ `get_controller_schema(id)` / `list_controllers`.
31
+ Narrative docs by controller family: `get_controller_docs(name)` / `controller://docs/{name}`.
32
+
33
+ ## How this server is organized
34
+ - **Resources** live under entity namespaces — `component://` (Rails `sp_*` helper surface: schema,
35
+ docs, helper, theme, tailwind, icons facets), `controller://` (plain-JS Stimulus surface: schema,
36
+ docs facets), `guide://` (per-package usage guides) — plus `aria://reference` and `versions://sources`.
37
+ **Tools** (`get_*`, `list_*`) expose the same data for targeted lookups. Use whichever your client prefers.
38
+ - `aria://reference` — WCAG 2.1 AA criteria and component ARIA patterns. For generic ARIA/WCAG reference,
39
+ connect the MDN MCP server too: `claude mcp add --transport http mdn https://mcp.mdn.mozilla.net/`
40
+ - Each source (schema, docs, theme, tailwind, icons, stimulus, stimulus_docs, guide) is read from an
41
+ independently-versioned gem/npm package — `versions://sources` / `get_source_versions` reports what's
42
+ actually resolved, useful if data looks stale or inconsistent with the app code you're generating for.
43
+ `icons` versions with the Tailwind theme gem, not the schema gem, since the bundled icon set is
44
+ Tailwind-specific.
45
+ - **View generation** uses the form builder + `sp_` helper tools/resources above. The `component://theme`
46
+ and `component://tailwind` facets are for *theme authors* implementing a custom theme — skip them when
47
+ generating views.
48
+ - **Errors are uniform:** a not-found tool/resource returns `{ "error": "..." }` (tools also set `isError`).
@@ -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