actionmcp 0.19.1 → 0.22.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/app/controllers/action_mcp/messages_controller.rb +2 -2
- data/app/models/action_mcp/session/message.rb +12 -1
- data/app/models/action_mcp/session.rb +8 -4
- data/lib/action_mcp/base_response.rb +86 -0
- data/lib/action_mcp/capability.rb +2 -3
- data/lib/action_mcp/client/base.rb +222 -0
- data/lib/action_mcp/client/blueprint.rb +227 -0
- data/lib/action_mcp/client/catalog.rb +226 -0
- data/lib/action_mcp/client/json_rpc_handler.rb +109 -0
- data/lib/action_mcp/client/logging.rb +20 -0
- data/lib/action_mcp/{transport → client}/messaging.rb +1 -1
- data/lib/action_mcp/client/prompt_book.rb +183 -0
- data/lib/action_mcp/client/prompts.rb +33 -0
- data/lib/action_mcp/client/resources.rb +70 -0
- data/lib/action_mcp/client/roots.rb +13 -0
- data/lib/action_mcp/client/server.rb +60 -0
- data/lib/action_mcp/{transport → client}/sse_client.rb +70 -111
- data/lib/action_mcp/{transport → client}/stdio_client.rb +38 -38
- data/lib/action_mcp/client/toolbox.rb +236 -0
- data/lib/action_mcp/client/tools.rb +33 -0
- data/lib/action_mcp/client.rb +20 -231
- data/lib/action_mcp/engine.rb +1 -3
- data/lib/action_mcp/instrumentation/controller_runtime.rb +1 -1
- data/lib/action_mcp/instrumentation/instrumentation.rb +2 -0
- data/lib/action_mcp/instrumentation/resource_instrumentation.rb +1 -0
- data/lib/action_mcp/json_rpc_handler_base.rb +106 -0
- data/lib/action_mcp/log_subscriber.rb +2 -0
- data/lib/action_mcp/logging.rb +1 -1
- data/lib/action_mcp/prompt.rb +4 -3
- data/lib/action_mcp/prompt_response.rb +14 -58
- data/lib/action_mcp/{transport → server}/capabilities.rb +2 -2
- data/lib/action_mcp/server/json_rpc_handler.rb +121 -0
- data/lib/action_mcp/server/messaging.rb +28 -0
- data/lib/action_mcp/{transport → server}/notifications.rb +1 -1
- data/lib/action_mcp/{transport → server}/prompts.rb +1 -1
- data/lib/action_mcp/{transport → server}/resources.rb +1 -18
- data/lib/action_mcp/{transport → server}/roots.rb +1 -1
- data/lib/action_mcp/{transport → server}/sampling.rb +1 -1
- data/lib/action_mcp/server/sampling_request.rb +115 -0
- data/lib/action_mcp/{transport → server}/tools.rb +1 -1
- data/lib/action_mcp/server/transport_handler.rb +41 -0
- data/lib/action_mcp/tool_response.rb +14 -59
- data/lib/action_mcp/uri_ambiguity_checker.rb +6 -10
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -1
- metadata +30 -33
- data/lib/action_mcp/base_json_rpc_handler.rb +0 -97
- data/lib/action_mcp/client_json_rpc_handler.rb +0 -69
- data/lib/action_mcp/json_rpc_handler.rb +0 -229
- data/lib/action_mcp/sampling_request.rb +0 -113
- data/lib/action_mcp/server_json_rpc_handler.rb +0 -90
- data/lib/action_mcp/transport/transport_base.rb +0 -126
- data/lib/action_mcp/transport_handler.rb +0 -39
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 5c997c4c99f4ecf6409fb3749bb58efab0eb03622e70d9a447781125620e6c82
|
4
|
+
data.tar.gz: dd9fc6be1b7364f306a6cbcfe0cd9e8db3b00530e46056c6ad49e6995ab7810b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c6de9245cf20071a23b0f35181d1eb20db9a2fb96298c6afc165a05aa31f14c3f8035b2b3dbd9ac9b04319f0981ce26609f8afde1ab67ef99a055a070ce3018c
|
7
|
+
data.tar.gz: e8b0006312a943b8228408ab0fe5b859d4a638bfbbe89ddcb6be0d38465476fdb9977f6d2b322676eb1ddc1608abcc7531c9352495c7bde11bb04868586edae1
|
@@ -17,11 +17,11 @@ module ActionMCP
|
|
17
17
|
private
|
18
18
|
|
19
19
|
def transport_handler
|
20
|
-
TransportHandler.new(mcp_session)
|
20
|
+
Server::TransportHandler.new(mcp_session)
|
21
21
|
end
|
22
22
|
|
23
23
|
def json_rpc_handler
|
24
|
-
@json_rpc_handler ||=
|
24
|
+
@json_rpc_handler ||= Server::JsonRpcHandler.new(transport_handler)
|
25
25
|
end
|
26
26
|
|
27
27
|
def handle_post_message(params, response)
|
@@ -53,6 +53,10 @@ module ActionMCP
|
|
53
53
|
# Scope to exclude both "ping" requests and their responses
|
54
54
|
scope :without_pings, -> { where(is_ping: false) }
|
55
55
|
|
56
|
+
scope :requests, -> { where(message_type: "request") }
|
57
|
+
scope :notifications, -> { where(message_type: "notification") }
|
58
|
+
scope :responses, -> { where(message_type: "response") }
|
59
|
+
|
56
60
|
# @param payload [String, Hash]
|
57
61
|
def data=(payload)
|
58
62
|
@data = payload
|
@@ -92,6 +96,11 @@ module ActionMCP
|
|
92
96
|
message_type == "response"
|
93
97
|
end
|
94
98
|
|
99
|
+
def rpc_method
|
100
|
+
return false unless request?
|
101
|
+
data["method"]
|
102
|
+
end
|
103
|
+
|
95
104
|
private
|
96
105
|
|
97
106
|
def outgoing_message?
|
@@ -99,7 +108,9 @@ module ActionMCP
|
|
99
108
|
end
|
100
109
|
|
101
110
|
def broadcast_message
|
102
|
-
adapter.
|
111
|
+
if adapter.present?
|
112
|
+
adapter.broadcast(session_key, data.to_json)
|
113
|
+
end
|
103
114
|
end
|
104
115
|
|
105
116
|
def process_json_content(content)
|
@@ -46,8 +46,11 @@ module ActionMCP
|
|
46
46
|
scope :closed, -> { where(status: "closed") }
|
47
47
|
scope :without_messages, -> { includes(:messages).where(action_mcp_session_messages: { id: nil }) }
|
48
48
|
|
49
|
-
|
50
|
-
|
49
|
+
scope :from_server, -> { where(role: "server") }
|
50
|
+
scope :from_client, -> { where(role: "client") }
|
51
|
+
|
52
|
+
before_create :set_server_info, if: -> { role == "server" }
|
53
|
+
before_create :set_server_capabilities, if: -> { role == "server" }
|
51
54
|
|
52
55
|
validates :protocol_version, inclusion: { in: [ PROTOCOL_VERSION ] }, allow_nil: true
|
53
56
|
|
@@ -58,6 +61,7 @@ module ActionMCP
|
|
58
61
|
subscriptions.delete_all # delete all subscriptions
|
59
62
|
end
|
60
63
|
|
64
|
+
# MESSAGING dispatch
|
61
65
|
def write(data)
|
62
66
|
if data.is_a?(JsonRpc::Request) || data.is_a?(JsonRpc::Response) || data.is_a?(JsonRpc::Notification)
|
63
67
|
data = data.to_json
|
@@ -100,8 +104,8 @@ module ActionMCP
|
|
100
104
|
end
|
101
105
|
|
102
106
|
def initialize!
|
103
|
-
# update the session initialized to true
|
104
|
-
return
|
107
|
+
# update the session initialized to true
|
108
|
+
return false if initialized?
|
105
109
|
|
106
110
|
update!(initialized: true,
|
107
111
|
status: "initialized")
|
@@ -0,0 +1,86 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
class BaseResponse
|
5
|
+
include Enumerable
|
6
|
+
attr_reader :is_error
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@is_error = false
|
10
|
+
end
|
11
|
+
|
12
|
+
# Mark response as error
|
13
|
+
def mark_as_error!(symbol = :invalid_request, message: nil, data: nil)
|
14
|
+
@is_error = true
|
15
|
+
@symbol = symbol
|
16
|
+
@error_message = message
|
17
|
+
@error_data = data
|
18
|
+
self
|
19
|
+
end
|
20
|
+
|
21
|
+
# Convert to hash format expected by MCP protocol
|
22
|
+
def to_h
|
23
|
+
if @is_error
|
24
|
+
JsonRpc::JsonRpcError.new(@symbol, message: @error_message, data: @error_data).to_h
|
25
|
+
else
|
26
|
+
build_success_hash
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
# Method to be implemented by subclasses
|
31
|
+
def build_success_hash
|
32
|
+
raise NotImplementedError, "Subclasses must implement #build_success_hash"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Alias as_json to to_h for consistency
|
36
|
+
alias as_json to_h
|
37
|
+
|
38
|
+
# Handle to_json directly
|
39
|
+
def to_json(options = nil)
|
40
|
+
to_h.to_json(options)
|
41
|
+
end
|
42
|
+
|
43
|
+
# Compare with hash for easier testing
|
44
|
+
def ==(other)
|
45
|
+
case other
|
46
|
+
when Hash
|
47
|
+
# Convert both to normalized format for comparison
|
48
|
+
hash_self = to_h.deep_transform_keys { |key| key.to_s.underscore }
|
49
|
+
hash_other = other.deep_transform_keys { |key| key.to_s.underscore }
|
50
|
+
hash_self == hash_other
|
51
|
+
when self.class
|
52
|
+
compare_with_same_class(other)
|
53
|
+
else
|
54
|
+
super
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Method to be implemented by subclasses for comparison
|
59
|
+
def compare_with_same_class(other)
|
60
|
+
raise NotImplementedError, "Subclasses must implement #compare_with_same_class"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Implement eql? for hash key comparison
|
64
|
+
def eql?(other)
|
65
|
+
self == other
|
66
|
+
end
|
67
|
+
|
68
|
+
# Method to be implemented by subclasses for hash generation
|
69
|
+
def hash_components
|
70
|
+
raise NotImplementedError, "Subclasses must implement #hash_components"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Implement hash method for hash key usage
|
74
|
+
def hash
|
75
|
+
hash_components.hash
|
76
|
+
end
|
77
|
+
|
78
|
+
def success?
|
79
|
+
!is_error
|
80
|
+
end
|
81
|
+
|
82
|
+
def error?
|
83
|
+
is_error
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -23,8 +23,8 @@ module ActionMCP
|
|
23
23
|
@abstract_capability ||= false # Default to false, unique to each class
|
24
24
|
end
|
25
25
|
|
26
|
-
|
27
|
-
|
26
|
+
class << self
|
27
|
+
attr_writer :abstract_capability
|
28
28
|
end
|
29
29
|
|
30
30
|
# Marks this tool as abstract so that it won’t be available for use.
|
@@ -49,6 +49,5 @@ module ActionMCP
|
|
49
49
|
_description
|
50
50
|
end
|
51
51
|
end
|
52
|
-
ActiveSupport.run_load_hooks(:active_mcp, self)
|
53
52
|
end
|
54
53
|
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Client
|
5
|
+
# Base client class containing common MCP functionality
|
6
|
+
class Base
|
7
|
+
# Include all transport protocol modules
|
8
|
+
include Messaging
|
9
|
+
include Tools
|
10
|
+
include Prompts
|
11
|
+
include Resources
|
12
|
+
include Roots
|
13
|
+
include Logging
|
14
|
+
|
15
|
+
attr_reader :logger, :type,
|
16
|
+
:connection_error, :server,
|
17
|
+
:server_capabilities, :session,
|
18
|
+
:catalog, :blueprint,
|
19
|
+
:prompt_book, :toolbox
|
20
|
+
delegate :initialized?, to: :session
|
21
|
+
|
22
|
+
def initialize(logger: ActionMCP.logger)
|
23
|
+
@logger = logger
|
24
|
+
@connected = false
|
25
|
+
@session = Session.from_client.new(
|
26
|
+
protocol_version: PROTOCOL_VERSION,
|
27
|
+
client_info: client_info,
|
28
|
+
client_capabilities: client_capabilities
|
29
|
+
)
|
30
|
+
@server_capabilities = nil
|
31
|
+
@message_callback = nil
|
32
|
+
@error_callback = nil
|
33
|
+
@connection_error = nil
|
34
|
+
@initialized = false
|
35
|
+
|
36
|
+
# Resource objects
|
37
|
+
@catalog = Catalog.new([], self)
|
38
|
+
# Resource template objects
|
39
|
+
@blueprint = Blueprint.new([], self)
|
40
|
+
# Prompt objects
|
41
|
+
@prompt_book = PromptBook.new([], self)
|
42
|
+
# Tool objects
|
43
|
+
@toolbox = Toolbox.new([], self)
|
44
|
+
end
|
45
|
+
|
46
|
+
def connected?
|
47
|
+
@connected
|
48
|
+
end
|
49
|
+
|
50
|
+
# Connect to the MCP server, if something went wrong at initialization
|
51
|
+
def connect
|
52
|
+
return true if @connected
|
53
|
+
|
54
|
+
begin
|
55
|
+
log_debug("Connecting to MCP server...")
|
56
|
+
@connection_error = nil
|
57
|
+
|
58
|
+
# Start transport with proper error handling
|
59
|
+
success = start_transport
|
60
|
+
|
61
|
+
unless success
|
62
|
+
log_error("Failed to establish connection to MCP server")
|
63
|
+
return false
|
64
|
+
end
|
65
|
+
|
66
|
+
@connected = true
|
67
|
+
log_debug("Connected to MCP server")
|
68
|
+
|
69
|
+
# Create handler only if it doesn't exist yet
|
70
|
+
@json_rpc_handler ||= JsonRpcHandler.new(session, self)
|
71
|
+
|
72
|
+
# Clear any existing message callback and set a new one
|
73
|
+
@message_callback = lambda do |response|
|
74
|
+
@json_rpc_handler.call(response)
|
75
|
+
end
|
76
|
+
|
77
|
+
true
|
78
|
+
rescue StandardError => e
|
79
|
+
@connection_error = e.message
|
80
|
+
log_error("Failed to connect to MCP server: #{e.message}")
|
81
|
+
@error_callback&.call(e)
|
82
|
+
false
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Disconnect from the MCP server
|
87
|
+
def disconnect
|
88
|
+
return true unless @connected
|
89
|
+
|
90
|
+
begin
|
91
|
+
stop_transport
|
92
|
+
@connected = false
|
93
|
+
log_debug("Disconnected from MCP server")
|
94
|
+
true
|
95
|
+
rescue StandardError => e
|
96
|
+
log_error("Error disconnecting from MCP server: #{e.message}")
|
97
|
+
@error_callback&.call(e)
|
98
|
+
false
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Set a callback for incoming messages
|
103
|
+
def on_message(&block)
|
104
|
+
@message_callback = block
|
105
|
+
end
|
106
|
+
|
107
|
+
# Set a callback for errors
|
108
|
+
def on_error(&block)
|
109
|
+
@error_callback = block
|
110
|
+
end
|
111
|
+
|
112
|
+
# Send a request to the MCP server
|
113
|
+
def write_message(payload)
|
114
|
+
unless @connected
|
115
|
+
log_error("Cannot send request - not connected")
|
116
|
+
return false
|
117
|
+
end
|
118
|
+
|
119
|
+
begin
|
120
|
+
session.write(payload)
|
121
|
+
data = payload.to_json unless payload.is_a?(String)
|
122
|
+
send_message(data)
|
123
|
+
true
|
124
|
+
rescue StandardError => e
|
125
|
+
log_error("Failed to send request: #{e.message}")
|
126
|
+
@error_callback&.call(e)
|
127
|
+
false
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# Methods to be implemented by subclasses
|
132
|
+
def start_transport
|
133
|
+
raise NotImplementedError, "#{self.class} must implement #start_transport"
|
134
|
+
end
|
135
|
+
|
136
|
+
def stop_transport
|
137
|
+
raise NotImplementedError, "#{self.class} must implement #stop_transport"
|
138
|
+
end
|
139
|
+
|
140
|
+
def send_message(json)
|
141
|
+
raise NotImplementedError, "#{self.class} must implement #send_message"
|
142
|
+
end
|
143
|
+
|
144
|
+
def ready?
|
145
|
+
raise NotImplementedError, "#{self.class} must implement #ready?"
|
146
|
+
end
|
147
|
+
|
148
|
+
def server=(server)
|
149
|
+
if server.is_a?(Client::Server)
|
150
|
+
@server = server
|
151
|
+
else
|
152
|
+
@server = Client::Server.new(server)
|
153
|
+
end
|
154
|
+
session.server_capabilities = server.capabilities
|
155
|
+
session.server_info = server.server_info
|
156
|
+
session.save
|
157
|
+
server
|
158
|
+
end
|
159
|
+
|
160
|
+
def inspect
|
161
|
+
"#<#{self.class.name} server: #{server}, client_name: #{client_info[:name]}, client_version: #{client_info[:version]}, capabilities: #{client_capabilities} , connected: #{connected?}, initialized: #{initialized?}, session: #{session.id}>"
|
162
|
+
end
|
163
|
+
|
164
|
+
protected
|
165
|
+
|
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
|
176
|
+
end
|
177
|
+
|
178
|
+
def send_initial_capabilities
|
179
|
+
log_debug("Sending client capabilities")
|
180
|
+
# We have contact! Let's send our CV to the recruiter.
|
181
|
+
# We persist the session object to the database
|
182
|
+
session.save
|
183
|
+
params= {
|
184
|
+
protocolVersion: session.protocol_version,
|
185
|
+
capabilities: session.client_capabilities,
|
186
|
+
clientInfo: session.client_info
|
187
|
+
}
|
188
|
+
send_jsonrpc_request("initialize", params: params, id: session.id)
|
189
|
+
end
|
190
|
+
|
191
|
+
def client_capabilities
|
192
|
+
{
|
193
|
+
# Base client capabilities can be defined here
|
194
|
+
# TODO
|
195
|
+
}
|
196
|
+
end
|
197
|
+
|
198
|
+
def user_agent
|
199
|
+
"ActionMCP-Client"
|
200
|
+
end
|
201
|
+
|
202
|
+
def client_info
|
203
|
+
{
|
204
|
+
name: user_agent,
|
205
|
+
version: ActionMCP.gem_version.to_s
|
206
|
+
}
|
207
|
+
end
|
208
|
+
|
209
|
+
def log_debug(message)
|
210
|
+
logger.debug("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
|
211
|
+
end
|
212
|
+
|
213
|
+
def log_info(message)
|
214
|
+
logger.info("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
|
215
|
+
end
|
216
|
+
|
217
|
+
def log_error(message)
|
218
|
+
logger.error("[ActionMCP::#{self.class.name.split('::').last}] #{message}")
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
@@ -0,0 +1,227 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Client
|
5
|
+
# Blueprints
|
6
|
+
#
|
7
|
+
# A collection that manages and provides access to URI templates (blueprints) for Model Context Protocol (MCP)
|
8
|
+
# resource discovery. These blueprints allow dynamic construction of resource URIs by filling in
|
9
|
+
# variable placeholders with specific values. The class supports lazy loading of templates when
|
10
|
+
# initialized with a client.
|
11
|
+
#
|
12
|
+
# Example usage:
|
13
|
+
# # Eager loading
|
14
|
+
# template_data = client.list_resource_templates # Returns array of URI template definitions
|
15
|
+
# blueprints = Blueprint.new(template_data)
|
16
|
+
#
|
17
|
+
# # Lazy loading
|
18
|
+
# blueprints = Blueprint.new([], client)
|
19
|
+
# templates = blueprints.all # Templates are loaded here
|
20
|
+
#
|
21
|
+
# # Access a specific blueprint by pattern
|
22
|
+
# file_blueprint = Blueprint.find_by_pattern("file://{path}")
|
23
|
+
#
|
24
|
+
# # Generate a concrete URI from a blueprint with parameters
|
25
|
+
# uri = Blueprint.construct("file://{path}", { path: "/logs/app.log" })
|
26
|
+
#
|
27
|
+
class Blueprint
|
28
|
+
# Initialize a new Blueprints collection with URI template definitions
|
29
|
+
#
|
30
|
+
# @param templates [Array<Hash>] Array of URI template definition hashes, each containing
|
31
|
+
# uriTemplate, name, description, and optionally mimeType keys
|
32
|
+
# @param client [Object, nil] Optional client for lazy loading of templates
|
33
|
+
attr_reader :client
|
34
|
+
|
35
|
+
def initialize(templates, client)
|
36
|
+
self.templates = templates
|
37
|
+
@client = client
|
38
|
+
@loaded = !templates.empty?
|
39
|
+
end
|
40
|
+
|
41
|
+
# Return all URI templates in the collection. If initialized with a client and templates
|
42
|
+
# haven't been loaded yet, this will trigger lazy loading from the client.
|
43
|
+
#
|
44
|
+
# @return [Array<Blueprint>] All blueprint objects in the collection
|
45
|
+
def all
|
46
|
+
load_templates unless @loaded
|
47
|
+
@templates
|
48
|
+
end
|
49
|
+
|
50
|
+
# Force reload all templates from the client and return them
|
51
|
+
#
|
52
|
+
# @return [Array<Blueprint>] All blueprint objects in the collection
|
53
|
+
def all!
|
54
|
+
load_templates(force: true)
|
55
|
+
@templates
|
56
|
+
end
|
57
|
+
|
58
|
+
# Find a blueprint by its URI pattern
|
59
|
+
#
|
60
|
+
# @param pattern [String] URI template pattern to find
|
61
|
+
# @return [Blueprint, nil] The blueprint with the given pattern, or nil if not found
|
62
|
+
def find_by_pattern(pattern)
|
63
|
+
all.find { |blueprint| blueprint.pattern == pattern }
|
64
|
+
end
|
65
|
+
|
66
|
+
# Find blueprints by name
|
67
|
+
#
|
68
|
+
# @param name [String] Name of the blueprints to find
|
69
|
+
# @return [Array<Blueprint>] Blueprints with the given name
|
70
|
+
def find_by_name(name)
|
71
|
+
all.select { |blueprint| blueprint.name == name }
|
72
|
+
end
|
73
|
+
|
74
|
+
# Construct a concrete URI by applying parameters to a blueprint
|
75
|
+
#
|
76
|
+
# @param pattern [String] URI template pattern to use
|
77
|
+
# @param params [Hash] Parameters to substitute into the pattern
|
78
|
+
# @return [String] The constructed URI with parameters applied
|
79
|
+
# @raise [KeyError] If a required parameter is missing
|
80
|
+
def construct(pattern, params)
|
81
|
+
blueprint = find_by_pattern(pattern)
|
82
|
+
raise ArgumentError, "Unknown blueprint pattern: #{pattern}" unless blueprint
|
83
|
+
|
84
|
+
blueprint.construct(params)
|
85
|
+
end
|
86
|
+
|
87
|
+
# Filter blueprints based on a given block
|
88
|
+
#
|
89
|
+
# @yield [blueprint] Block that determines whether to include a blueprint
|
90
|
+
# @yieldparam blueprint [Blueprint] A blueprint from the collection
|
91
|
+
# @yieldreturn [Boolean] true to include the blueprint, false to exclude it
|
92
|
+
# @return [Array<Blueprint>] Blueprints that match the filter criteria
|
93
|
+
def filter(&block)
|
94
|
+
all.select(&block)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Number of blueprints in the collection
|
98
|
+
#
|
99
|
+
# @return [Integer] The number of blueprints
|
100
|
+
def size
|
101
|
+
all.size
|
102
|
+
end
|
103
|
+
|
104
|
+
# Check if the collection contains a blueprint with the given pattern
|
105
|
+
#
|
106
|
+
# @param pattern [String] The blueprint pattern to check for
|
107
|
+
# @return [Boolean] true if a blueprint with the pattern exists
|
108
|
+
def contains?(pattern)
|
109
|
+
all.any? { |blueprint| blueprint.pattern == pattern }
|
110
|
+
end
|
111
|
+
|
112
|
+
# Group blueprints by their base protocol
|
113
|
+
#
|
114
|
+
# @return [Hash<String, Array<Blueprint>>] Hash mapping protocols to arrays of blueprints
|
115
|
+
def group_by_protocol
|
116
|
+
all.group_by(&:protocol)
|
117
|
+
end
|
118
|
+
|
119
|
+
# Implements enumerable functionality for the collection
|
120
|
+
include Enumerable
|
121
|
+
|
122
|
+
# Yield each blueprint in the collection to the given block
|
123
|
+
#
|
124
|
+
# @yield [blueprint] Block to execute for each blueprint
|
125
|
+
# @yieldparam blueprint [Blueprint] A blueprint from the collection
|
126
|
+
# @return [Enumerator] If no block is given
|
127
|
+
def each(&block)
|
128
|
+
all.each(&block)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Convert raw template data into ResourceTemplate objects
|
132
|
+
#
|
133
|
+
# @param templates [Array<Hash>] Array of template definition hashes
|
134
|
+
def templates=(templates)
|
135
|
+
@templates = templates.map { |template_data| ResourceTemplate.new(template_data) }
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
# Load or reload templates using the client
|
141
|
+
#
|
142
|
+
# @param force [Boolean] Whether to force reload even if templates are already loaded
|
143
|
+
# @return [void]
|
144
|
+
def load_templates(force: false)
|
145
|
+
return if @loaded && !force
|
146
|
+
|
147
|
+
begin
|
148
|
+
@client.list_resource_templates
|
149
|
+
@loaded = true
|
150
|
+
rescue StandardError => e
|
151
|
+
Rails.logger.error("Failed to load templates: #{e.message}")
|
152
|
+
@loaded = true unless @templates.empty?
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Internal Blueprint class to represent individual URI templates
|
157
|
+
class ResourceTemplate
|
158
|
+
attr_reader :pattern, :name, :description, :mime_type
|
159
|
+
|
160
|
+
# Initialize a new ResourceTemplate instance
|
161
|
+
#
|
162
|
+
# @param data [Hash] ResourceTemplate definition hash containing uriTemplate, name, description,
|
163
|
+
# and optionally mimeType
|
164
|
+
def initialize(data)
|
165
|
+
@pattern = data["uriTemplate"]
|
166
|
+
@name = data["name"]
|
167
|
+
@description = data["description"]
|
168
|
+
@mime_type = data["mimeType"]
|
169
|
+
@variable_pattern = /{([^}]+)}/
|
170
|
+
end
|
171
|
+
|
172
|
+
# Extract variable names from the template pattern
|
173
|
+
#
|
174
|
+
# @return [Array<String>] List of variable names in the pattern
|
175
|
+
def variables
|
176
|
+
@pattern.scan(@variable_pattern).flatten
|
177
|
+
end
|
178
|
+
|
179
|
+
# Get the protocol part of the URI template
|
180
|
+
#
|
181
|
+
# @return [String] The protocol (scheme) of the URI template
|
182
|
+
def protocol
|
183
|
+
@pattern.split("://").first
|
184
|
+
end
|
185
|
+
|
186
|
+
# Construct a concrete URI by substituting parameters into the template pattern
|
187
|
+
#
|
188
|
+
# @param params [Hash] Parameters to substitute into the pattern
|
189
|
+
# @return [String] The constructed URI with parameters applied
|
190
|
+
# @raise [KeyError] If a required parameter is missing
|
191
|
+
def construct(params)
|
192
|
+
result = @pattern.dup
|
193
|
+
|
194
|
+
variables.each do |var|
|
195
|
+
raise KeyError, "Missing required parameter: #{var}" unless params.key?(var.to_sym) || params.key?(var)
|
196
|
+
|
197
|
+
value = params[var.to_sym] || params[var]
|
198
|
+
result.gsub!("{#{var}}", value.to_s)
|
199
|
+
end
|
200
|
+
|
201
|
+
result
|
202
|
+
end
|
203
|
+
|
204
|
+
# Check if this template is compatible with a set of parameters
|
205
|
+
#
|
206
|
+
# @param params [Hash] Parameters to check
|
207
|
+
# @return [Boolean] true if all required variables have corresponding parameters
|
208
|
+
def compatible_with?(params)
|
209
|
+
symbolized_params = params.transform_keys(&:to_sym)
|
210
|
+
variables.all? { |var| symbolized_params.key?(var.to_sym) }
|
211
|
+
end
|
212
|
+
|
213
|
+
# Generate a hash representation of the blueprint
|
214
|
+
#
|
215
|
+
# @return [Hash] Hash containing blueprint details
|
216
|
+
def to_h
|
217
|
+
{
|
218
|
+
"uriTemplate" => @pattern,
|
219
|
+
"name" => @name,
|
220
|
+
"description" => @description,
|
221
|
+
"mimeType" => @mime_type
|
222
|
+
}
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|