actionmcp 0.20.0 → 0.24.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 +21 -5
- data/app/models/action_mcp/session.rb +8 -4
- data/lib/action_mcp/capability.rb +2 -3
- data/lib/action_mcp/client/base.rb +222 -0
- data/lib/action_mcp/client/blueprint.rb +161 -0
- data/lib/action_mcp/client/catalog.rb +160 -0
- data/lib/action_mcp/client/collection.rb +93 -0
- data/lib/action_mcp/client/json_rpc_handler.rb +114 -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 +117 -0
- data/lib/action_mcp/client/prompts.rb +39 -0
- data/lib/action_mcp/client/request_timeouts.rb +76 -0
- data/lib/action_mcp/client/resources.rb +85 -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 +194 -0
- data/lib/action_mcp/client/tools.rb +39 -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/{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/uri_ambiguity_checker.rb +6 -10
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +2 -1
- metadata +31 -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: 6b50744d4dcd16237d18d1b5c787f721e56281c82d1037c776201226a06cc60c
|
4
|
+
data.tar.gz: ed2b466652be17b4443508719da7b474b1bfc941047815f9a45bc4acf39ca2d6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ba4f687a5368fb04a2f986b61859e19d05658c00e39dc2b600984db39f34fd3e0f56259e6d08c425d140c1f1641d55ee209ec0ae69109bde1e0ec5312b01cf57
|
7
|
+
data.tar.gz: 6daf198545188c392453e6a53b905fafd3aa6c84ab508114c563ef59ff1c8b0e44712d5c8c82dc8643497daef7136bc3c284baec482d9fd8193c3cf1a32d4fe0
|
@@ -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)
|
@@ -48,11 +48,15 @@ module ActionMCP
|
|
48
48
|
|
49
49
|
after_create_commit :broadcast_message, if: :outgoing_message?
|
50
50
|
# Set is_ping on responses if the original request was a ping
|
51
|
-
after_create :
|
51
|
+
after_create :acknowledge_request, if: -> { %w[response error].include?(message_type) }
|
52
52
|
|
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)
|
@@ -125,17 +136,22 @@ module ActionMCP
|
|
125
136
|
end
|
126
137
|
end
|
127
138
|
|
128
|
-
def
|
139
|
+
def acknowledge_request
|
129
140
|
return unless jsonrpc_id.present?
|
130
141
|
|
131
142
|
request_message = session.messages.find_by(
|
132
143
|
jsonrpc_id: jsonrpc_id,
|
133
144
|
message_type: "request"
|
134
145
|
)
|
135
|
-
return unless request_message&.is_ping
|
136
146
|
|
137
|
-
|
147
|
+
return unless request_message
|
148
|
+
|
149
|
+
# Set is_ping based on the request
|
150
|
+
self.is_ping = request_message.is_ping
|
151
|
+
|
152
|
+
# Mark the request as acknowledged for all responses
|
138
153
|
request_message.update(request_acknowledged: true)
|
154
|
+
|
139
155
|
save! if changed?
|
140
156
|
end
|
141
157
|
end
|
@@ -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")
|
@@ -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,161 @@
|
|
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 < Collection
|
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
|
+
def initialize(templates, client)
|
34
|
+
super(templates, client)
|
35
|
+
self.templates = @collection_data
|
36
|
+
@load_method = :list_resource_templates
|
37
|
+
end
|
38
|
+
|
39
|
+
# Find a blueprint by its URI pattern
|
40
|
+
#
|
41
|
+
# @param pattern [String] URI template pattern to find
|
42
|
+
# @return [Blueprint, nil] The blueprint with the given pattern, or nil if not found
|
43
|
+
def find_by_pattern(pattern)
|
44
|
+
all.find { |blueprint| blueprint.pattern == pattern }
|
45
|
+
end
|
46
|
+
|
47
|
+
# Find blueprints by name
|
48
|
+
#
|
49
|
+
# @param name [String] Name of the blueprints to find
|
50
|
+
# @return [Array<Blueprint>] Blueprints with the given name
|
51
|
+
def find_by_name(name)
|
52
|
+
all.select { |blueprint| blueprint.name == name }
|
53
|
+
end
|
54
|
+
|
55
|
+
# Construct a concrete URI by applying parameters to a blueprint
|
56
|
+
#
|
57
|
+
# @param pattern [String] URI template pattern to use
|
58
|
+
# @param params [Hash] Parameters to substitute into the pattern
|
59
|
+
# @return [String] The constructed URI with parameters applied
|
60
|
+
# @raise [KeyError] If a required parameter is missing
|
61
|
+
def construct(pattern, params)
|
62
|
+
blueprint = find_by_pattern(pattern)
|
63
|
+
raise ArgumentError, "Unknown blueprint pattern: #{pattern}" unless blueprint
|
64
|
+
|
65
|
+
blueprint.construct(params)
|
66
|
+
end
|
67
|
+
|
68
|
+
# Check if the collection contains a blueprint with the given pattern
|
69
|
+
#
|
70
|
+
# @param pattern [String] The blueprint pattern to check for
|
71
|
+
# @return [Boolean] true if a blueprint with the pattern exists
|
72
|
+
def contains?(pattern)
|
73
|
+
all.any? { |blueprint| blueprint.pattern == pattern }
|
74
|
+
end
|
75
|
+
|
76
|
+
# Group blueprints by their base protocol
|
77
|
+
#
|
78
|
+
# @return [Hash<String, Array<Blueprint>>] Hash mapping protocols to arrays of blueprints
|
79
|
+
def group_by_protocol
|
80
|
+
all.group_by(&:protocol)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Convert raw template data into ResourceTemplate objects
|
84
|
+
#
|
85
|
+
# @param templates [Array<Hash>] Array of template definition hashes
|
86
|
+
def templates=(templates)
|
87
|
+
@collection_data = templates.map { |template_data| ResourceTemplate.new(template_data) }
|
88
|
+
end
|
89
|
+
|
90
|
+
# Internal Blueprint class to represent individual URI templates
|
91
|
+
class ResourceTemplate
|
92
|
+
attr_reader :pattern, :name, :description, :mime_type
|
93
|
+
|
94
|
+
# Initialize a new ResourceTemplate instance
|
95
|
+
#
|
96
|
+
# @param data [Hash] ResourceTemplate definition hash containing uriTemplate, name, description,
|
97
|
+
# and optionally mimeType
|
98
|
+
def initialize(data)
|
99
|
+
@pattern = data["uriTemplate"]
|
100
|
+
@name = data["name"]
|
101
|
+
@description = data["description"]
|
102
|
+
@mime_type = data["mimeType"]
|
103
|
+
@variable_pattern = /{([^}]+)}/
|
104
|
+
end
|
105
|
+
|
106
|
+
# Extract variable names from the template pattern
|
107
|
+
#
|
108
|
+
# @return [Array<String>] List of variable names in the pattern
|
109
|
+
def variables
|
110
|
+
@pattern.scan(@variable_pattern).flatten
|
111
|
+
end
|
112
|
+
|
113
|
+
# Get the protocol part of the URI template
|
114
|
+
#
|
115
|
+
# @return [String] The protocol (scheme) of the URI template
|
116
|
+
def protocol
|
117
|
+
@pattern.split("://").first
|
118
|
+
end
|
119
|
+
|
120
|
+
# Construct a concrete URI by substituting parameters into the template pattern
|
121
|
+
#
|
122
|
+
# @param params [Hash] Parameters to substitute into the pattern
|
123
|
+
# @return [String] The constructed URI with parameters applied
|
124
|
+
# @raise [KeyError] If a required parameter is missing
|
125
|
+
def construct(params)
|
126
|
+
result = @pattern.dup
|
127
|
+
|
128
|
+
variables.each do |var|
|
129
|
+
raise KeyError, "Missing required parameter: #{var}" unless params.key?(var.to_sym) || params.key?(var)
|
130
|
+
|
131
|
+
value = params[var.to_sym] || params[var]
|
132
|
+
result.gsub!("{#{var}}", value.to_s)
|
133
|
+
end
|
134
|
+
|
135
|
+
result
|
136
|
+
end
|
137
|
+
|
138
|
+
# Check if this template is compatible with a set of parameters
|
139
|
+
#
|
140
|
+
# @param params [Hash] Parameters to check
|
141
|
+
# @return [Boolean] true if all required variables have corresponding parameters
|
142
|
+
def compatible_with?(params)
|
143
|
+
symbolized_params = params.transform_keys(&:to_sym)
|
144
|
+
variables.all? { |var| symbolized_params.key?(var.to_sym) }
|
145
|
+
end
|
146
|
+
|
147
|
+
# Generate a hash representation of the blueprint
|
148
|
+
#
|
149
|
+
# @return [Hash] Hash containing blueprint details
|
150
|
+
def to_h
|
151
|
+
{
|
152
|
+
"uriTemplate" => @pattern,
|
153
|
+
"name" => @name,
|
154
|
+
"description" => @description,
|
155
|
+
"mimeType" => @mime_type
|
156
|
+
}
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
@@ -0,0 +1,160 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module Client
|
5
|
+
# Catalog
|
6
|
+
#
|
7
|
+
# A collection that manages and provides access to resources.
|
8
|
+
# This class stores resource definitions and provides methods for
|
9
|
+
# retrieving, filtering, and accessing resources by URI or other attributes.
|
10
|
+
# It supports lazy loading of resources when initialized with a client.
|
11
|
+
#
|
12
|
+
# Example usage:
|
13
|
+
# # Eager loading
|
14
|
+
# resources_data = client.list_resources # Returns array of resource definitions
|
15
|
+
# catalog = Catalog.new(resources_data)
|
16
|
+
#
|
17
|
+
# # Lazy loading
|
18
|
+
# catalog = Catalog.new([], client)
|
19
|
+
# resources = catalog.all # Resources are loaded here
|
20
|
+
#
|
21
|
+
# # Access a specific resource by URI
|
22
|
+
# main_file = catalog.find_by_uri("file:///project/src/main.rs")
|
23
|
+
#
|
24
|
+
# # Get all resources matching a criteria
|
25
|
+
# rust_files = catalog.filter { |r| r.mime_type == "text/x-rust" }
|
26
|
+
#
|
27
|
+
class Catalog < Collection
|
28
|
+
# Initialize a new Catalog with resource definitions
|
29
|
+
#
|
30
|
+
# @param resources [Array<Hash>] Array of resource definition hashes, each containing
|
31
|
+
# uri, name, description, and mimeType keys
|
32
|
+
# @param client [Object, nil] Optional client for lazy loading of resources
|
33
|
+
def initialize(resources, client)
|
34
|
+
super(resources, client)
|
35
|
+
self.resources = @collection_data
|
36
|
+
@load_method = :list_resources
|
37
|
+
end
|
38
|
+
|
39
|
+
# Find a resource by URI
|
40
|
+
#
|
41
|
+
# @param uri [String] URI of the resource to find
|
42
|
+
# @return [Resource, nil] The resource with the given URI, or nil if not found
|
43
|
+
def find_by_uri(uri)
|
44
|
+
all.find { |resource| resource.uri == uri }
|
45
|
+
end
|
46
|
+
|
47
|
+
# Find resources by name
|
48
|
+
#
|
49
|
+
# @param name [String] Name of the resources to find
|
50
|
+
# @return [Array<Resource>] Resources with the given name
|
51
|
+
def find_by_name(name)
|
52
|
+
all.select { |resource| resource.name == name }
|
53
|
+
end
|
54
|
+
|
55
|
+
# Find resources by MIME type
|
56
|
+
#
|
57
|
+
# @param mime_type [String] MIME type to search for
|
58
|
+
# @return [Array<Resource>] Resources with the given MIME type
|
59
|
+
def find_by_mime_type(mime_type)
|
60
|
+
all.select { |resource| resource.mime_type == mime_type }
|
61
|
+
end
|
62
|
+
|
63
|
+
# Get a list of all resource URIs
|
64
|
+
#
|
65
|
+
# @return [Array<String>] URIs of all resources in the collection
|
66
|
+
def uris
|
67
|
+
all.map(&:uri)
|
68
|
+
end
|
69
|
+
|
70
|
+
# Check if the collection contains a resource with the given URI
|
71
|
+
#
|
72
|
+
# @param uri [String] The resource URI to check for
|
73
|
+
# @return [Boolean] true if a resource with the URI exists
|
74
|
+
def contains_uri?(uri)
|
75
|
+
all.any? { |resource| resource.uri == uri }
|
76
|
+
end
|
77
|
+
|
78
|
+
# Group resources by MIME type
|
79
|
+
#
|
80
|
+
# @return [Hash<String, Array<Resource>>] Hash mapping MIME types to arrays of resources
|
81
|
+
def group_by_mime_type
|
82
|
+
all.group_by(&:mime_type)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Search resources by keyword in name or description
|
86
|
+
#
|
87
|
+
# @param keyword [String] Keyword to search for
|
88
|
+
# @return [Array<Resource>] Resources matching the search term
|
89
|
+
def search(keyword)
|
90
|
+
keyword = keyword.downcase
|
91
|
+
all.select do |resource|
|
92
|
+
resource.name.downcase.include?(keyword) ||
|
93
|
+
(resource.description && resource.description.downcase.include?(keyword))
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
# Convert raw resource data into Resource objects
|
98
|
+
#
|
99
|
+
# @param raw_resources [Array<Hash>] Array of resource definition hashes
|
100
|
+
def resources=(raw_resources)
|
101
|
+
@collection_data = raw_resources.map { |resource_data| Resource.new(resource_data) }
|
102
|
+
end
|
103
|
+
|
104
|
+
# Internal Resource class to represent individual resources
|
105
|
+
class Resource
|
106
|
+
attr_reader :uri, :name, :description, :mime_type
|
107
|
+
|
108
|
+
# Initialize a new Resource instance
|
109
|
+
#
|
110
|
+
# @param data [Hash] Resource definition hash containing uri, name, description, and mimeType
|
111
|
+
def initialize(data = [])
|
112
|
+
@uri = data["uri"]
|
113
|
+
@name = data["name"]
|
114
|
+
@description = data["description"]
|
115
|
+
@mime_type = data["mimeType"]
|
116
|
+
end
|
117
|
+
|
118
|
+
# Get the file extension from the resource name
|
119
|
+
#
|
120
|
+
# @return [String, nil] The file extension or nil if no extension
|
121
|
+
def extension
|
122
|
+
File.extname(@name)[1..-1] if @name.include?(".")
|
123
|
+
end
|
124
|
+
|
125
|
+
# Check if this resource is a text file based on MIME type
|
126
|
+
#
|
127
|
+
# @return [Boolean] true if the resource is a text file
|
128
|
+
def text?
|
129
|
+
@mime_type&.start_with?("text/")
|
130
|
+
end
|
131
|
+
|
132
|
+
# Check if this resource is an image based on MIME type
|
133
|
+
#
|
134
|
+
# @return [Boolean] true if the resource is an image
|
135
|
+
def image?
|
136
|
+
@mime_type&.start_with?("image/")
|
137
|
+
end
|
138
|
+
|
139
|
+
# Get the path portion of the URI
|
140
|
+
#
|
141
|
+
# @return [String, nil] The path component of the URI
|
142
|
+
def path
|
143
|
+
URI(@uri).path rescue nil
|
144
|
+
end
|
145
|
+
|
146
|
+
# Generate a hash representation of the resource
|
147
|
+
#
|
148
|
+
# @return [Hash] Hash containing resource details
|
149
|
+
def to_h
|
150
|
+
{
|
151
|
+
"uri" => @uri,
|
152
|
+
"name" => @name,
|
153
|
+
"description" => @description,
|
154
|
+
"mimeType" => @mime_type
|
155
|
+
}
|
156
|
+
end
|
157
|
+
end
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|