actionmcp 0.24.0 → 0.25.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b50744d4dcd16237d18d1b5c787f721e56281c82d1037c776201226a06cc60c
4
- data.tar.gz: ed2b466652be17b4443508719da7b474b1bfc941047815f9a45bc4acf39ca2d6
3
+ metadata.gz: 843ee149da81be07d55d13eb26fad244a21232a0d4f795c98429d556c67c2736
4
+ data.tar.gz: fbf8aa774b9afe3e72cc325a93d054f98950163e14bd618d851c10d70bf823dc
5
5
  SHA512:
6
- metadata.gz: ba4f687a5368fb04a2f986b61859e19d05658c00e39dc2b600984db39f34fd3e0f56259e6d08c425d140c1f1641d55ee209ec0ae69109bde1e0ec5312b01cf57
7
- data.tar.gz: 6daf198545188c392453e6a53b905fafd3aa6c84ab508114c563ef59ff1c8b0e44712d5c8c82dc8643497daef7136bc3c284baec482d9fd8193c3cf1a32d4fe0
6
+ metadata.gz: b32d0b8d10b4a7ed6d8c86a76db959d5a453a5d0004dc6ccf2cee643d57c347777a4581dc0a1cf34f9d41dd202c01b0a28ce24169d9b43fabce96b980d857b8e
7
+ data.tar.gz: a42653c1903e8e5c8078fd4fc3b3e206c1c92a58cde623b166b6bbd840be45cf1d6339f08d55cbe1018a2c448522901c30d7d6d72ba1d6ff97b22f0c1e4a76e1
data/README.md CHANGED
@@ -293,6 +293,117 @@ npx @modelcontextprotocol/inspector
293
293
 
294
294
  The default path will be http://localhost:3000/action_mcp
295
295
 
296
- ## Conclusion
296
+ Here's a section you can add to explain the profile system in ActionMCP:
297
+
298
+ ## Profiles
299
+
300
+ ActionMCP supports a flexible profile system that allows you to selectively expose tools, prompts, and resources based on different usage scenarios. This is particularly useful for applications that need different MCP capabilities for different contexts (e.g., public API vs. admin interface).
301
+
302
+ ### Understanding Profiles
303
+
304
+ Profiles are named configurations that define:
305
+
306
+ - Which tools are available
307
+ - Which prompts are accessible
308
+ - Which resources can be accessed
309
+ - Configuration options like logging level and change notifications
310
+
311
+ By default, ActionMCP includes two profiles:
312
+ - `primary`: Exposes all tools, prompts, and resources
313
+ - `minimal`: Exposes no tools, prompts, or resources by default
314
+
315
+ ### Configuring Profiles
316
+
317
+ Profiles are configured via a `config/mcp.yml` file in your Rails application. If this file doesn't exist, ActionMCP will use default settings from the gem.
318
+
319
+ **Example configuration:**
320
+
321
+ ```yaml
322
+ default:
323
+ tools:
324
+ - all # Include all tools
325
+ prompts:
326
+ - all # Include all prompts
327
+ resources:
328
+ - all # Include all resources
329
+ options:
330
+ list_changed: false
331
+ logging_enabled: true
332
+ logging_level: info
333
+ resources_subscribe: false
334
+
335
+ api_only:
336
+ tools:
337
+ - calculator
338
+ - weather
339
+ prompts: [] # No prompts for API
340
+ resources:
341
+ - user_profile
342
+ options:
343
+ list_changed: false
344
+ logging_level: warn
345
+
346
+ admin:
347
+ tools:
348
+ - all
349
+ options:
350
+ logging_level: debug
351
+ list_changed: true
352
+ resources_subscribe: true
353
+ ```
354
+
355
+ Each profile can specify:
356
+ - `tools`: Array of tool names to include (use `all` to include all tools)
357
+ - `prompts`: Array of prompt names to include (use `all` to include all prompts)
358
+ - `resources`: Array of resource names to include (use `all` to include all resources)
359
+ - `options`: Additional configuration options:
360
+ - `list_changed`: Whether to send change notifications
361
+ - `logging_enabled`: Whether to enable logging
362
+ - `logging_level`: The logging level to use
363
+ - `resources_subscribe`: Whether to enable resource subscriptions
364
+
365
+ ### Switching Profiles
366
+
367
+ You can switch between profiles programmatically in your code:
368
+
369
+ ```ruby
370
+ # Permanently switch to a different profile
371
+ ActionMCP.configuration.use_profile(:only_tools) # Switch to a profile named "only_tools"
372
+
373
+ # Temporarily use a profile for a specific operation
374
+ ActionMCP.with_profile(:minimal) do
375
+ # Code here uses the minimal profile
376
+ # After the block, reverts to the previous profile
377
+ end
378
+ ```
379
+
380
+ This makes it easy to control which MCP capabilities are available in different contexts of your application.
381
+
382
+ ### Inspecting Profiles
383
+
384
+ ActionMCP includes rake tasks to help you manage and inspect your profiles:
385
+
386
+ ```bash
387
+ # List all available profiles with their configurations
388
+ bin/rails action_mcp:list_profiles
389
+
390
+ # Show detailed information about a specific profile
391
+ bin/rails action_mcp:show_profile[admin]
392
+
393
+ # List all tools, prompts, resources, and profiles
394
+ bin/rails action_mcp:list
395
+ ```
396
+
397
+ The profile inspection tasks will highlight any issues, such as configured tools, prompts, or resources that don't actually exist in your application.
398
+
399
+ ### Use Cases
400
+
401
+ Profiles are particularly useful for:
402
+
403
+ 1. **Multi-tenant applications**: Use different profiles for different customer tiers with Dorp or other gems
404
+ 2. **Access control**: Create profiles for different user roles (admin, staff, public)
405
+ 3. **Performance optimization**: Use a minimal profile for high-traffic endpoints
406
+ 4. **Testing environments**: Use specific test profiles in your test environment
407
+ 5. **Progressive enhancement**: Start with a minimal profile and gradually add capabilities
297
408
 
298
- ActionMCP empowers developers to build MCP-compliant servers efficiently by handling the standardization and boilerplate associated with integrating with LLMs. With its intuitive abstractions for tools, prompts, and resource templates, you can quickly expose your application's capabilities to AI models while maintaining full control over how they interact with your system.
409
+ By leveraging profiles, you can maintain a single ActionMCP codebase while providing tailored MCP capabilities for different contexts.
@@ -6,11 +6,7 @@ module ActionMCP
6
6
 
7
7
  # @route POST / (sse_in)
8
8
  def create
9
- begin
10
- handle_post_message(clean_params, response)
11
- rescue StandardError
12
- head :internal_server_error
13
- end
9
+ handle_post_message(params, response)
14
10
  head response.status
15
11
  end
16
12
 
@@ -25,22 +21,14 @@ module ActionMCP
25
21
  end
26
22
 
27
23
  def handle_post_message(params, response)
28
- mcp_session.initialize! if params[:method] == "initialize"
29
24
  json_rpc_handler.call(params)
30
-
31
25
  response.status = :accepted
32
- rescue StandardError => e
33
- puts "Error: #{e.message}"
34
- puts e.backtrace.join("\n")
26
+ rescue StandardError => _e
35
27
  response.status = :bad_request
36
28
  end
37
29
 
38
30
  def mcp_session
39
31
  @mcp_session ||= Session.find_or_create_by(id: params[:session_id])
40
32
  end
41
-
42
- def clean_params
43
- params.slice(:id, :method, :jsonrpc, :params, :result, :error)
44
- end
45
33
  end
46
34
  end
@@ -2,70 +2,122 @@
2
2
 
3
3
  module ActionMCP
4
4
  class SSEController < MCPController
5
- HEARTBEAT_INTERVAL = 30 # TODO: The frequency of pings SHOULD be configurable
5
+ HEARTBEAT_INTERVAL = 30 # in seconds
6
+ INITIAL_CONNECTION_TIMEOUT = 5 # in seconds
6
7
  include ActionController::Live
7
8
 
8
9
  # @route GET /sse (sse_out)
9
10
  def events
10
- # Set headers first
11
+ # Set headers for SSE
11
12
  response.headers["X-Accel-Buffering"] = "no"
12
13
  response.headers["Content-Type"] = "text/event-stream"
13
14
  response.headers["Cache-Control"] = "no-cache"
14
15
  response.headers["Connection"] = "keep-alive"
15
16
 
16
- # Now start streaming - send endpoint
17
+ # Send the endpoint URL to the client
17
18
  send_endpoint_event(sse_in_url)
18
19
 
20
+ Rails.logger.info "SSE: Starting connection for session: #{session_id}"
21
+
22
+ # Use Concurrent primitives for state management
23
+ message_received = Concurrent::AtomicBoolean.new(false)
24
+ connection_active = Concurrent::AtomicBoolean.new(true)
25
+
19
26
  begin
20
- # Create SSE instance once outside the callback with retry parameter
27
+ # Create SSE instance
21
28
  sse = SSE.new(response.stream)
22
29
 
23
- # Start listener and process messages via the transport
30
+ # Start the connection monitor using a proper scheduled task
31
+ timeout_task = Concurrent::ScheduledTask.execute(INITIAL_CONNECTION_TIMEOUT) do
32
+ unless message_received.true?
33
+ Rails.logger.warn "No message received within #{INITIAL_CONNECTION_TIMEOUT} seconds, closing connection for session: #{session_id}"
34
+ error = build_timeout_error
35
+ # Safely write error and close the stream
36
+ Concurrent::Promise.execute do
37
+ sse.write(error) rescue nil
38
+ response.stream.close rescue nil
39
+ connection_active.make_false
40
+ end
41
+ end
42
+ end
43
+
44
+ # Initialize the listener
24
45
  listener = SSEListener.new(mcp_session)
25
- message_received = false
26
- if listener.start do |message|
27
- # Send with proper SSE formatting
46
+ listener_started = listener.start do |message|
47
+ message_received.make_true
28
48
  sse.write(message)
29
- message_received = true
30
49
  end
31
- sleep 1
32
- # Heartbeat loop
33
- unless message_received
34
- Rails.logger.warn "No message received within 1 second, closing connection for session: #{session_id}"
35
- error = JsonRpc::Response.new(id: SecureRandom.uuid_v7,
36
- error: JsonRpc::JsonRpcError.new(
37
- :server_error, message: "No message received within 1 second"
38
- ).to_h).to_h
39
- sse.write(error)
40
- return
41
- end
42
50
 
43
- until response.stream.closed?
44
- sleep HEARTBEAT_INTERVAL
45
- # mcp_session.send_ping!
46
- end
47
- else
51
+ unless listener_started
48
52
  Rails.logger.error "Listener failed to activate for session: #{session_id}"
49
- raise "Failed to establish subscription"
53
+ error = build_listener_error
54
+ sse.write(error)
55
+ connection_active.make_false
56
+ return
57
+ end
58
+
59
+ # Schedule heartbeats using a proper timer
60
+ heartbeat = Concurrent::TimerTask.new(
61
+ execution_interval: HEARTBEAT_INTERVAL,
62
+ timeout_interval: 5 # Timeout for heartbeat operation
63
+ ) do
64
+ if connection_active.true? && !response.stream.closed?
65
+ begin
66
+ sse.write({ ping: true })
67
+ rescue StandardError => e
68
+ Rails.logger.debug "SSE: Heartbeat error: #{e.message}"
69
+ connection_active.make_false
70
+ end
71
+ else
72
+ raise Concurrent::CancelledOperationError
73
+ end
74
+ end
75
+ heartbeat.execute
76
+
77
+ # Wait for connection to be closed or cancelled
78
+ while connection_active.true? && !response.stream.closed?
79
+ sleep 0.1
50
80
  end
51
81
  rescue ActionController::Live::ClientDisconnected, IOError => e
52
- Rails.logger.debug "SSE: Expected disconnection: #{e.message}"
82
+ Rails.logger.debug "SSE: Client disconnected: #{e.message}"
53
83
  rescue StandardError => e
54
84
  Rails.logger.error "SSE: Unexpected error: #{e.class} - #{e.message}\n#{e.backtrace.join("\n")}"
55
85
  ensure
56
- response.stream.close
57
- listener.stop
58
- Rails.logger.debug "SSE: Connection closed for session: #{session_id}"
86
+ # Clean up resources
87
+ timeout_task&.cancel
88
+ heartbeat&.shutdown
89
+ listener&.stop
90
+ response.stream.close rescue nil
91
+
92
+ Rails.logger.debug "SSE: Connection cleaned up for session: #{session_id}"
59
93
  end
60
94
  end
61
95
 
62
96
  private
63
97
 
98
+ def build_timeout_error
99
+ JsonRpc::Response.new(
100
+ id: SecureRandom.uuid_v7,
101
+ error: JsonRpc::JsonRpcError.new(
102
+ :server_error,
103
+ message: "No message received within initial connection timeout"
104
+ ).to_h
105
+ ).to_h
106
+ end
107
+
108
+ def build_listener_error
109
+ JsonRpc::Response.new(
110
+ id: SecureRandom.uuid_v7,
111
+ error: JsonRpc::JsonRpcError.new(
112
+ :server_error,
113
+ message: "Failed to establish server connection"
114
+ ).to_h
115
+ ).to_h
116
+ end
117
+
64
118
  def send_endpoint_event(messages_url)
65
119
  endpoint = "#{messages_url}?session_id=#{session_id}"
66
- SSE.new(response.stream,
67
- event: "endpoint")
68
- .write(endpoint)
120
+ SSE.new(response.stream, event: "endpoint").write(endpoint)
69
121
  end
70
122
 
71
123
  def default_url_options
@@ -77,11 +129,11 @@ module ActionMCP
77
129
  end
78
130
 
79
131
  def session_id
80
- @session_id ||= mcp_session.id
132
+ mcp_session.id
81
133
  end
82
134
 
83
135
  def cache_key
84
- "action_mcp:session:#{session_id}"
136
+ mcp_session.session_key
85
137
  end
86
138
  end
87
139
 
@@ -93,7 +145,8 @@ module ActionMCP
93
145
  # @param session [ActionMCP::Session]
94
146
  def initialize(session)
95
147
  @session = session
96
- @stopped = false
148
+ @stopped = Concurrent::AtomicBoolean.new(false)
149
+ @subscription_active = Concurrent::AtomicBoolean.new(false)
97
150
  end
98
151
 
99
152
  # Start listening using ActionCable's adapter
@@ -101,34 +154,49 @@ module ActionMCP
101
154
  Rails.logger.debug "Starting listener for channel: #{session_key}"
102
155
 
103
156
  success_callback = lambda {
104
- puts "Successfully subscribed to channel: #{session_key}"
105
- @subscription_active = true
157
+ Rails.logger.info "Successfully subscribed to channel: #{session_key}"
158
+ @subscription_active.make_true
106
159
  }
107
160
 
108
161
  # Set up message callback
109
162
  message_callback = lambda { |raw_message|
163
+ return if @stopped.true?
164
+
110
165
  begin
111
166
  # Try to parse the message if it's JSON
112
167
  message = MultiJson.load(raw_message)
113
168
  # Send the message to the callback
114
- callback.call(message) if callback && !@stopped
115
- rescue StandardError
169
+ callback.call(message) if callback
170
+ rescue StandardError => e
171
+ Rails.logger.error "Error processing message: #{e.message}"
116
172
  # Still try to send the raw message as a fallback
117
- callback.call(raw_message) if callback && !@stopped
173
+ callback.call(raw_message) if callback
118
174
  end
119
175
  }
120
176
 
121
177
  # Subscribe using the ActionCable adapter
122
178
  adapter.subscribe(session_key, message_callback, success_callback)
123
179
 
124
- # Give some time for the subscription to be established
125
- sleep 0.5
180
+ # Use a future with timeout to check subscription status
181
+ subscription_future = Concurrent::Promises.future do
182
+ while !@subscription_active.true? && !@stopped.true?
183
+ sleep 0.1
184
+ end
185
+ @subscription_active.true?
186
+ end
126
187
 
127
- @subscription_active
188
+ # Wait up to 1 second for subscription to be established
189
+ begin
190
+ subscription_result = subscription_future.value(1)
191
+ subscription_result || @subscription_active.true?
192
+ rescue Concurrent::TimeoutError
193
+ Rails.logger.warn "Timed out waiting for subscription activation"
194
+ false
195
+ end
128
196
  end
129
197
 
130
198
  def stop
131
- @stopped = true
199
+ @stopped.make_true
132
200
  if (mcp_session = Session.find_by(id: session_key))
133
201
  mcp_session.close
134
202
  end
@@ -8,7 +8,6 @@
8
8
  # direction(The message recipient) :string default("client"), not null
9
9
  # is_ping(Whether the message is a ping) :boolean default(FALSE), not null
10
10
  # message_json :jsonb
11
- # message_text :string
12
11
  # message_type(The type of the message) :string not null
13
12
  # request_acknowledged :boolean default(FALSE), not null
14
13
  # request_cancelled :boolean default(FALSE), not null
@@ -61,26 +60,26 @@ module ActionMCP
61
60
  def data=(payload)
62
61
  @data = payload
63
62
 
64
- # Store original version and attempt to determine type
63
+ # Convert string payloads to JSON
65
64
  if payload.is_a?(String)
66
- self.message_text = payload
67
65
  begin
68
66
  parsed_json = MultiJson.load(payload)
69
67
  self.message_json = parsed_json
70
- self.message_text = nil
71
- process_json_content(parsed_json)
72
68
  rescue MultiJson::ParseError
73
- self.message_type = "text"
69
+ # Handle invalid JSON by creating an error object
70
+ self.message_json = { "error" => "Invalid JSON", "raw" => payload }
71
+ self.message_type = "invalid_json"
72
+ return
74
73
  end
75
74
  else
75
+ # Handle direct hash assignment
76
76
  self.message_json = payload
77
- self.message_text = nil
78
- process_json_content(payload)
79
77
  end
78
+ process_json_content(payload)
80
79
  end
81
80
 
82
81
  def data
83
- message_json.presence || message_text
82
+ message_json
84
83
  end
85
84
 
86
85
  # Helper methods
@@ -98,6 +97,7 @@ module ActionMCP
98
97
 
99
98
  def rpc_method
100
99
  return false unless request?
100
+
101
101
  data["method"]
102
102
  end
103
103
 
@@ -108,9 +108,9 @@ module ActionMCP
108
108
  end
109
109
 
110
110
  def broadcast_message
111
- if adapter.present?
112
- adapter.broadcast(session_key, data.to_json)
113
- end
111
+ return unless adapter.present?
112
+
113
+ adapter.broadcast(session_key, data.to_json)
114
114
  end
115
115
 
116
116
  def process_json_content(content)
@@ -107,8 +107,9 @@ module ActionMCP
107
107
  # update the session initialized to true
108
108
  return false if initialized?
109
109
 
110
- update!(initialized: true,
111
- status: "initialized")
110
+ self.initialized = true
111
+ self.status = "initialized"
112
+ save
112
113
  end
113
114
 
114
115
  def message_flow
data/config/routes.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  ActionMCP::Engine.routes.draw do
4
4
  get "/", to: "sse#events", as: :sse_out
5
- post "/", to: "messages#create", as: :sse_in
5
+ post "/", to: "messages#create", as: :sse_in, defaults: { format: "json" }
6
6
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RemoveSessionMessageText < ActiveRecord::Migration[8.0]
4
+ def up
5
+ remove_column :action_mcp_session_messages, :message_text
6
+ end
7
+ end
@@ -17,6 +17,7 @@ module ActionMCP
17
17
  :server_capabilities, :session,
18
18
  :catalog, :blueprint,
19
19
  :prompt_book, :toolbox
20
+
20
21
  delegate :initialized?, to: :session
21
22
 
22
23
  def initialize(logger: ActionMCP.logger)
@@ -146,15 +147,14 @@ module ActionMCP
146
147
  end
147
148
 
148
149
  def server=(server)
149
- if server.is_a?(Client::Server)
150
- @server = server
150
+ @server = if server.is_a?(Client::Server)
151
+ server
151
152
  else
152
- @server = Client::Server.new(server)
153
+ Client::Server.new(server)
153
154
  end
154
155
  session.server_capabilities = server.capabilities
155
156
  session.server_info = server.server_info
156
157
  session.save
157
- server
158
158
  end
159
159
 
160
160
  def inspect
@@ -164,15 +164,13 @@ module ActionMCP
164
164
  protected
165
165
 
166
166
  def handle_raw_message(raw)
167
- begin
168
- @message_callback&.call(raw)
169
- rescue MultiJson::ParseError => e
170
- log_error("JSON parse error: #{e} (raw: #{raw})")
171
- @error_callback&.call(e)
172
- rescue StandardError => e
173
- log_error("Error handling message: #{e} (raw: #{raw})")
174
- @error_callback&.call(e)
175
- end
167
+ @message_callback&.call(raw)
168
+ rescue MultiJson::ParseError => e
169
+ log_error("JSON parse error: #{e} (raw: #{raw})")
170
+ @error_callback&.call(e)
171
+ rescue StandardError => e
172
+ log_error("Error handling message: #{e} (raw: #{raw})")
173
+ @error_callback&.call(e)
176
174
  end
177
175
 
178
176
  def send_initial_capabilities
@@ -180,7 +178,7 @@ module ActionMCP
180
178
  # We have contact! Let's send our CV to the recruiter.
181
179
  # We persist the session object to the database
182
180
  session.save
183
- params= {
181
+ params = {
184
182
  protocolVersion: session.protocol_version,
185
183
  capabilities: session.client_capabilities,
186
184
  clientInfo: session.client_info
@@ -90,7 +90,7 @@ module ActionMCP
90
90
  keyword = keyword.downcase
91
91
  all.select do |resource|
92
92
  resource.name.downcase.include?(keyword) ||
93
- (resource.description && resource.description.downcase.include?(keyword))
93
+ resource.description&.downcase&.include?(keyword)
94
94
  end
95
95
  end
96
96
 
@@ -119,7 +119,7 @@ module ActionMCP
119
119
  #
120
120
  # @return [String, nil] The file extension or nil if no extension
121
121
  def extension
122
- File.extname(@name)[1..-1] if @name.include?(".")
122
+ File.extname(@name)[1..] if @name.include?(".")
123
123
  end
124
124
 
125
125
  # Check if this resource is a text file based on MIME type
@@ -140,7 +140,9 @@ module ActionMCP
140
140
  #
141
141
  # @return [String, nil] The path component of the URI
142
142
  def path
143
- URI(@uri).path rescue nil
143
+ URI(@uri).path
144
+ rescue StandardError
145
+ nil
144
146
  end
145
147
 
146
148
  # Generate a hash representation of the resource
@@ -11,7 +11,7 @@ module ActionMCP
11
11
  def initialize(items, client, silence_sql: true)
12
12
  @collection_data = items || []
13
13
  @client = client
14
- @loaded = !(@collection_data.empty?)
14
+ @loaded = !@collection_data.empty?
15
15
  @silence_sql = silence_sql
16
16
  end
17
17
 
@@ -4,6 +4,7 @@ module ActionMCP
4
4
  module Client
5
5
  class JsonRpcHandler < JsonRpcHandlerBase
6
6
  attr_reader :client
7
+
7
8
  def initialize(transport, client)
8
9
  super(transport)
9
10
  @client = client
@@ -104,7 +105,6 @@ module ActionMCP
104
105
  puts "\e[31mUnknown error: #{id} #{error}\e[0m"
105
106
  end
106
107
 
107
-
108
108
  def send_initialized_notification
109
109
  transport.initialize!
110
110
  client.send_jsonrpc_notification("notifications/initialized")
@@ -12,8 +12,7 @@ module ActionMCP
12
12
  # Send request
13
13
  send_jsonrpc_request("client/setLoggingLevel",
14
14
  params: { level: level },
15
- id: request_id
16
- )
15
+ id: request_id)
17
16
  end
18
17
  end
19
18
  end
@@ -28,8 +28,7 @@ module ActionMCP
28
28
  name: name,
29
29
  arguments: arguments
30
30
  },
31
- id: request_id
32
- )
31
+ id: request_id)
33
32
 
34
33
  # Return request ID for tracking the request
35
34
  request_id
@@ -22,9 +22,7 @@ module ActionMCP
22
22
  # Wait until either:
23
23
  # 1. The collection is loaded (@loaded becomes true from JsonRpcHandler)
24
24
  # 2. The timeout is reached
25
- while !@loaded && (Time.now - start_time) < timeout
26
- sleep(0.1)
27
- end
25
+ sleep(0.1) while !@loaded && (Time.now - start_time) < timeout
28
26
 
29
27
  # If we timed out
30
28
  unless @loaded
@@ -33,9 +31,9 @@ module ActionMCP
33
31
  if request && !request.request_acknowledged?
34
32
  # Send cancel notification
35
33
  client.send_jsonrpc_notification("notifications/cancelled", {
36
- requestId: request_id,
37
- reason: "Request timed out after #{timeout} seconds"
38
- })
34
+ requestId: request_id,
35
+ reason: "Request timed out after #{timeout} seconds"
36
+ })
39
37
 
40
38
  # Mark as cancelled in the database
41
39
  request.update(request_cancelled: true)
@@ -58,18 +56,18 @@ module ActionMCP
58
56
  # Find the request
59
57
  request = client.session.messages.requests.find_by(jsonrpc_id: request_id)
60
58
 
61
- if request && !request.request_acknowledged?
62
- # Send cancel notification
63
- client.send_jsonrpc_notification("notifications/cancelled", {
64
- requestId: request_id,
65
- reason: "Request timed out after #{timeout} seconds"
66
- })
59
+ return unless request && !request.request_acknowledged?
67
60
 
68
- # Mark as cancelled in the database
69
- request.update(request_cancelled: true)
61
+ # Send cancel notification
62
+ client.send_jsonrpc_notification("notifications/cancelled", {
63
+ requestId: request_id,
64
+ reason: "Request timed out after #{timeout} seconds"
65
+ })
70
66
 
71
- log_error("Request #{method_name} timed out after #{timeout} seconds")
72
- end
67
+ # Mark as cancelled in the database
68
+ request.update(request_cancelled: true)
69
+
70
+ log_error("Request #{method_name} timed out after #{timeout} seconds")
73
71
  end
74
72
  end
75
73
  end
@@ -36,8 +36,7 @@ module ActionMCP
36
36
  # Send request
37
37
  send_jsonrpc_request("resources/read",
38
38
  params: { uri: uri },
39
- id: request_id
40
- )
39
+ id: request_id)
41
40
 
42
41
  # Return request ID for tracking the request
43
42
  request_id
@@ -56,8 +55,7 @@ module ActionMCP
56
55
  # Send request
57
56
  send_jsonrpc_request("resources/subscribe",
58
57
  params: { uri: uri },
59
- id: request_id
60
- )
58
+ id: request_id)
61
59
 
62
60
  # Return request ID for tracking the request
63
61
  request_id
@@ -74,8 +72,7 @@ module ActionMCP
74
72
  # Send request
75
73
  send_jsonrpc_request("resources/unsubscribe",
76
74
  params: { uri: uri },
77
- id: request_id
78
- )
75
+ id: request_id)
79
76
 
80
77
  # Return request ID for tracking the request
81
78
  request_id
@@ -1,9 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActionMCP
2
4
  module Client
3
5
  class Server
4
- attr_reader :name, :version
5
-
6
- attr_reader :server_info, :capabilities
6
+ attr_reader :name, :version, :server_info, :capabilities
7
7
 
8
8
  def initialize(data)
9
9
  # Store protocol version if needed for later use
@@ -76,7 +76,7 @@ module ActionMCP
76
76
  def search(keyword)
77
77
  all.select do |tool|
78
78
  tool.name.include?(keyword) ||
79
- (tool.description && tool.description.downcase.include?(keyword.downcase))
79
+ tool.description&.downcase&.include?(keyword.downcase)
80
80
  end
81
81
  end
82
82
 
@@ -126,7 +126,7 @@ module ActionMCP
126
126
  #
127
127
  # @return [Hash] Hash of property definitions
128
128
  def properties
129
- @input_schema.dig("properties") || {}
129
+ @input_schema["properties"] || {}
130
130
  end
131
131
 
132
132
  # Check if the tool requires a specific property
@@ -28,8 +28,7 @@ module ActionMCP
28
28
  name: name,
29
29
  arguments: arguments
30
30
  },
31
- id: request_id
32
- )
31
+ id: request_id)
33
32
 
34
33
  # Return request ID for tracking the request
35
34
  request_id
@@ -14,21 +14,22 @@ module ActionMCP
14
14
  # @!attribute resources_subscribe
15
15
  # @return [Boolean] Whether to subscribe to resources.
16
16
  # @!attribute logging_level
17
- # @return [Symbol] The logging level.
17
+ # @return [Symbol] The logging level. attr_writer :name, :version
18
18
  attr_writer :name, :version
19
- attr_accessor :logging_enabled, # This is not working yet
20
- :list_changed, # This is not working yet
21
- :resources_subscribe, # This is not working yet
22
- :logging_level # This is not working yet
23
-
24
- # Initializes a new Configuration instance.
25
- #
26
- # @return [void]
19
+ attr_accessor :logging_enabled,
20
+ :list_changed,
21
+ :resources_subscribe,
22
+ :logging_level,
23
+ :active_profile,
24
+ :profiles
27
25
 
28
26
  def initialize
29
27
  @logging_enabled = true
30
28
  @list_changed = false
31
29
  @logging_level = :info
30
+ @resources_subscribe = false
31
+ @active_profile = :primary
32
+ @profiles = default_profiles
32
33
  end
33
34
 
34
35
  def name
@@ -39,23 +40,127 @@ module ActionMCP
39
40
  @version || (has_rails_version ? Rails.application.version.to_s : "0.0.1")
40
41
  end
41
42
 
42
- # Returns a hash of capabilities.
43
- #
44
- # @return [Hash] A hash containing the resources capabilities.
43
+ # Load custom profiles from Rails configuration
44
+ def load_profiles
45
+ # First load defaults from the gem
46
+ @profiles = default_profiles
47
+
48
+ # Then try to load from config/mcp.yml in the Rails app
49
+ config_path = Rails.root.join("config", "mcp.yml")
50
+ if File.exist?(config_path)
51
+ begin
52
+ yaml_content = YAML.safe_load(File.read(config_path), symbolize_names: true)
53
+ # Merge with defaults so user config overrides gem defaults
54
+ @profiles.deep_merge!(yaml_content) if yaml_content
55
+ rescue StandardError => e
56
+ Rails.logger.error "Failed to load MCP profiles from #{config_path}: #{e.message}"
57
+ end
58
+ end
59
+
60
+ # Apply the active profile
61
+ use_profile(@active_profile)
62
+
63
+ self
64
+ end
65
+
66
+ # Switch to a specific profile
67
+ def use_profile(profile_name)
68
+ profile_name = profile_name.to_sym
69
+ unless @profiles.key?(profile_name)
70
+ Rails.logger.warn "Profile '#{profile_name}' not found, using default"
71
+ profile_name = :default
72
+ end
73
+
74
+ @active_profile = profile_name
75
+ apply_profile_options
76
+
77
+ self
78
+ end
79
+
80
+ # Filter tools based on active profile
81
+ def filtered_tools
82
+ return ToolsRegistry.non_abstract if should_include_all?(:tools)
83
+
84
+ tool_names = @profiles[@active_profile][:tools] || []
85
+ # Convert tool names to underscored format
86
+ tool_names = tool_names.map { |name| name.to_s.underscore }
87
+ ToolsRegistry.non_abstract.select { |tool| tool_names.include?(tool.name.underscore) }
88
+ end
89
+
90
+ # Filter prompts based on active profile
91
+ def filtered_prompts
92
+ return PromptsRegistry.non_abstract if should_include_all?(:prompts)
93
+
94
+ prompt_names = @profiles[@active_profile][:prompts] || []
95
+ PromptsRegistry.non_abstract.select { |prompt| prompt_names.include?(prompt.name) }
96
+ end
97
+
98
+ # Filter resources based on active profile
99
+ def filtered_resources
100
+ return ResourceTemplatesRegistry.non_abstract if should_include_all?(:resources)
101
+
102
+ resource_names = @profiles[@active_profile][:resources] || []
103
+ ResourceTemplatesRegistry.non_abstract.select { |resource| resource_names.include?(resource.name) }
104
+ end
105
+
106
+ # Returns capabilities based on active profile
45
107
  def capabilities
46
108
  capabilities = {}
47
- # Only include each capability if the corresponding registry is non-empty.
48
- capabilities[:tools] = { listChanged: false } if ToolsRegistry.non_abstract.any?
49
- capabilities[:prompts] = { listChanged: false } if PromptsRegistry.non_abstract.any?
109
+ # Only include each capability if the corresponding filtered registry is non-empty
110
+ capabilities[:tools] = { listChanged: @list_changed } if filtered_tools.any?
111
+ capabilities[:prompts] = { listChanged: @list_changed } if filtered_prompts.any?
50
112
  capabilities[:logging] = {} if @logging_enabled
51
- # For now, we only have one type of resource, ResourceTemplate
52
- # For Resources, we need to think about how to pass the list to the session.
53
- capabilities[:resources] = {} if ResourceTemplatesRegistry.non_abstract.any?
113
+ capabilities[:resources] = { subscribe: @resources_subscribe } if filtered_resources.any?
54
114
  capabilities
55
115
  end
56
116
 
57
117
  private
58
118
 
119
+ def default_profiles
120
+ {
121
+ primary: {
122
+ tools: [ "all" ],
123
+ prompts: [ "all" ],
124
+ resources: [ "all" ],
125
+ options: {
126
+ list_changed: false,
127
+ logging_enabled: true,
128
+ logging_level: :info,
129
+ resources_subscribe: false
130
+ }
131
+ },
132
+ minimal: {
133
+ tools: [],
134
+ prompts: [],
135
+ resources: [],
136
+ options: {
137
+ list_changed: false,
138
+ logging_enabled: false,
139
+ logging_level: :warn,
140
+ resources_subscribe: false
141
+ }
142
+ }
143
+ }
144
+ end
145
+
146
+ def apply_profile_options
147
+ profile = @profiles[@active_profile]
148
+ return unless profile && profile[:options]
149
+
150
+ options = profile[:options]
151
+ @list_changed = options[:list_changed] unless options[:list_changed].nil?
152
+ @logging_enabled = options[:logging_enabled] unless options[:logging_enabled].nil?
153
+ @logging_level = options[:logging_level] unless options[:logging_level].nil?
154
+ @resources_subscribe = options[:resources_subscribe] unless options[:resources_subscribe].nil?
155
+ end
156
+
157
+ def should_include_all?(type)
158
+ return true unless @profiles[@active_profile]
159
+
160
+ items = @profiles[@active_profile][type]
161
+ items.nil? || items.include?("all")
162
+ end
163
+
59
164
  def has_rails_version
60
165
  gem "rails_app_version"
61
166
  require "rails_app_version/railtie"
@@ -66,21 +171,26 @@ module ActionMCP
66
171
  end
67
172
 
68
173
  class << self
69
- attr_accessor :server
174
+ attr_accessor :server, :logger
70
175
 
71
176
  # Returns the configuration instance.
72
- #
73
- # @return [Configuration] the configuration instance
74
177
  def configuration
75
178
  @configuration ||= Configuration.new
76
179
  end
77
180
 
78
181
  # Configures the ActionMCP module.
79
- #
80
- # @yield [configuration] the configuration instance
81
- # @return [void]
82
182
  def configure
83
183
  yield(configuration)
84
184
  end
185
+
186
+ # Temporarily use a different profile
187
+ def with_profile(profile_name)
188
+ previous_profile = configuration.active_profile
189
+ configuration.use_profile(profile_name)
190
+
191
+ yield if block_given?
192
+ ensure
193
+ configuration.use_profile(previous_profile) if block_given?
194
+ end
85
195
  end
86
196
  end
@@ -12,6 +12,7 @@ module ActionMCP
12
12
  inflect.acronym "SSE"
13
13
  inflect.acronym "MCP"
14
14
  end
15
+
15
16
  # Provide a configuration namespace for ActionMCP
16
17
  config.action_mcp = ActionMCP.configuration
17
18
 
@@ -19,6 +20,11 @@ module ActionMCP
19
20
  ActionMCP::ResourceTemplate.registered_templates.clear
20
21
  end
21
22
 
23
+ # Load MCP profiles during initialization
24
+ initializer "action_mcp.load_profiles" do
25
+ ActionMCP.configuration.load_profiles
26
+ end
27
+
22
28
  # Configure autoloading for the mcp/tools directory
23
29
  initializer "action_mcp.autoloading", before: :set_autoload_paths do |app|
24
30
  mcp_path = app.root.join("app/mcp")
@@ -75,6 +75,7 @@ module ActionMCP
75
75
  # @param request [Hash]
76
76
  def process_request(request)
77
77
  return unless valid_request?(request)
78
+
78
79
  read(request)
79
80
 
80
81
  id = request["id"]
@@ -64,7 +64,9 @@ module ActionMCP
64
64
  include Enumerable
65
65
 
66
66
  # Using a Data type for items.
67
- Item = Data.define(:name, :klass)
67
+ Item = Data.define(:name, :klass) do
68
+ delegate :description, to: :klass
69
+ end
68
70
 
69
71
  # Initializes a new RegistryScope instance.
70
72
  #
@@ -12,7 +12,7 @@ module ActionMCP
12
12
  session.store_client_info(@client_info)
13
13
  session.store_client_capabilities(@client_capabilities)
14
14
  session.set_protocol_version(@protocol_version)
15
- session.save
15
+ session.initialize!
16
16
  # TODO: , if the server don't support the protocol version, send a response with error
17
17
  send_jsonrpc_response(request_id, result: session.server_capabilities_payload)
18
18
  end
@@ -22,7 +22,7 @@ module ActionMCP
22
22
  when "completion/complete" # Completion requests
23
23
  process_completion_complete(id, params)
24
24
  else
25
- puts "\e[31mUnknown client method: #{rpc_method}\e[0m"
25
+ transport.send_jsonrpc_error(id, :method_not_found, "Method not found")
26
26
  end
27
27
  end
28
28
 
@@ -4,24 +4,47 @@ module ActionMCP
4
4
  module Server
5
5
  module Messaging
6
6
  def send_jsonrpc_request(method, params: nil, id: SecureRandom.uuid_v7)
7
- request = JsonRpc::Request.new(id: id, method: method, params: params)
8
- write_message(request)
7
+ send_message(:request, method: method, params: params, id: id)
9
8
  end
10
9
 
11
10
  def send_jsonrpc_response(request_id, result: nil, error: nil)
12
- response = JsonRpc::Response.new(id: request_id, result: result, error: error)
13
- write_message(response)
11
+ send_message(:response, id: request_id, result: result, error: error)
14
12
  end
15
13
 
16
14
  def send_jsonrpc_notification(method, params = nil)
17
- notification = JsonRpc::Notification.new(method: method, params: params)
18
- write_message(notification)
15
+ send_message(:notification, method: method, params: params)
19
16
  end
20
17
 
21
18
  def send_jsonrpc_error(request_id, symbol, message, data = nil)
22
- error = JsonRpc::JsonRpcError.new(symbol, message:, data:)
23
- response = JsonRpc::Response.new(id: request_id, error:)
24
- write_message(response)
19
+ error = JsonRpc::JsonRpcError.new(symbol, message: message, data: data)
20
+ send_jsonrpc_response(request_id, error: error)
21
+ end
22
+
23
+ private
24
+
25
+ # Factory method to create and send appropriate JSON-RPC message
26
+ def send_message(type, **args)
27
+ message = case type
28
+ when :request
29
+ JsonRpc::Request.new(
30
+ id: args[:id],
31
+ method: args[:method],
32
+ params: args[:params]
33
+ )
34
+ when :response
35
+ JsonRpc::Response.new(
36
+ id: args[:id],
37
+ result: args[:result],
38
+ error: args[:error]
39
+ )
40
+ when :notification
41
+ JsonRpc::Notification.new(
42
+ method: args[:method],
43
+ params: args[:params]
44
+ )
45
+ end
46
+
47
+ write_message(message)
25
48
  end
26
49
  end
27
50
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.24.0"
5
+ VERSION = "0.25.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
@@ -19,6 +19,10 @@ module ActionMcp
19
19
  template "application_mcp_res_template.rb",
20
20
  File.join("app/mcp/resource_templates", "application_mcp_res_template.rb")
21
21
  end
22
+
23
+ def create_mcp_profile_file
24
+ template "mcp.yml", File.join("config", "mcp.yml")
25
+ end
22
26
  end
23
27
  end
24
28
  end
@@ -0,0 +1,11 @@
1
+ primary:
2
+ tools:
3
+ - all
4
+ prompts:
5
+ - all
6
+ resources:
7
+ - all
8
+ options:
9
+ list_changed: false
10
+ resources_subscribe: false
11
+
@@ -23,12 +23,12 @@ namespace :action_mcp do
23
23
  # Ensure Rails eager loads all classes
24
24
  Rails.application.eager_load!
25
25
 
26
- puts "\e[32mACTION MCP PROMPTS\e[0m" # Red
27
- puts "\e[32m-----------------\e[0m" # Red
26
+ puts "\e[32mACTION MCP PROMPTS\e[0m" # Green
27
+ puts "\e[32m-----------------\e[0m" # Green
28
28
  ActionMCP::Prompt.descendants.each do |prompt|
29
29
  next if prompt.abstract?
30
30
 
31
- puts "\e[32m#{prompt.capability_name}:\e[0m #{prompt.description}" # Red name
31
+ puts "\e[32m#{prompt.capability_name}:\e[0m #{prompt.description}" # Green name
32
32
  end
33
33
  puts "\n"
34
34
  end
@@ -49,8 +49,79 @@ namespace :action_mcp do
49
49
  puts "\n"
50
50
  end
51
51
 
52
- desc "List all tools and prompts with their names and descriptions"
53
- task list: %i[list_tools list_prompts list_resources] do
54
- # This task lists all tools, prompts, and resources
52
+ # bin/rails action_mcp:show_profile[profile_name]
53
+ desc "Show configuration for a specific profile"
54
+ task :show_profile, [ :profile_name ] => :environment do |t, args|
55
+ # Ensure Rails eager loads all classes
56
+ Rails.application.eager_load!
57
+
58
+ profile_name = (args[:profile_name] || "primary").to_sym
59
+ profiles = ActionMCP.configuration.profiles
60
+
61
+ unless profiles.key?(profile_name)
62
+ puts "\e[31mProfile '#{profile_name}' not found!\e[0m"
63
+ puts "Available profiles: #{profiles.keys.join(', ')}"
64
+ next
65
+ end
66
+
67
+ # Temporarily activate this profile to show what would be included
68
+ ActionMCP.with_profile(profile_name) do
69
+ profile_config = profiles[profile_name]
70
+
71
+ puts "\e[35mPROFILE: #{profile_name.to_s.upcase}\e[0m" # Purple
72
+ puts "\e[35m#{'-' * (profile_name.to_s.length + 9)}\e[0m"
73
+
74
+ # Show options
75
+ if profile_config[:options]
76
+ puts "\n\e[36mOptions:\e[0m" # Cyan
77
+ profile_config[:options].each do |key, value|
78
+ puts " #{key}: #{value}"
79
+ end
80
+ end
81
+
82
+ # Show Tools
83
+ puts "\n\e[34mIncluded Tools:\e[0m" # Blue
84
+ if ActionMCP.configuration.filtered_tools.any?
85
+ ActionMCP.configuration.filtered_tools.each do |tool|
86
+ puts " \e[34m#{tool.name}:\e[0m #{tool.description}"
87
+ end
88
+ else
89
+ puts " None"
90
+ end
91
+
92
+ # Show Prompts
93
+ puts "\n\e[32mIncluded Prompts:\e[0m" # Green
94
+ if ActionMCP.configuration.filtered_prompts.any?
95
+ ActionMCP.configuration.filtered_prompts.each do |prompt|
96
+ puts " \e[32m#{prompt.name}:\e[0m #{prompt.description}"
97
+ end
98
+ else
99
+ puts " None"
100
+ end
101
+
102
+ # Show Resources
103
+ puts "\n\e[33mIncluded Resources:\e[0m" # Yellow
104
+ if ActionMCP.configuration.filtered_resources.any?
105
+ ActionMCP.configuration.filtered_resources.each do |resource|
106
+ puts " \e[33m#{resource.name}:\e[0m #{resource.description}"
107
+ end
108
+ else
109
+ puts " None"
110
+ end
111
+
112
+ # Show Capabilities
113
+ puts "\n\e[36mActive Capabilities:\e[0m" # Cyan
114
+ capabilities = ActionMCP.configuration.capabilities
115
+ capabilities.each do |cap_name, cap_config|
116
+ puts " #{cap_name}: #{cap_config.inspect}"
117
+ end
118
+ end
119
+
120
+ puts "\n"
121
+ end
122
+
123
+ desc "List all tools, prompts, resources and available profiles"
124
+ task list: %i[list_tools list_prompts list_resources list_profiles] do
125
+ # This task lists all tools, prompts, resources and profiles
55
126
  end
56
127
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.24.0
4
+ version: 0.25.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-03-24 00:00:00.000000000 Z
11
+ date: 2025-03-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -106,6 +106,7 @@ files:
106
106
  - db/migrate/20250314230152_add_is_ping_to_session_message.rb
107
107
  - db/migrate/20250316005021_create_action_mcp_session_subscriptions.rb
108
108
  - db/migrate/20250316005649_create_action_mcp_session_resources.rb
109
+ - db/migrate/20250324203409_remove_session_message_text.rb
109
110
  - exe/actionmcp_cli
110
111
  - lib/action_mcp.rb
111
112
  - lib/action_mcp/base_response.rb
@@ -183,6 +184,7 @@ files:
183
184
  - lib/generators/action_mcp/install/templates/application_mcp_prompt.rb
184
185
  - lib/generators/action_mcp/install/templates/application_mcp_res_template.rb
185
186
  - lib/generators/action_mcp/install/templates/application_mcp_tool.rb
187
+ - lib/generators/action_mcp/install/templates/mcp.yml
186
188
  - lib/generators/action_mcp/prompt/prompt_generator.rb
187
189
  - lib/generators/action_mcp/prompt/templates/prompt.rb.erb
188
190
  - lib/generators/action_mcp/resource_template/resource_template_generator.rb