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 +4 -4
- data/README.md +62 -19
- data/config/routes.rb +4 -4
- data/lib/action_mcp/configuration.rb +6 -1
- data/lib/action_mcp/engine.rb +3 -1
- data/lib/action_mcp/resource.rb +38 -3
- data/lib/action_mcp/resource_response.rb +16 -6
- data/lib/action_mcp/resource_template.rb +58 -1
- data/lib/action_mcp/resource_templates_registry.rb +3 -2
- data/lib/action_mcp/server/handlers/resource_handler.rb +4 -4
- data/lib/action_mcp/server/resources.rb +180 -38
- data/lib/action_mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 277441f068f32aec18d72c2db1df9990ea805fa4f32c0ca1447ff368740c6644
|
|
4
|
+
data.tar.gz: 8572d668e2f38444a9c6dd5162599901122e2eb03676f5334b32b359c4e29a34
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
8
|
-
post
|
|
9
|
-
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
|
data/lib/action_mcp/engine.rb
CHANGED
|
@@ -45,7 +45,9 @@ module ActionMCP
|
|
|
45
45
|
ActionMCP.configuration.eager_load_if_needed
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
|
|
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
|
data/lib/action_mcp/resource.rb
CHANGED
|
@@ -2,21 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
module ActionMCP
|
|
4
4
|
# Represents a resource with its metadata.
|
|
5
|
-
|
|
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]
|
|
14
|
-
hash[: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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
79
|
-
|
|
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
|
-
#
|
|
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
|
-
# @
|
|
93
|
-
#
|
|
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
|
-
# @
|
|
96
|
-
#
|
|
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
|
data/lib/action_mcp/version.rb
CHANGED
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.
|
|
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:
|
|
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: []
|