webmcp 0.0.1 → 0.1.1
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/CHANGELOG.md +30 -0
- data/README.md +387 -15
- data/Rakefile +21 -0
- data/app/assets/javascripts/webmcp/runtime.js +344 -0
- data/conformance/README.md +29 -0
- data/conformance/RUNTIME.sha256 +1 -0
- data/conformance/fixtures/escape-parity.json +50 -0
- data/conformance/fixtures/projection-result.json +72 -0
- data/conformance/fixtures/read-array.json +52 -0
- data/conformance/fixtures/write-post.json +67 -0
- data/lib/tasks/webmcp.rake +20 -0
- data/lib/webmcp/form_helper.rb +139 -0
- data/lib/webmcp/manifest.rb +36 -0
- data/lib/webmcp/origin_trial.rb +39 -0
- data/lib/webmcp/projection.rb +70 -0
- data/lib/webmcp/railtie.rb +43 -0
- data/lib/webmcp/registry.rb +30 -0
- data/lib/webmcp/schema.rb +73 -0
- data/lib/webmcp/testing.rb +29 -0
- data/lib/webmcp/tool.rb +103 -0
- data/lib/webmcp/value.rb +79 -0
- data/lib/webmcp/version.rb +1 -1
- data/lib/webmcp/view_helpers.rb +29 -0
- data/lib/webmcp.rb +52 -10
- data/runtime/webmcp-runtime.js +344 -0
- metadata +120 -8
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
module FormOptions
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def extract(options)
|
|
8
|
+
options = options.dup
|
|
9
|
+
metadata = options.delete(:webmcp) || options.delete("webmcp")
|
|
10
|
+
[options, metadata.nil? ? nil : Value.normalize(metadata)]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def form_attributes(metadata)
|
|
14
|
+
Value.keys!(metadata, %w[tool description autosubmit], "form webmcp")
|
|
15
|
+
Tool.validate_name!(metadata["tool"])
|
|
16
|
+
unless metadata["description"].is_a?(String) && !metadata["description"].empty?
|
|
17
|
+
raise DefinitionError, "form webmcp description must be a nonempty string"
|
|
18
|
+
end
|
|
19
|
+
if metadata.key?("autosubmit") && ![true, false].include?(metadata["autosubmit"])
|
|
20
|
+
raise DefinitionError, "autosubmit must be a boolean"
|
|
21
|
+
end
|
|
22
|
+
attrs = { "toolname" => metadata["tool"], "tooldescription" => metadata["description"] }
|
|
23
|
+
attrs["toolautosubmit"] = "toolautosubmit" if metadata["autosubmit"]
|
|
24
|
+
attrs
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def field(options)
|
|
28
|
+
options, metadata = extract(options)
|
|
29
|
+
return options unless metadata
|
|
30
|
+
Value.keys!(metadata, %w[param_description], "field webmcp")
|
|
31
|
+
if metadata.key?("param_description")
|
|
32
|
+
raise DefinitionError, "param_description must be a string" unless metadata["param_description"].is_a?(String)
|
|
33
|
+
options["toolparamdescription"] = metadata["param_description"]
|
|
34
|
+
end
|
|
35
|
+
options
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def select_options(options, html_options)
|
|
39
|
+
options, metadata = extract(options)
|
|
40
|
+
html_options, html_metadata = extract(html_options)
|
|
41
|
+
html_options[:webmcp] = html_metadata || metadata if html_metadata || metadata
|
|
42
|
+
[options, field(html_options)]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
module FormHelper
|
|
47
|
+
def form_with(**options, &block)
|
|
48
|
+
options, metadata = FormOptions.extract(options)
|
|
49
|
+
options[:html] = (options[:html] || {}).merge(FormOptions.form_attributes(metadata)) if metadata
|
|
50
|
+
super(**options, &block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.install!
|
|
54
|
+
ActionView::Helpers::FormHelper.prepend(self)
|
|
55
|
+
ActionView::Helpers::FormBuilder.prepend(FormBuilderOptions)
|
|
56
|
+
ActionView::Helpers::FormTagHelper.prepend(FormTagOptions)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
module FormBuilderOptions
|
|
61
|
+
%i[text_field password_field hidden_field file_field text_area color_field search_field
|
|
62
|
+
telephone_field phone_field date_field time_field datetime_field datetime_local_field
|
|
63
|
+
month_field week_field url_field email_field number_field range_field].each do |method_name|
|
|
64
|
+
define_method(method_name) do |attribute, options = {}|
|
|
65
|
+
super(attribute, FormOptions.field(options))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_box(attribute, options = {}, checked_value = "1", unchecked_value = "0")
|
|
70
|
+
super(attribute, FormOptions.field(options), checked_value, unchecked_value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def checkbox(attribute, options = {}, checked_value = "1", unchecked_value = "0")
|
|
74
|
+
super(attribute, FormOptions.field(options), checked_value, unchecked_value)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def radio_button(attribute, value, options = {})
|
|
78
|
+
super(attribute, value, FormOptions.field(options))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def select(attribute, choices = nil, options = {}, html_options = {}, &block)
|
|
82
|
+
options, html_options = FormOptions.select_options(options, html_options)
|
|
83
|
+
super(attribute, choices, options, html_options, &block)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def collection_select(attribute, collection, value_method, text_method, options = {}, html_options = {})
|
|
87
|
+
options, html_options = FormOptions.select_options(options, html_options)
|
|
88
|
+
super(attribute, collection, value_method, text_method, options, html_options)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def grouped_collection_select(attribute, collection, group_method, group_label_method, option_key_method, option_value_method, options = {}, html_options = {})
|
|
92
|
+
options, html_options = FormOptions.select_options(options, html_options)
|
|
93
|
+
super(attribute, collection, group_method, group_label_method, option_key_method, option_value_method, options, html_options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
%i[collection_check_boxes collection_checkboxes collection_radio_buttons].each do |method_name|
|
|
97
|
+
define_method(method_name) do |attribute, collection, value_method, text_method, options = {}, html_options = {}, &block|
|
|
98
|
+
options, html_options = FormOptions.select_options(options, html_options)
|
|
99
|
+
super(attribute, collection, value_method, text_method, options, html_options, &block)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def time_zone_select(attribute, priority_zones = nil, options = {}, html_options = {})
|
|
104
|
+
options, html_options = FormOptions.select_options(options, html_options)
|
|
105
|
+
super(attribute, priority_zones, options, html_options)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
%i[weekday_select date_select time_select datetime_select].each do |method_name|
|
|
109
|
+
define_method(method_name) do |attribute, options = {}, html_options = {}|
|
|
110
|
+
options, html_options = FormOptions.select_options(options, html_options)
|
|
111
|
+
super(attribute, options, html_options)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
module FormTagOptions
|
|
117
|
+
# Other scalar *_field_tag methods delegate to text_field_tag.
|
|
118
|
+
%i[text_field_tag text_area_tag select_tag].each do |method_name|
|
|
119
|
+
define_method(method_name) do |name, value = nil, options = {}|
|
|
120
|
+
super(name, value, FormOptions.field(options))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
%i[check_box_tag checkbox_tag radio_button_tag].each do |method_name|
|
|
125
|
+
define_method(method_name) do |*args|
|
|
126
|
+
args[-1] = FormOptions.field(args.last) if args.last.is_a?(Hash)
|
|
127
|
+
super(*args)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def html_options_for_form(url_for_options, options)
|
|
134
|
+
options, metadata = FormOptions.extract(options)
|
|
135
|
+
options = options.merge(FormOptions.form_attributes(metadata)) if metadata
|
|
136
|
+
super(url_for_options, options)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
module Manifest
|
|
5
|
+
ESCAPES = { "<" => '\u003c', ">" => '\u003e', "&" => '\u0026', "\u2028" => '\u2028', "\u2029" => '\u2029' }.freeze
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def normalize_transport(transport)
|
|
9
|
+
transport = Value.normalize(transport)
|
|
10
|
+
Value.keys!(transport, %w[csrf], "transport")
|
|
11
|
+
if transport.key?("csrf")
|
|
12
|
+
csrf = transport["csrf"]
|
|
13
|
+
Value.keys!(csrf, %w[source name header], "csrf")
|
|
14
|
+
unless %w[meta cookie].include?(csrf["source"]) && %w[name header].all? { |k| csrf[k].is_a?(String) && !csrf[k].empty? && !/[\x00-\x1f\x7f]/.match?(csrf[k]) }
|
|
15
|
+
raise DefinitionError, "csrf requires source meta/cookie and nonempty name/header"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
transport
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build(tools, transport:)
|
|
22
|
+
transport = normalize_transport(transport)
|
|
23
|
+
entries = tools.map { |tool| tool.to_manifest_entry(transport) }
|
|
24
|
+
names = entries.map { |entry| entry["name"] }
|
|
25
|
+
raise DefinitionError, "duplicate tool names in manifest" unless names.uniq == names
|
|
26
|
+
{ "webmcpManifestVersion" => 1, "transport" => transport, "tools" => entries }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_script_tag(hash, nonce: nil, autostart: true)
|
|
30
|
+
json = JSON.generate(Value.normalize(hash)).gsub(/[<>&\u2028\u2029]/, ESCAPES)
|
|
31
|
+
nonce_attr = nonce.nil? ? "" : %( nonce="#{Value.escape_html(nonce)}")
|
|
32
|
+
autostart_attr = autostart ? " data-webmcp-autostart" : ""
|
|
33
|
+
Value.html_safe(%(<script type="application/json" id="webmcp-manifest"#{nonce_attr}#{autostart_attr}>#{json}</script>))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
class OriginTrial
|
|
5
|
+
def initialize(app, token: nil, warn_on_oac_opt_out: true, logger: WebMCP.config.logger)
|
|
6
|
+
@app, @token, @logger = app, token.to_s, logger
|
|
7
|
+
@warn_on_oac_opt_out = warn_on_oac_opt_out
|
|
8
|
+
@warned = false
|
|
9
|
+
@warning_lock = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(env)
|
|
13
|
+
response = @app.call(env)
|
|
14
|
+
return response if @token.empty?
|
|
15
|
+
status, headers, body = response
|
|
16
|
+
unless headers.keys.any? { |key| key.downcase == "origin-trial" }
|
|
17
|
+
headers = headers.dup
|
|
18
|
+
# Rack 3 requires lowercase field names; Rack 2 preserves conventional casing.
|
|
19
|
+
key = env["rack.version"]&.first.to_i >= 3 || (defined?(Rack::RELEASE) && Rack::RELEASE.to_i >= 3) ? "origin-trial" : "Origin-Trial"
|
|
20
|
+
headers[key] = @token
|
|
21
|
+
end
|
|
22
|
+
oac_key = headers.keys.find { |key| key.downcase == "origin-agent-cluster" }
|
|
23
|
+
if @warn_on_oac_opt_out && headers[oac_key] == "?0"
|
|
24
|
+
@warning_lock.synchronize do
|
|
25
|
+
unless @warned
|
|
26
|
+
@logger&.warn("WebMCP: Origin-Agent-Cluster: ?0 may cause SecurityError in older Origin-Trial builds; header left unchanged")
|
|
27
|
+
@warned = true
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
[status, headers, body]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.meta_tag(token)
|
|
35
|
+
return Value.html_safe("") if token.nil? || token.to_s.empty?
|
|
36
|
+
Value.html_safe(%(<meta http-equiv="origin-trial" content="#{Value.escape_html(token)}">))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
class Projection
|
|
5
|
+
def initialize(source, fingerprint)
|
|
6
|
+
@source = source
|
|
7
|
+
@metadata = Testing.source_metadata(source)
|
|
8
|
+
@fingerprint = Value.fingerprint(@metadata)
|
|
9
|
+
unless fingerprint == @fingerprint
|
|
10
|
+
raise DefinitionError, "source_fingerprint is missing or stale; current source_fingerprint: #{@fingerprint}"
|
|
11
|
+
end
|
|
12
|
+
@overrides = {}
|
|
13
|
+
@renames = {}
|
|
14
|
+
@notes = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def rename_params(**map)
|
|
18
|
+
@renames.merge!(Value.normalize(map))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def input_schema(&block)
|
|
22
|
+
raise DefinitionError, "input_schema requires a projection block" unless block
|
|
23
|
+
@schema_block = block
|
|
24
|
+
@notes << "input_schema override"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
%i[name title description max_response_chars].each do |field|
|
|
28
|
+
define_method(field) do |value|
|
|
29
|
+
@overrides[field] = value
|
|
30
|
+
@notes << "#{field} override"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def annotations(**values)
|
|
35
|
+
@overrides[:annotations] = values
|
|
36
|
+
@notes << "annotations explicitly declared"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def endpoint(**values)
|
|
40
|
+
@overrides[:endpoint] = values
|
|
41
|
+
@notes << "endpoint override"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def build(&block)
|
|
45
|
+
instance_eval(&block) if block
|
|
46
|
+
raise DefinitionError, "from_mcp requires an explicit annotations declaration (MCP hints are not inherited)" unless @overrides.key?(:annotations)
|
|
47
|
+
raise DefinitionError, "from_mcp requires an endpoint declaration" unless @overrides.key?(:endpoint)
|
|
48
|
+
schema = Value.normalize(@metadata["inputSchema"])
|
|
49
|
+
unless @renames.empty?
|
|
50
|
+
Schema.validate!(schema)
|
|
51
|
+
properties = schema.fetch("properties", {})
|
|
52
|
+
@renames.each do |old, replacement|
|
|
53
|
+
raise DefinitionError, "rename_params source is not a property: #{old}" unless properties.key?(old)
|
|
54
|
+
unless replacement.is_a?(String) && !replacement.empty?
|
|
55
|
+
raise DefinitionError, "rename_params destination must be a nonempty name"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
names = properties.keys.map { |key| @renames.fetch(key, key) }
|
|
59
|
+
raise DefinitionError, "rename_params destinations collide" unless names.uniq == names
|
|
60
|
+
schema["properties"] = properties.to_h { |key, value| [@renames.fetch(key, key), value] }
|
|
61
|
+
schema["required"] = schema["required"].map { |key| @renames.fetch(key, key) } if schema.key?("required")
|
|
62
|
+
@renames.each { |old, replacement| @notes << "rename parameter #{old} -> #{replacement}" }
|
|
63
|
+
end
|
|
64
|
+
schema = @schema_block.call(schema) if @schema_block
|
|
65
|
+
Tool.new(name: @metadata["name"], title: @metadata["title"], description: @metadata["description"],
|
|
66
|
+
input_schema: schema, **@overrides, projection_notes: @notes,
|
|
67
|
+
source_fingerprint: @fingerprint, source: @source)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
class Railtie < Rails::Railtie
|
|
5
|
+
config.webmcp = ActiveSupport::OrderedOptions.new
|
|
6
|
+
config.webmcp.origin_trial_token = ENV["WEBMCP_ORIGIN_TRIAL_TOKEN"]
|
|
7
|
+
|
|
8
|
+
initializer "webmcp.configure" do |app|
|
|
9
|
+
if Gem::Version.new(Rails.version) < Gem::Version.new("7.1")
|
|
10
|
+
raise DefinitionError, "WebMCP Rails integration requires Rails >= 7.1"
|
|
11
|
+
end
|
|
12
|
+
token = app.config.webmcp.origin_trial_token
|
|
13
|
+
WebMCP.config.origin_trial_token = token
|
|
14
|
+
WebMCP.config.logger = Rails.logger if Rails.logger
|
|
15
|
+
app.middleware.use WebMCP::OriginTrial, token: token unless token.nil? || token.empty?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "webmcp.helpers" do
|
|
19
|
+
ActiveSupport.on_load(:action_view) do
|
|
20
|
+
WebMCP::FormHelper.install!
|
|
21
|
+
include WebMCP::ViewHelpers
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
initializer "webmcp.assets", after: :load_config_initializers do |app|
|
|
26
|
+
if app.config.respond_to?(:assets)
|
|
27
|
+
# The parent preserves the logical path webmcp/runtime.js in both pipelines.
|
|
28
|
+
root = File.expand_path("../../app/assets/javascripts", __dir__)
|
|
29
|
+
app.config.assets.paths << root
|
|
30
|
+
app.config.assets.paths << File.join(root, "webmcp")
|
|
31
|
+
if app.config.assets.respond_to?(:precompile)
|
|
32
|
+
app.config.assets.precompile += ["webmcp/runtime.js"]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
config.after_initialize { WebMCP.freeze! }
|
|
38
|
+
|
|
39
|
+
rake_tasks do
|
|
40
|
+
load File.expand_path("../tasks/webmcp.rake", __dir__)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
class Registry
|
|
5
|
+
def initialize
|
|
6
|
+
@tools = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def register(tool)
|
|
10
|
+
raise DefinitionError, "registry is frozen" if frozen?
|
|
11
|
+
raise DefinitionError, "expected a WebMCP::Tool" unless tool.is_a?(Tool)
|
|
12
|
+
raise DefinitionError, "duplicate tool name: #{tool.name}" if @tools.key?(tool.name)
|
|
13
|
+
@tools[tool.name] = tool
|
|
14
|
+
tool
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(name)
|
|
18
|
+
@tools.fetch(name.to_s) { raise DefinitionError, "unknown tool name: #{name}" }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def tools
|
|
22
|
+
@tools.values.freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def freeze!
|
|
26
|
+
@tools.freeze
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
module Schema
|
|
5
|
+
SCALARS = %w[string number integer boolean].freeze
|
|
6
|
+
METADATA = %w[enum description default minimum maximum maxLength maxItems].freeze
|
|
7
|
+
RESERVED = %w[__proto__ constructor prototype].freeze
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def validate!(schema)
|
|
11
|
+
Value.keys!(schema, %w[type properties required description], "input_schema")
|
|
12
|
+
raise DefinitionError, 'input_schema type must be object' unless schema["type"] == "object"
|
|
13
|
+
properties = schema.fetch("properties", {})
|
|
14
|
+
Value.hash!(properties, "properties")
|
|
15
|
+
properties.each do |name, property|
|
|
16
|
+
raise DefinitionError, "reserved property name: #{name}" if RESERVED.include?(name)
|
|
17
|
+
validate_property!(property, name)
|
|
18
|
+
end
|
|
19
|
+
if schema.key?("description") && !schema["description"].is_a?(String)
|
|
20
|
+
raise DefinitionError, "schema description must be a string"
|
|
21
|
+
end
|
|
22
|
+
if schema.key?("required")
|
|
23
|
+
required = schema["required"]
|
|
24
|
+
unless required.is_a?(Array) && required.all? { |key| key.is_a?(String) && properties.key?(key) } && required.uniq == required
|
|
25
|
+
raise DefinitionError, "required must contain unique declared property names"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
schema
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate_property!(property, label, scalar_only: false)
|
|
32
|
+
Value.keys!(property, ["type", *METADATA, *(scalar_only ? [] : ["items"])], "property #{label}")
|
|
33
|
+
type = property["type"]
|
|
34
|
+
unless SCALARS.include?(type) || (!scalar_only && type == "array")
|
|
35
|
+
raise DefinitionError, "#{label}: only scalar properties and arrays of scalars are supported"
|
|
36
|
+
end
|
|
37
|
+
if type == "array"
|
|
38
|
+
validate_property!(property["items"], "#{label}.items", scalar_only: true)
|
|
39
|
+
elsif property.key?("items")
|
|
40
|
+
raise DefinitionError, "#{label}: items requires array type"
|
|
41
|
+
end
|
|
42
|
+
if property.key?("description") && !property["description"].is_a?(String)
|
|
43
|
+
raise DefinitionError, "#{label}: description must be a string"
|
|
44
|
+
end
|
|
45
|
+
%w[minimum maximum maxLength maxItems].each do |key|
|
|
46
|
+
next unless property.key?(key)
|
|
47
|
+
v = property[key]
|
|
48
|
+
unless v.is_a?(Integer) && (!%w[maxLength maxItems].include?(key) || v >= 0)
|
|
49
|
+
raise DefinitionError, "#{label}: #{key} must be an integer (lengths must be nonnegative)"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
if property.key?("enum")
|
|
53
|
+
values = property["enum"]
|
|
54
|
+
unless values.is_a?(Array) && !values.empty? && values.all? { |v| matches?(v, property) }
|
|
55
|
+
raise DefinitionError, "#{label}: enum must be a nonempty array matching the property type"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
if property.key?("default") && !matches?(property["default"], property)
|
|
59
|
+
raise DefinitionError, "#{label}: default must match the property type"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def matches?(value, schema)
|
|
64
|
+
case schema["type"]
|
|
65
|
+
when "string" then value.is_a?(String)
|
|
66
|
+
when "number", "integer" then value.is_a?(Integer)
|
|
67
|
+
when "boolean" then value == true || value == false
|
|
68
|
+
when "array" then value.is_a?(Array) && value.all? { |item| matches?(item, schema["items"]) }
|
|
69
|
+
else false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
module Testing
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def source_metadata(source)
|
|
8
|
+
raise DefinitionError, "MCP tool must respond to to_h" unless source.respond_to?(:to_h)
|
|
9
|
+
raw = source.to_h
|
|
10
|
+
Value.hash!(raw, "MCP tool metadata")
|
|
11
|
+
# Ignore MCP annotations (and other unrelated metadata) even for hashing.
|
|
12
|
+
selected = raw.select { |key, _| %w[name title description inputSchema].include?(key.to_s) }
|
|
13
|
+
Value.normalize(selected)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def current_source_fingerprint(source)
|
|
17
|
+
Value.fingerprint(source_metadata(source))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def assert_projection_fresh(tool)
|
|
21
|
+
raise DefinitionError, "tool is not an MCP projection" unless tool.source
|
|
22
|
+
current = current_source_fingerprint(tool.source)
|
|
23
|
+
unless tool.source_fingerprint == current
|
|
24
|
+
raise DefinitionError, "MCP projection #{tool.name} is stale; current source_fingerprint: #{current}"
|
|
25
|
+
end
|
|
26
|
+
true
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/webmcp/tool.rb
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
class Tool
|
|
5
|
+
ANNOTATIONS = { "read_only" => "readOnlyHint", "untrusted_content" => "untrustedContentHint",
|
|
6
|
+
"consequential" => "consequentialHint", "debugging" => "debugging" }.freeze
|
|
7
|
+
METHODS = %w[GET POST PATCH PUT DELETE].freeze
|
|
8
|
+
FORBIDDEN_PARAMS = %w[_method authenticity_token csrfmiddlewaretoken __proto__ constructor prototype].freeze
|
|
9
|
+
attr_reader :name, :description, :input_schema, :endpoint, :annotations, :title,
|
|
10
|
+
:max_response_chars, :projection_notes, :source_fingerprint, :source
|
|
11
|
+
|
|
12
|
+
def self.define(**options)
|
|
13
|
+
new(**options)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_mcp(source, source_fingerprint: nil, &block)
|
|
17
|
+
Projection.new(source, source_fingerprint).build(&block)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.validate_name!(name)
|
|
21
|
+
unless name.is_a?(String) && name.length.between?(1, 128) && /\A[A-Za-z0-9_.-]+\z/.match?(name)
|
|
22
|
+
raise DefinitionError, "tool name must be 1..128 characters from A-Z, a-z, 0-9, _, . and -"
|
|
23
|
+
end
|
|
24
|
+
name
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(name:, description:, input_schema:, endpoint:, annotations: {}, title: nil,
|
|
28
|
+
max_response_chars: nil, projection_notes: [], source_fingerprint: nil, source: nil)
|
|
29
|
+
@name = Value.normalize(name)
|
|
30
|
+
self.class.validate_name!(@name)
|
|
31
|
+
@description = Value.normalize(description)
|
|
32
|
+
unless @description.is_a?(String) && !@description.empty?
|
|
33
|
+
raise DefinitionError, "description must be a nonempty string"
|
|
34
|
+
end
|
|
35
|
+
@title = Value.normalize(title)
|
|
36
|
+
raise DefinitionError, "title must be a string" unless @title.nil? || @title.is_a?(String)
|
|
37
|
+
@input_schema = Schema.validate!(Value.normalize(input_schema))
|
|
38
|
+
@annotations = Value.normalize(annotations)
|
|
39
|
+
Value.hash!(@annotations, "annotations")
|
|
40
|
+
unless (@annotations.keys - ANNOTATIONS.keys).empty?
|
|
41
|
+
raise DefinitionError, "MCP and WebMCP annotation sets differ; WebMCP accepts only #{ANNOTATIONS.keys.join(', ')}; MCP destructive/idempotent/openWorld hints are not mapped"
|
|
42
|
+
end
|
|
43
|
+
unless @annotations.values.all? { |v| v == true || v == false }
|
|
44
|
+
raise DefinitionError, "annotations must be booleans"
|
|
45
|
+
end
|
|
46
|
+
@endpoint = validate_endpoint!(Value.normalize(endpoint))
|
|
47
|
+
@max_response_chars = Value.normalize(max_response_chars)
|
|
48
|
+
unless @max_response_chars.nil? || (@max_response_chars.is_a?(Integer) && @max_response_chars.positive?)
|
|
49
|
+
raise DefinitionError, "max_response_chars must be a positive integer"
|
|
50
|
+
end
|
|
51
|
+
@projection_notes = Value.normalize(projection_notes)
|
|
52
|
+
@source_fingerprint = Value.normalize(source_fingerprint)
|
|
53
|
+
@source = source # Keep the live source for drift checks; never freeze the SDK class.
|
|
54
|
+
[@name, @description, @title, @input_schema, @annotations, @endpoint,
|
|
55
|
+
@projection_notes, @source_fingerprint].each { |v| Value.deep_freeze(v) }
|
|
56
|
+
WebMCP.config.logger&.warn("WebMCP name exceeds Chrome's recommended 30-character budget: #{@name}") if @name.length > 30
|
|
57
|
+
WebMCP.config.logger&.warn("WebMCP description exceeds Chrome's recommended 500-character budget: #{@name}") if @description.length > 500
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_manifest_entry(transport)
|
|
62
|
+
entry = { "name" => name, "description" => description, "inputSchema" => input_schema,
|
|
63
|
+
"annotations" => annotations.select { |_, v| v }.to_h { |k, v| [ANNOTATIONS.fetch(k), v] },
|
|
64
|
+
"endpoint" => { "path" => endpoint.fetch("path"), "method" => endpoint.fetch("method") } }
|
|
65
|
+
entry["title"] = title unless title.nil?
|
|
66
|
+
entry["maxResponseChars"] = max_response_chars unless max_response_chars.nil?
|
|
67
|
+
entry["endpoint"]["paramMap"] = endpoint["param_map"] unless endpoint["param_map"].empty?
|
|
68
|
+
entry["endpoint"]["arrayFormat"] = endpoint["array_format"] if endpoint["array_format"]
|
|
69
|
+
entry["fingerprint"] = Value.fingerprint(entry.merge("transport" => Manifest.normalize_transport(transport)))
|
|
70
|
+
Value.normalize(entry)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def validate_endpoint!(endpoint)
|
|
76
|
+
Value.keys!(endpoint, %w[path method param_map array_format], "endpoint")
|
|
77
|
+
path = endpoint["path"]
|
|
78
|
+
unless path.is_a?(String) && path.start_with?("/") && !path.start_with?("//") && !/[\\:\x00-\x1f\x7f]/.match?(path)
|
|
79
|
+
raise DefinitionError, "endpoint path must be same-origin, start with /, and contain no // prefix, backslash, colon or control character"
|
|
80
|
+
end
|
|
81
|
+
method = endpoint["method"].to_s.upcase
|
|
82
|
+
raise DefinitionError, "endpoint method must be GET, POST, PATCH, PUT or DELETE" unless METHODS.include?(method)
|
|
83
|
+
if method == "GET" && annotations["read_only"] != true
|
|
84
|
+
raise DefinitionError, "GET endpoints require read_only: true"
|
|
85
|
+
end
|
|
86
|
+
map = endpoint.fetch("param_map", {})
|
|
87
|
+
Value.hash!(map, "param_map")
|
|
88
|
+
properties = input_schema.fetch("properties", {})
|
|
89
|
+
map.each do |key, destination|
|
|
90
|
+
raise DefinitionError, "param_map key is not in schema properties: #{key}" unless properties.key?(key)
|
|
91
|
+
unless destination.is_a?(String) && /\A[A-Za-z][A-Za-z0-9_]*\z/.match?(destination) && !FORBIDDEN_PARAMS.include?(destination)
|
|
92
|
+
raise DefinitionError, "invalid or reserved param_map destination: #{destination.inspect}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
destinations = properties.keys.map { |key| map.fetch(key, key) }
|
|
96
|
+
raise DefinitionError, "param_map destinations collide" unless destinations.uniq == destinations
|
|
97
|
+
format = endpoint["array_format"]
|
|
98
|
+
raise DefinitionError, "array_format must be brackets or repeat" unless format.nil? || %w[brackets repeat].include?(format)
|
|
99
|
+
format ||= "brackets" if method == "GET" && properties.values.any? { |p| p["type"] == "array" }
|
|
100
|
+
{ "path" => path, "method" => method, "param_map" => map, "array_format" => format }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
data/lib/webmcp/value.rb
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WebMCP
|
|
4
|
+
# Copy metadata, normalize Ruby symbol keys/values and reject non-JSON values.
|
|
5
|
+
module Value
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def normalize(value, ancestors = [])
|
|
9
|
+
if value.is_a?(Hash) || value.is_a?(Array)
|
|
10
|
+
raise DefinitionError, "metadata cannot contain circular references" if ancestors.include?(value.object_id)
|
|
11
|
+
ancestors = ancestors + [value.object_id]
|
|
12
|
+
end
|
|
13
|
+
case value
|
|
14
|
+
when Hash
|
|
15
|
+
value.each_with_object({}) do |(key, item), result|
|
|
16
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
17
|
+
raise DefinitionError, "metadata keys must be strings or symbols"
|
|
18
|
+
end
|
|
19
|
+
key = key.to_s
|
|
20
|
+
raise DefinitionError, "duplicate metadata key: #{key}" if result.key?(key)
|
|
21
|
+
result[key] = normalize(item, ancestors)
|
|
22
|
+
end
|
|
23
|
+
when Array then value.map { |item| normalize(item, ancestors) }
|
|
24
|
+
when String
|
|
25
|
+
string = String.new(value).encode(Encoding::UTF_8)
|
|
26
|
+
raise DefinitionError, "metadata must be valid UTF-8" unless string.valid_encoding?
|
|
27
|
+
string
|
|
28
|
+
when Symbol then value.to_s
|
|
29
|
+
when Integer
|
|
30
|
+
raise DefinitionError, "metadata integers must be within +/-2^53" if value.abs > 2**53
|
|
31
|
+
value
|
|
32
|
+
when TrueClass, FalseClass, NilClass then value
|
|
33
|
+
else raise DefinitionError, "metadata must contain JSON values; floats are not supported"
|
|
34
|
+
end
|
|
35
|
+
rescue EncodingError => e
|
|
36
|
+
raise DefinitionError, "metadata must be UTF-8: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def deep_freeze(value)
|
|
40
|
+
case value
|
|
41
|
+
when Hash then value.each { |k, v| deep_freeze(k); deep_freeze(v) }
|
|
42
|
+
when Array then value.each { |v| deep_freeze(v) }
|
|
43
|
+
end
|
|
44
|
+
value.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def sorted(value)
|
|
48
|
+
case value
|
|
49
|
+
when Hash then value.keys.sort.to_h { |key| [key, sorted(value[key])] }
|
|
50
|
+
when Array then value.map { |item| sorted(item) }
|
|
51
|
+
else value
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fingerprint(value)
|
|
56
|
+
"sha256:#{Digest::SHA256.hexdigest(JSON.generate(sorted(normalize(value))))}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def hash!(value, label)
|
|
60
|
+
raise DefinitionError, "#{label} must be an object" unless value.is_a?(Hash)
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def keys!(value, allowed, label)
|
|
65
|
+
hash!(value, label)
|
|
66
|
+
unknown = value.keys - allowed
|
|
67
|
+
raise DefinitionError, "unsupported #{label} keys: #{unknown.join(', ')}" unless unknown.empty?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def html_safe(value)
|
|
71
|
+
value.respond_to?(:html_safe) ? value.html_safe : value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# String.new deliberately discards ActiveSupport's safe-string marker.
|
|
75
|
+
def escape_html(value)
|
|
76
|
+
CGI.escapeHTML(String.new(value.to_s))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/webmcp/version.rb
CHANGED