actionmcp 0.111.1 → 0.200.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -18
  3. data/Rakefile +3 -0
  4. data/app/controllers/action_mcp/application_controller.rb +166 -109
  5. data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
  6. data/app/helpers/action_mcp/apps_helper.rb +48 -0
  7. data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
  8. data/app/models/action_mcp/session/message.rb +8 -3
  9. data/app/models/action_mcp/session/task.rb +15 -5
  10. data/app/models/action_mcp/session.rb +93 -19
  11. data/config/routes.rb +1 -1
  12. data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
  13. data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
  14. data/lib/action_mcp/apps/view_manifest.rb +170 -0
  15. data/lib/action_mcp/apps.rb +17 -2
  16. data/lib/action_mcp/configuration.rb +14 -30
  17. data/lib/action_mcp/content/audio.rb +4 -1
  18. data/lib/action_mcp/content/base.rb +3 -1
  19. data/lib/action_mcp/content/image.rb +4 -1
  20. data/lib/action_mcp/content/resource.rb +15 -14
  21. data/lib/action_mcp/content/resource_link.rb +15 -5
  22. data/lib/action_mcp/content/text.rb +2 -1
  23. data/lib/action_mcp/content/validation.rb +253 -0
  24. data/lib/action_mcp/content.rb +3 -1
  25. data/lib/action_mcp/dev/runner.rb +203 -0
  26. data/lib/action_mcp/engine.rb +24 -2
  27. data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
  28. data/lib/action_mcp/logging/logger.rb +16 -11
  29. data/lib/action_mcp/logging.rb +34 -1
  30. data/lib/action_mcp/middleware/origin_validation.rb +9 -13
  31. data/lib/action_mcp/output_schema_builder.rb +2 -0
  32. data/lib/action_mcp/prompt_response.rb +12 -2
  33. data/lib/action_mcp/protocol_validator.rb +513 -0
  34. data/lib/action_mcp/renderable.rb +4 -4
  35. data/lib/action_mcp/resource.rb +15 -11
  36. data/lib/action_mcp/resource_template.rb +105 -90
  37. data/lib/action_mcp/resource_templates_registry.rb +31 -97
  38. data/lib/action_mcp/schema_validator.rb +43 -0
  39. data/lib/action_mcp/server/base_session.rb +134 -28
  40. data/lib/action_mcp/server/base_session_store.rb +1 -1
  41. data/lib/action_mcp/server/capabilities.rb +10 -30
  42. data/lib/action_mcp/server/client_notifications.rb +63 -0
  43. data/lib/action_mcp/server/completions.rb +68 -0
  44. data/lib/action_mcp/server/elicitation.rb +47 -25
  45. data/lib/action_mcp/server/elicitation_request.rb +136 -51
  46. data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
  47. data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
  48. data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
  49. data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
  50. data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
  51. data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
  52. data/lib/action_mcp/server/messaging_service.rb +3 -1
  53. data/lib/action_mcp/server/prompts.rb +5 -3
  54. data/lib/action_mcp/server/resources.rb +35 -2
  55. data/lib/action_mcp/server/response_collector.rb +5 -1
  56. data/lib/action_mcp/server/roots.rb +15 -2
  57. data/lib/action_mcp/server/sampling.rb +36 -1
  58. data/lib/action_mcp/server/sampling_request.rb +317 -55
  59. data/lib/action_mcp/server/tasks.rb +27 -45
  60. data/lib/action_mcp/server/tool_result.rb +27 -0
  61. data/lib/action_mcp/server/tools.rb +12 -10
  62. data/lib/action_mcp/server/transport_handler.rb +3 -0
  63. data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
  64. data/lib/action_mcp/tool.rb +133 -199
  65. data/lib/action_mcp/tool_response.rb +35 -4
  66. data/lib/action_mcp/tools_registry.rb +1 -1
  67. data/lib/action_mcp/version.rb +1 -1
  68. data/lib/action_mcp.rb +2 -5
  69. data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
  70. data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
  71. data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
  72. data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
  73. data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
  74. data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
  75. data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
  76. data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
  77. data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
  78. data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
  79. data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
  80. data/lib/generators/action_mcp/view/view_generator.rb +57 -0
  81. data/lib/tasks/action_mcp_apps.rake +17 -0
  82. data/lib/tasks/action_mcp_tasks.rake +135 -74
  83. metadata +47 -17
@@ -7,6 +7,8 @@ require "logger"
7
7
  module ActionMCP
8
8
  # Global MCP logging interface
9
9
  module Logging
10
+ SESSION_LEVEL_KEY = "action_mcp_logging_level"
11
+
10
12
  class << self
11
13
  # Get the global logging state
12
14
  # @return [ActionMCP::Logging::State] The global state
@@ -54,12 +56,43 @@ module ActionMCP
54
56
  end
55
57
  alias_method :set_level, :level=
56
58
 
59
+ # Return the minimum log level negotiated for a session, falling back to
60
+ # the configured server default until the client sends logging/setLevel.
61
+ def level_for(session)
62
+ session_data = session.respond_to?(:session_data) && session.session_data
63
+ stored_level = session_data[SESSION_LEVEL_KEY] if session_data.is_a?(Hash)
64
+ Level.name_for(Level.coerce(stored_level || level))
65
+ end
66
+
67
+ def set_level_for(session, new_level)
68
+ normalized_level = Level.name_for(Level.coerce(new_level))
69
+ session_data = session.session_data.is_a?(Hash) ? session.session_data.deep_dup : {}
70
+ session_data[SESSION_LEVEL_KEY] = normalized_level.to_s
71
+
72
+ if session.respond_to?(:update!)
73
+ session.update!(session_data: session_data)
74
+ else
75
+ session.session_data = session_data
76
+ session.save! if session.respond_to?(:save!)
77
+ end
78
+
79
+ normalized_level
80
+ end
81
+
82
+ def supported_by?(session)
83
+ return false unless session&.respond_to?(:server_capabilities)
84
+
85
+ capabilities = session.server_capabilities
86
+ capabilities.is_a?(Hash) &&
87
+ (capabilities.key?("logging") || capabilities.key?(:logging))
88
+ end
89
+
57
90
  # Create a logger for the given session
58
91
  # @param name [String, nil] Optional logger name
59
92
  # @param session [ActionMCP::Session] The MCP session
60
93
  # @return [ActionMCP::Logging::Logger, ActionMCP::Logging::NullLogger] logger instance
61
94
  def logger(name: nil, session:)
62
- if enabled?
95
+ if enabled? && supported_by?(session)
63
96
  Logger.new(name: name, session: session, state: state)
64
97
  else
65
98
  NullLogger.new
@@ -7,8 +7,8 @@ module ActionMCP
7
7
  # Rack middleware that validates the Origin header on MCP requests to
8
8
  # prevent DNS rebinding attacks per the MCP Streamable HTTP security
9
9
  # section. Non-browser clients (Claude Desktop, curl) never send Origin
10
- # and are always allowed. Present Origins must match either
11
- # `ActionMCP.configuration.allowed_origins` or the server's own host.
10
+ # and are always allowed. Present Origins must match the explicitly trusted
11
+ # hosts in `ActionMCP.configuration.allowed_origins`.
12
12
  #
13
13
  # Runs as middleware — same layer as `ActionDispatch::HostAuthorization` —
14
14
  # so invalid requests are rejected before they reach routing.
@@ -28,7 +28,7 @@ module ActionMCP
28
28
 
29
29
  request = ActionDispatch::Request.new(env)
30
30
  origin = request.origin
31
- return @app.call(env) if origin.nil? || origin.empty?
31
+ return @app.call(env) if origin.nil?
32
32
  return @app.call(env) if origin_allowed?(origin, request)
33
33
 
34
34
  forbidden_response
@@ -49,21 +49,17 @@ module ActionMCP
49
49
  end
50
50
  end
51
51
 
52
- def origin_allowed?(origin, request)
52
+ def origin_allowed?(origin, _request)
53
53
  return false if origin == "null"
54
54
 
55
55
  uri = URI.parse(origin)
56
- return false if uri.host.nil? || uri.host.empty?
56
+ return false unless %w[http https].include?(uri.scheme)
57
+ return false if uri.host.nil? || uri.host.empty? || uri.userinfo || uri.query || uri.fragment
58
+ return false unless uri.path.empty?
57
59
 
58
60
  origin_host = strip_brackets(uri.host)
59
- allowed = ActionMCP.configuration.allowed_origins
60
-
61
- if allowed && !allowed.empty?
62
- allowed.any? { |pattern| match?(pattern, origin_host) }
63
- else
64
- # ActionDispatch::Request#host strips the port and handles IPv6 brackets
65
- origin_host.casecmp?(strip_brackets(request.host))
66
- end
61
+ allowed = Array(ActionMCP.configuration.allowed_origins)
62
+ allowed.any? { |pattern| match?(pattern, origin_host) }
67
63
  rescue URI::InvalidURIError
68
64
  false
69
65
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "schema_helpers"
4
+ require_relative "schema_validator"
4
5
 
5
6
  module ActionMCP
6
7
  # DSL builder for creating output JSON Schema from Ruby-like syntax
@@ -173,6 +174,7 @@ module ActionMCP
173
174
  # Generate the final JSON Schema
174
175
  def to_json_schema
175
176
  schema = {
177
+ "$schema" => SchemaValidator::DEFAULT_DIALECT,
176
178
  "type" => "object",
177
179
  "properties" => @properties
178
180
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  module ActionMCP
4
4
  class PromptResponse < BaseResponse
5
+ ROLES = %w[user assistant].freeze
6
+
5
7
  attr_reader :messages
6
8
 
7
9
  # Delegate methods to the underlying messages array
@@ -14,7 +16,13 @@ module ActionMCP
14
16
 
15
17
  # Add a message to the response
16
18
  def add_message(role:, content:)
17
- @messages << { role: role, content: content }
19
+ normalized_role = role.to_s
20
+ unless ROLES.include?(normalized_role)
21
+ raise ArgumentError, "role must be one of: #{ROLES.join(', ')}"
22
+ end
23
+ raise ArgumentError, "content must be an MCP content object" unless content.is_a?(Hash)
24
+
25
+ @messages << { role: normalized_role, content: Content::Validation.copy_content_block!(content) }
18
26
  self
19
27
  end
20
28
 
@@ -26,9 +34,11 @@ module ActionMCP
26
34
 
27
35
  # Implementation of build_success_hash for PromptResponse
28
36
  def build_success_hash
29
- {
37
+ result = {
30
38
  messages: @messages
31
39
  }
40
+ Content::Validation.validate_prompt_result!(result)
41
+ result
32
42
  end
33
43
 
34
44
  # Implementation of compare_with_same_class for PromptResponse
@@ -0,0 +1,513 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json_schemer"
4
+
5
+ module ActionMCP
6
+ # Validates the stable MCP JSON-RPC envelope and client message parameters.
7
+ class ProtocolValidator
8
+ ValidationError = Data.define(:code, :message)
9
+
10
+ ID_SCHEMA = { "type" => [ "string", "integer" ] }.freeze
11
+ PROGRESS_TOKEN_SCHEMA = { "type" => [ "string", "integer" ] }.freeze
12
+
13
+ REQUEST_META_SCHEMA = {
14
+ "type" => "object",
15
+ "properties" => {
16
+ "progressToken" => PROGRESS_TOKEN_SCHEMA
17
+ },
18
+ "additionalProperties" => true
19
+ }.freeze
20
+
21
+ NOTIFICATION_META_SCHEMA = {
22
+ "type" => "object",
23
+ "additionalProperties" => true
24
+ }.freeze
25
+
26
+ REQUEST_PARAMS_SCHEMA = {
27
+ "type" => "object",
28
+ "properties" => {
29
+ "_meta" => REQUEST_META_SCHEMA
30
+ },
31
+ "additionalProperties" => true
32
+ }.freeze
33
+
34
+ NOTIFICATION_PARAMS_SCHEMA = {
35
+ "type" => "object",
36
+ "properties" => {
37
+ "_meta" => NOTIFICATION_META_SCHEMA
38
+ },
39
+ "additionalProperties" => true
40
+ }.freeze
41
+
42
+ PAGINATED_PARAMS_SCHEMA = {
43
+ "type" => "object",
44
+ "properties" => {
45
+ "_meta" => REQUEST_META_SCHEMA,
46
+ "cursor" => { "type" => "string" }
47
+ },
48
+ "additionalProperties" => true
49
+ }.freeze
50
+
51
+ ICON_SCHEMA = {
52
+ "type" => "object",
53
+ "required" => [ "src" ],
54
+ "properties" => {
55
+ "src" => { "type" => "string", "format" => "uri" },
56
+ "mimeType" => { "type" => "string" },
57
+ "sizes" => {
58
+ "type" => "array",
59
+ "items" => { "type" => "string" }
60
+ },
61
+ "theme" => {
62
+ "type" => "string",
63
+ "enum" => %w[dark light]
64
+ }
65
+ },
66
+ "additionalProperties" => true
67
+ }.freeze
68
+
69
+ IMPLEMENTATION_SCHEMA = {
70
+ "type" => "object",
71
+ "required" => %w[name version],
72
+ "properties" => {
73
+ "name" => { "type" => "string" },
74
+ "title" => { "type" => "string" },
75
+ "version" => { "type" => "string" },
76
+ "description" => { "type" => "string" },
77
+ "websiteUrl" => { "type" => "string", "format" => "uri" },
78
+ "icons" => {
79
+ "type" => "array",
80
+ "items" => ICON_SCHEMA
81
+ }
82
+ },
83
+ "additionalProperties" => true
84
+ }.freeze
85
+
86
+ EMPTY_CAPABILITY_SCHEMA = {
87
+ "type" => "object",
88
+ "additionalProperties" => true
89
+ }.freeze
90
+
91
+ CLIENT_CAPABILITIES_SCHEMA = {
92
+ "type" => "object",
93
+ "properties" => {
94
+ "elicitation" => {
95
+ "type" => "object",
96
+ "properties" => {
97
+ "form" => EMPTY_CAPABILITY_SCHEMA,
98
+ "url" => EMPTY_CAPABILITY_SCHEMA
99
+ },
100
+ "additionalProperties" => true
101
+ },
102
+ "experimental" => {
103
+ "type" => "object",
104
+ "additionalProperties" => EMPTY_CAPABILITY_SCHEMA
105
+ },
106
+ "roots" => {
107
+ "type" => "object",
108
+ "properties" => {
109
+ "listChanged" => { "type" => "boolean" }
110
+ },
111
+ "additionalProperties" => true
112
+ },
113
+ "sampling" => {
114
+ "type" => "object",
115
+ "properties" => {
116
+ "context" => EMPTY_CAPABILITY_SCHEMA,
117
+ "tools" => EMPTY_CAPABILITY_SCHEMA
118
+ },
119
+ "additionalProperties" => true
120
+ },
121
+ "tasks" => {
122
+ "type" => "object",
123
+ "properties" => {
124
+ "cancel" => EMPTY_CAPABILITY_SCHEMA,
125
+ "list" => EMPTY_CAPABILITY_SCHEMA,
126
+ "requests" => {
127
+ "type" => "object",
128
+ "properties" => {
129
+ "elicitation" => {
130
+ "type" => "object",
131
+ "properties" => {
132
+ "create" => EMPTY_CAPABILITY_SCHEMA
133
+ },
134
+ "additionalProperties" => true
135
+ },
136
+ "sampling" => {
137
+ "type" => "object",
138
+ "properties" => {
139
+ "createMessage" => EMPTY_CAPABILITY_SCHEMA
140
+ },
141
+ "additionalProperties" => true
142
+ }
143
+ },
144
+ "additionalProperties" => true
145
+ }
146
+ },
147
+ "additionalProperties" => true
148
+ }
149
+ },
150
+ "additionalProperties" => true
151
+ }.freeze
152
+
153
+ INITIALIZE_PARAMS_SCHEMA = {
154
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
155
+ "type" => "object",
156
+ "required" => %w[protocolVersion capabilities clientInfo],
157
+ "properties" => {
158
+ "_meta" => REQUEST_META_SCHEMA,
159
+ "protocolVersion" => { "type" => "string" },
160
+ "capabilities" => CLIENT_CAPABILITIES_SCHEMA,
161
+ "clientInfo" => IMPLEMENTATION_SCHEMA
162
+ },
163
+ "additionalProperties" => true
164
+ }.freeze
165
+
166
+ RESOURCE_URI_PARAMS_SCHEMA = {
167
+ "type" => "object",
168
+ "required" => [ "uri" ],
169
+ "properties" => {
170
+ "_meta" => REQUEST_META_SCHEMA,
171
+ "uri" => { "type" => "string", "format" => "uri" }
172
+ },
173
+ "additionalProperties" => true
174
+ }.freeze
175
+
176
+ GET_PROMPT_PARAMS_SCHEMA = {
177
+ "type" => "object",
178
+ "required" => [ "name" ],
179
+ "properties" => {
180
+ "_meta" => REQUEST_META_SCHEMA,
181
+ "name" => { "type" => "string" },
182
+ "arguments" => {
183
+ "type" => "object",
184
+ "additionalProperties" => { "type" => "string" }
185
+ }
186
+ },
187
+ "additionalProperties" => true
188
+ }.freeze
189
+
190
+ TASK_METADATA_SCHEMA = {
191
+ "type" => "object",
192
+ "properties" => {
193
+ "ttl" => { "type" => "integer" }
194
+ },
195
+ "additionalProperties" => true
196
+ }.freeze
197
+
198
+ CALL_TOOL_PARAMS_SCHEMA = {
199
+ "type" => "object",
200
+ "required" => [ "name" ],
201
+ "properties" => {
202
+ "_meta" => REQUEST_META_SCHEMA,
203
+ "name" => { "type" => "string" },
204
+ "arguments" => {
205
+ "type" => "object",
206
+ "additionalProperties" => true
207
+ },
208
+ "task" => TASK_METADATA_SCHEMA
209
+ },
210
+ "additionalProperties" => true
211
+ }.freeze
212
+
213
+ TASK_ID_PARAMS_SCHEMA = {
214
+ "type" => "object",
215
+ "required" => [ "taskId" ],
216
+ "properties" => {
217
+ "taskId" => { "type" => "string" }
218
+ },
219
+ "additionalProperties" => true
220
+ }.freeze
221
+
222
+ SET_LEVEL_PARAMS_SCHEMA = {
223
+ "type" => "object",
224
+ "required" => [ "level" ],
225
+ "properties" => {
226
+ "_meta" => REQUEST_META_SCHEMA,
227
+ "level" => {
228
+ "type" => "string",
229
+ "enum" => %w[alert critical debug emergency error info notice warning]
230
+ }
231
+ },
232
+ "additionalProperties" => true
233
+ }.freeze
234
+
235
+ COMPLETE_PARAMS_SCHEMA = {
236
+ "type" => "object",
237
+ "required" => %w[argument ref],
238
+ "properties" => {
239
+ "_meta" => REQUEST_META_SCHEMA,
240
+ "argument" => {
241
+ "type" => "object",
242
+ "required" => %w[name value],
243
+ "properties" => {
244
+ "name" => { "type" => "string" },
245
+ "value" => { "type" => "string" }
246
+ },
247
+ "additionalProperties" => true
248
+ },
249
+ "context" => {
250
+ "type" => "object",
251
+ "properties" => {
252
+ "arguments" => {
253
+ "type" => "object",
254
+ "additionalProperties" => { "type" => "string" }
255
+ }
256
+ },
257
+ "additionalProperties" => true
258
+ },
259
+ "ref" => {
260
+ "anyOf" => [
261
+ {
262
+ "type" => "object",
263
+ "required" => %w[name type],
264
+ "properties" => {
265
+ "name" => { "type" => "string" },
266
+ "title" => { "type" => "string" },
267
+ "type" => { "const" => "ref/prompt" }
268
+ },
269
+ "additionalProperties" => true
270
+ },
271
+ {
272
+ "type" => "object",
273
+ "required" => %w[type uri],
274
+ "properties" => {
275
+ "type" => { "const" => "ref/resource" },
276
+ "uri" => { "type" => "string", "format" => "uri-template" }
277
+ },
278
+ "additionalProperties" => true
279
+ }
280
+ ]
281
+ }
282
+ },
283
+ "additionalProperties" => true
284
+ }.freeze
285
+
286
+ CANCELLED_NOTIFICATION_PARAMS_SCHEMA = {
287
+ "type" => "object",
288
+ "properties" => {
289
+ "_meta" => NOTIFICATION_META_SCHEMA,
290
+ "reason" => { "type" => "string" },
291
+ "requestId" => ID_SCHEMA
292
+ },
293
+ "additionalProperties" => true
294
+ }.freeze
295
+
296
+ PROGRESS_NOTIFICATION_PARAMS_SCHEMA = {
297
+ "type" => "object",
298
+ "required" => %w[progress progressToken],
299
+ "properties" => {
300
+ "_meta" => NOTIFICATION_META_SCHEMA,
301
+ "message" => { "type" => "string" },
302
+ "progress" => { "type" => "number" },
303
+ "progressToken" => PROGRESS_TOKEN_SCHEMA,
304
+ "total" => { "type" => "number" }
305
+ },
306
+ "additionalProperties" => true
307
+ }.freeze
308
+
309
+ TASK_STATUS_NOTIFICATION_PARAMS_SCHEMA = {
310
+ "type" => "object",
311
+ "required" => %w[createdAt lastUpdatedAt status taskId ttl],
312
+ "properties" => {
313
+ "_meta" => NOTIFICATION_META_SCHEMA,
314
+ "createdAt" => { "type" => "string" },
315
+ "lastUpdatedAt" => { "type" => "string" },
316
+ "pollInterval" => { "type" => "integer" },
317
+ "status" => {
318
+ "type" => "string",
319
+ "enum" => %w[cancelled completed failed input_required working]
320
+ },
321
+ "statusMessage" => { "type" => "string" },
322
+ "taskId" => { "type" => "string" },
323
+ "ttl" => { "type" => [ "integer", "null" ] }
324
+ },
325
+ "additionalProperties" => true
326
+ }.freeze
327
+
328
+ MESSAGE_SCHEMA = {
329
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
330
+ "anyOf" => [
331
+ {
332
+ "type" => "object",
333
+ "required" => %w[jsonrpc id method],
334
+ "properties" => {
335
+ "jsonrpc" => { "const" => "2.0" },
336
+ "id" => ID_SCHEMA,
337
+ "method" => { "type" => "string" },
338
+ "params" => { "type" => "object", "additionalProperties" => true }
339
+ },
340
+ "additionalProperties" => true
341
+ },
342
+ {
343
+ "type" => "object",
344
+ "required" => %w[jsonrpc method],
345
+ "not" => { "required" => [ "id" ] },
346
+ "properties" => {
347
+ "jsonrpc" => { "const" => "2.0" },
348
+ "method" => { "type" => "string" },
349
+ "params" => { "type" => "object", "additionalProperties" => true }
350
+ },
351
+ "additionalProperties" => true
352
+ },
353
+ {
354
+ "type" => "object",
355
+ "required" => %w[jsonrpc id result],
356
+ "properties" => {
357
+ "jsonrpc" => { "const" => "2.0" },
358
+ "id" => ID_SCHEMA,
359
+ "result" => { "type" => "object", "additionalProperties" => true }
360
+ },
361
+ "additionalProperties" => true
362
+ },
363
+ {
364
+ "type" => "object",
365
+ "required" => %w[jsonrpc error],
366
+ "properties" => {
367
+ "jsonrpc" => { "const" => "2.0" },
368
+ "id" => ID_SCHEMA,
369
+ "error" => {
370
+ "type" => "object",
371
+ "required" => %w[code message],
372
+ "properties" => {
373
+ "code" => { "type" => "integer" },
374
+ "message" => { "type" => "string" },
375
+ "data" => {}
376
+ },
377
+ "additionalProperties" => true
378
+ }
379
+ },
380
+ "additionalProperties" => true
381
+ }
382
+ ]
383
+ }.freeze
384
+
385
+ REQUEST_PARAM_SCHEMAS = {
386
+ "initialize" => INITIALIZE_PARAMS_SCHEMA,
387
+ "ping" => REQUEST_PARAMS_SCHEMA,
388
+ "resources/list" => PAGINATED_PARAMS_SCHEMA,
389
+ "resources/templates/list" => PAGINATED_PARAMS_SCHEMA,
390
+ "resources/read" => RESOURCE_URI_PARAMS_SCHEMA,
391
+ "resources/subscribe" => RESOURCE_URI_PARAMS_SCHEMA,
392
+ "resources/unsubscribe" => RESOURCE_URI_PARAMS_SCHEMA,
393
+ "prompts/list" => PAGINATED_PARAMS_SCHEMA,
394
+ "prompts/get" => GET_PROMPT_PARAMS_SCHEMA,
395
+ "tools/list" => PAGINATED_PARAMS_SCHEMA,
396
+ "tools/call" => CALL_TOOL_PARAMS_SCHEMA,
397
+ "tasks/get" => TASK_ID_PARAMS_SCHEMA,
398
+ "tasks/result" => TASK_ID_PARAMS_SCHEMA,
399
+ "tasks/cancel" => TASK_ID_PARAMS_SCHEMA,
400
+ "tasks/list" => PAGINATED_PARAMS_SCHEMA,
401
+ "logging/setLevel" => SET_LEVEL_PARAMS_SCHEMA,
402
+ "completion/complete" => COMPLETE_PARAMS_SCHEMA
403
+ }.freeze
404
+
405
+ REQUIRED_REQUEST_PARAMS = %w[
406
+ initialize
407
+ resources/read
408
+ resources/subscribe
409
+ resources/unsubscribe
410
+ prompts/get
411
+ tools/call
412
+ tasks/get
413
+ tasks/result
414
+ tasks/cancel
415
+ logging/setLevel
416
+ completion/complete
417
+ ].freeze
418
+
419
+ NOTIFICATION_PARAM_SCHEMAS = {
420
+ "notifications/cancelled" => CANCELLED_NOTIFICATION_PARAMS_SCHEMA,
421
+ "notifications/initialized" => NOTIFICATION_PARAMS_SCHEMA,
422
+ "notifications/progress" => PROGRESS_NOTIFICATION_PARAMS_SCHEMA,
423
+ "notifications/tasks/status" => TASK_STATUS_NOTIFICATION_PARAMS_SCHEMA,
424
+ "notifications/roots/list_changed" => NOTIFICATION_PARAMS_SCHEMA
425
+ }.freeze
426
+
427
+ REQUIRED_NOTIFICATION_PARAMS = %w[
428
+ notifications/cancelled
429
+ notifications/progress
430
+ notifications/tasks/status
431
+ ].freeze
432
+
433
+ MESSAGE_SCHEMER = JSONSchemer.schema(MESSAGE_SCHEMA)
434
+ REQUEST_PARAM_SCHEMERS = REQUEST_PARAM_SCHEMAS.transform_values { |schema| JSONSchemer.schema(schema) }.freeze
435
+ NOTIFICATION_PARAM_SCHEMERS = NOTIFICATION_PARAM_SCHEMAS.transform_values do |schema|
436
+ JSONSchemer.schema(schema)
437
+ end.freeze
438
+
439
+ class << self
440
+ def valid_message?(payload)
441
+ MESSAGE_SCHEMER.valid?(normalize(payload))
442
+ end
443
+
444
+ def valid?(payload)
445
+ valid_message?(payload)
446
+ end
447
+
448
+ def valid_initialize_params?(params)
449
+ REQUEST_PARAM_SCHEMERS.fetch("initialize").valid?(normalize(params))
450
+ end
451
+
452
+ def client_message_validation_error(payload)
453
+ case payload
454
+ when JSON_RPC::Request
455
+ validate_request(payload)
456
+ when JSON_RPC::Notification
457
+ validate_notification(payload)
458
+ end
459
+ end
460
+
461
+ def request_params_validation_error(method, params)
462
+ schemer = REQUEST_PARAM_SCHEMERS[method]
463
+ return unless schemer
464
+
465
+ validate_params(method, params, schemer, REQUIRED_REQUEST_PARAMS)
466
+ end
467
+
468
+ private
469
+
470
+ def validate_request(payload)
471
+ request_params_validation_error(payload.method, payload.params)
472
+ end
473
+
474
+ def validate_notification(payload)
475
+ schemer = NOTIFICATION_PARAM_SCHEMERS[payload.method]
476
+ unless schemer
477
+ return ValidationError.new(
478
+ code: :method_not_found,
479
+ message: "Unsupported MCP notification method: #{payload.method}"
480
+ )
481
+ end
482
+
483
+ validate_params(payload.method, payload.params, schemer, REQUIRED_NOTIFICATION_PARAMS)
484
+ end
485
+
486
+ def validate_params(method, params, schemer, required_methods)
487
+ if params.nil?
488
+ return unless required_methods.include?(method)
489
+
490
+ return ValidationError.new(code: :invalid_params, message: "Invalid params for #{method}: params are required")
491
+ end
492
+
493
+ errors = schemer.validate(normalize(params)).to_a
494
+ return if errors.empty?
495
+
496
+ ValidationError.new(
497
+ code: :invalid_params,
498
+ message: "Invalid params for #{method}: #{validation_messages(errors).join(', ')}"
499
+ )
500
+ end
501
+
502
+ def validation_messages(errors)
503
+ errors.map do |error|
504
+ error["error"].presence || "#{error['data_pointer'].presence || '/'} (#{error['type']})"
505
+ end.uniq
506
+ end
507
+
508
+ def normalize(value)
509
+ value.is_a?(Hash) ? value.deep_stringify_keys : value
510
+ end
511
+ end
512
+ end
513
+ end