actionmcp 0.104.1 → 0.106.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: 6cfb5ce2385d840e5a70a47707ff2a684b2b1129ab19e55869832cff57286619
4
- data.tar.gz: aedeccd60db4263d29eee4da2d051d5910978c50951765dc7ec6b83cb1c2c6c9
3
+ metadata.gz: 277441f068f32aec18d72c2db1df9990ea805fa4f32c0ca1447ff368740c6644
4
+ data.tar.gz: 8572d668e2f38444a9c6dd5162599901122e2eb03676f5334b32b359c4e29a34
5
5
  SHA512:
6
- metadata.gz: c6fe8f1b667633f25e566df67f6c8da36d652ef993eb568b8c73b23fd46bb85c3c096560b91c62056bc00325fd1daa23aba789d960697007b5d28881ce02d292
7
- data.tar.gz: dc9a41f5f340b09e11c551d64857239501341602731136686f508e3f8fa22558e9f10392941887c20f2ae6cbd7f1174d87fa4b371142e90bcbd3e0bfe8b90353
6
+ metadata.gz: cf603eaffd1eca7723b2c4cc0e47e1342c4adb31e51be395a072178550f90595aa9aac1eeb53298abf609c2bbc088a796c221150abe960f5b99d15a17d1fbec5
7
+ data.tar.gz: 418c3fa2ef01218372b6477f5df63e0d50fd063e21af6b883d391fc5dbb78e1fbe8fa1a73e41214d88b2d0d2a118ce5dd602dcccee6c8015c99cff33b99dcde9
data/README.md CHANGED
@@ -337,33 +337,50 @@ Poll task status with `tasks/get` or fetch the result when finished with `tasks/
337
337
  `ActionMCP::ResourceTemplate` facilitates the creation of URI templates for dynamic resources that LLMs can access.
338
338
  This allows models to request specific data using parameterized URIs.
339
339
 
340
+ Templates support two MCP resource surfaces:
341
+ - **`resources/templates/list`** — parameterized URI patterns (existing behavior)
342
+ - **`resources/list`** — concrete resources with known URIs (via `self.list`)
343
+
344
+ > **Note:** Not all MCP clients support both resource endpoints. Claude Code (as of v2.1.50) only calls `resources/list`, and Codex stubs resource methods entirely. Implement `self.list` on your templates to ensure resources are visible to all clients. Crush and VS Code support both endpoints.
345
+
340
346
  **Example:**
341
347
 
342
348
  ```ruby
343
-
344
349
  class ProductResourceTemplate < ApplicationMCPResTemplate
345
350
  uri_template "product/{id}"
346
351
  description "Access product information by ID"
352
+ mime_type "application/json"
347
353
 
348
354
  parameter :id, description: "Product identifier", required: true
349
355
 
350
356
  validates :id, format: { with: /\A\d+\z/, message: "must be numeric" }
351
357
 
358
+ # Optional: enumerate concrete resources for resources/list
359
+ def self.list(session: nil)
360
+ Product.limit(50).map do |product|
361
+ build_resource(
362
+ uri: "product/#{product.id}",
363
+ name: product.name,
364
+ title: "Product ##{product.id}"
365
+ )
366
+ end
367
+ end
368
+
369
+ # Resolve a specific resource for resources/read
352
370
  def resolve
353
371
  product = Product.find_by(id: id)
354
372
  return unless product
355
- ActionMCP::Resource.new(
356
- uri: "ecommerce://products/#{product_id}",
357
- name: "Product #{product_id}",
358
- description: "Product information for product #{product_id}",
359
- mime_type: "application/json",
360
- size: product.to_json.length
373
+
374
+ ActionMCP::Content::Resource.new(
375
+ "product/#{id}",
376
+ mime_type,
377
+ text: product.to_json
361
378
  )
362
379
  end
363
380
  end
364
381
  ```
365
382
 
366
- # Example of callbacks:
383
+ **Callbacks:**
367
384
 
368
385
  ```ruby
369
386
  before_resolve do |template|
@@ -376,21 +393,13 @@ end
376
393
 
377
394
  around_resolve do |template, block|
378
395
  start_time = Time.current
379
- # Starting resolution for product: #{template.product_id}
380
-
381
396
  resource = block.call
382
-
383
- if resource
384
- # Product #{template.product_id} resolved successfully in #{Time.current - start_time}s
385
- else
386
- # Product #{template.product_id} not found
387
- end
388
-
389
397
  resource
390
398
  end
391
399
  ```
392
400
 
393
401
  Resource templates are automatically registered and used when LLMs request resources matching their patterns.
402
+ See [RESOURCE_TEMPLATES.md](RESOURCE_TEMPLATES.md) for the full API reference including pagination, deduplication, and read contract details.
394
403
 
395
404
  ## 📚 Documentation
396
405
 
@@ -763,7 +772,7 @@ This ensures all thread pools are properly terminated and tasks are completed.
763
772
 
764
773
  ## Engine and Mounting
765
774
 
766
- **ActionMCP** runs as a standalone Rack application. **Do not attempt to mount it in your application's `routes.rb`**—it is not designed to be mounted as an engine at a custom path. When you use `run ActionMCP::Engine` in your `mcp.ru`, the MCP endpoint is always available at the root path (`/`).
775
+ **ActionMCP** runs as a standalone Rack application. **Do not attempt to mount it in your application's `routes.rb`**—it is not designed to be mounted as an engine at a custom path. When you use `run ActionMCP::Engine` in your `mcp.ru`, the MCP endpoint is available at the root path (`/`) by default and can be configured via `config.action_mcp.base_path`.
767
776
 
768
777
  ### Installing ActionMCP
769
778
 
@@ -916,7 +925,7 @@ In production, **MCPS0** (the MCP server) is a standard Rack application. You ca
916
925
 
917
926
  > **For best performance and concurrency, it is highly recommended to use a modern, synchronous server like [Falcon](https://github.com/socketry/falcon)**. Falcon is optimized for streaming and concurrent workloads, making it ideal for MCP servers. You can still use Puma, Unicorn, or Passenger, but Falcon will generally provide superior throughput and responsiveness for real-time and streaming use cases.
918
927
 
919
- You have two main options for exposing the server:
928
+ You have several main options for exposing the server:
920
929
 
921
930
  ### 1. Dedicated Port
922
931
 
@@ -932,6 +941,11 @@ bundle exec falcon serve --bind http://0.0.0.0:62770 --config mcp.ru
932
941
  bundle exec rails s -c mcp.ru -p 62770
933
942
  ```
934
943
 
944
+ **With Passenger:**
945
+ ```bash
946
+ passenger start --rackup mcp.ru --port 62770
947
+ ```
948
+
935
949
  Then, use your web server (Nginx, Apache, etc.) to reverse proxy requests to this port.
936
950
 
937
951
  ### 2. Unix Socket
@@ -948,6 +962,11 @@ bundle exec falcon serve --bind unix:/tmp/mcps0.sock mcp.ru
948
962
  bundle exec puma -C config/puma.rb -b unix:///tmp/mcps0.sock -c mcp.ru
949
963
  ```
950
964
 
965
+ **With Passenger:**
966
+ ```bash
967
+ passenger start --rackup mcp.ru --socket /tmp/mcps0.sock
968
+ ```
969
+
951
970
  And configure your web server to proxy to the socket:
952
971
 
953
972
  ```nginx
@@ -958,6 +977,30 @@ location /mcp/ {
958
977
  }
959
978
  ```
960
979
 
980
+ ### 3. Nginx With Passenger
981
+
982
+ You can run both the main app and the MCP app using Passenger processes within Nginx.
983
+
984
+ ```nginx
985
+ location / {
986
+ root /path/to/current/public;
987
+ passenger_app_root /path/to/current;
988
+ passenger_enabled on;
989
+
990
+ # ... additional configuration for the main Rails app
991
+ }
992
+
993
+ location ~* ^/mcp {
994
+ root /path/to/current/public;
995
+ passenger_app_root /path/to/current;
996
+ passenger_enabled on;
997
+ passenger_startup_file mcp.ru;
998
+ passenger_app_group_name mcp;
999
+ }
1000
+ ```
1001
+
1002
+ You must set the `config.action_mcp.base_path` to match the above Nginx configuration, i.e. `config.action_mcp.base_path = '/mcp'`.
1003
+
961
1004
  **Key Points:**
962
1005
  - MCPS0 is a standalone Rack app—run it separately from your main Rails server.
963
1006
  - You can expose it via a TCP port (e.g., 62770) or a Unix socket.
data/config/routes.rb CHANGED
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  ActionMCP::Engine.routes.draw do
4
- get "/up", to: "/rails/health#show", as: :action_mcp_health_check
4
+ get "#{ActionMCP.configuration.base_path}}/up", to: "/rails/health#show", as: :action_mcp_health_check
5
5
 
6
6
  # MCP 2025-03-26 Spec routes
7
- get "/", to: "application#show", as: :mcp_get
8
- post "/", to: "application#create", as: :mcp_post
9
- delete "/", to: "application#destroy", as: :mcp_delete
7
+ get ActionMCP.configuration.base_path, to: "application#show", as: :mcp_get
8
+ post ActionMCP.configuration.base_path, to: "application#create", as: :mcp_post
9
+ delete ActionMCP.configuration.base_path, to: "application#destroy", as: :mcp_delete
10
10
  end
@@ -51,7 +51,9 @@ module ActionMCP
51
51
  # --- Schema Validation Options ---
52
52
  :validate_structured_content,
53
53
  # --- Allowed identity keys for gateway ---
54
- :allowed_identity_keys
54
+ :allowed_identity_keys,
55
+ # --- JSON-RPC Path ---
56
+ :base_path
55
57
 
56
58
  def initialize
57
59
  @logging_enabled = false
@@ -91,6 +93,9 @@ module ActionMCP
91
93
  # and unauthorized attribute assignment. Extend this list if you use custom
92
94
  # identifier names in your GatewayIdentifier implementations.
93
95
  @allowed_identity_keys = %w[user api_key jwt bearer token account session].freeze
96
+
97
+ # Path for JSON-RPC endpoint
98
+ @base_path = "/"
94
99
  end
95
100
 
96
101
  def name
@@ -45,7 +45,9 @@ module ActionMCP
45
45
  ActionMCP.configuration.eager_load_if_needed
46
46
  end
47
47
 
48
- config.middleware.use JSONRPC_Rails::Middleware::Validator, [ "/" ]
48
+ initializer "action_mcp.insert_middleware" do |app|
49
+ config.middleware.use JSONRPC_Rails::Middleware::Validator, [ ActionMCP.configuration.base_path ].compact.freeze
50
+ end
49
51
 
50
52
  # Load MCP profiles during initialization
51
53
  initializer "action_mcp.load_profiles" do
@@ -2,21 +2,56 @@
2
2
 
3
3
  module ActionMCP
4
4
  # Represents a resource with its metadata.
5
- Resource = Data.define(:uri, :name, :description, :mime_type, :size) do
5
+ # Used by resources/list to describe concrete resources.
6
+ class Resource
7
+ attr_reader :uri, :name, :title, :description, :mime_type, :size, :annotations
8
+
9
+ # @param uri [String] The URI of the resource
10
+ # @param name [String] Display name of the resource
11
+ # @param title [String, nil] Human-readable title
12
+ # @param description [String, nil] Description of the resource
13
+ # @param mime_type [String, nil] MIME type of the resource content
14
+ # @param size [Integer, nil] Size of the resource in bytes
15
+ # @param annotations [Hash, nil] Optional annotations
16
+ def initialize(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil)
17
+ @uri = uri
18
+ @name = name
19
+ @title = title
20
+ @description = description
21
+ @mime_type = mime_type
22
+ @size = size
23
+ @annotations = annotations
24
+ freeze
25
+ end
26
+
6
27
  # Convert the resource to a hash with the keys expected by MCP.
7
28
  # Note: The key for mime_type is converted to 'mimeType' as specified.
8
29
  #
9
30
  # @return [Hash] A hash representation of the resource.
10
31
  def to_h
11
32
  hash = { uri: uri, name: name }
33
+ hash[:title] = title if title
12
34
  hash[:description] = description if description
13
- hash[:mimeType] = mime_type if mime_type
14
- hash[:size] = size if size
35
+ hash[:mimeType] = mime_type if mime_type
36
+ hash[:size] = size if size
37
+ hash[:annotations] = annotations if annotations
15
38
  hash
16
39
  end
17
40
 
18
41
  def to_json(*)
19
42
  MultiJson.dump(to_h, *)
20
43
  end
44
+
45
+ def ==(other)
46
+ other.is_a?(Resource) && uri == other.uri && name == other.name &&
47
+ title == other.title && description == other.description &&
48
+ mime_type == other.mime_type && size == other.size &&
49
+ annotations == other.annotations
50
+ end
51
+ alias eql? ==
52
+
53
+ def hash
54
+ [ uri, name, title, description, mime_type, size, annotations ].hash
55
+ end
21
56
  end
22
57
  end
@@ -73,12 +73,22 @@ module ActionMCP
73
73
  end
74
74
 
75
75
  def mark_as_not_found!(uri)
76
- # Use method_not_found for resource not found (closest standard JSON-RPC error)
77
- mark_as_error!(
78
- :method_not_found, # -32601 - closest standard error for "not found"
79
- message: "Resource not found",
80
- data: { uri: uri }
81
- )
76
+ @is_error = true
77
+ @symbol = nil
78
+ @error_message = "Resource not found"
79
+ @error_data = { uri: uri }
80
+ self
81
+ end
82
+
83
+ # Override to_h to use -32002 for resource not found (consistent with send_resource_read)
84
+ def to_h(_options = nil)
85
+ if @is_error && @symbol.nil?
86
+ { code: -32_002, message: @error_message, data: @error_data }
87
+ elsif @is_error
88
+ JSON_RPC::JsonRpcError.new(@symbol, message: @error_message, data: @error_data).to_h
89
+ else
90
+ build_success_hash
91
+ end
82
92
  end
83
93
 
84
94
  # Implementation of build_success_hash for ResourceResponse
@@ -13,7 +13,10 @@ module ActionMCP
13
13
 
14
14
  # Track all registered templates
15
15
  @registered_templates = []
16
- attr_reader :execution_context, :description, :uri_template, :mime_type
16
+ attr_reader :execution_context
17
+
18
+ # Delegate to class-level DSL values so instances see template metadata.
19
+ delegate :description, :uri_template, :mime_type, to: :class
17
20
 
18
21
  class << self
19
22
  attr_reader :registered_templates, :description, :uri_template,
@@ -128,6 +131,60 @@ module ActionMCP
128
131
  @capability_name ||= name.demodulize.underscore.sub(/_template$/, "")
129
132
  end
130
133
 
134
+ # --- Static resource listing API ---
135
+
136
+ # Override in subclasses to enumerate concrete resources.
137
+ # Returns Array<ActionMCP::Resource>.
138
+ #
139
+ # @param session [Object, nil] The current MCP session
140
+ # @return [Array<ActionMCP::Resource>]
141
+ def list(session: nil)
142
+ []
143
+ end
144
+
145
+ # Returns true if this template subclass overrides +list+.
146
+ def lists_resources?
147
+ method(:list).owner != ActionMCP::ResourceTemplate.singleton_class
148
+ end
149
+
150
+ # Factory helper that fills in template-level defaults.
151
+ #
152
+ # @param uri [String] The concrete resource URI
153
+ # @param name [String] Display name
154
+ # @param title [String, nil] Human-readable title
155
+ # @param description [String, nil] Falls back to template description
156
+ # @param mime_type [String, nil] Falls back to template mime_type
157
+ # @param size [Integer, nil] Size in bytes
158
+ # @param annotations [Hash, nil] Optional annotations
159
+ # @return [ActionMCP::Resource]
160
+ def build_resource(uri:, name:, title: nil, description: nil, mime_type: nil, size: nil, annotations: nil)
161
+ ActionMCP::Resource.new(
162
+ uri: uri,
163
+ name: name,
164
+ title: title,
165
+ description: description || @description,
166
+ mime_type: mime_type || @mime_type,
167
+ size: size,
168
+ annotations: annotations
169
+ )
170
+ end
171
+
172
+ # Check if a concrete URI is readable by this template.
173
+ # Returns false if the URI doesn't match the template pattern.
174
+ #
175
+ # @param uri [String] A concrete URI to check
176
+ # @return [Boolean]
177
+ def readable_uri?(uri)
178
+ return false unless @uri_template
179
+
180
+ params = extract_params_from_uri(uri)
181
+ return false if params.nil?
182
+
183
+ new(params).valid?
184
+ rescue StandardError
185
+ false
186
+ end
187
+
131
188
  # Process a URI string to create a template instance
132
189
  def process(uri_string)
133
190
  return nil unless @uri_template
@@ -22,7 +22,8 @@ module ActionMCP
22
22
  ResourceTemplate
23
23
  end
24
24
 
25
- # Find the most specific template for a given URI
25
+ # Find the most specific template for a given URI.
26
+ # Uses registry-backed source (not ResourceTemplate.registered_templates class array).
26
27
  def find_template_for_uri(uri)
27
28
  parse_result = parse_uri(uri)
28
29
  return nil unless parse_result
@@ -31,7 +32,7 @@ module ActionMCP
31
32
  path = parse_result[:path]
32
33
  path_segments = path.split("/")
33
34
 
34
- matching_templates = ResourceTemplate.registered_templates.select do |template|
35
+ matching_templates = resource_templates.values.select do |template|
35
36
  next unless template.uri_template
36
37
 
37
38
  # Parse the template
@@ -32,12 +32,12 @@ module ActionMCP
32
32
  }
33
33
  end
34
34
 
35
- def handle_resources_list(id, _params)
36
- transport.send_resources_list(id)
35
+ def handle_resources_list(id, params)
36
+ transport.send_resources_list(id, params)
37
37
  end
38
38
 
39
- def handle_resources_templates_list(id, _params)
40
- transport.send_resource_templates_list(id)
39
+ def handle_resources_templates_list(id, params)
40
+ transport.send_resource_templates_list(id, params)
41
41
  end
42
42
 
43
43
  def handle_resources_read(id, params)
@@ -3,59 +3,112 @@
3
3
  module ActionMCP
4
4
  module Server
5
5
  module Resources
6
- # Send list of available resources to the client
6
+ # Default page size for cursor-based pagination
7
+ RESOURCES_PAGE_SIZE = 100
8
+
9
+ # Send list of concrete resources to the client.
10
+ # Aggregates resources from templates that implement self.list.
7
11
  #
8
12
  # @param request_id [String, Integer] The ID of the request to respond to
9
- #
10
- # @example Input:
11
- # request_id = "req-123"
12
- #
13
- # @example Output:
14
- # # Sends: {"jsonrpc":"2.0","id":"req-123","result":{"resources":[]}}
15
- def send_resources_list(request_id)
16
- send_jsonrpc_response(request_id, result: { resources: [] })
13
+ # @param params [Hash] Optional params including "cursor" for pagination
14
+ def send_resources_list(request_id, params = {})
15
+ templates = session.registered_resource_templates
16
+
17
+ # Collect resources from templates that implement list
18
+ all_resources = []
19
+ seen_uris = {}
20
+
21
+ templates.each do |template_class|
22
+ next unless template_class.lists_resources?
23
+
24
+ begin
25
+ listed = template_class.list(session: session)
26
+ rescue StandardError => e
27
+ Rails.logger.error "[MCP] Error listing resources from #{template_class.name}: #{e.message}"
28
+ next
29
+ end
30
+
31
+ unless listed.is_a?(Array)
32
+ Rails.logger.warn "[MCP] #{template_class.name}.list returned #{listed.class}, expected Array; skipping"
33
+ next
34
+ end
35
+
36
+ listed.each do |resource|
37
+ unless resource.is_a?(ActionMCP::Resource)
38
+ Rails.logger.warn "[MCP] #{template_class.name}.list returned non-Resource: #{resource.class}"
39
+ next
40
+ end
41
+
42
+ # Validate the listed URI is readable by the declaring template
43
+ unless template_class.readable_uri?(resource.uri)
44
+ Rails.logger.warn "[MCP] #{template_class.name}.list returned URI not readable by its own template: #{resource.uri}"
45
+ next
46
+ end
47
+
48
+ # Deduplicate by URI
49
+ if (existing = seen_uris[resource.uri])
50
+ if existing == resource
51
+ # Identical duplicate, skip silently
52
+ next
53
+ else
54
+ # Conflicting metadata for same URI
55
+ send_jsonrpc_error(request_id, :invalid_params,
56
+ "Resource URI collision: '#{resource.uri}' listed by multiple templates with conflicting metadata")
57
+ return
58
+ end
59
+ end
60
+
61
+ seen_uris[resource.uri] = resource
62
+ all_resources << resource
63
+ end
64
+ end
65
+
66
+ # Apply cursor-based pagination
67
+ result = paginate_resources(all_resources, params["cursor"])
68
+ if result == :invalid_cursor
69
+ send_jsonrpc_error(request_id, :invalid_params, "Invalid cursor value")
70
+ return
71
+ end
72
+ send_jsonrpc_response(request_id, result: result)
17
73
  end
18
74
 
19
75
  # Send list of resource templates to the client
20
76
  #
21
77
  # @param request_id [String, Integer] The ID of the request to respond to
22
- #
23
- # @example Input:
24
- # request_id = "req-456"
25
- #
26
- # @example Output:
27
- # # Sends: {"jsonrpc":"2.0","id":"req-456","result":{"resourceTemplates":[{"uriTemplate":"db://{table}","name":"Database Table"}]}}
28
- def send_resource_templates_list(request_id)
29
- templates = ActionMCP::ResourceTemplatesRegistry.resource_templates.values.map(&:to_h)
30
- # TODO: add pagination support
31
- # TODO add autocomplete
78
+ # @param params [Hash] Optional params including "cursor" for pagination
79
+ def send_resource_templates_list(request_id, params = {})
80
+ templates = session.registered_resource_templates.map(&:to_h)
32
81
  log_resource_templates
33
- send_jsonrpc_response(request_id, result: { resourceTemplates: templates })
82
+
83
+ # Apply cursor-based pagination
84
+ result = paginate_templates(templates, params["cursor"])
85
+ if result == :invalid_cursor
86
+ send_jsonrpc_error(request_id, :invalid_params, "Invalid cursor value")
87
+ return
88
+ end
89
+ send_jsonrpc_response(request_id, result: result)
34
90
  end
35
91
 
36
92
  # Read and return the contents of a resource
37
93
  #
38
94
  # @param id [String, Integer] The ID of the request to respond to
39
95
  # @param params [Hash] Parameters specifying which resource to read
40
- #
41
- # @example Input:
42
- # id = "req-789"
43
- # params = { uri: "file:///example.txt" }
44
- #
45
- # @example Output:
46
- # # Sends: {"jsonrpc":"2.0","id":"req-789","result":{"contents":[{"uri":"file:///example.txt","text":"Example content"}]}}
47
96
  def send_resource_read(id, params)
48
97
  uri = params["uri"]
49
98
  template = ResourceTemplatesRegistry.find_template_for_uri(uri)
50
99
 
51
100
  unless template
52
- send_jsonrpc_error(id, :method_not_found, "No resource template found for URI: '#{uri}'")
101
+ error = {
102
+ code: -32_002,
103
+ message: "Resource not found",
104
+ data: { uri: uri }
105
+ }
106
+ send_jsonrpc_response(id, error: error)
53
107
  return
54
108
  end
55
109
 
56
110
  # Check if resource requires consent and if consent is granted
57
111
  if template.respond_to?(:requires_consent?) && template.requires_consent? && !session.consent_granted_for?("resource:#{template.name}")
58
- # Use custom error response for consent required (-32002)
59
112
  error = {
60
113
  code: -32_002,
61
114
  message: "Consent required for resource template '#{template.name}'"
@@ -64,22 +117,41 @@ module ActionMCP
64
117
  return
65
118
  end
66
119
 
120
+ unless template.readable_uri?(uri)
121
+ error = {
122
+ code: -32_002,
123
+ message: "Resource not found",
124
+ data: { uri: uri }
125
+ }
126
+ send_jsonrpc_response(id, error: error)
127
+ return
128
+ end
129
+
67
130
  begin
68
131
  # Create template instance and set execution context
69
132
  record = template.process(uri)
133
+ unless record
134
+ error = {
135
+ code: -32_002,
136
+ message: "Resource not found",
137
+ data: { uri: uri }
138
+ }
139
+ send_jsonrpc_response(id, error: error)
140
+ return
141
+ end
142
+
70
143
  record.with_context({ session: session })
71
144
 
72
145
  response = record.call
73
146
 
74
147
  if response.error?
75
- # response.to_h works properly when error? is true:
76
148
  send_jsonrpc_response(id, error: response.to_h)
77
149
  else
78
- # Handle successful response - ResourceResponse.contents is already an array
79
- send_jsonrpc_response(id, result: { contents: response.contents.map(&:to_h) })
150
+ # Normalize contents to MCP ReadResourceResult shape
151
+ contents = response.contents.map { |c| normalize_read_content(c, uri) }
152
+ send_jsonrpc_response(id, result: { contents: contents })
80
153
  end
81
154
  rescue StandardError => e
82
- # Should rather `ErrorHandling` module be included here? Then we could use `log_error(e)` directly.
83
155
  Rails.logger.error "[MCP Error] #{e.class}: #{e.message}"
84
156
  send_jsonrpc_error(id, :internal_error, "Failed to read resource: #{e.message}")
85
157
  end
@@ -87,13 +159,83 @@ module ActionMCP
87
159
 
88
160
  private
89
161
 
90
- # Log all registered resource templates
162
+ # Normalize a content object to MCP ReadResourceResult content shape.
163
+ #
164
+ # @return [Hash] with keys: uri, mimeType, and text or blob
165
+ def normalize_read_content(content, _uri)
166
+ case content
167
+ when ActionMCP::Content::Resource
168
+ inner = { uri: content.uri, mimeType: content.mime_type }
169
+ inner[:text] = content.text if content.text
170
+ inner[:blob] = content.blob if content.blob
171
+ inner
172
+ else
173
+ content.respond_to?(:to_h) ? content.to_h : content
174
+ end
175
+ end
176
+
177
+ # Paginate a list of resources with cursor support.
91
178
  #
92
- # @example Input:
93
- # # No parameters
179
+ # @param resources [Array<ActionMCP::Resource>] All resources
180
+ # @param cursor [String, nil] Base64-encoded offset cursor
181
+ # @return [Hash] Result hash with :resources and optional :nextCursor
182
+ def paginate_resources(resources, cursor)
183
+ offset = decode_cursor(cursor)
184
+ return :invalid_cursor if offset == :invalid
185
+
186
+ page = resources[offset, RESOURCES_PAGE_SIZE] || []
187
+
188
+ result = { resources: page.map(&:to_h) }
189
+
190
+ next_offset = offset + RESOURCES_PAGE_SIZE
191
+ if next_offset < resources.size
192
+ result[:nextCursor] = encode_cursor(next_offset)
193
+ end
194
+
195
+ result
196
+ end
197
+
198
+ # Paginate a list of templates with cursor support.
94
199
  #
95
- # @example Output:
96
- # # Logs: "Registered Resource Templates: ["db://{table}", "file://{path}"]"
200
+ # @param templates [Array<Hash>] All template hashes
201
+ # @param cursor [String, nil] Base64-encoded offset cursor
202
+ # @return [Hash] Result hash with :resourceTemplates and optional :nextCursor
203
+ def paginate_templates(templates, cursor)
204
+ offset = decode_cursor(cursor)
205
+ return :invalid_cursor if offset == :invalid
206
+
207
+ page = templates[offset, RESOURCES_PAGE_SIZE] || []
208
+
209
+ result = { resourceTemplates: page }
210
+
211
+ next_offset = offset + RESOURCES_PAGE_SIZE
212
+ if next_offset < templates.size
213
+ result[:nextCursor] = encode_cursor(next_offset)
214
+ end
215
+
216
+ result
217
+ end
218
+
219
+ # Decode a cursor string to a non-negative integer offset.
220
+ # Returns 0 for nil cursors, :invalid for malformed/negative/non-string values.
221
+ def decode_cursor(cursor)
222
+ return 0 if cursor.nil?
223
+ return :invalid unless cursor.is_a?(String) && !cursor.empty?
224
+
225
+ decoded = Base64.strict_decode64(cursor)
226
+ return :invalid unless decoded.match?(/\A\d+\z/)
227
+
228
+ decoded.to_i
229
+ rescue ArgumentError
230
+ :invalid
231
+ end
232
+
233
+ # Encode an integer offset as a cursor string.
234
+ def encode_cursor(offset)
235
+ Base64.strict_encode64(offset.to_s)
236
+ end
237
+
238
+ # Log all registered resource templates
97
239
  def log_resource_templates
98
240
  Rails.logger.debug "Registered Resource Templates: #{ActionMCP::ResourceTemplatesRegistry.resource_templates.keys}"
99
241
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "gem_version"
4
4
  module ActionMCP
5
- VERSION = "0.104.1"
5
+ VERSION = "0.106.0"
6
6
 
7
7
  class << self
8
8
  alias version gem_version
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actionmcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.104.1
4
+ version: 0.106.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Abdelkader Boudih
@@ -302,7 +302,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
302
302
  - !ruby/object:Gem::Version
303
303
  version: '0'
304
304
  requirements: []
305
- rubygems_version: 4.0.3
305
+ rubygems_version: 3.6.9
306
306
  specification_version: 4
307
307
  summary: Lightweight Model Context Protocol (MCP) server toolkit for Ruby/Rails
308
308
  test_files: []