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.
- checksums.yaml +4 -4
- data/README.md +26 -18
- data/Rakefile +3 -0
- data/app/controllers/action_mcp/application_controller.rb +166 -109
- data/app/controllers/action_mcp/mcp_app_renderer.rb +1 -0
- data/app/helpers/action_mcp/apps_helper.rb +48 -0
- data/app/jobs/action_mcp/tool_execution_job.rb +67 -35
- data/app/models/action_mcp/session/message.rb +8 -3
- data/app/models/action_mcp/session/task.rb +15 -5
- data/app/models/action_mcp/session.rb +93 -19
- data/config/routes.rb +1 -1
- data/lib/action_mcp/apps/javascript/ext_apps.LICENSE +216 -0
- data/lib/action_mcp/apps/javascript/ext_apps.js +78 -0
- data/lib/action_mcp/apps/view_manifest.rb +170 -0
- data/lib/action_mcp/apps.rb +17 -2
- data/lib/action_mcp/configuration.rb +14 -30
- data/lib/action_mcp/content/audio.rb +4 -1
- data/lib/action_mcp/content/base.rb +3 -1
- data/lib/action_mcp/content/image.rb +4 -1
- data/lib/action_mcp/content/resource.rb +15 -14
- data/lib/action_mcp/content/resource_link.rb +15 -5
- data/lib/action_mcp/content/text.rb +2 -1
- data/lib/action_mcp/content/validation.rb +253 -0
- data/lib/action_mcp/content.rb +3 -1
- data/lib/action_mcp/dev/runner.rb +203 -0
- data/lib/action_mcp/engine.rb +24 -2
- data/lib/action_mcp/json_rpc_handler_base.rb +7 -12
- data/lib/action_mcp/logging/logger.rb +16 -11
- data/lib/action_mcp/logging.rb +34 -1
- data/lib/action_mcp/middleware/origin_validation.rb +9 -13
- data/lib/action_mcp/output_schema_builder.rb +2 -0
- data/lib/action_mcp/prompt_response.rb +12 -2
- data/lib/action_mcp/protocol_validator.rb +513 -0
- data/lib/action_mcp/renderable.rb +4 -4
- data/lib/action_mcp/resource.rb +15 -11
- data/lib/action_mcp/resource_template.rb +105 -90
- data/lib/action_mcp/resource_templates_registry.rb +31 -97
- data/lib/action_mcp/schema_validator.rb +43 -0
- data/lib/action_mcp/server/base_session.rb +134 -28
- data/lib/action_mcp/server/base_session_store.rb +1 -1
- data/lib/action_mcp/server/capabilities.rb +10 -30
- data/lib/action_mcp/server/client_notifications.rb +63 -0
- data/lib/action_mcp/server/completions.rb +68 -0
- data/lib/action_mcp/server/elicitation.rb +47 -25
- data/lib/action_mcp/server/elicitation_request.rb +136 -51
- data/lib/action_mcp/server/handlers/logging_handler.rb +7 -5
- data/lib/action_mcp/server/handlers/prompt_handler.rb +12 -1
- data/lib/action_mcp/server/handlers/resource_handler.rb +41 -7
- data/lib/action_mcp/server/handlers/task_handler.rb +26 -0
- data/lib/action_mcp/server/handlers/tool_handler.rb +8 -0
- data/lib/action_mcp/server/json_rpc_handler.rb +5 -21
- data/lib/action_mcp/server/messaging_service.rb +3 -1
- data/lib/action_mcp/server/prompts.rb +5 -3
- data/lib/action_mcp/server/resources.rb +35 -2
- data/lib/action_mcp/server/response_collector.rb +5 -1
- data/lib/action_mcp/server/roots.rb +15 -2
- data/lib/action_mcp/server/sampling.rb +36 -1
- data/lib/action_mcp/server/sampling_request.rb +317 -55
- data/lib/action_mcp/server/tasks.rb +27 -45
- data/lib/action_mcp/server/tool_result.rb +27 -0
- data/lib/action_mcp/server/tools.rb +12 -10
- data/lib/action_mcp/server/transport_handler.rb +3 -0
- data/lib/action_mcp/server/url_elicitation_request.rb +13 -0
- data/lib/action_mcp/tool.rb +133 -199
- data/lib/action_mcp/tool_response.rb +35 -4
- data/lib/action_mcp/tools_registry.rb +1 -1
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -5
- data/lib/generators/action_mcp/identifier/identifier_generator.rb +1 -1
- data/lib/generators/action_mcp/identifier/templates/identifier.rb.erb +1 -1
- data/lib/generators/action_mcp/install/templates/bin/mcp +16 -4
- data/lib/generators/action_mcp/install/templates/mcp/config.ru.tt +2 -2
- data/lib/generators/action_mcp/install/templates/mcp.yml +4 -0
- data/lib/generators/action_mcp/resource_template/resource_template_generator.rb +18 -1
- data/lib/generators/action_mcp/resource_template/templates/resource_template_ui.rb.erb +27 -0
- data/lib/generators/action_mcp/tool/templates/tool.rb.erb +6 -0
- data/lib/generators/action_mcp/tool/tool_generator.rb +2 -0
- data/lib/generators/action_mcp/view/templates/resource_template.rb.erb +16 -0
- data/lib/generators/action_mcp/view/templates/view.html.erb +27 -0
- data/lib/generators/action_mcp/view/view_generator.rb +57 -0
- data/lib/tasks/action_mcp_apps.rake +17 -0
- data/lib/tasks/action_mcp_tasks.rake +135 -74
- 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
|
data/lib/action_mcp/content.rb
CHANGED
|
@@ -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
|
data/lib/action_mcp/engine.rb
CHANGED
|
@@ -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
|
-
|
|
52
|
-
config.middleware.use JSONRPC_Rails::Middleware::Validator,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|