fast-mcp 0.1.0 → 1.1.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.
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators/base'
4
+
5
+ module FastMcp
6
+ module Generators
7
+ class InstallGenerator < Rails::Generators::Base
8
+ source_root File.expand_path('templates', __dir__)
9
+
10
+ desc 'Creates a FastMcp initializer for Rails applications'
11
+
12
+ def copy_initializer
13
+ template 'fast_mcp_initializer.rb', 'config/initializers/fast_mcp.rb'
14
+ end
15
+
16
+ def create_directories
17
+ empty_directory 'app/tools'
18
+ empty_directory 'app/resources'
19
+ end
20
+
21
+ def copy_application_tool
22
+ template 'application_tool.rb', 'app/tools/application_tool.rb'
23
+ end
24
+
25
+ def copy_application_resource
26
+ template 'application_resource.rb', 'app/resources/application_resource.rb'
27
+ end
28
+
29
+ def copy_sample_tool
30
+ template 'sample_tool.rb', 'app/tools/sample_tool.rb'
31
+ end
32
+
33
+ def copy_sample_resource
34
+ template 'sample_resource.rb', 'app/resources/sample_resource.rb'
35
+ end
36
+
37
+ def display_post_install_message
38
+ say "\n========================================================="
39
+ say 'FastMcp was successfully installed! 🎉'
40
+ say "=========================================================\n"
41
+ say 'You can now create:'
42
+ say ' • Tools in app/tools/'
43
+ say ' • Resources in app/resources/'
44
+ say "\n"
45
+ say 'Check config/initializers/fast_mcp.rb to configure the middleware.'
46
+ say "=========================================================\n"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationResource < ActionResource::Base
4
+ # write your custom logic to be shared across all resources here
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationTool < ActionTool::Base
4
+ # write your custom logic to be shared across all tools here
5
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # FastMcp - Model Context Protocol for Rails
4
+ # This initializer sets up the MCP middleware in your Rails application.
5
+ #
6
+ # In Rails applications, you can use:
7
+ # - ActionTool::Base as an alias for FastMcp::Tool
8
+ # - ActionResource::Base as an alias for FastMcp::Resource
9
+ #
10
+ # All your tools should inherit from ApplicationTool which already uses ActionTool::Base,
11
+ # and all your resources should inherit from ApplicationResource which uses ActionResource::Base.
12
+
13
+ # Mount the MCP middleware in your Rails application
14
+ # You can customize the options below to fit your needs.
15
+ require 'fast_mcp'
16
+
17
+ FastMcp.mount_in_rails(
18
+ Rails.application,
19
+ name: Rails.application.class.module_parent_name.underscore.dasherize,
20
+ version: '1.0.0',
21
+ path_prefix: '/mcp', # This is the default path prefix
22
+ messages_route: 'messages', # This is the default route for the messages endpoint
23
+ sse_route: 'sse' # This is the default route for the SSE endpoint
24
+ # Add allowed origins below, it defaults to Rails.application.config.hosts
25
+ # allowed_origins: ['localhost', '127.0.0.1', 'example.com', /.*\.example\.com/],
26
+ # authenticate: true, # Uncomment to enable authentication
27
+ # auth_token: 'your-token', # Required if authenticate: true
28
+ ) do |server|
29
+ Rails.application.config.after_initialize do
30
+ # FastMcp will automatically discover and register:
31
+ # - All classes that inherit from ApplicationTool (which uses ActionTool::Base)
32
+ # - All classes that inherit from ApplicationResource (which uses ActionResource::Base)
33
+ server.register_tools(*ApplicationTool.descendants)
34
+ server.register_resources(*ApplicationResource.descendants)
35
+ # alternatively, you can register tools and resources manually:
36
+ # server.register_tool(MyTool)
37
+ # server.register_resource(MyResource)
38
+ end
39
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SampleResource < ApplicationResource
4
+ uri 'examples/users'
5
+ resource_name 'Users'
6
+ description 'A user resource for demonstration'
7
+ mime_type 'application/json'
8
+
9
+ def content
10
+ JSON.generate(User.all.as_json)
11
+ end
12
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SampleTool < ApplicationTool
4
+ description 'Greet a user'
5
+
6
+ arguments do
7
+ required(:id).filled(:integer).description('ID of the user to greet')
8
+ optional(:prefix).filled(:string).description('Prefix to add to the greeting')
9
+ end
10
+
11
+ def call(id:, prefix: 'Hey')
12
+ user = User.find(id)
13
+
14
+ "#{prefix} #{user.first_name} !"
15
+ end
16
+ end
data/lib/mcp/logger.rb CHANGED
@@ -1,33 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # This class is not used yet.
4
- module MCP
4
+ module FastMcp
5
5
  class Logger < Logger
6
- def initialize
6
+ def initialize(transport: :stdio)
7
7
  @client_initialized = false
8
- @transport = nil
8
+ @transport = transport
9
9
 
10
- super($stdout)
10
+ # we don't want to log to stdout if we're using the stdio transport
11
+ super($stdout) unless stdio_transport?
11
12
  end
12
13
 
13
14
  attr_accessor :transport, :client_initialized
14
-
15
- def client_initialized?
16
- client_initialized
17
- end
15
+ alias client_initialized? client_initialized
18
16
 
19
17
  def stdio_transport?
20
18
  transport == :stdio
21
19
  end
22
20
 
21
+ def add(severity, message = nil, progname = nil, &block)
22
+ return if stdio_transport? # we don't want to log to stdout if we're using the stdio transport
23
+
24
+ # TODO: implement logging as the specification requires
25
+ super
26
+ end
27
+
23
28
  def rack_transport?
24
29
  transport == :rack
25
30
  end
26
-
27
- # def add(severity, message = nil, progname = nil, &block)
28
- # # return unless client_initialized? && rack_transport?
29
-
30
- # super
31
- # end
32
31
  end
33
32
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'fileutils'
5
+ require_relative '../mcp/server'
6
+
7
+ # Create ActionTool and ActionResource modules at load time
8
+ unless defined?(ActionTool)
9
+ module ::ActionTool
10
+ Base = FastMcp::Tool
11
+ end
12
+ end
13
+
14
+ unless defined?(ActionResource)
15
+ module ::ActionResource
16
+ Base = FastMcp::Resource
17
+ end
18
+ end
19
+
20
+ module FastMcp
21
+ # Railtie for integrating Fast MCP with Rails applications
22
+ class Railtie < Rails::Railtie
23
+ # Add tools and resources directories to autoload paths
24
+ initializer 'fast_mcp.setup_autoload_paths' do |app|
25
+ app.config.autoload_paths += %W[
26
+ #{app.root}/app/tools
27
+ #{app.root}/app/resources
28
+ ]
29
+ end
30
+
31
+ # Auto-register all tools and resources after the application is fully loaded
32
+ config.after_initialize do
33
+ # Load all files in app/tools and app/resources directories
34
+ Dir[Rails.root.join('app', 'tools', '**', '*.rb')].each { |f| require f }
35
+ Dir[Rails.root.join('app', 'resources', '**', '*.rb')].each { |f| require f }
36
+ end
37
+
38
+ # Add rake tasks
39
+ rake_tasks do
40
+ # Path to the tasks directory in the gem
41
+ path = File.expand_path('../tasks', __dir__)
42
+ Dir.glob("#{path}/**/*.rake").each { |f| load f }
43
+ end
44
+ end
45
+ end
data/lib/mcp/resource.rb CHANGED
@@ -5,11 +5,13 @@ require 'base64'
5
5
  require 'mime/types'
6
6
  require 'singleton'
7
7
 
8
- module MCP
8
+ module FastMcp
9
9
  # Resource class for MCP Resources feature
10
10
  # Represents a resource that can be exposed to clients
11
11
  class Resource
12
12
  class << self
13
+ attr_accessor :server
14
+
13
15
  # Define URI for this resource
14
16
  # @param value [String, nil] The URI for this resource
15
17
  # @return [String] The URI for this resource
@@ -74,7 +76,7 @@ module MCP
74
76
  end
75
77
 
76
78
  # Override content method to load from file
77
- define_method :default_content do
79
+ define_method :content do
78
80
  if binary?
79
81
  File.binread(file_path)
80
82
  else
@@ -87,13 +89,6 @@ module MCP
87
89
 
88
90
  include Singleton
89
91
 
90
- attr_accessor :content
91
-
92
- # Initialize a resource singleton instance
93
- def initialize
94
- @content = default_content
95
- end
96
-
97
92
  # URI of the resource - delegates to class method
98
93
  # @return [String, nil] The URI for this resource
99
94
  def uri
@@ -120,7 +115,7 @@ module MCP
120
115
 
121
116
  # Method to be overridden by subclasses to dynamically generate content
122
117
  # @return [String, nil] Generated content for this resource
123
- def default_content
118
+ def content
124
119
  raise NotImplementedError, 'Subclasses must implement content'
125
120
  end
126
121
 
data/lib/mcp/server.rb CHANGED
@@ -9,9 +9,9 @@ require_relative 'transports/rack_transport'
9
9
  require_relative 'transports/authenticated_rack_transport'
10
10
  require_relative 'logger'
11
11
 
12
- module MCP
12
+ module FastMcp
13
13
  class Server
14
- attr_reader :name, :version, :tools, :resources, :logger, :transport, :capabilities
14
+ attr_reader :name, :version, :tools, :resources, :capabilities
15
15
 
16
16
  DEFAULT_CAPABILITIES = {
17
17
  resources: {
@@ -23,7 +23,7 @@ module MCP
23
23
  }
24
24
  }.freeze
25
25
 
26
- def initialize(name:, version:, logger: MCP::Logger.new, capabilities: {})
26
+ def initialize(name:, version:, logger: FastMcp::Logger.new, capabilities: {})
27
27
  @name = name
28
28
  @version = version
29
29
  @tools = {}
@@ -32,13 +32,17 @@ module MCP
32
32
  @logger = logger
33
33
  @logger.level = Logger::INFO
34
34
  @request_id = 0
35
+ @transport_klass = nil
35
36
  @transport = nil
36
37
  @capabilities = DEFAULT_CAPABILITIES.dup
37
38
 
38
39
  # Merge with provided capabilities
39
40
  @capabilities.merge!(capabilities) if capabilities.is_a?(Hash)
40
41
  end
42
+ attr_accessor :transport, :transport_klass, :logger
41
43
 
44
+ # Register multiple tools at once
45
+ # @param tools [Array<Tool>] Tools to register
42
46
  def register_tools(*tools)
43
47
  tools.each do |tool|
44
48
  register_tool(tool)
@@ -49,6 +53,7 @@ module MCP
49
53
  def register_tool(tool)
50
54
  @tools[tool.tool_name] = tool
51
55
  @logger.info("Registered tool: #{tool.tool_name}")
56
+ tool.server = self
52
57
  end
53
58
 
54
59
  # Register multiple resources at once
@@ -63,7 +68,7 @@ module MCP
63
68
  def register_resource(resource)
64
69
  @resources[resource.uri] = resource
65
70
  @logger.info("Registered resource: #{resource.name} (#{resource.uri})")
66
-
71
+ resource.server = self
67
72
  # Notify subscribers about the list change
68
73
  notify_resource_list_changed if @transport
69
74
 
@@ -93,7 +98,8 @@ module MCP
93
98
  @logger.info("Available resources: #{@resources.keys.join(', ')}")
94
99
 
95
100
  # Use STDIO transport by default
96
- @transport = MCP::Transports::StdioTransport.new(self, logger: @logger)
101
+ @transport_klass = FastMcp::Transports::StdioTransport
102
+ @transport = @transport_klass.new(self, logger: @logger)
97
103
  @transport.start
98
104
  end
99
105
 
@@ -104,7 +110,8 @@ module MCP
104
110
  @logger.info("Available resources: #{@resources.keys.join(', ')}")
105
111
 
106
112
  # Use Rack transport
107
- @transport = MCP::Transports::RackTransport.new(self, app, options.merge(logger: @logger))
113
+ transport_klass = FastMcp::Transports::RackTransport
114
+ @transport = transport_klass.new(app, self, options.merge(logger: @logger))
108
115
  @transport.start
109
116
 
110
117
  # Return the transport as middleware
@@ -117,7 +124,8 @@ module MCP
117
124
  @logger.info("Available resources: #{@resources.keys.join(', ')}")
118
125
 
119
126
  # Use Rack transport
120
- @transport = MCP::Transports::AuthenticatedRackTransport.new(self, app, options.merge(logger: @logger))
127
+ transport_klass = FastMcp::Transports::AuthenticatedRackTransport
128
+ @transport = transport_klass.new(app, self, options.merge(logger: @logger))
121
129
  @transport.start
122
130
 
123
131
  # Return the transport as middleware
@@ -180,47 +188,6 @@ module MCP
180
188
  end
181
189
  end
182
190
 
183
- # Register a callback for resource updates
184
- def on_resource_update(&block)
185
- @resource_update_callbacks ||= []
186
- callback_id = SecureRandom.uuid
187
- @resource_update_callbacks << { id: callback_id, callback: block }
188
- callback_id
189
- end
190
-
191
- # Remove a resource update callback
192
- def remove_resource_update_callback(callback_id)
193
- @resource_update_callbacks ||= []
194
- @resource_update_callbacks.reject! { |cb| cb[:id] == callback_id }
195
- end
196
-
197
- # Update a resource and notify subscribers
198
- def update_resource(uri, content)
199
- return false unless @resources.key?(uri)
200
-
201
- resource = @resources[uri]
202
- resource.instance.content = content
203
-
204
- # Notify subscribers
205
- notify_resource_updated(uri) if @transport && @resource_subscriptions.key?(uri)
206
-
207
- # Notify resource update callbacks
208
- if @resource_update_callbacks && !@resource_update_callbacks.empty?
209
- @resource_update_callbacks.each do |cb|
210
- cb[:callback].call(
211
- {
212
- uri: uri,
213
- name: resource.name,
214
- mime_type: resource.mime_type,
215
- content: content
216
- }
217
- )
218
- end
219
- end
220
-
221
- true
222
- end
223
-
224
191
  # Read a resource directly
225
192
  def read_resource(uri)
226
193
  resource = @resources[uri]
@@ -229,13 +196,32 @@ module MCP
229
196
  resource
230
197
  end
231
198
 
199
+ # Notify subscribers about a resource update
200
+ def notify_resource_updated(uri)
201
+ @logger.warn("Notifying subscribers about resource update: #{uri}, #{@resource_subscriptions.inspect}")
202
+ return unless @client_initialized && @resource_subscriptions.key?(uri)
203
+
204
+ resource = @resources[uri]
205
+ notification = {
206
+ jsonrpc: '2.0',
207
+ method: 'notifications/resources/updated',
208
+ params: {
209
+ uri: uri,
210
+ name: resource.name,
211
+ mimeType: resource.mime_type
212
+ }
213
+ }
214
+
215
+ @transport.send_message(notification)
216
+ end
217
+
232
218
  private
233
219
 
234
220
  PROTOCOL_VERSION = '2024-11-05'
235
221
 
236
222
  def handle_initialize(params, id)
237
- params['protocolVersion']
238
- client_capabilities = params['capabilities'] || {}
223
+ # Store client capabilities for later use
224
+ @client_capabilities = params['capabilities'] || {}
239
225
  client_info = params['clientInfo'] || {}
240
226
 
241
227
  # Log client information
@@ -254,9 +240,6 @@ module MCP
254
240
 
255
241
  @logger.info("Server response: #{response.inspect}")
256
242
 
257
- # Store client capabilities for later use
258
- @client_capabilities = client_capabilities
259
-
260
243
  send_result(response, id)
261
244
  end
262
245
 
@@ -290,8 +273,9 @@ module MCP
290
273
  # The client is now ready for normal operation
291
274
  # No response needed for notifications
292
275
  @client_initialized = true
293
- @logger.set_client_initialized
294
276
  @logger.info('Client initialized, beginning normal operation')
277
+
278
+ send_result({}, nil)
295
279
  end
296
280
 
297
281
  # Handle tools/list request
@@ -324,7 +308,7 @@ module MCP
324
308
 
325
309
  # Format and send the result
326
310
  send_formatted_result(result, id)
327
- rescue MCP::Tool::InvalidArgumentsError => e
311
+ rescue FastMcp::Tool::InvalidArgumentsError => e
328
312
  @logger.error("Invalid arguments for tool #{tool_name}: #{e.message}")
329
313
  send_error_result(e.message, id)
330
314
  rescue StandardError => e
@@ -410,24 +394,6 @@ module MCP
410
394
  send_result({ unsubscribed: true }, id)
411
395
  end
412
396
 
413
- # Notify subscribers about a resource update
414
- def notify_resource_updated(uri)
415
- return unless @client_initialized && @resource_subscriptions.key?(uri)
416
-
417
- resource = @resources[uri]
418
- notification = {
419
- jsonrpc: '2.0',
420
- method: 'notifications/resources/updated',
421
- params: {
422
- uri: uri,
423
- name: resource.name,
424
- mimeType: resource.mime_type
425
- }
426
- }
427
-
428
- @transport.send_message(notification)
429
- end
430
-
431
397
  # Notify clients about resource list changes
432
398
  def notify_resource_list_changed
433
399
  return unless @client_initialized
@@ -470,10 +436,10 @@ module MCP
470
436
  def send_response(response)
471
437
  if @transport
472
438
  @logger.info("Sending response: #{response.inspect}")
473
- @logger.info("Transport: #{@transport.inspect}")
474
439
  @transport.send_message(response)
475
440
  else
476
441
  @logger.warn("No transport available to send response: #{response.inspect}")
442
+ @logger.warn("Transport: #{@transport.inspect}, transport_klass: #{@transport_klass.inspect}")
477
443
  end
478
444
  end
479
445
 
data/lib/mcp/tool.rb CHANGED
@@ -62,12 +62,14 @@ module Dry
62
62
  end
63
63
  end
64
64
 
65
- module MCP
65
+ module FastMcp
66
66
  # Main Tool class that represents an MCP Tool
67
67
  class Tool
68
68
  class InvalidArgumentsError < StandardError; end
69
69
 
70
70
  class << self
71
+ attr_accessor :server
72
+
71
73
  def arguments(&block)
72
74
  @input_schema = Dry::Schema.JSON(&block)
73
75
  end
@@ -100,6 +102,10 @@ module MCP
100
102
  end
101
103
  end
102
104
 
105
+ def notify_resource_updated(uri)
106
+ self.class.server.notify_resource_updated(uri)
107
+ end
108
+
103
109
  def call_with_schema_validation!(**args)
104
110
  arg_validation = self.class.input_schema.call(args)
105
111
  raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any?
@@ -788,7 +794,7 @@ module MCP
788
794
  end
789
795
 
790
796
  # Example
791
- # class ExampleTool < MCP::Tool
797
+ # class ExampleTool < FastMcp::Tool
792
798
  # description 'An example tool'
793
799
 
794
800
  # arguments do
@@ -2,10 +2,10 @@
2
2
 
3
3
  require_relative 'rack_transport'
4
4
 
5
- module MCP
5
+ module FastMcp
6
6
  module Transports
7
7
  class AuthenticatedRackTransport < RackTransport
8
- def initialize(server, app, options = {})
8
+ def initialize(app, server, options = {})
9
9
  super
10
10
 
11
11
  @auth_token = options[:auth_token]
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module MCP
3
+ module FastMcp
4
4
  module Transports
5
5
  # Base class for all MCP transports
6
6
  # This defines the interface that all transports must implement