actionmcp 0.50.13 → 0.52.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 +83 -0
- data/app/controllers/action_mcp/application_controller.rb +12 -6
- data/app/models/action_mcp/session.rb +5 -1
- data/lib/action_mcp/client/base.rb +88 -82
- data/lib/action_mcp/client/json_rpc_handler.rb +42 -5
- data/lib/action_mcp/client/session_store.rb +231 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +291 -0
- data/lib/action_mcp/client/transport.rb +107 -0
- data/lib/action_mcp/client.rb +45 -5
- data/lib/action_mcp/configuration.rb +16 -1
- data/lib/action_mcp/current.rb +19 -0
- data/lib/action_mcp/current_helpers.rb +19 -0
- data/lib/action_mcp/gateway.rb +85 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +6 -1
- data/lib/action_mcp/jwt_decoder.rb +26 -0
- data/lib/action_mcp/prompt.rb +1 -0
- data/lib/action_mcp/resource_template.rb +1 -0
- data/lib/action_mcp/server/base_messaging.rb +14 -0
- data/lib/action_mcp/server/capabilities.rb +23 -0
- data/lib/action_mcp/server/error_aware.rb +8 -1
- data/lib/action_mcp/server/handlers/tool_handler.rb +2 -1
- data/lib/action_mcp/server/json_rpc_handler.rb +12 -4
- data/lib/action_mcp/server/messaging.rb +12 -1
- data/lib/action_mcp/server/registry_management.rb +0 -1
- data/lib/action_mcp/server/response_collector.rb +40 -0
- data/lib/action_mcp/server/session_store.rb +762 -0
- data/lib/action_mcp/server/tools.rb +14 -3
- data/lib/action_mcp/server/transport_handler.rb +9 -5
- data/lib/action_mcp/server.rb +7 -0
- data/lib/action_mcp/tagged_stream_logging.rb +0 -4
- data/lib/action_mcp/test_helper/progress_notification_assertions.rb +105 -0
- data/lib/action_mcp/test_helper/session_store_assertions.rb +130 -0
- data/lib/action_mcp/test_helper.rb +4 -0
- data/lib/action_mcp/tool.rb +1 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +0 -1
- data/lib/generators/action_mcp/install/install_generator.rb +4 -0
- data/lib/generators/action_mcp/install/templates/application_gateway.rb +40 -0
- metadata +30 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 123d51b56f85e45cb622885fb6bfcf6caf6d310292302a6ad4a9886e84c36af2
|
4
|
+
data.tar.gz: 15eb5bd01e1985a4eac9c6d83b463d0f939c02ccd330f72366fa0d08603c6f2b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 724a4dc93cc887ce3dc735fe6a00b375a36012887f2aa081b01a45b314460a297ebd3b17cfa423a55276462cca5736feea816a8691fcc0b6006bfb7a385b0f9b
|
7
|
+
data.tar.gz: 451af9c7919a5e65d80af1f3cc3b36e666148bc4b1351049a7ae33e01fd26daaad13b5d72b6a32476e9709c8b44a0291c2ad304f25d9607c99082bf59d99db02
|
data/README.md
CHANGED
@@ -380,6 +380,89 @@ This will create `config/mcp.yml` with example configurations for all environmen
|
|
380
380
|
|
381
381
|
> **Note:** Authentication and authorization are not included. You are responsible for securing the endpoint.
|
382
382
|
|
383
|
+
## Authentication with Gateway
|
384
|
+
|
385
|
+
ActionMCP provides a Gateway system similar to ActionCable's Connection for handling authentication. The Gateway allows you to authenticate users and make them available throughout your MCP components.
|
386
|
+
|
387
|
+
### Creating an ApplicationGateway
|
388
|
+
|
389
|
+
When you run the install generator, it creates an `ApplicationGateway` class:
|
390
|
+
|
391
|
+
```ruby
|
392
|
+
# app/mcp/application_gateway.rb
|
393
|
+
class ApplicationGateway < ActionMCP::Gateway
|
394
|
+
# Specify what attributes identify a connection
|
395
|
+
identified_by :user
|
396
|
+
|
397
|
+
protected
|
398
|
+
|
399
|
+
def authenticate!
|
400
|
+
token = extract_bearer_token
|
401
|
+
raise ActionMCP::UnauthorizedError, "Missing token" unless token
|
402
|
+
|
403
|
+
payload = ActionMCP::JwtDecoder.decode(token)
|
404
|
+
user = resolve_user(payload)
|
405
|
+
|
406
|
+
raise ActionMCP::UnauthorizedError, "Unauthorized" unless user
|
407
|
+
|
408
|
+
# Return a hash with all identified_by attributes
|
409
|
+
{ user: user }
|
410
|
+
end
|
411
|
+
|
412
|
+
private
|
413
|
+
|
414
|
+
def resolve_user(payload)
|
415
|
+
user_id = payload["user_id"] || payload["sub"]
|
416
|
+
User.find_by(id: user_id) if user_id
|
417
|
+
end
|
418
|
+
end
|
419
|
+
```
|
420
|
+
|
421
|
+
### Using Multiple Identifiers
|
422
|
+
|
423
|
+
You can identify connections by multiple attributes:
|
424
|
+
|
425
|
+
```ruby
|
426
|
+
class ApplicationGateway < ActionMCP::Gateway
|
427
|
+
identified_by :user, :organization
|
428
|
+
|
429
|
+
protected
|
430
|
+
|
431
|
+
def authenticate!
|
432
|
+
# ... authentication logic ...
|
433
|
+
|
434
|
+
{
|
435
|
+
user: user,
|
436
|
+
organization: user.organization
|
437
|
+
}
|
438
|
+
end
|
439
|
+
end
|
440
|
+
```
|
441
|
+
|
442
|
+
### Accessing Current User in Components
|
443
|
+
|
444
|
+
Once authenticated, the current user (and other identifiers) are available in your tools, prompts, and resource templates:
|
445
|
+
|
446
|
+
```ruby
|
447
|
+
class MyTool < ApplicationMCPTool
|
448
|
+
def perform
|
449
|
+
# Access the authenticated user
|
450
|
+
if current_user
|
451
|
+
render text: "Hello, #{current_user.name}!"
|
452
|
+
else
|
453
|
+
render text: "Hi Stranger! It's been a while "
|
454
|
+
end
|
455
|
+
end
|
456
|
+
end
|
457
|
+
```
|
458
|
+
|
459
|
+
### Current Attributes
|
460
|
+
|
461
|
+
ActionMCP uses Rails' CurrentAttributes to store the authenticated context. The `ActionMCP::Current` class provides:
|
462
|
+
- `ActionMCP::Current.user` - The authenticated user
|
463
|
+
- `ActionMCP::Current.gateway` - The gateway instance
|
464
|
+
- Any other attributes you define with `identified_by`
|
465
|
+
|
383
466
|
### 1. Create `mcp.ru`
|
384
467
|
|
385
468
|
```ruby
|
@@ -158,11 +158,11 @@ module ActionMCP
|
|
158
158
|
response.headers[MCP_SESSION_ID_HEADER] = session.id
|
159
159
|
end
|
160
160
|
|
161
|
-
|
161
|
+
# Use return mode for the transport handler when we need to capture responses
|
162
|
+
transport_handler = Server::TransportHandler.new(session, messaging_mode: :return)
|
162
163
|
json_rpc_handler = Server::JsonRpcHandler.new(transport_handler)
|
163
164
|
|
164
165
|
result = json_rpc_handler.call(jsonrpc_params)
|
165
|
-
|
166
166
|
process_handler_results(result, session, session_initially_missing, is_initialize_request)
|
167
167
|
rescue ActionController::Live::ClientDisconnected, IOError => e
|
168
168
|
Rails.logger.debug "Unified SSE (POST): Client disconnected during response: #{e.message}"
|
@@ -182,7 +182,7 @@ module ActionMCP
|
|
182
182
|
session_id_from_header = extract_session_id
|
183
183
|
return render_bad_request("Mcp-Session-Id header is required for DELETE requests.") unless session_id_from_header
|
184
184
|
|
185
|
-
session =
|
185
|
+
session = Server.session_store.load_session(session_id_from_header)
|
186
186
|
if session.nil?
|
187
187
|
return render_not_found("Session not found.")
|
188
188
|
elsif session.status == "closed"
|
@@ -206,7 +206,7 @@ module ActionMCP
|
|
206
206
|
def find_or_initialize_session
|
207
207
|
session_id = extract_session_id
|
208
208
|
if session_id
|
209
|
-
session =
|
209
|
+
session = Server.session_store.load_session(session_id)
|
210
210
|
if session
|
211
211
|
if ActionMCP.configuration.vibed_ignore_version
|
212
212
|
if session.protocol_version != self.class::REQUIRED_PROTOCOL_VERSION
|
@@ -218,7 +218,7 @@ module ActionMCP
|
|
218
218
|
end
|
219
219
|
session
|
220
220
|
else
|
221
|
-
|
221
|
+
Server.session_store.create_session(nil, protocol_version: self.class::REQUIRED_PROTOCOL_VERSION)
|
222
222
|
end
|
223
223
|
end
|
224
224
|
|
@@ -266,7 +266,13 @@ module ActionMCP
|
|
266
266
|
end
|
267
267
|
|
268
268
|
# Convert to hash for rendering
|
269
|
-
payload = result.
|
269
|
+
payload = if result.respond_to?(:to_h)
|
270
|
+
result.to_h
|
271
|
+
elsif result.respond_to?(:to_json)
|
272
|
+
JSON.parse(result.to_json)
|
273
|
+
else
|
274
|
+
result
|
275
|
+
end
|
270
276
|
|
271
277
|
# Determine response format
|
272
278
|
server_preference = ActionMCP.configuration.post_response_preference
|
@@ -171,7 +171,11 @@ module ActionMCP
|
|
171
171
|
)
|
172
172
|
|
173
173
|
# Maintain cache limit by removing oldest events if needed
|
174
|
-
|
174
|
+
count = sse_events.count
|
175
|
+
excess = count - max_events
|
176
|
+
if excess.positive?
|
177
|
+
sse_events.order(event_id: :asc).limit(excess).delete_all
|
178
|
+
end
|
175
179
|
|
176
180
|
event
|
177
181
|
end
|
@@ -1,5 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative "transport"
|
4
|
+
|
3
5
|
module ActionMCP
|
4
6
|
module Client
|
5
7
|
# Base client class containing common MCP functionality
|
@@ -12,25 +14,20 @@ module ActionMCP
|
|
12
14
|
include Roots
|
13
15
|
include Logging
|
14
16
|
|
15
|
-
attr_reader :logger, :
|
17
|
+
attr_reader :logger, :transport,
|
16
18
|
:connection_error, :server,
|
17
19
|
:server_capabilities, :session,
|
18
20
|
:catalog, :blueprint,
|
19
21
|
:prompt_book, :toolbox
|
20
22
|
|
21
|
-
delegate :
|
23
|
+
delegate :connected?, :ready?, to: :transport
|
22
24
|
|
23
|
-
def initialize(logger: ActionMCP.logger)
|
25
|
+
def initialize(transport:, logger: ActionMCP.logger, **options)
|
24
26
|
@logger = logger
|
25
|
-
@
|
26
|
-
@session = Session
|
27
|
-
|
28
|
-
client_info: client_info,
|
29
|
-
client_capabilities: client_capabilities
|
30
|
-
)
|
27
|
+
@transport = transport
|
28
|
+
@session = nil # Session will be created/loaded based on server response
|
29
|
+
@session_id = options[:session_id] # Optional session ID for resumption
|
31
30
|
@server_capabilities = nil
|
32
|
-
@message_callback = nil
|
33
|
-
@error_callback = nil
|
34
31
|
@connection_error = nil
|
35
32
|
@initialized = false
|
36
33
|
|
@@ -42,148 +39,157 @@ module ActionMCP
|
|
42
39
|
@prompt_book = PromptBook.new([], self)
|
43
40
|
# Tool objects
|
44
41
|
@toolbox = Toolbox.new([], self)
|
45
|
-
end
|
46
42
|
|
47
|
-
|
48
|
-
@connected
|
43
|
+
setup_transport_callbacks
|
49
44
|
end
|
50
45
|
|
51
|
-
# Connect to the MCP server
|
46
|
+
# Connect to the MCP server
|
52
47
|
def connect
|
53
|
-
return true if
|
48
|
+
return true if connected?
|
54
49
|
|
55
50
|
begin
|
56
|
-
log_debug("Connecting to MCP server...")
|
51
|
+
log_debug("Connecting to MCP server via #{transport.class.name}...")
|
57
52
|
@connection_error = nil
|
58
53
|
|
59
|
-
|
60
|
-
success = start_transport
|
61
|
-
|
54
|
+
success = @transport.connect
|
62
55
|
unless success
|
63
|
-
log_error("Failed to establish connection
|
56
|
+
log_error("Failed to establish transport connection")
|
64
57
|
return false
|
65
58
|
end
|
66
59
|
|
67
|
-
@connected = true
|
68
60
|
log_debug("Connected to MCP server")
|
69
|
-
|
70
|
-
# Create handler only if it doesn't exist yet
|
71
|
-
@json_rpc_handler ||= JsonRpcHandler.new(session, self)
|
72
|
-
|
73
|
-
# Clear any existing message callback and set a new one
|
74
|
-
@message_callback = lambda do |response|
|
75
|
-
@json_rpc_handler.call(response)
|
76
|
-
end
|
77
|
-
|
78
61
|
true
|
79
62
|
rescue StandardError => e
|
80
63
|
@connection_error = e.message
|
81
64
|
log_error("Failed to connect to MCP server: #{e.message}")
|
82
|
-
@error_callback&.call(e)
|
83
65
|
false
|
84
66
|
end
|
85
67
|
end
|
86
68
|
|
87
69
|
# Disconnect from the MCP server
|
88
70
|
def disconnect
|
89
|
-
return true unless
|
71
|
+
return true unless connected?
|
90
72
|
|
91
73
|
begin
|
92
|
-
|
93
|
-
@connected = false
|
74
|
+
@transport.disconnect
|
94
75
|
log_debug("Disconnected from MCP server")
|
95
76
|
true
|
96
77
|
rescue StandardError => e
|
97
78
|
log_error("Error disconnecting from MCP server: #{e.message}")
|
98
|
-
@error_callback&.call(e)
|
99
79
|
false
|
100
80
|
end
|
101
81
|
end
|
102
82
|
|
103
|
-
# Set a callback for incoming messages
|
104
|
-
def on_message(&block)
|
105
|
-
@message_callback = block
|
106
|
-
end
|
107
|
-
|
108
|
-
# Set a callback for errors
|
109
|
-
def on_error(&block)
|
110
|
-
@error_callback = block
|
111
|
-
end
|
112
|
-
|
113
83
|
# Send a request to the MCP server
|
114
84
|
def write_message(payload)
|
115
|
-
unless
|
116
|
-
log_error("Cannot send request - not
|
85
|
+
unless ready?
|
86
|
+
log_error("Cannot send request - transport not ready")
|
117
87
|
return false
|
118
88
|
end
|
119
89
|
|
120
90
|
begin
|
121
|
-
session
|
91
|
+
# Only write to session if it exists (after initialization)
|
92
|
+
session.write(payload) if session
|
122
93
|
data = payload.to_json unless payload.is_a?(String)
|
123
|
-
send_message(data)
|
94
|
+
@transport.send_message(data)
|
124
95
|
true
|
125
96
|
rescue StandardError => e
|
126
97
|
log_error("Failed to send request: #{e.message}")
|
127
|
-
@error_callback&.call(e)
|
128
98
|
false
|
129
99
|
end
|
130
100
|
end
|
131
101
|
|
132
|
-
# Methods to be implemented by subclasses
|
133
|
-
def start_transport
|
134
|
-
raise NotImplementedError, "#{self.class} must implement #start_transport"
|
135
|
-
end
|
136
|
-
|
137
|
-
def stop_transport
|
138
|
-
raise NotImplementedError, "#{self.class} must implement #stop_transport"
|
139
|
-
end
|
140
|
-
|
141
|
-
def send_message(json)
|
142
|
-
raise NotImplementedError, "#{self.class} must implement #send_message"
|
143
|
-
end
|
144
|
-
|
145
|
-
def ready?
|
146
|
-
raise NotImplementedError, "#{self.class} must implement #ready?"
|
147
|
-
end
|
148
|
-
|
149
102
|
def server=(server)
|
150
103
|
@server = if server.is_a?(Client::Server)
|
151
104
|
server
|
152
105
|
else
|
153
106
|
Client::Server.new(server)
|
154
107
|
end
|
155
|
-
|
156
|
-
session
|
157
|
-
session
|
108
|
+
|
109
|
+
# Only update session if it exists
|
110
|
+
if @session
|
111
|
+
@session.server_capabilities = server.capabilities
|
112
|
+
@session.server_info = server.server_info
|
113
|
+
@session.save
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def initialized?
|
118
|
+
@initialized && @session&.initialized?
|
158
119
|
end
|
159
120
|
|
160
121
|
def inspect
|
161
|
-
|
122
|
+
session_info = @session ? "session: #{@session.id}" : "session: none"
|
123
|
+
"#<#{self.class.name} transport: #{transport.class.name}, server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities}, connected: #{connected?}, initialized: #{initialized?}, #{session_info}>"
|
162
124
|
end
|
163
125
|
|
164
126
|
protected
|
165
127
|
|
128
|
+
def setup_transport_callbacks
|
129
|
+
# Create JSON-RPC handler
|
130
|
+
@json_rpc_handler = JsonRpcHandler.new(session, self)
|
131
|
+
|
132
|
+
# Set up transport callbacks
|
133
|
+
@transport.on_message do |message|
|
134
|
+
handle_raw_message(message)
|
135
|
+
end
|
136
|
+
|
137
|
+
@transport.on_error do |error|
|
138
|
+
handle_transport_error(error)
|
139
|
+
end
|
140
|
+
|
141
|
+
@transport.on_connect do
|
142
|
+
handle_transport_connect
|
143
|
+
end
|
144
|
+
|
145
|
+
@transport.on_disconnect do
|
146
|
+
handle_transport_disconnect
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
166
150
|
def handle_raw_message(raw)
|
167
|
-
@
|
151
|
+
@json_rpc_handler.call(raw)
|
168
152
|
rescue MultiJson::ParseError => e
|
169
153
|
log_error("JSON parse error: #{e} (raw: #{raw})")
|
170
|
-
@error_callback&.call(e)
|
171
154
|
rescue StandardError => e
|
172
155
|
log_error("Error handling message: #{e} (raw: #{raw})")
|
173
|
-
|
156
|
+
end
|
157
|
+
|
158
|
+
def handle_transport_error(error)
|
159
|
+
@connection_error = error.message
|
160
|
+
log_error("Transport error: #{error.message}")
|
161
|
+
end
|
162
|
+
|
163
|
+
def handle_transport_connect
|
164
|
+
log_debug("Transport connected")
|
165
|
+
# Send initial capabilities after connection
|
166
|
+
send_initial_capabilities
|
167
|
+
end
|
168
|
+
|
169
|
+
def handle_transport_disconnect
|
170
|
+
log_debug("Transport disconnected")
|
174
171
|
end
|
175
172
|
|
176
173
|
def send_initial_capabilities
|
177
174
|
log_debug("Sending client capabilities")
|
178
|
-
|
179
|
-
#
|
180
|
-
|
175
|
+
|
176
|
+
# If we have a session_id, we're trying to resume
|
177
|
+
if @session_id
|
178
|
+
log_debug("Attempting to resume session: #{@session_id}")
|
179
|
+
end
|
180
|
+
|
181
181
|
params = {
|
182
|
-
protocolVersion:
|
183
|
-
capabilities:
|
184
|
-
clientInfo:
|
182
|
+
protocolVersion: PROTOCOL_VERSION,
|
183
|
+
capabilities: client_capabilities,
|
184
|
+
clientInfo: client_info
|
185
185
|
}
|
186
|
-
|
186
|
+
|
187
|
+
# Include session_id if we're trying to resume
|
188
|
+
params[:sessionId] = @session_id if @session_id
|
189
|
+
|
190
|
+
# Use a unique request ID (not session ID since we don't have one yet)
|
191
|
+
request_id = SecureRandom.uuid
|
192
|
+
send_jsonrpc_request("initialize", params: params, id: request_id)
|
187
193
|
end
|
188
194
|
|
189
195
|
def client_capabilities
|
@@ -70,13 +70,16 @@ module ActionMCP
|
|
70
70
|
end
|
71
71
|
|
72
72
|
def process_response(id, result)
|
73
|
-
if
|
74
|
-
|
75
|
-
|
73
|
+
# Check if this is a response to an initialize request
|
74
|
+
# We need to check the actual request method, not just compare IDs
|
75
|
+
request = client.session ? transport.messages.requests.find_by(jsonrpc_id: id) : nil
|
76
|
+
|
77
|
+
# If no session yet, this might be the initialize response
|
78
|
+
if !client.session && result["serverInfo"]
|
79
|
+
handle_initialize_response(id, result)
|
76
80
|
return send_initialized_notification
|
77
81
|
end
|
78
82
|
|
79
|
-
request = transport.messages.requests.find_by(jsonrpc_id: id)
|
80
83
|
return unless request
|
81
84
|
|
82
85
|
# Mark the request as acknowledged
|
@@ -113,8 +116,42 @@ module ActionMCP
|
|
113
116
|
puts "\e[31mUnknown error: #{id} #{error}\e[0m"
|
114
117
|
end
|
115
118
|
|
119
|
+
def handle_initialize_response(request_id, result)
|
120
|
+
# Session ID comes from HTTP headers, not the response body
|
121
|
+
# The transport should have already extracted it
|
122
|
+
session_id = transport.instance_variable_get(:@session_id)
|
123
|
+
|
124
|
+
if session_id.nil?
|
125
|
+
client.log_error("No session ID received from server")
|
126
|
+
return
|
127
|
+
end
|
128
|
+
|
129
|
+
# Check if we're resuming an existing session
|
130
|
+
if client.instance_variable_get(:@session_id) && session_id == client.instance_variable_get(:@session_id)
|
131
|
+
# We're resuming an existing session
|
132
|
+
client.instance_variable_set(:@session, ActionMCP::Session.find(session_id))
|
133
|
+
client.log_info("Resumed existing session: #{session_id}")
|
134
|
+
else
|
135
|
+
# Create a new session with the server-provided ID
|
136
|
+
client.instance_variable_set(:@session, ActionMCP::Session.from_client.new(
|
137
|
+
id: session_id,
|
138
|
+
protocol_version: result["protocolVersion"] || PROTOCOL_VERSION,
|
139
|
+
client_info: client.client_info,
|
140
|
+
client_capabilities: client.client_capabilities,
|
141
|
+
server_info: result["serverInfo"],
|
142
|
+
server_capabilities: result["capabilities"]
|
143
|
+
))
|
144
|
+
client.session.save
|
145
|
+
client.log_info("Created new session: #{session_id}")
|
146
|
+
end
|
147
|
+
|
148
|
+
# Set the server info
|
149
|
+
client.server = Client::Server.new(result)
|
150
|
+
client.instance_variable_set(:@initialized, true)
|
151
|
+
end
|
152
|
+
|
116
153
|
def send_initialized_notification
|
117
|
-
transport.initialize!
|
154
|
+
transport.initialize! if transport.respond_to?(:initialize!)
|
118
155
|
client.send_jsonrpc_notification("notifications/initialized")
|
119
156
|
end
|
120
157
|
end
|