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.
- checksums.yaml +4 -4
- data/README.md +34 -24
- 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 +31 -11
- metadata +22 -12
- data/lib/stimulus_plumbers/mcp/loaders/component_controller_map.rb +0 -38
- data/lib/stimulus_plumbers/mcp/loaders/docs_loader.rb +0 -129
- data/lib/stimulus_plumbers/mcp/loaders/guide/overview.md +0 -48
- data/lib/stimulus_plumbers/mcp/loaders/schema_loader.rb +0 -45
- data/lib/stimulus_plumbers/mcp/loaders/stimulus_manifest.rb +0 -47
- 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
|
@@ -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
|
|
@@ -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
|