fast-mcp-annotations 1.5.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.
data/lib/mcp/server.rb ADDED
@@ -0,0 +1,499 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'logger'
5
+ require 'securerandom'
6
+ require 'base64'
7
+ require_relative 'transports/stdio_transport'
8
+ require_relative 'transports/rack_transport'
9
+ require_relative 'transports/authenticated_rack_transport'
10
+ require_relative 'logger'
11
+ require_relative 'server_filtering'
12
+
13
+ module FastMcp
14
+ class Server
15
+ include ServerFiltering
16
+
17
+ attr_reader :name, :version, :tools, :resources, :capabilities
18
+
19
+ DEFAULT_CAPABILITIES = {
20
+ resources: {
21
+ subscribe: true,
22
+ listChanged: true
23
+ },
24
+ tools: {
25
+ listChanged: true
26
+ }
27
+ }.freeze
28
+
29
+ def initialize(name:, version:, logger: FastMcp::Logger.new, capabilities: {})
30
+ @name = name
31
+ @version = version
32
+ @tools = {}
33
+ @resources = []
34
+ @resource_subscriptions = {}
35
+ @logger = logger
36
+ @request_id = 0
37
+ @transport_klass = nil
38
+ @transport = nil
39
+ @capabilities = DEFAULT_CAPABILITIES.dup
40
+ @tool_filters = []
41
+ @resource_filters = []
42
+
43
+ # Merge with provided capabilities
44
+ @capabilities.merge!(capabilities) if capabilities.is_a?(Hash)
45
+ end
46
+ attr_accessor :transport, :transport_klass, :logger
47
+
48
+ # Register multiple tools at once
49
+ # @param tools [Array<Tool>] Tools to register
50
+ def register_tools(*tools)
51
+ tools.each do |tool|
52
+ register_tool(tool)
53
+ end
54
+ end
55
+
56
+ # Register a tool with the server
57
+ def register_tool(tool)
58
+ @tools[tool.tool_name] = tool
59
+ @logger.debug("Registered tool: #{tool.tool_name}")
60
+ tool.server = self
61
+ end
62
+
63
+ # Register multiple resources at once
64
+ # @param resources [Array<Resource>] Resources to register
65
+ def register_resources(*resources)
66
+ resources.each do |resource|
67
+ register_resource(resource)
68
+ end
69
+ end
70
+
71
+ # Register a resource with the server
72
+ def register_resource(resource)
73
+ @resources << resource
74
+
75
+ @logger.debug("Registered resource: #{resource.resource_name} (#{resource.uri})")
76
+ resource.server = self
77
+ # Notify subscribers about the list change
78
+ notify_resource_list_changed if @transport
79
+
80
+ resource
81
+ end
82
+
83
+ # Remove a resource from the server
84
+ def remove_resource(uri)
85
+ resource = @resources.find { |r| r.uri == uri }
86
+
87
+ if resource
88
+ @resources.delete(resource)
89
+ @logger.debug("Removed resource: #{resource.name} (#{uri})")
90
+
91
+ # Notify subscribers about the list change
92
+ notify_resource_list_changed if @transport
93
+
94
+ true
95
+ else
96
+ false
97
+ end
98
+ end
99
+
100
+ # Start the server using stdio transport
101
+ def start
102
+ @logger.transport = :stdio
103
+ @logger.info("Starting MCP server: #{@name} v#{@version}")
104
+ @logger.info("Available tools: #{@tools.keys.join(', ')}")
105
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
106
+
107
+ # Use STDIO transport by default
108
+ @transport_klass = FastMcp::Transports::StdioTransport
109
+ @transport = @transport_klass.new(self, logger: @logger)
110
+ @transport.start
111
+ end
112
+
113
+ # Start the server as a Rack middleware
114
+ def start_rack(app, options = {})
115
+ @logger.info("Starting MCP server as Rack middleware: #{@name} v#{@version}")
116
+ @logger.info("Available tools: #{@tools.keys.join(', ')}")
117
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
118
+
119
+ # Use Rack transport
120
+ transport_klass = FastMcp::Transports::RackTransport
121
+ @transport = transport_klass.new(app, self, options.merge(logger: @logger))
122
+ @transport.start
123
+
124
+ # Return the transport as middleware
125
+ @transport
126
+ end
127
+
128
+ def start_authenticated_rack(app, options = {})
129
+ @logger.info("Starting MCP server as Authenticated Rack middleware: #{@name} v#{@version}")
130
+ @logger.info("Available tools: #{@tools.keys.join(', ')}")
131
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
132
+
133
+ # Use Rack transport
134
+ transport_klass = FastMcp::Transports::AuthenticatedRackTransport
135
+ @transport = transport_klass.new(app, self, options.merge(logger: @logger))
136
+ @transport.start
137
+
138
+ # Return the transport as middleware
139
+ @transport
140
+ end
141
+
142
+ # Handle a JSON-RPC request and return the response as a JSON string
143
+ def handle_json_request(request, headers: {})
144
+ request_str = request.is_a?(String) ? request : JSON.generate(request)
145
+
146
+ handle_request(request_str, headers: headers)
147
+ end
148
+
149
+ # Handle incoming JSON-RPC request
150
+ def handle_request(json_str, headers: {}) # rubocop:disable Metrics/MethodLength
151
+ begin
152
+ request = JSON.parse(json_str)
153
+ rescue JSON::ParserError, TypeError
154
+ return send_error(-32_600, 'Invalid Request', nil)
155
+ end
156
+
157
+ @logger.debug("Received request: #{request.inspect}")
158
+
159
+ method = request['method']
160
+ params = request['params'] || {}
161
+ id = request['id']
162
+
163
+ # Check if it's a valid JSON-RPC 2.0 request
164
+ return send_error(-32_600, 'Invalid Request', id) unless request['jsonrpc'] == '2.0'
165
+
166
+ case method
167
+ when 'ping'
168
+ send_result({}, id)
169
+ when 'initialize'
170
+ handle_initialize(params, id)
171
+ when 'notifications/initialized'
172
+ handle_initialized_notification
173
+ when 'tools/list'
174
+ handle_tools_list(id)
175
+ when 'tools/call'
176
+ handle_tools_call(params, headers, id)
177
+ when 'resources/list'
178
+ handle_resources_list(id)
179
+ when 'resources/templates/list'
180
+ handle_resources_templates_list(id)
181
+ when 'resources/read'
182
+ handle_resources_read(params, id)
183
+ when 'resources/subscribe'
184
+ handle_resources_subscribe(params, id)
185
+ when 'resources/unsubscribe'
186
+ handle_resources_unsubscribe(params, id)
187
+ when nil
188
+ # This is a notification response, we don't need to handle it
189
+ nil
190
+ else
191
+ send_error(-32_601, "Method not found: #{method}", id)
192
+ end
193
+ rescue StandardError => e
194
+ @logger.error("Error handling request: #{e.message}, #{e.backtrace.join("\n")}")
195
+ send_error(-32_600, "Internal error: #{e.message}, #{e.backtrace.join("\n")}", id)
196
+ end
197
+
198
+ # Notify subscribers about a resource update
199
+ def notify_resource_updated(uri)
200
+ @logger.warn("Notifying subscribers about resource update: #{uri}, #{@resource_subscriptions.inspect}")
201
+ return unless @client_initialized && @resource_subscriptions.key?(uri)
202
+
203
+ resource = @resources[uri]
204
+ notification = {
205
+ jsonrpc: '2.0',
206
+ method: 'notifications/resources/updated',
207
+ params: {
208
+ uri: uri,
209
+ name: resource.name,
210
+ mimeType: resource.mime_type
211
+ }
212
+ }
213
+
214
+ @transport.send_message(notification)
215
+ end
216
+
217
+ def read_resource(uri)
218
+ @resources.find { |r| r.match(uri) }
219
+ end
220
+
221
+ private
222
+
223
+ PROTOCOL_VERSION = '2024-11-05'
224
+
225
+ def handle_initialize(params, id)
226
+ # Store client capabilities for later use
227
+ @client_capabilities = params['capabilities'] || {}
228
+ client_info = params['clientInfo'] || {}
229
+
230
+ # Log client information
231
+ @logger.info("Client connected: #{client_info['name']} v#{client_info['version']}")
232
+ # @logger.debug("Client capabilities: #{client_capabilities.inspect}")
233
+
234
+ # Prepare server response
235
+ response = {
236
+ protocolVersion: PROTOCOL_VERSION, # For now, only version 2024-11-05 is supported.
237
+ capabilities: @capabilities,
238
+ serverInfo: {
239
+ name: @name,
240
+ version: @version
241
+ }
242
+ }
243
+
244
+ @logger.info("Server response: #{response.inspect}")
245
+
246
+ send_result(response, id)
247
+ end
248
+
249
+ # Handle a resource read
250
+ def handle_resources_read(params, id)
251
+ uri = params['uri']
252
+
253
+ return send_error(-32_602, 'Invalid params: missing resource URI', id) unless uri
254
+
255
+ @logger.debug("Looking for resource with URI: #{uri}")
256
+
257
+ begin
258
+ resource = read_resource(uri)
259
+ return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
260
+
261
+ @logger.debug("Found resource: #{resource.resource_name}, templated: #{resource.templated?}")
262
+
263
+ base_content = { uri: uri }
264
+ base_content[:mimeType] = resource.mime_type if resource.mime_type
265
+ resource_instance = resource.initialize_from_uri(uri)
266
+ @logger.debug("Resource instance params: #{resource_instance.params.inspect}")
267
+
268
+ result = if resource_instance.binary?
269
+ {
270
+ contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
271
+ }
272
+ else
273
+ {
274
+ contents: [base_content.merge(text: resource_instance.content)]
275
+ }
276
+ end
277
+
278
+ # # rescue StandardError => e
279
+ # @logger.error("Error reading resource: #{e.message}")
280
+ # @logger.error(e.backtrace.join("\n"))
281
+ send_result(result, id)
282
+ end
283
+ end
284
+
285
+ def handle_initialized_notification
286
+ # The client is now ready for normal operation
287
+ # No response needed for notifications
288
+ @client_initialized = true
289
+ @logger.info('Client initialized, beginning normal operation')
290
+
291
+ nil
292
+ end
293
+
294
+ # Handle tools/list request
295
+ def handle_tools_list(id)
296
+ tools_list = @tools.values.map do |tool|
297
+ tool_info = {
298
+ name: tool.tool_name,
299
+ description: tool.description || '',
300
+ inputSchema: tool.input_schema_to_json || { type: 'object', properties: {}, required: [] }
301
+ }
302
+
303
+ # Add annotations if they exist
304
+ annotations = tool.annotations
305
+ unless annotations.empty?
306
+ # Convert snake_case keys to camelCase for MCP protocol
307
+ camel_case_annotations = {}
308
+ annotations.each do |key, value|
309
+ camel_key = key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
310
+ camel_case_annotations[camel_key] = value
311
+ end
312
+ tool_info[:annotations] = camel_case_annotations
313
+ end
314
+
315
+ tool_info
316
+ end
317
+
318
+ send_result({ tools: tools_list }, id)
319
+ end
320
+
321
+ # Handle tools/call request
322
+ def handle_tools_call(params, headers, id)
323
+ tool_name = params['name']
324
+ arguments = params['arguments'] || {}
325
+
326
+ return send_error(-32_602, 'Invalid params: missing tool name', id) unless tool_name
327
+
328
+ tool = @tools[tool_name]
329
+ return send_error(-32_602, "Tool not found: #{tool_name}", id) unless tool
330
+
331
+ begin
332
+ # Convert string keys to symbols for Ruby
333
+ symbolized_args = symbolize_keys(arguments)
334
+
335
+ tool_instance = tool.new(headers: headers)
336
+ authorized = tool_instance.authorized?(**symbolized_args)
337
+
338
+ return send_error(-32_602, 'Unauthorized', id) unless authorized
339
+
340
+ result, metadata = tool_instance.call_with_schema_validation!(**symbolized_args)
341
+
342
+ # Format and send the result
343
+ send_formatted_result(result, id, metadata)
344
+ rescue FastMcp::Tool::InvalidArgumentsError => e
345
+ @logger.error("Invalid arguments for tool #{tool_name}: #{e.message}")
346
+ send_error_result(e.message, id)
347
+ rescue StandardError => e
348
+ @logger.error("Error calling tool #{tool_name}: #{e.message}")
349
+ send_error_result("#{e.message}, #{e.backtrace.join("\n")}", id)
350
+ end
351
+ end
352
+
353
+ # Format and send successful result
354
+ def send_formatted_result(result, id, metadata)
355
+ # Check if the result is already in the expected format
356
+ if result.is_a?(Hash) && result.key?(:content)
357
+ send_result(result, id, metadata: metadata)
358
+ else
359
+ # Format the result according to the MCP specification
360
+ formatted_result = {
361
+ content: [{ type: 'text', text: result.to_s }],
362
+ isError: false
363
+ }
364
+
365
+ send_result(formatted_result, id, metadata: metadata)
366
+ end
367
+ end
368
+
369
+ # Format and send error result
370
+ def send_error_result(message, id)
371
+ # Format error according to the MCP specification
372
+ error_result = {
373
+ content: [{ type: 'text', text: "Error: #{message}" }],
374
+ isError: true
375
+ }
376
+
377
+ send_result(error_result, id)
378
+ end
379
+
380
+ # Handle resources/list request
381
+ def handle_resources_list(id)
382
+ resources_list = @resources.select(&:non_templated?).map(&:metadata)
383
+
384
+ send_result({ resources: resources_list }, id)
385
+ end
386
+
387
+ # Handle resources/templates/list request
388
+ def handle_resources_templates_list(id)
389
+ # Collect templated resources
390
+ templated_resources_list = @resources.select(&:templated?).map(&:metadata)
391
+
392
+ send_result({ resourceTemplates: templated_resources_list }, id)
393
+ end
394
+
395
+ # Handle resources/subscribe request
396
+ def handle_resources_subscribe(params, id)
397
+ return unless @client_initialized
398
+
399
+ uri = params['uri']
400
+
401
+ unless uri
402
+ send_error(-32_602, 'Invalid params: missing resource URI', id)
403
+ return
404
+ end
405
+
406
+ resource = @resources.find { |r| r.match(uri) }
407
+ return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
408
+
409
+ # Add to subscriptions
410
+ @resource_subscriptions[uri] ||= []
411
+ @resource_subscriptions[uri] << id
412
+
413
+ send_result({ subscribed: true }, id)
414
+ end
415
+
416
+ # Handle resources/unsubscribe request
417
+ def handle_resources_unsubscribe(params, id)
418
+ return unless @client_initialized
419
+
420
+ uri = params['uri']
421
+
422
+ unless uri
423
+ send_error(-32_602, 'Invalid params: missing resource URI', id)
424
+ return
425
+ end
426
+
427
+ # Remove from subscriptions
428
+ if @resource_subscriptions.key?(uri)
429
+ @resource_subscriptions[uri].delete(id)
430
+ @resource_subscriptions.delete(uri) if @resource_subscriptions[uri].empty?
431
+ end
432
+
433
+ send_result({ unsubscribed: true }, id)
434
+ end
435
+
436
+ # Notify clients about resource list changes
437
+ def notify_resource_list_changed
438
+ return unless @client_initialized
439
+
440
+ notification = {
441
+ jsonrpc: '2.0',
442
+ method: 'notifications/resources/listChanged',
443
+ params: {}
444
+ }
445
+
446
+ @transport.send_message(notification)
447
+ end
448
+
449
+ # Send a JSON-RPC result response
450
+ def send_result(result, id, metadata: {})
451
+ result[:_meta] = metadata if metadata.is_a?(Hash) && !metadata.empty?
452
+
453
+ response = {
454
+ jsonrpc: '2.0',
455
+ id: id,
456
+ result: result
457
+ }
458
+
459
+ @logger.info("Sending result: #{response.inspect}")
460
+ send_response(response)
461
+ end
462
+
463
+ # Send a JSON-RPC error response
464
+ def send_error(code, message, id = nil)
465
+ response = {
466
+ jsonrpc: '2.0',
467
+ error: {
468
+ code: code,
469
+ message: message
470
+ },
471
+ id: id
472
+ }
473
+
474
+ send_response(response)
475
+ end
476
+
477
+ # Send a JSON-RPC response
478
+ def send_response(response)
479
+ if @transport
480
+ @logger.debug("Sending response: #{response.inspect}")
481
+ @transport.send_message(response)
482
+ else
483
+ @logger.warn("No transport available to send response: #{response.inspect}")
484
+ @logger.warn("Transport: #{@transport.inspect}, transport_klass: #{@transport_klass.inspect}")
485
+ end
486
+ end
487
+
488
+ # Helper method to convert string keys to symbols
489
+ def symbolize_keys(hash)
490
+ return hash unless hash.is_a?(Hash)
491
+
492
+ hash.each_with_object({}) do |(key, value), result|
493
+ new_key = key.is_a?(String) ? key.to_sym : key
494
+ new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
495
+ result[new_key] = new_value
496
+ end
497
+ end
498
+ end
499
+ end
@@ -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