fast-mcp 1.4.0 → 1.6.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/CHANGELOG.md +69 -5
- data/README.md +78 -22
- data/lib/fast_mcp.rb +3 -14
- data/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +2 -2
- data/lib/generators/fast_mcp/install/templates/sample_tool.rb +7 -0
- data/lib/mcp/railtie.rb +4 -0
- data/lib/mcp/resource.rb +86 -29
- data/lib/mcp/server.rb +107 -75
- data/lib/mcp/server_filtering.rb +80 -0
- data/lib/mcp/tool.rb +218 -632
- data/lib/mcp/transports/base_transport.rb +2 -2
- data/lib/mcp/transports/rack_transport.rb +148 -62
- data/lib/mcp/version.rb +1 -1
- metadata +27 -6
data/lib/mcp/server.rb
CHANGED
@@ -8,9 +8,12 @@ require_relative 'transports/stdio_transport'
|
|
8
8
|
require_relative 'transports/rack_transport'
|
9
9
|
require_relative 'transports/authenticated_rack_transport'
|
10
10
|
require_relative 'logger'
|
11
|
+
require_relative 'server_filtering'
|
11
12
|
|
12
13
|
module FastMcp
|
13
14
|
class Server
|
15
|
+
include ServerFiltering
|
16
|
+
|
14
17
|
attr_reader :name, :version, :tools, :resources, :capabilities
|
15
18
|
|
16
19
|
DEFAULT_CAPABILITIES = {
|
@@ -27,14 +30,16 @@ module FastMcp
|
|
27
30
|
@name = name
|
28
31
|
@version = version
|
29
32
|
@tools = {}
|
30
|
-
@resources =
|
33
|
+
@resources = []
|
31
34
|
@resource_subscriptions = {}
|
32
35
|
@logger = logger
|
33
|
-
@logger.level = Logger::INFO
|
34
36
|
@request_id = 0
|
35
37
|
@transport_klass = nil
|
36
38
|
@transport = nil
|
37
39
|
@capabilities = DEFAULT_CAPABILITIES.dup
|
40
|
+
@tool_filters = []
|
41
|
+
@resource_filters = []
|
42
|
+
@on_error_result = nil
|
38
43
|
|
39
44
|
# Merge with provided capabilities
|
40
45
|
@capabilities.merge!(capabilities) if capabilities.is_a?(Hash)
|
@@ -66,8 +71,9 @@ module FastMcp
|
|
66
71
|
|
67
72
|
# Register a resource with the server
|
68
73
|
def register_resource(resource)
|
69
|
-
@resources
|
70
|
-
|
74
|
+
@resources << resource
|
75
|
+
|
76
|
+
@logger.debug("Registered resource: #{resource.resource_name} (#{resource.uri})")
|
71
77
|
resource.server = self
|
72
78
|
# Notify subscribers about the list change
|
73
79
|
notify_resource_list_changed if @transport
|
@@ -75,10 +81,16 @@ module FastMcp
|
|
75
81
|
resource
|
76
82
|
end
|
77
83
|
|
84
|
+
def on_error_result(&block)
|
85
|
+
@on_error_result = block
|
86
|
+
end
|
87
|
+
|
78
88
|
# Remove a resource from the server
|
79
89
|
def remove_resource(uri)
|
80
|
-
|
81
|
-
|
90
|
+
resource = @resources.find { |r| r.uri == uri }
|
91
|
+
|
92
|
+
if resource
|
93
|
+
@resources.delete(resource)
|
82
94
|
@logger.debug("Removed resource: #{resource.name} (#{uri})")
|
83
95
|
|
84
96
|
# Notify subscribers about the list change
|
@@ -95,7 +107,7 @@ module FastMcp
|
|
95
107
|
@logger.transport = :stdio
|
96
108
|
@logger.info("Starting MCP server: #{@name} v#{@version}")
|
97
109
|
@logger.info("Available tools: #{@tools.keys.join(', ')}")
|
98
|
-
@logger.info("Available resources: #{@resources.
|
110
|
+
@logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
|
99
111
|
|
100
112
|
# Use STDIO transport by default
|
101
113
|
@transport_klass = FastMcp::Transports::StdioTransport
|
@@ -105,35 +117,29 @@ module FastMcp
|
|
105
117
|
|
106
118
|
# Start the server as a Rack middleware
|
107
119
|
def start_rack(app, options = {})
|
108
|
-
@
|
120
|
+
@transport_klass = options.delete(:transport) || FastMcp::Transports::RackTransport
|
121
|
+
transport_name = @transport_klass.name.split('::').last
|
122
|
+
|
123
|
+
@logger.info("Starting MCP server with #{transport_name}: #{@name} v#{@version}")
|
109
124
|
@logger.info("Available tools: #{@tools.keys.join(', ')}")
|
110
|
-
@logger.info("Available resources: #{@resources.
|
125
|
+
@logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
|
111
126
|
|
112
|
-
|
113
|
-
transport_klass = FastMcp::Transports::RackTransport
|
114
|
-
@transport = transport_klass.new(app, self, options.merge(logger: @logger))
|
127
|
+
@transport = @transport_klass.new(app, self, options.merge(logger: @logger))
|
115
128
|
@transport.start
|
116
129
|
|
117
130
|
# Return the transport as middleware
|
118
131
|
@transport
|
119
132
|
end
|
120
133
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
@logger.info("Available resources: #{@resources.keys.join(', ')}")
|
125
|
-
|
126
|
-
# Use Rack transport
|
127
|
-
transport_klass = FastMcp::Transports::AuthenticatedRackTransport
|
128
|
-
@transport = transport_klass.new(app, self, options.merge(logger: @logger))
|
129
|
-
@transport.start
|
134
|
+
# Handle a JSON-RPC request and return the response as a JSON string
|
135
|
+
def handle_json_request(request, headers: {})
|
136
|
+
request_str = request.is_a?(String) ? request : JSON.generate(request)
|
130
137
|
|
131
|
-
|
132
|
-
@transport
|
138
|
+
handle_request(request_str, headers: headers)
|
133
139
|
end
|
134
140
|
|
135
141
|
# Handle incoming JSON-RPC request
|
136
|
-
def handle_request(json_str) # rubocop:disable Metrics/MethodLength
|
142
|
+
def handle_request(json_str, headers: {}) # rubocop:disable Metrics/MethodLength
|
137
143
|
begin
|
138
144
|
request = JSON.parse(json_str)
|
139
145
|
rescue JSON::ParserError, TypeError
|
@@ -142,15 +148,13 @@ module FastMcp
|
|
142
148
|
|
143
149
|
@logger.debug("Received request: #{request.inspect}")
|
144
150
|
|
145
|
-
# Check if it's a valid JSON-RPC 2.0 request
|
146
|
-
unless request['jsonrpc'] == '2.0' && request['method']
|
147
|
-
return send_error(-32_600, 'Invalid Request', request['id'])
|
148
|
-
end
|
149
|
-
|
150
151
|
method = request['method']
|
151
152
|
params = request['params'] || {}
|
152
153
|
id = request['id']
|
153
154
|
|
155
|
+
# Check if it's a valid JSON-RPC 2.0 request
|
156
|
+
return send_error(-32_600, 'Invalid Request', id) unless request['jsonrpc'] == '2.0'
|
157
|
+
|
154
158
|
case method
|
155
159
|
when 'ping'
|
156
160
|
send_result({}, id)
|
@@ -161,39 +165,26 @@ module FastMcp
|
|
161
165
|
when 'tools/list'
|
162
166
|
handle_tools_list(id)
|
163
167
|
when 'tools/call'
|
164
|
-
handle_tools_call(params, id)
|
168
|
+
handle_tools_call(params, headers, id)
|
165
169
|
when 'resources/list'
|
166
170
|
handle_resources_list(id)
|
171
|
+
when 'resources/templates/list'
|
172
|
+
handle_resources_templates_list(id)
|
167
173
|
when 'resources/read'
|
168
174
|
handle_resources_read(params, id)
|
169
175
|
when 'resources/subscribe'
|
170
176
|
handle_resources_subscribe(params, id)
|
171
177
|
when 'resources/unsubscribe'
|
172
178
|
handle_resources_unsubscribe(params, id)
|
179
|
+
when nil
|
180
|
+
# This is a notification response, we don't need to handle it
|
181
|
+
nil
|
173
182
|
else
|
174
183
|
send_error(-32_601, "Method not found: #{method}", id)
|
175
184
|
end
|
176
185
|
rescue StandardError => e
|
177
186
|
@logger.error("Error handling request: #{e.message}, #{e.backtrace.join("\n")}")
|
178
|
-
send_error(-32_600, "Internal error: #{e.message}", id)
|
179
|
-
end
|
180
|
-
|
181
|
-
# Handle a JSON-RPC request and return the response as a JSON string
|
182
|
-
def handle_json_request(request)
|
183
|
-
# Process the request
|
184
|
-
if request.is_a?(String)
|
185
|
-
handle_request(request)
|
186
|
-
else
|
187
|
-
handle_request(JSON.generate(request))
|
188
|
-
end
|
189
|
-
end
|
190
|
-
|
191
|
-
# Read a resource directly
|
192
|
-
def read_resource(uri)
|
193
|
-
resource = @resources[uri]
|
194
|
-
raise "Resource not found: #{uri}" unless resource
|
195
|
-
|
196
|
-
resource
|
187
|
+
send_error(-32_600, "Internal error: #{e.message}, #{e.backtrace.join("\n")}", id)
|
197
188
|
end
|
198
189
|
|
199
190
|
# Notify subscribers about a resource update
|
@@ -215,6 +206,10 @@ module FastMcp
|
|
215
206
|
@transport.send_message(notification)
|
216
207
|
end
|
217
208
|
|
209
|
+
def read_resource(uri)
|
210
|
+
@resources.find { |r| r.match(uri) }
|
211
|
+
end
|
212
|
+
|
218
213
|
private
|
219
214
|
|
220
215
|
PROTOCOL_VERSION = '2024-11-05'
|
@@ -249,24 +244,34 @@ module FastMcp
|
|
249
244
|
|
250
245
|
return send_error(-32_602, 'Invalid params: missing resource URI', id) unless uri
|
251
246
|
|
252
|
-
resource
|
253
|
-
return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
|
247
|
+
@logger.debug("Looking for resource with URI: #{uri}")
|
254
248
|
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
249
|
+
begin
|
250
|
+
resource = read_resource(uri)
|
251
|
+
return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
|
252
|
+
|
253
|
+
@logger.debug("Found resource: #{resource.resource_name}, templated: #{resource.templated?}")
|
254
|
+
|
255
|
+
base_content = { uri: uri }
|
256
|
+
base_content[:mimeType] = resource.mime_type if resource.mime_type
|
257
|
+
resource_instance = resource.initialize_from_uri(uri)
|
258
|
+
@logger.debug("Resource instance params: #{resource_instance.params.inspect}")
|
259
|
+
|
260
|
+
result = if resource_instance.binary?
|
261
|
+
{
|
262
|
+
contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
|
263
|
+
}
|
264
|
+
else
|
265
|
+
{
|
266
|
+
contents: [base_content.merge(text: resource_instance.content)]
|
267
|
+
}
|
268
|
+
end
|
269
|
+
|
270
|
+
# # rescue StandardError => e
|
271
|
+
# @logger.error("Error reading resource: #{e.message}")
|
272
|
+
# @logger.error(e.backtrace.join("\n"))
|
273
|
+
send_result(result, id)
|
274
|
+
end
|
270
275
|
end
|
271
276
|
|
272
277
|
def handle_initialized_notification
|
@@ -281,18 +286,32 @@ module FastMcp
|
|
281
286
|
# Handle tools/list request
|
282
287
|
def handle_tools_list(id)
|
283
288
|
tools_list = @tools.values.map do |tool|
|
284
|
-
{
|
289
|
+
tool_info = {
|
285
290
|
name: tool.tool_name,
|
286
291
|
description: tool.description || '',
|
287
292
|
inputSchema: tool.input_schema_to_json || { type: 'object', properties: {}, required: [] }
|
288
293
|
}
|
294
|
+
|
295
|
+
# Add annotations if they exist
|
296
|
+
annotations = tool.annotations
|
297
|
+
unless annotations.empty?
|
298
|
+
# Convert snake_case keys to camelCase for MCP protocol
|
299
|
+
camel_case_annotations = {}
|
300
|
+
annotations.each do |key, value|
|
301
|
+
camel_key = key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
|
302
|
+
camel_case_annotations[camel_key] = value
|
303
|
+
end
|
304
|
+
tool_info[:annotations] = camel_case_annotations
|
305
|
+
end
|
306
|
+
|
307
|
+
tool_info
|
289
308
|
end
|
290
309
|
|
291
310
|
send_result({ tools: tools_list }, id)
|
292
311
|
end
|
293
312
|
|
294
313
|
# Handle tools/call request
|
295
|
-
def handle_tools_call(params, id)
|
314
|
+
def handle_tools_call(params, headers, id)
|
296
315
|
tool_name = params['name']
|
297
316
|
arguments = params['arguments'] || {}
|
298
317
|
|
@@ -304,7 +323,13 @@ module FastMcp
|
|
304
323
|
begin
|
305
324
|
# Convert string keys to symbols for Ruby
|
306
325
|
symbolized_args = symbolize_keys(arguments)
|
307
|
-
|
326
|
+
|
327
|
+
tool_instance = tool.new(headers: headers)
|
328
|
+
authorized = tool_instance.authorized?(**symbolized_args)
|
329
|
+
|
330
|
+
return send_error(-32_602, 'Unauthorized', id) unless authorized
|
331
|
+
|
332
|
+
result, metadata = tool_instance.call_with_schema_validation!(**symbolized_args)
|
308
333
|
|
309
334
|
# Format and send the result
|
310
335
|
send_formatted_result(result, id, metadata)
|
@@ -335,6 +360,8 @@ module FastMcp
|
|
335
360
|
|
336
361
|
# Format and send error result
|
337
362
|
def send_error_result(message, id)
|
363
|
+
@on_error_result&.call(message)
|
364
|
+
|
338
365
|
# Format error according to the MCP specification
|
339
366
|
error_result = {
|
340
367
|
content: [{ type: 'text', text: "Error: #{message}" }],
|
@@ -346,11 +373,19 @@ module FastMcp
|
|
346
373
|
|
347
374
|
# Handle resources/list request
|
348
375
|
def handle_resources_list(id)
|
349
|
-
resources_list = @resources.
|
376
|
+
resources_list = @resources.select(&:non_templated?).map(&:metadata)
|
350
377
|
|
351
378
|
send_result({ resources: resources_list }, id)
|
352
379
|
end
|
353
380
|
|
381
|
+
# Handle resources/templates/list request
|
382
|
+
def handle_resources_templates_list(id)
|
383
|
+
# Collect templated resources
|
384
|
+
templated_resources_list = @resources.select(&:templated?).map(&:metadata)
|
385
|
+
|
386
|
+
send_result({ resourceTemplates: templated_resources_list }, id)
|
387
|
+
end
|
388
|
+
|
354
389
|
# Handle resources/subscribe request
|
355
390
|
def handle_resources_subscribe(params, id)
|
356
391
|
return unless @client_initialized
|
@@ -362,11 +397,8 @@ module FastMcp
|
|
362
397
|
return
|
363
398
|
end
|
364
399
|
|
365
|
-
resource = @resources
|
366
|
-
unless resource
|
367
|
-
send_error(-32_602, "Resource not found: #{uri}", id)
|
368
|
-
return
|
369
|
-
end
|
400
|
+
resource = @resources.find { |r| r.match(uri) }
|
401
|
+
return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
|
370
402
|
|
371
403
|
# Add to subscriptions
|
372
404
|
@resource_subscriptions[uri] ||= []
|
@@ -0,0 +1,80 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module FastMcp
|
4
|
+
# Module for handling server filtering functionality
|
5
|
+
module ServerFiltering
|
6
|
+
# Add filter for tools
|
7
|
+
def filter_tools(&block)
|
8
|
+
@tool_filters << block if block_given?
|
9
|
+
end
|
10
|
+
|
11
|
+
# Add filter for resources
|
12
|
+
def filter_resources(&block)
|
13
|
+
@resource_filters << block if block_given?
|
14
|
+
end
|
15
|
+
|
16
|
+
# Check if filters are configured
|
17
|
+
def contains_filters?
|
18
|
+
@tool_filters.any? || @resource_filters.any?
|
19
|
+
end
|
20
|
+
|
21
|
+
# Create a filtered copy for a specific request
|
22
|
+
def create_filtered_copy(request)
|
23
|
+
filtered_server = self.class.new(
|
24
|
+
name: @name,
|
25
|
+
version: @version,
|
26
|
+
logger: @logger,
|
27
|
+
capabilities: @capabilities
|
28
|
+
)
|
29
|
+
|
30
|
+
# Copy transport settings
|
31
|
+
filtered_server.transport_klass = @transport_klass
|
32
|
+
|
33
|
+
# Apply filters and register items
|
34
|
+
register_filtered_tools(filtered_server, request)
|
35
|
+
register_filtered_resources(filtered_server, request)
|
36
|
+
|
37
|
+
filtered_server
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
# Apply tool filters and register filtered tools
|
43
|
+
def register_filtered_tools(filtered_server, request)
|
44
|
+
filtered_tools = apply_tool_filters(request)
|
45
|
+
|
46
|
+
# Register filtered tools
|
47
|
+
filtered_tools.each do |tool|
|
48
|
+
filtered_server.register_tool(tool)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Apply resource filters and register filtered resources
|
53
|
+
def register_filtered_resources(filtered_server, request)
|
54
|
+
filtered_resources = apply_resource_filters(request)
|
55
|
+
|
56
|
+
# Register filtered resources
|
57
|
+
filtered_resources.each do |resource|
|
58
|
+
filtered_server.register_resource(resource)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Apply all tool filters to the tools collection
|
63
|
+
def apply_tool_filters(request)
|
64
|
+
filtered_tools = @tools.values
|
65
|
+
@tool_filters.each do |filter|
|
66
|
+
filtered_tools = filter.call(request, filtered_tools)
|
67
|
+
end
|
68
|
+
filtered_tools
|
69
|
+
end
|
70
|
+
|
71
|
+
# Apply all resource filters to the resources collection
|
72
|
+
def apply_resource_filters(request)
|
73
|
+
filtered_resources = @resources
|
74
|
+
@resource_filters.each do |filter|
|
75
|
+
filtered_resources = filter.call(request, filtered_resources)
|
76
|
+
end
|
77
|
+
filtered_resources
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|