webmcp 0.0.1 → 0.1.0

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.
@@ -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
@@ -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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module WebMCP
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end