actionmcp 0.111.0 → 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 +13 -8
  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
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "addressable/uri"
4
+ require "base64"
5
+ require "json"
6
+ require "json_schemer"
7
+ require "active_support/core_ext/object/deep_dup"
8
+
9
+ module ActionMCP
10
+ module Content
11
+ # Stable MCP wire-shape validation shared by content and response value objects.
12
+ module Validation
13
+ META_PROPERTY = {
14
+ "_meta" => { "type" => "object" }
15
+ }.freeze
16
+
17
+ ANNOTATIONS = {
18
+ "type" => "object",
19
+ "properties" => {
20
+ "audience" => {
21
+ "type" => "array",
22
+ "items" => { "type" => "string", "enum" => %w[assistant user] }
23
+ },
24
+ "lastModified" => { "type" => "string" },
25
+ "priority" => { "type" => "number", "minimum" => 0, "maximum" => 1 }
26
+ }
27
+ }.freeze
28
+
29
+ ICON = {
30
+ "type" => "object",
31
+ "required" => [ "src" ],
32
+ "properties" => {
33
+ "src" => { "type" => "string", "format" => "uri" },
34
+ "mimeType" => { "type" => "string" },
35
+ "sizes" => { "type" => "array", "items" => { "type" => "string" } },
36
+ "theme" => { "type" => "string", "enum" => %w[dark light] }
37
+ }
38
+ }.freeze
39
+
40
+ DEFINITIONS = {
41
+ "Annotations" => ANNOTATIONS,
42
+ "Icon" => ICON,
43
+ "TextContent" => {
44
+ "type" => "object",
45
+ "required" => %w[text type],
46
+ "properties" => META_PROPERTY.merge(
47
+ "annotations" => { "$ref" => "#/$defs/Annotations" },
48
+ "text" => { "type" => "string" },
49
+ "type" => { "type" => "string", "const" => "text" }
50
+ )
51
+ },
52
+ "ImageContent" => {
53
+ "type" => "object",
54
+ "required" => %w[data mimeType type],
55
+ "properties" => META_PROPERTY.merge(
56
+ "annotations" => { "$ref" => "#/$defs/Annotations" },
57
+ "data" => { "type" => "string", "format" => "byte" },
58
+ "mimeType" => { "type" => "string" },
59
+ "type" => { "type" => "string", "const" => "image" }
60
+ )
61
+ },
62
+ "AudioContent" => {
63
+ "type" => "object",
64
+ "required" => %w[data mimeType type],
65
+ "properties" => META_PROPERTY.merge(
66
+ "annotations" => { "$ref" => "#/$defs/Annotations" },
67
+ "data" => { "type" => "string", "format" => "byte" },
68
+ "mimeType" => { "type" => "string" },
69
+ "type" => { "type" => "string", "const" => "audio" }
70
+ )
71
+ },
72
+ "TextResourceContents" => {
73
+ "type" => "object",
74
+ "required" => %w[text uri],
75
+ "properties" => META_PROPERTY.merge(
76
+ "mimeType" => { "type" => "string" },
77
+ "text" => { "type" => "string" },
78
+ "uri" => { "type" => "string", "format" => "uri" }
79
+ )
80
+ },
81
+ "BlobResourceContents" => {
82
+ "type" => "object",
83
+ "required" => %w[blob uri],
84
+ "properties" => META_PROPERTY.merge(
85
+ "blob" => { "type" => "string", "format" => "byte" },
86
+ "mimeType" => { "type" => "string" },
87
+ "uri" => { "type" => "string", "format" => "uri" }
88
+ )
89
+ },
90
+ "EmbeddedResource" => {
91
+ "type" => "object",
92
+ "required" => %w[resource type],
93
+ "properties" => META_PROPERTY.merge(
94
+ "annotations" => { "$ref" => "#/$defs/Annotations" },
95
+ "resource" => {
96
+ "anyOf" => [
97
+ { "$ref" => "#/$defs/TextResourceContents" },
98
+ { "$ref" => "#/$defs/BlobResourceContents" }
99
+ ]
100
+ },
101
+ "type" => { "type" => "string", "const" => "resource" }
102
+ )
103
+ },
104
+ "ResourceLink" => {
105
+ "type" => "object",
106
+ "required" => %w[name type uri],
107
+ "properties" => META_PROPERTY.merge(
108
+ "annotations" => { "$ref" => "#/$defs/Annotations" },
109
+ "description" => { "type" => "string" },
110
+ "icons" => { "type" => "array", "items" => { "$ref" => "#/$defs/Icon" } },
111
+ "mimeType" => { "type" => "string" },
112
+ "name" => { "type" => "string" },
113
+ "size" => { "type" => "integer" },
114
+ "title" => { "type" => "string" },
115
+ "type" => { "type" => "string", "const" => "resource_link" },
116
+ "uri" => { "type" => "string", "format" => "uri" }
117
+ )
118
+ },
119
+ "ContentBlock" => {
120
+ "anyOf" => %w[TextContent ImageContent AudioContent ResourceLink EmbeddedResource].map do |name|
121
+ { "$ref" => "#/$defs/#{name}" }
122
+ end
123
+ },
124
+ "Resource" => {
125
+ "type" => "object",
126
+ "required" => %w[name uri],
127
+ "properties" => META_PROPERTY.merge(
128
+ "annotations" => { "$ref" => "#/$defs/Annotations" },
129
+ "description" => { "type" => "string" },
130
+ "icons" => { "type" => "array", "items" => { "$ref" => "#/$defs/Icon" } },
131
+ "mimeType" => { "type" => "string" },
132
+ "name" => { "type" => "string" },
133
+ "size" => { "type" => "integer" },
134
+ "title" => { "type" => "string" },
135
+ "uri" => { "type" => "string", "format" => "uri" }
136
+ )
137
+ },
138
+ "PromptMessage" => {
139
+ "type" => "object",
140
+ "required" => %w[content role],
141
+ "properties" => {
142
+ "content" => { "$ref" => "#/$defs/ContentBlock" },
143
+ "role" => { "type" => "string", "enum" => %w[assistant user] }
144
+ }
145
+ },
146
+ "GetPromptResult" => {
147
+ "type" => "object",
148
+ "required" => [ "messages" ],
149
+ "properties" => META_PROPERTY.merge(
150
+ "description" => { "type" => "string" },
151
+ "messages" => { "type" => "array", "items" => { "$ref" => "#/$defs/PromptMessage" } }
152
+ )
153
+ },
154
+ "CallToolResult" => {
155
+ "type" => "object",
156
+ "required" => [ "content" ],
157
+ "properties" => META_PROPERTY.merge(
158
+ "content" => { "type" => "array", "items" => { "$ref" => "#/$defs/ContentBlock" } },
159
+ "isError" => { "type" => "boolean" },
160
+ "structuredContent" => { "type" => "object" }
161
+ )
162
+ }
163
+ }.freeze
164
+
165
+ FORMAT_VALIDATORS = {
166
+ "byte" => lambda do |value, _format|
167
+ Base64.strict_decode64(value)
168
+ true
169
+ rescue ArgumentError
170
+ false
171
+ end,
172
+ "uri" => lambda do |value, _format|
173
+ Addressable::URI.parse(value).absolute?
174
+ rescue Addressable::URI::InvalidURIError, TypeError
175
+ false
176
+ end
177
+ }.freeze
178
+
179
+ SCHEMERS = %w[Annotations ContentBlock Resource GetPromptResult CallToolResult].to_h do |name|
180
+ schema = {
181
+ "$schema" => "https://json-schema.org/draft/2020-12/schema",
182
+ "$ref" => "#/$defs/#{name}",
183
+ "$defs" => DEFINITIONS
184
+ }
185
+ [ name, JSONSchemer.schema(schema, formats: FORMAT_VALIDATORS) ]
186
+ end.freeze
187
+
188
+ module_function
189
+
190
+ def validate_annotations!(annotations)
191
+ return if annotations.nil?
192
+
193
+ validate!(SCHEMERS.fetch("Annotations"), annotations, "annotations")
194
+ end
195
+
196
+ def validate_content_block!(content)
197
+ validate!(SCHEMERS.fetch("ContentBlock"), content, "content")
198
+ end
199
+
200
+ def validate_resource!(resource)
201
+ validate!(SCHEMERS.fetch("Resource"), resource, "resource")
202
+ end
203
+
204
+ def validate_prompt_result!(result)
205
+ validate!(SCHEMERS.fetch("GetPromptResult"), result, "prompt result")
206
+ end
207
+
208
+ def validate_tool_result!(result)
209
+ validate!(SCHEMERS.fetch("CallToolResult"), result, "tool result")
210
+ end
211
+
212
+ def copy_object!(value, label)
213
+ source =
214
+ if value.is_a?(Hash)
215
+ value
216
+ elsif value.is_a?(Array)
217
+ nil
218
+ elsif value.respond_to?(:to_hash)
219
+ value.to_hash
220
+ elsif value.respond_to?(:to_h)
221
+ value.to_h
222
+ end
223
+
224
+ raise ArgumentError, "#{label} must be a JSON object" unless source.is_a?(Hash)
225
+
226
+ source.deep_dup.tap { |copy| ensure_json!(copy, label) }
227
+ end
228
+
229
+ def copy_content_block!(content)
230
+ copy = copy_object!(content, "content")
231
+ validate_content_block!(copy)
232
+ copy
233
+ end
234
+
235
+ def ensure_json!(value, label)
236
+ JSON.generate(value)
237
+ rescue JSON::GeneratorError, JSON::NestingError => e
238
+ raise ArgumentError, "#{label} must be JSON-serializable: #{e.message}"
239
+ end
240
+
241
+ def validate!(schemer, value, label)
242
+ ensure_json!(value, label)
243
+ error = schemer.validate(value).first
244
+ return value unless error
245
+
246
+ pointer = error.fetch("data_pointer", "")
247
+ location = pointer.empty? ? "" : " at #{pointer}"
248
+ raise ArgumentError, "#{label} is not valid MCP 2025-11-25 data#{location}: #{error.fetch('error')}"
249
+ end
250
+ private_class_method :validate!
251
+ end
252
+ end
253
+ end
@@ -15,7 +15,8 @@ module ActionMCP
15
15
  # @param annotations [Hash, nil] Optional annotations for the content.
16
16
  def initialize(type, annotations: nil)
17
17
  @type = type
18
- @annotations = annotations
18
+ @annotations = annotations.nil? ? nil : Validation.copy_object!(annotations, "annotations")
19
+ Validation.validate_annotations!(@annotations)
19
20
  end
20
21
 
21
22
  # Returns a hash representation of the content.
@@ -24,6 +25,7 @@ module ActionMCP
24
25
  def to_h
25
26
  h = { type: @type }
26
27
  h[:annotations] = @annotations if @annotations
28
+ Validation.validate_annotations!(@annotations)
27
29
  h
28
30
  end
29
31
 
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionMCP
4
+ module Dev
5
+ # A single command that boots the ActionMCP dev loop: the standalone MCP
6
+ # server plus, when a compiled-views tier is present, the vite view builder
7
+ # in watch mode, plus an optional public tunnel.
8
+ #
9
+ # Note on the reload model: MCP Apps views are delivered to hosts as
10
+ # inlined resource text inside a sandboxed iframe, so there is no websocket
11
+ # channel for classic in-browser HMR. The dev loop is watch-and-rebuild:
12
+ # editing a view triggers a vite rebuild of its bundle + manifest, and the
13
+ # next tool invocation in the MCP client renders the fresh view.
14
+ #
15
+ # Usage from the generated binstub:
16
+ # ActionMCP::Dev::Runner.new(root: Dir.pwd, argv: ARGV).run
17
+ class Runner
18
+ DEFAULT_PORT = 62_770
19
+ VIEWS_SUBDIR = "app/mcp/views"
20
+ BUILD_BIN = "action-mcp-build-views"
21
+
22
+ # A child process to supervise.
23
+ Spec = Struct.new(:label, :command, :env, keyword_init: true)
24
+
25
+ attr_reader :root, :options
26
+
27
+ def initialize(root:, argv: [], env: ENV, out: $stdout)
28
+ @root = File.expand_path(root)
29
+ @env = env
30
+ @out = out
31
+ @options = parse(argv)
32
+ @pids = {}
33
+ end
34
+
35
+ # The set of child processes this invocation would start. Pure — no
36
+ # spawning — so the wiring is unit-testable.
37
+ #
38
+ # @return [Array<Spec>]
39
+ def plan
40
+ specs = [ server_spec ]
41
+ specs << views_spec if run_views?
42
+ specs << tunnel_spec if @options[:tunnel]
43
+ specs
44
+ end
45
+
46
+ # Whether the vite view-builder should run: opt-in tier is present and the
47
+ # user did not disable it.
48
+ def run_views?
49
+ @options[:views] && File.directory?(File.join(@root, VIEWS_SUBDIR))
50
+ end
51
+
52
+ def run
53
+ if @options[:help]
54
+ @out.puts usage
55
+ return 0
56
+ end
57
+
58
+ specs = plan
59
+ print_banner(specs)
60
+
61
+ specs.each { |spec| spawn(spec) }
62
+ install_signal_traps
63
+ supervise
64
+ end
65
+
66
+ private
67
+
68
+ def parse(argv)
69
+ opts = { port: DEFAULT_PORT, views: true, tunnel: false, help: false }
70
+ args = argv.dup
71
+
72
+ until args.empty?
73
+ arg = args.shift
74
+ case arg
75
+ when "-h", "--help" then opts[:help] = true
76
+ when "--no-views" then opts[:views] = false
77
+ when "--tunnel" then opts[:tunnel] = true
78
+ when "-p", "--port" then opts[:port] = Integer(args.shift)
79
+ else
80
+ if (m = arg.match(/\A--port=(.+)\z/))
81
+ opts[:port] = Integer(m[1])
82
+ elsif arg.match?(/\A\d+\z/)
83
+ opts[:port] = Integer(arg) # bare port, matching `bin/mcp 3001`
84
+ else
85
+ raise ArgumentError, "unknown option: #{arg}\n\n#{usage}"
86
+ end
87
+ end
88
+ end
89
+
90
+ opts
91
+ end
92
+
93
+ def server_spec
94
+ rackup = File.join(@root, "mcp", "config.ru")
95
+ command =
96
+ if falcon_available?
97
+ [ "bundle", "exec", "falcon", "serve",
98
+ "--bind", "http://127.0.0.1:#{@options[:port]}", "--config", rackup ]
99
+ else
100
+ [ "bundle", "exec", "rails", "server", "-c", rackup,
101
+ "-b", "127.0.0.1", "-p", @options[:port].to_s ]
102
+ end
103
+ Spec.new(label: "server", command: command, env: {})
104
+ end
105
+
106
+ def views_spec
107
+ command = [ *node_runner, BUILD_BIN, "--watch", "--root", @root ]
108
+ Spec.new(label: "views", command: command, env: {})
109
+ end
110
+
111
+ def tunnel_spec
112
+ command = [ "cloudflared", "tunnel", "--url", "http://localhost:#{@options[:port]}" ]
113
+ Spec.new(label: "tunnel", command: command, env: {})
114
+ end
115
+
116
+ # Prefer the package runner matching the lockfile in the project so the
117
+ # locally-installed @action-mcp/vite-plugin bin resolves.
118
+ def node_runner
119
+ if File.exist?(File.join(@root, "bun.lock")) || File.exist?(File.join(@root, "bun.lockb"))
120
+ [ "bunx" ]
121
+ elsif File.exist?(File.join(@root, "pnpm-lock.yaml"))
122
+ [ "pnpm", "exec" ]
123
+ else
124
+ [ "npx" ]
125
+ end
126
+ end
127
+
128
+ def falcon_available?
129
+ Gem::Specification.find_by_name("falcon")
130
+ true
131
+ rescue Gem::MissingSpecError
132
+ false
133
+ end
134
+
135
+ def spawn(spec)
136
+ @out.puts " ▸ #{spec.label}: #{spec.command.join(' ')}"
137
+ pid = Process.spawn(spec.env, *spec.command, chdir: @root)
138
+ @pids[pid] = spec.label
139
+ end
140
+
141
+ def install_signal_traps
142
+ %w[INT TERM].each do |sig|
143
+ Signal.trap(sig) { shutdown(sig) }
144
+ end
145
+ end
146
+
147
+ # Wait for any child to exit; when one dies, tear the rest down so the dev
148
+ # loop never limps along half-up.
149
+ def supervise
150
+ pid = Process.wait
151
+ label = @pids.delete(pid)
152
+ status = $?
153
+ @out.puts "\n[action-mcp] #{label} exited (#{status.exitstatus || status.termsig}); stopping dev loop."
154
+ shutdown("TERM")
155
+ status.exitstatus || 1
156
+ rescue Interrupt
157
+ shutdown("TERM")
158
+ 130
159
+ end
160
+
161
+ def shutdown(sig)
162
+ @pids.each_key do |pid|
163
+ Process.kill(sig, pid)
164
+ rescue Errno::ESRCH
165
+ # already gone
166
+ end
167
+ @pids.each_key do |pid|
168
+ Process.wait(pid)
169
+ rescue Errno::ECHILD
170
+ # already reaped
171
+ end
172
+ @pids.clear
173
+ exit(0) if sig == "INT"
174
+ end
175
+
176
+ def print_banner(specs)
177
+ @out.puts "ActionMCP dev loop"
178
+ @out.puts " server: http://localhost:#{@options[:port]}"
179
+ @out.puts " views: #{run_views? ? 'watching app/mcp/views' : 'disabled'}"
180
+ @out.puts " tunnel: #{@options[:tunnel] ? 'cloudflared' : 'off (pass --tunnel)'}"
181
+ @out.puts " inspect: npx @modelcontextprotocol/inspector " \
182
+ "--url http://localhost:#{@options[:port]}"
183
+ @out.puts " (#{specs.size} process#{'es' if specs.size != 1})"
184
+ @out.puts
185
+ end
186
+
187
+ def usage
188
+ <<~USAGE
189
+ Usage: bin/mcp dev [options]
190
+
191
+ Boots the MCP server and, when app/mcp/views exists, the vite view
192
+ builder in watch mode.
193
+
194
+ Options:
195
+ -p, --port PORT Server port (default: #{DEFAULT_PORT})
196
+ --no-views Do not start the view builder
197
+ --tunnel Expose the server publicly via cloudflared
198
+ -h, --help Show this help
199
+ USAGE
200
+ end
201
+ end
202
+ end
203
+ end
@@ -9,6 +9,16 @@ module ActionMCP
9
9
  class Engine < ::Rails::Engine
10
10
  isolate_namespace ActionMCP
11
11
 
12
+ def self.endpoint_path_matcher(path)
13
+ endpoint = ActionDispatch::Journey::Router::Utils.normalize_path(path)
14
+ formatted_endpoint = /\A#{Regexp.escape(endpoint)}(?:\.[^\/.]+)?\z/
15
+
16
+ lambda do |request_path|
17
+ normalized_path = ActionDispatch::Journey::Router::Utils.normalize_path(request_path)
18
+ formatted_endpoint.match?(normalized_path)
19
+ end
20
+ end
21
+
12
22
  ActiveSupport::Inflector.inflections(:en) do |inflect|
13
23
  inflect.acronym "MCP"
14
24
  end
@@ -43,13 +53,25 @@ module ActionMCP
43
53
  # Eager load MCP components if profile includes "all"
44
54
  # This runs after Zeitwerk is fully set up
45
55
  ActionMCP.configuration.eager_load_if_needed
56
+
57
+ # Register compiled MCP Apps views after registries
58
+ # are (re)built. Runs on each dev reload so rebuilt view bundles/hashes
59
+ # are picked up without restarting the server; runs once at boot in
60
+ # production.
61
+ ActionMCP::Apps::ViewManifest.load! if ActionMCP.configuration.mcp_apps_enabled
46
62
  end
47
63
 
48
64
  initializer "action_mcp.insert_middleware" do |app|
65
+ endpoint_paths = [ ActionMCP::Engine.endpoint_path_matcher(ActionMCP.configuration.base_path) ].freeze
66
+
49
67
  config.middleware.use ActionDispatch::HostAuthorization, app.config.hosts if app.config.hosts.present?
50
68
  config.middleware.use ActionMCP::Middleware::OriginValidation,
51
- [ ActionMCP.configuration.base_path ].compact.freeze
52
- config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.base_path ].compact.freeze
69
+ endpoint_paths
70
+ config.middleware.use JSONRPC_Rails::Middleware::Validator,
71
+ endpoint_paths,
72
+ payload_validator: ActionMCP::ProtocolValidator,
73
+ batch_policy: :reject,
74
+ require_json_content_type: true
53
75
  end
54
76
 
55
77
  # Load MCP profiles during initialization
@@ -31,6 +31,8 @@ module ActionMCP
31
31
  # Notification methods
32
32
  NOTIFICATIONS_INITIALIZED = "notifications/initialized"
33
33
  NOTIFICATIONS_CANCELLED = "notifications/cancelled"
34
+ NOTIFICATIONS_PROGRESS = "notifications/progress"
35
+ NOTIFICATIONS_ROOTS_LIST_CHANGED = "notifications/roots/list_changed"
34
36
 
35
37
  # Elicitation methods
36
38
  ELICITATION_CREATE = "elicitation/create"
@@ -73,9 +75,6 @@ module ActionMCP
73
75
  transport.send_pong(id)
74
76
  # In return mode, get the response that was just created
75
77
  transport.messaging_mode == :return ? transport.get_last_response : true
76
- when %r{^notifications/}
77
- process_notifications(rpc_method, params)
78
- true
79
78
  end
80
79
  end
81
80
 
@@ -83,18 +82,14 @@ module ActionMCP
83
82
  def process_notifications(rpc_method, params)
84
83
  case rpc_method
85
84
  when Methods::NOTIFICATIONS_CANCELLED
86
- handle_cancelled_notification(params)
85
+ transport.receive_cancelled_notification(params)
86
+ when Methods::NOTIFICATIONS_PROGRESS
87
+ transport.receive_progress_notification(params)
88
+ when Methods::NOTIFICATIONS_TASKS_STATUS
89
+ transport.receive_task_status_notification(params)
87
90
  else
88
91
  Rails.logger.warn("Unknown notifications method: #{rpc_method}")
89
92
  end
90
93
  end
91
-
92
- private
93
-
94
- # Handle cancelled notification
95
- def handle_cancelled_notification(params)
96
- Rails.logger.warn "\e[31m Request #{params['requestId']} cancelled: #{params['reason']}\e[0m"
97
- # we don't need to do anything here
98
- end
99
94
  end
100
95
  end
@@ -92,50 +92,50 @@ module ActionMCP
92
92
  # Check if debug level is enabled
93
93
  # @return [Boolean] true if debug messages will be logged
94
94
  def debug?
95
- state.should_log?(:debug)
95
+ should_log?(:debug)
96
96
  end
97
97
 
98
98
  # Check if info level is enabled
99
99
  # @return [Boolean] true if info messages will be logged
100
100
  def info?
101
- state.should_log?(:info)
101
+ should_log?(:info)
102
102
  end
103
103
 
104
104
  # Check if notice level is enabled
105
105
  # @return [Boolean] true if notice messages will be logged
106
106
  def notice?
107
- state.should_log?(:notice)
107
+ should_log?(:notice)
108
108
  end
109
109
 
110
110
  # Check if warning level is enabled
111
111
  # @return [Boolean] true if warning messages will be logged
112
112
  def warning?
113
- state.should_log?(:warning)
113
+ should_log?(:warning)
114
114
  end
115
115
  alias_method :warn?, :warning?
116
116
 
117
117
  # Check if error level is enabled
118
118
  # @return [Boolean] true if error messages will be logged
119
119
  def error?
120
- state.should_log?(:error)
120
+ should_log?(:error)
121
121
  end
122
122
 
123
123
  # Check if critical level is enabled
124
124
  # @return [Boolean] true if critical messages will be logged
125
125
  def critical?
126
- state.should_log?(:critical)
126
+ should_log?(:critical)
127
127
  end
128
128
 
129
129
  # Check if alert level is enabled
130
130
  # @return [Boolean] true if alert messages will be logged
131
131
  def alert?
132
- state.should_log?(:alert)
132
+ should_log?(:alert)
133
133
  end
134
134
 
135
135
  # Check if emergency level is enabled
136
136
  # @return [Boolean] true if emergency messages will be logged
137
137
  def emergency?
138
- state.should_log?(:emergency)
138
+ should_log?(:emergency)
139
139
  end
140
140
 
141
141
  private
@@ -147,7 +147,7 @@ module ActionMCP
147
147
  # @yield Block that returns message
148
148
  # @return [void]
149
149
  def log(level, message = nil, data: nil, &block)
150
- return unless state.should_log?(level)
150
+ return unless should_log?(level)
151
151
 
152
152
  # Evaluate message from block if provided
153
153
  final_message = if block_given?
@@ -174,8 +174,7 @@ module ActionMCP
174
174
  # Add logger name if present
175
175
  params[:logger] = name if name
176
176
 
177
- # Send via session's messaging service
178
- session.messaging_service.send_notification("notifications/message", params)
177
+ ActionMCP::Server::TransportHandler.new(session).send_logging_message_notification(**params)
179
178
  rescue StandardError => e
180
179
  # Fallback to Rails logger if MCP transport fails
181
180
  Rails.logger.error "Failed to send MCP log notification: #{e.message}"
@@ -203,6 +202,12 @@ module ActionMCP
203
202
  end
204
203
  end
205
204
  end
205
+
206
+ def should_log?(message_level)
207
+ return false unless ActionMCP::Logging.enabled?
208
+
209
+ Level.coerce(message_level) >= Level.coerce(ActionMCP::Logging.level_for(session))
210
+ end
206
211
  end
207
212
  end
208
213
  end