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/fast_mcp.rb ADDED
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Fast MCP - A Ruby Implementation of the Model Context Protocol (Server-side)
4
+ # https://modelcontextprotocol.io/introduction
5
+
6
+ # Define the MCP module
7
+ module FastMcp
8
+ class << self
9
+ attr_accessor :server
10
+ end
11
+ end
12
+
13
+ # Require the core components
14
+ require_relative 'mcp/tool'
15
+ require_relative 'mcp/server'
16
+ require_relative 'mcp/resource'
17
+ require_relative 'mcp/railtie' if defined?(Rails::Railtie)
18
+
19
+ # Load generators if Rails is available
20
+ require_relative 'generators/fast_mcp/install/install_generator' if defined?(Rails::Generators)
21
+
22
+ # Require all transport files
23
+ require_relative 'mcp/transports/base_transport'
24
+ Dir[File.join(File.dirname(__FILE__), 'mcp/transports', '*.rb')].each do |file|
25
+ require file
26
+ end
27
+
28
+ # Version information
29
+ require_relative 'mcp/version'
30
+
31
+ # Convenience method to create a Rack middleware
32
+ module FastMcp
33
+ # Create a Rack middleware for the MCP server
34
+ # @param app [#call] The Rack application
35
+ # @param options [Hash] Options for the middleware
36
+ # @option options [String] :name The name of the server
37
+ # @option options [String] :version The version of the server
38
+ # @option options [String] :path_prefix The path prefix for the MCP endpoints
39
+ # @option options [String] :messages_route The route for the messages endpoint
40
+ # @option options [String] :sse_route The route for the SSE endpoint
41
+ # @option options [Logger] :logger The logger to use
42
+ # @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
43
+ # @yield [server] A block to configure the server
44
+ # @yieldparam server [FastMcp::Server] The server to configure
45
+ # @return [#call] The Rack middleware
46
+ def self.rack_middleware(app, options = {})
47
+ name = options.delete(:name) || 'mcp-server'
48
+ version = options.delete(:version) || '1.0.0'
49
+ logger = options.delete(:logger) || Logger.new
50
+
51
+ server = FastMcp::Server.new(name: name, version: version, logger: logger)
52
+ yield server if block_given?
53
+
54
+ # Store the server in the Sinatra settings if available
55
+ app.settings.set(:mcp_server, server) if app.respond_to?(:settings) && app.settings.respond_to?(:mcp_server=)
56
+
57
+ # Store the server in the FastMcp module
58
+ self.server = server
59
+
60
+ server.start_rack(app, options)
61
+ end
62
+
63
+ # Create a Rack middleware for the MCP server with authentication
64
+ # @param app [#call] The Rack application
65
+ # @param options [Hash] Options for the middleware
66
+ # @option options [String] :name The name of the server
67
+ # @option options [String] :version The version of the server
68
+ # @option options [String] :auth_token The authentication token
69
+ # @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
70
+ # @yield [server] A block to configure the server
71
+ # @yieldparam server [FastMcp::Server] The server to configure
72
+ # @return [#call] The Rack middleware
73
+ def self.authenticated_rack_middleware(app, options = {})
74
+ name = options.delete(:name) || 'mcp-server'
75
+ version = options.delete(:version) || '1.0.0'
76
+ logger = options.delete(:logger) || Logger.new
77
+
78
+ server = FastMcp::Server.new(name: name, version: version, logger: logger)
79
+ yield server if block_given?
80
+
81
+ # Store the server in the FastMcp module
82
+ self.server = server
83
+
84
+ server.start_authenticated_rack(app, options)
85
+ end
86
+
87
+ # Register a tool with the MCP server
88
+ # @param tool [FastMcp::Tool] The tool to register
89
+ # @return [FastMcp::Tool] The registered tool
90
+ def self.register_tool(tool)
91
+ self.server ||= FastMcp::Server.new(name: 'mcp-server', version: '1.0.0')
92
+ self.server.register_tool(tool)
93
+ end
94
+
95
+ # Register multiple tools at once
96
+ # @param tools [Array<FastMcp::Tool>] The tools to register
97
+ # @return [Array<FastMcp::Tool>] The registered tools
98
+ def self.register_tools(*tools)
99
+ self.server ||= FastMcp::Server.new(name: 'mcp-server', version: '1.0.0')
100
+ self.server.register_tools(*tools)
101
+ end
102
+
103
+ # Register a resource with the MCP server
104
+ # @param resource [FastMcp::Resource] The resource to register
105
+ # @return [FastMcp::Resource] The registered resource
106
+ def self.register_resource(resource)
107
+ self.server ||= FastMcp::Server.new(name: 'mcp-server', version: '1.0.0')
108
+ self.server.register_resource(resource)
109
+ end
110
+
111
+ # Register multiple resources at once
112
+ # @param resources [Array<FastMcp::Resource>] The resources to register
113
+ # @return [Array<FastMcp::Resource>] The registered resources
114
+ def self.register_resources(*resources)
115
+ self.server ||= FastMcp::Server.new(name: 'mcp-server', version: '1.0.0')
116
+ self.server.register_resources(*resources)
117
+ end
118
+
119
+ # Mount the MCP middleware in a Rails application
120
+ # @param app [Rails::Application] The Rails application
121
+ # @param options [Hash] Options for the middleware
122
+ # @option options [String] :name The name of the server
123
+ # @option options [String] :version The version of the server
124
+ # @option options [String] :path_prefix The path prefix for the MCP endpoints
125
+ # @option options [String] :messages_route The route for the messages endpoint
126
+ # @option options [String] :sse_route The route for the SSE endpoint
127
+ # @option options [Logger] :logger The logger to use
128
+ # @option options [Boolean] :authenticate Whether to use authentication
129
+ # @option options [String] :auth_token The authentication token
130
+ # @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
131
+ # @yield [server] A block to configure the server
132
+ # @yieldparam server [FastMcp::Server] The server to configure
133
+ # @return [#call] The Rack middleware
134
+ def self.mount_in_rails(app, options = {})
135
+ # Default options
136
+ name = options.delete(:name) || app.class.module_parent_name.underscore.dasherize
137
+ version = options.delete(:version) || '1.0.0'
138
+ logger = options[:logger] || Rails.logger
139
+ path_prefix = options.delete(:path_prefix) || '/mcp'
140
+ messages_route = options.delete(:messages_route) || 'messages'
141
+ sse_route = options.delete(:sse_route) || 'sse'
142
+ authenticate = options.delete(:authenticate) || false
143
+ allowed_origins = options[:allowed_origins] || default_rails_allowed_origins(app)
144
+ allowed_ips = options[:allowed_ips] || FastMcp::Transports::RackTransport::DEFAULT_ALLOWED_IPS
145
+
146
+ options[:localhost_only] = Rails.env.local? if options[:localhost_only].nil?
147
+ options[:allowed_ips] = allowed_ips
148
+ options[:logger] = logger
149
+ options[:allowed_origins] = allowed_origins
150
+
151
+ # Create or get the server
152
+ self.server = FastMcp::Server.new(name: name, version: version, logger: logger)
153
+ yield self.server if block_given?
154
+
155
+ # Choose the right middleware based on authentication
156
+ self.server.transport_klass = if authenticate
157
+ FastMcp::Transports::AuthenticatedRackTransport
158
+ else
159
+ FastMcp::Transports::RackTransport
160
+ end
161
+
162
+ # Insert the middleware in the Rails middleware stack
163
+ app.middleware.use(
164
+ self.server.transport_klass,
165
+ self.server,
166
+ options.merge(path_prefix: path_prefix, messages_route: messages_route, sse_route: sse_route)
167
+ )
168
+ end
169
+
170
+ def self.default_rails_allowed_origins(rail_app)
171
+ hosts = rail_app.config.hosts
172
+
173
+ hosts.map do |host|
174
+ if host.is_a?(String) && host.start_with?('.')
175
+ # Convert .domain to domain and *.domain
176
+ host_without_dot = host[1..]
177
+ [host_without_dot, Regexp.new(".*\.#{host_without_dot}")] # rubocop:disable Style/RedundantStringEscape
178
+ else
179
+ host
180
+ end
181
+ end.flatten.compact
182
+ end
183
+
184
+ # Notify the server that a resource has been updated
185
+ # @param uri [String] The URI of the resource
186
+ def self.notify_resource_updated(uri)
187
+ self.server.notify_resource_updated(uri)
188
+ end
189
+ end
@@ -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,42 @@
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', '[::1]', 'example.com', /.*\.example\.com/],
26
+ # localhost_only: true, # Set to false to allow connections from other hosts
27
+ # whitelist specific ips to if you want to run on localhost and allow connections from other IPs
28
+ # allowed_ips: ['127.0.0.1', '::1']
29
+ # authenticate: true, # Uncomment to enable authentication
30
+ # auth_token: 'your-token', # Required if authenticate: true
31
+ ) do |server|
32
+ Rails.application.config.after_initialize do
33
+ # FastMcp will automatically discover and register:
34
+ # - All classes that inherit from ApplicationTool (which uses ActionTool::Base)
35
+ # - All classes that inherit from ApplicationResource (which uses ActionResource::Base)
36
+ server.register_tools(*ApplicationTool.descendants)
37
+ server.register_resources(*ApplicationResource.descendants)
38
+ # alternatively, you can register tools and resources manually:
39
+ # server.register_tool(MyTool)
40
+ # server.register_resource(MyResource)
41
+ end
42
+ 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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class SampleTool < ApplicationTool
4
+ description 'Greet a user'
5
+
6
+ # Optional: Add annotations to provide hints about the tool's behavior
7
+ # annotations(
8
+ # title: 'User Greeting',
9
+ # read_only_hint: true, # This tool only reads data
10
+ # open_world_hint: false # This tool only accesses the local database
11
+ # )
12
+
13
+ arguments do
14
+ required(:id).filled(:integer).description('ID of the user to greet')
15
+ optional(:prefix).filled(:string).description('Prefix to add to the greeting')
16
+ end
17
+
18
+ def call(id:, prefix: 'Hey')
19
+ user = User.find(id)
20
+
21
+ "#{prefix} #{user.first_name} !"
22
+ end
23
+ end
data/lib/mcp/logger.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class is not used yet.
4
+ module FastMcp
5
+ class Logger < Logger
6
+ def initialize(transport: :stdio)
7
+ @client_initialized = false
8
+ @transport = transport
9
+
10
+ # we don't want to log to stdout if we're using the stdio transport
11
+ super($stdout) unless stdio_transport?
12
+ end
13
+
14
+ attr_accessor :transport, :client_initialized
15
+ alias client_initialized? client_initialized
16
+
17
+ def stdio_transport?
18
+ transport == :stdio
19
+ end
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
+
28
+ def rack_transport?
29
+ transport == :rack
30
+ end
31
+ end
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
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'base64'
5
+ require 'mime/types'
6
+ require 'addressable/template'
7
+
8
+ module FastMcp
9
+ # Resource class for MCP Resources feature
10
+ # Represents a resource that can be exposed to clients
11
+ class Resource
12
+ class << self
13
+ attr_accessor :server
14
+
15
+ # Define URI for this resource
16
+ # @param value [String, nil] The URI for this resource
17
+ # @return [String] The URI for this resource
18
+ def uri(value = nil)
19
+ @uri = value if value
20
+
21
+ @uri || (superclass.respond_to?(:uri) ? superclass.uri : nil)
22
+ end
23
+
24
+ # Variabilize the URI with the given params
25
+ # @param params [Hash] The parameters to variabilize the URI with
26
+ # @return [String] The variabilized URI
27
+ def variabilized_uri(params = {})
28
+ addressable_template.partial_expand(params).pattern
29
+ end
30
+
31
+ # Get the Addressable::Template for this resource
32
+ # @return [Addressable::Template] The Addressable::Template for this resource
33
+ def addressable_template
34
+ @addressable_template ||= Addressable::Template.new(uri)
35
+ end
36
+
37
+ # Get the template variables for this resource
38
+ # @return [Array] The template variables for this resource
39
+ def template_variables
40
+ addressable_template.variables
41
+ end
42
+
43
+ # Check if this resource has a templated URI
44
+ # @return [Boolean] true if the URI contains template parameters
45
+ def templated?
46
+ template_variables.any?
47
+ end
48
+
49
+ # Check if this resource has a non-templated URI
50
+ # @return [Boolean] true if the URI does not contain template parameters
51
+ def non_templated?
52
+ !templated?
53
+ end
54
+
55
+ # Match the given URI against the resource's addressable template
56
+ # @param uri [String] The URI to match
57
+ # @return [Addressable::Template::MatchData, nil] The match data if the URI matches, nil otherwise
58
+ def match(uri)
59
+ addressable_template.match(uri)
60
+ end
61
+
62
+ # Initialize a new instance from the given URI
63
+ # @param uri [String] The URI to initialize from
64
+ # @return [Resource] A new resource instance
65
+ def initialize_from_uri(uri)
66
+ new(params_from_uri(uri))
67
+ end
68
+
69
+ # Get the parameters from the given URI
70
+ # @param uri [String] The URI to get the parameters from
71
+ # @return [Hash] The parameters from the URI
72
+ def params_from_uri(uri)
73
+ match(uri).mapping.transform_keys(&:to_sym)
74
+ end
75
+
76
+ # Define name for this resource
77
+ # @param value [String, nil] The name for this resource
78
+ # @return [String] The name for this resource
79
+ def resource_name(value = nil)
80
+ @name = value if value
81
+ @name || (superclass.respond_to?(:resource_name) ? superclass.resource_name : nil)
82
+ end
83
+
84
+ alias original_name name
85
+ def name
86
+ return resource_name if resource_name
87
+
88
+ original_name
89
+ end
90
+
91
+ # Define description for this resource
92
+ # @param value [String, nil] The description for this resource
93
+ # @return [String] The description for this resource
94
+ def description(value = nil)
95
+ @description = value if value
96
+ @description || (superclass.respond_to?(:description) ? superclass.description : nil)
97
+ end
98
+
99
+ # Define MIME type for this resource
100
+ # @param value [String, nil] The MIME type for this resource
101
+ # @return [String] The MIME type for this resource
102
+ def mime_type(value = nil)
103
+ @mime_type = value if value
104
+ @mime_type || (superclass.respond_to?(:mime_type) ? superclass.mime_type : nil)
105
+ end
106
+
107
+ # Get the resource metadata (without content)
108
+ # @return [Hash] Resource metadata
109
+ def metadata
110
+ if templated?
111
+ {
112
+ uriTemplate: uri,
113
+ name: resource_name,
114
+ description: description,
115
+ mimeType: mime_type
116
+ }.compact
117
+ else
118
+ {
119
+ uri: uri,
120
+ name: resource_name,
121
+ description: description,
122
+ mimeType: mime_type
123
+ }.compact
124
+ end
125
+ end
126
+
127
+ # Load content from a file (class method)
128
+ # @param file_path [String] Path to the file
129
+ # @return [Resource] New resource instance with content loaded from file
130
+ def from_file(file_path, name: nil, description: nil)
131
+ file_uri = "file://#{File.absolute_path(file_path)}"
132
+ file_name = name || File.basename(file_path)
133
+
134
+ # Create a resource subclass on the fly
135
+ Class.new(self) do
136
+ uri file_uri
137
+ resource_name file_name
138
+ description description if description
139
+
140
+ # Auto-detect mime type
141
+ extension = File.extname(file_path)
142
+ unless extension.empty?
143
+ detected_types = MIME::Types.type_for(extension)
144
+ mime_type detected_types.first.to_s unless detected_types.empty?
145
+ end
146
+
147
+ # Override content method to load from file
148
+ define_method :content do
149
+ if binary?
150
+ File.binread(file_path)
151
+ else
152
+ File.read(file_path)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ # Initialize with instance variables
160
+ # @param params [Hash] The parameters for this resource instance
161
+ def initialize(params = {})
162
+ @params = params
163
+ end
164
+
165
+ # URI of the resource - delegates to class method
166
+ # @return [String, nil] The URI for this resource
167
+ def uri
168
+ self.class.uri
169
+ end
170
+
171
+ # Name of the resource - delegates to class method
172
+ # @return [String, nil] The name for this resource
173
+ def name
174
+ self.class.resource_name
175
+ end
176
+
177
+ # Description of the resource - delegates to class method
178
+ # @return [String, nil] The description for this resource
179
+ def description
180
+ self.class.description
181
+ end
182
+
183
+ # MIME type of the resource - delegates to class method
184
+ # @return [String, nil] The MIME type for this resource
185
+ def mime_type
186
+ self.class.mime_type
187
+ end
188
+
189
+ # Get parameters from the URI template
190
+ # @return [Hash] The parameters extracted from the URI
191
+ attr_reader :params
192
+
193
+ # Method to be overridden by subclasses to dynamically generate content
194
+ # @return [String, nil] Generated content for this resource
195
+ def content
196
+ raise NotImplementedError, 'Subclasses must implement content'
197
+ end
198
+
199
+ # Check if the resource is binary
200
+ # @return [Boolean] true if the resource is binary, false otherwise
201
+ def binary?
202
+ return false if mime_type.nil?
203
+
204
+ !(mime_type.start_with?('text/') ||
205
+ mime_type == 'application/json' ||
206
+ mime_type == 'application/xml' ||
207
+ mime_type == 'application/javascript')
208
+ end
209
+ end
210
+ end