fast-mcp 1.4.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50141727429ea7f39887b8a0f6ec2da8515cc3c35803a1c3e328287931bebcae
4
- data.tar.gz: 518a481ba94b59f339b4e4957c54971866a76835e54833fd408efdb12e97c0e8
3
+ metadata.gz: 1a99f230c0ff73568287d0812e53c266dffca1bd46de3c0308000d5658b5b9a4
4
+ data.tar.gz: 38fb4275c6cf123691ed02145687c70f5251be28e784f71b42a00c85b5fbee3d
5
5
  SHA512:
6
- metadata.gz: 76f1f6136bc4f1041021f5a3522f230ae1f044bb19e2e0d8f0871249aac222740b3907229db4b7d482812396f03d492cd6ea414206008f3737361e4346cbb5ca
7
- data.tar.gz: 1ac14395b264d76b826feb2612a0ceae18933c8b37695ba8e5c673c6c5b29045f241617a2196409267f1c4c58b1fef0b3ff9865d92fae759306a6b17e2795db2
6
+ metadata.gz: f3863d28ed426ef2274475d0ba39630dcc673ef91a416a3ae4b76833cdb87dac3d40d6f01806dddf32572e0d0fa7ab5f5b0a5dd0c5619c6c73202dcfbfa4ccf5
7
+ data.tar.gz: 246ee983dc56d22708261681f5df1f0150e1376f19a18e373024ed5ecd12c1a54f592998a9738fb8d0aabb7194fe937ce98304a145f7bbf12efc1555d0b7d794
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2025-06-01
9
+ ### Added
10
+ - Handle filtering tools and resources [#85 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/85)
11
+ - Support for resource templates 🥳 Big thanks to @danielcooper for the contribution [#84 co-authored by @danielcooper and @yjacquin](https://github.com/yjacquin/fast-mcp/pull/84)
12
+ - Possibility to authorize requests before tool calls [#79 @EuanEdgar](https://github.com/yjacquin/fast-mcp/pull/79)
13
+ - Possibility to read request headers in tool calls [#78 @EuanEdgar](https://github.com/yjacquin/fast-mcp/pull/78)
14
+ ### Changed
15
+ - Bump Dependencies [#86 @aothelal](https://github.com/yjacquin/fast-mcp/pull/86)
16
+ - ⚠️ Resources are now stateless, meaning that in-memory resources won't work anymore, they require an external data source such as database, file to read and write too, etc. This was needed for a refactoring of the resource class for the [resource template PR](https://github.com/yjacquin/fast-mcp/pull/84)
17
+ ### Fixed
18
+ - Stop overriding log level to info [#91 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/91)
19
+ - Properly handle ping request responses from clients [#89 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/89)
20
+ - Add Thread Safety to RackTransport sse_clients [#82 @Kevin-K](https://github.com/yjacquin/fast-mcp/pull/82)
21
+
8
22
  ## [1.4.0] - 2025-05-10
9
23
  ### Added
10
24
  - Conditionnally hidden properties for tool calls (#70) [#70 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/70)
data/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  <a href="https://github.com/yjacquin/fast-mcp/workflows/CI/badge.svg"><img src="https://github.com/yjacquin/fast-mcp/workflows/CI/badge.svg" alt="CI Status" /></a>
11
11
  <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
12
12
  <a href="code_of_conduct.md"><img src="https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg" alt="Contributor Covenant" /></a>
13
- <a href="https://discord.gg/ayu8ZKvz"><img src = "https://dcbadge.limes.pink/api/server/https://discord.gg/ayu8ZKvz?style=flat" alt="Discord invite link" /></a>
13
+ <a href="https://discord.gg/9HHfAtY3HF"><img src = "https://dcbadge.limes.pink/api/server/https://discord.gg/9HHfAtY3HF?style=flat" alt="Discord invite link" /></a>
14
14
  </p>
15
15
 
16
16
  ## 🌟 Interface your Servers with LLMs in minutes !
@@ -32,6 +32,7 @@ Fast MCP solves all these problems by providing a clean, Ruby-focused implementa
32
32
  - 🧩 **Framework Integration** - Works seamlessly with Rails, Sinatra or any Rack app.
33
33
  - 🔒 **Authentication Support** - Secure your AI-powered endpoints with ease
34
34
  - 🚀 **Real-time Updates** - Subscribe to changes for interactive applications
35
+ - 🎯 **Dynamic Filtering** - Control tool/resource access based on request context (permissions, API versions, etc.)
35
36
 
36
37
 
37
38
  ## 💎 What Makes FastMCP Great
@@ -65,7 +66,7 @@ server.register_tool(CreateUserTool)
65
66
 
66
67
  # Share data resources with AI models by inheriting from FastMcp::Resource
67
68
  class PopularUsers < FastMcp::Resource
68
- uri "file://popular_users.json"
69
+ uri "myapp:///users/popular"
69
70
  resource_name "Popular Users"
70
71
  mime_type "application/json"
71
72
 
@@ -74,14 +75,59 @@ class PopularUsers < FastMcp::Resource
74
75
  end
75
76
  end
76
77
 
78
+ class User < FastMcp::Resource
79
+ uri "myapp:///users/{id}" # This is a resource template
80
+ resource_name "user"
81
+ mime_type "application/json"
82
+
83
+ def content
84
+ id = params[:id] # params are computed from the uri pattern
85
+
86
+ JSON.generate(User.find(id).as_json)
87
+ end
88
+ end
89
+
77
90
  # Register the resource with the server
78
- server.register_resource(PopularUsers)
91
+ server.register_resources(PopularUsers, User)
79
92
 
80
93
  # Accessing the resource through the server
81
94
  server.read_resource(PopularUsers.uri)
82
95
 
83
96
  # Notify the resource content has been updated to clients
84
- server.notify_resource_updated(PopularUsers.uri)
97
+ server.notify_resource_updated(PopularUsers.variabilized_uri)
98
+
99
+ # Notifiy the content of a resource from a template has been updated to clients
100
+ server.notify_resource_updated(User.variabilized_uri(id: 1))
101
+ ```
102
+
103
+ ### 🎯 Dynamic Tool Filtering
104
+
105
+ Control which tools and resources are available based on request context:
106
+
107
+ ```ruby
108
+ # Tag your tools for easy filtering
109
+ class AdminTool < FastMcp::Tool
110
+ tags :admin, :dangerous
111
+ description "Perform admin operations"
112
+
113
+ def call
114
+ # Admin only functionality
115
+ end
116
+ end
117
+
118
+ # Filter tools based on user permissions
119
+ server.filter_tools do |request, tools|
120
+ user_role = request.params['role']
121
+
122
+ case user_role
123
+ when 'admin'
124
+ tools # Admins see all tools
125
+ when 'user'
126
+ tools.reject { |t| t.tags.include?(:admin) }
127
+ else
128
+ tools.select { |t| t.tags.include?(:public) }
129
+ end
130
+ end
85
131
  ```
86
132
 
87
133
  ### 🚂 Fast Ruby on Rails implementation
@@ -300,7 +346,7 @@ Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md)
300
346
  |---------|--------|
301
347
  | ✅ **JSON-RPC 2.0** | Full implementation for communication |
302
348
  | ✅ **Tool Definition & Calling** | Define and call tools with rich argument types |
303
- | ✅ **Resource Management** | Create, read, update, and subscribe to resources |
349
+ | ✅ **Resource & Resource Templates Management** | Create, read, update, and subscribe to resources |
304
350
  | ✅ **Transport Options** | STDIO, HTTP, and SSE for flexible integration |
305
351
  | ✅ **Framework Integration** | Rails, Sinatra, Hanami, and any Rack-compatible framework |
306
352
  | ✅ **Authentication** | Secure your AI endpoints with token authentication |
@@ -353,6 +399,7 @@ FastMcp.authenticated_rack_middleware(app,
353
399
  - [📚 Resources](docs/resources.md)
354
400
  - [🛠️ Tools](docs/tools.md)
355
401
  - [🔒 Security](docs/security.md)
402
+ - [🎯 Dynamic Filtering](docs/filtering.md)
356
403
 
357
404
  ## 💻 Examples
358
405
 
data/lib/mcp/resource.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require 'json'
4
4
  require 'base64'
5
5
  require 'mime/types'
6
- require 'singleton'
6
+ require 'addressable/template'
7
7
 
8
8
  module FastMcp
9
9
  # Resource class for MCP Resources feature
@@ -17,9 +17,62 @@ module FastMcp
17
17
  # @return [String] The URI for this resource
18
18
  def uri(value = nil)
19
19
  @uri = value if value
20
+
20
21
  @uri || (superclass.respond_to?(:uri) ? superclass.uri : nil)
21
22
  end
22
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
+
23
76
  # Define name for this resource
24
77
  # @param value [String, nil] The name for this resource
25
78
  # @return [String] The name for this resource
@@ -28,6 +81,13 @@ module FastMcp
28
81
  @name || (superclass.respond_to?(:resource_name) ? superclass.resource_name : nil)
29
82
  end
30
83
 
84
+ alias original_name name
85
+ def name
86
+ return resource_name if resource_name
87
+
88
+ original_name
89
+ end
90
+
31
91
  # Define description for this resource
32
92
  # @param value [String, nil] The description for this resource
33
93
  # @return [String] The description for this resource
@@ -47,12 +107,21 @@ module FastMcp
47
107
  # Get the resource metadata (without content)
48
108
  # @return [Hash] Resource metadata
49
109
  def metadata
50
- {
51
- uri: uri,
52
- name: resource_name,
53
- description: description,
54
- mimeType: mime_type
55
- }.compact
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
56
125
  end
57
126
 
58
127
  # Load content from a file (class method)
@@ -87,7 +156,11 @@ module FastMcp
87
156
  end
88
157
  end
89
158
 
90
- include Singleton
159
+ # Initialize with instance variables
160
+ # @param params [Hash] The parameters for this resource instance
161
+ def initialize(params = {})
162
+ @params = params
163
+ end
91
164
 
92
165
  # URI of the resource - delegates to class method
93
166
  # @return [String, nil] The URI for this resource
@@ -98,7 +171,7 @@ module FastMcp
98
171
  # Name of the resource - delegates to class method
99
172
  # @return [String, nil] The name for this resource
100
173
  def name
101
- self.class.name
174
+ self.class.resource_name
102
175
  end
103
176
 
104
177
  # Description of the resource - delegates to class method
@@ -113,6 +186,10 @@ module FastMcp
113
186
  self.class.mime_type
114
187
  end
115
188
 
189
+ # Get parameters from the URI template
190
+ # @return [Hash] The parameters extracted from the URI
191
+ attr_reader :params
192
+
116
193
  # Method to be overridden by subclasses to dynamically generate content
117
194
  # @return [String, nil] Generated content for this resource
118
195
  def content
@@ -129,25 +206,5 @@ module FastMcp
129
206
  mime_type == 'application/xml' ||
130
207
  mime_type == 'application/javascript')
131
208
  end
132
-
133
- # Get the resource contents
134
- # @return [Hash] Resource contents
135
- def contents
136
- result = {
137
- uri: uri,
138
- mimeType: mime_type
139
- }
140
-
141
- content_value = content
142
- if content_value
143
- if binary?
144
- result[:blob] = Base64.strict_encode64(content_value)
145
- else
146
- result[:text] = content_value
147
- end
148
- end
149
-
150
- result
151
- end
152
209
  end
153
210
  end
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,15 @@ 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 = []
38
42
 
39
43
  # Merge with provided capabilities
40
44
  @capabilities.merge!(capabilities) if capabilities.is_a?(Hash)
@@ -66,8 +70,9 @@ module FastMcp
66
70
 
67
71
  # Register a resource with the server
68
72
  def register_resource(resource)
69
- @resources[resource.uri] = resource
70
- @logger.debug("Registered resource: #{resource.name} (#{resource.uri})")
73
+ @resources << resource
74
+
75
+ @logger.debug("Registered resource: #{resource.resource_name} (#{resource.uri})")
71
76
  resource.server = self
72
77
  # Notify subscribers about the list change
73
78
  notify_resource_list_changed if @transport
@@ -77,8 +82,10 @@ module FastMcp
77
82
 
78
83
  # Remove a resource from the server
79
84
  def remove_resource(uri)
80
- if @resources.key?(uri)
81
- resource = @resources.delete(uri)
85
+ resource = @resources.find { |r| r.uri == uri }
86
+
87
+ if resource
88
+ @resources.delete(resource)
82
89
  @logger.debug("Removed resource: #{resource.name} (#{uri})")
83
90
 
84
91
  # Notify subscribers about the list change
@@ -95,7 +102,7 @@ module FastMcp
95
102
  @logger.transport = :stdio
96
103
  @logger.info("Starting MCP server: #{@name} v#{@version}")
97
104
  @logger.info("Available tools: #{@tools.keys.join(', ')}")
98
- @logger.info("Available resources: #{@resources.keys.join(', ')}")
105
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
99
106
 
100
107
  # Use STDIO transport by default
101
108
  @transport_klass = FastMcp::Transports::StdioTransport
@@ -107,7 +114,7 @@ module FastMcp
107
114
  def start_rack(app, options = {})
108
115
  @logger.info("Starting MCP server as Rack middleware: #{@name} v#{@version}")
109
116
  @logger.info("Available tools: #{@tools.keys.join(', ')}")
110
- @logger.info("Available resources: #{@resources.keys.join(', ')}")
117
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
111
118
 
112
119
  # Use Rack transport
113
120
  transport_klass = FastMcp::Transports::RackTransport
@@ -121,7 +128,7 @@ module FastMcp
121
128
  def start_authenticated_rack(app, options = {})
122
129
  @logger.info("Starting MCP server as Authenticated Rack middleware: #{@name} v#{@version}")
123
130
  @logger.info("Available tools: #{@tools.keys.join(', ')}")
124
- @logger.info("Available resources: #{@resources.keys.join(', ')}")
131
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
125
132
 
126
133
  # Use Rack transport
127
134
  transport_klass = FastMcp::Transports::AuthenticatedRackTransport
@@ -132,8 +139,15 @@ module FastMcp
132
139
  @transport
133
140
  end
134
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
+
135
149
  # Handle incoming JSON-RPC request
136
- def handle_request(json_str) # rubocop:disable Metrics/MethodLength
150
+ def handle_request(json_str, headers: {}) # rubocop:disable Metrics/MethodLength
137
151
  begin
138
152
  request = JSON.parse(json_str)
139
153
  rescue JSON::ParserError, TypeError
@@ -142,15 +156,13 @@ module FastMcp
142
156
 
143
157
  @logger.debug("Received request: #{request.inspect}")
144
158
 
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
159
  method = request['method']
151
160
  params = request['params'] || {}
152
161
  id = request['id']
153
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
+
154
166
  case method
155
167
  when 'ping'
156
168
  send_result({}, id)
@@ -161,39 +173,26 @@ module FastMcp
161
173
  when 'tools/list'
162
174
  handle_tools_list(id)
163
175
  when 'tools/call'
164
- handle_tools_call(params, id)
176
+ handle_tools_call(params, headers, id)
165
177
  when 'resources/list'
166
178
  handle_resources_list(id)
179
+ when 'resources/templates/list'
180
+ handle_resources_templates_list(id)
167
181
  when 'resources/read'
168
182
  handle_resources_read(params, id)
169
183
  when 'resources/subscribe'
170
184
  handle_resources_subscribe(params, id)
171
185
  when 'resources/unsubscribe'
172
186
  handle_resources_unsubscribe(params, id)
187
+ when nil
188
+ # This is a notification response, we don't need to handle it
189
+ nil
173
190
  else
174
191
  send_error(-32_601, "Method not found: #{method}", id)
175
192
  end
176
193
  rescue StandardError => e
177
194
  @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
195
+ send_error(-32_600, "Internal error: #{e.message}, #{e.backtrace.join("\n")}", id)
197
196
  end
198
197
 
199
198
  # Notify subscribers about a resource update
@@ -215,6 +214,10 @@ module FastMcp
215
214
  @transport.send_message(notification)
216
215
  end
217
216
 
217
+ def read_resource(uri)
218
+ @resources.find { |r| r.match(uri) }
219
+ end
220
+
218
221
  private
219
222
 
220
223
  PROTOCOL_VERSION = '2024-11-05'
@@ -249,24 +252,34 @@ module FastMcp
249
252
 
250
253
  return send_error(-32_602, 'Invalid params: missing resource URI', id) unless uri
251
254
 
252
- resource = @resources[uri]
253
- return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
255
+ @logger.debug("Looking for resource with URI: #{uri}")
254
256
 
255
- base_content = { uri: resource.uri }
256
- base_content[:mimeType] = resource.mime_type if resource.mime_type
257
- resource_instance = resource.instance
258
- # Format the response according to the MCP specification
259
- result = if resource_instance.binary?
260
- {
261
- contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
262
- }
263
- else
264
- {
265
- contents: [base_content.merge(text: resource_instance.content)]
266
- }
267
- end
268
-
269
- send_result(result, id)
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
270
283
  end
271
284
 
272
285
  def handle_initialized_notification
@@ -292,7 +305,7 @@ module FastMcp
292
305
  end
293
306
 
294
307
  # Handle tools/call request
295
- def handle_tools_call(params, id)
308
+ def handle_tools_call(params, headers, id)
296
309
  tool_name = params['name']
297
310
  arguments = params['arguments'] || {}
298
311
 
@@ -304,7 +317,13 @@ module FastMcp
304
317
  begin
305
318
  # Convert string keys to symbols for Ruby
306
319
  symbolized_args = symbolize_keys(arguments)
307
- result, metadata = tool.new.call_with_schema_validation!(**symbolized_args)
320
+
321
+ tool_instance = tool.new(headers: headers)
322
+ authorized = tool_instance.authorized?(**symbolized_args)
323
+
324
+ return send_error(-32_602, 'Unauthorized', id) unless authorized
325
+
326
+ result, metadata = tool_instance.call_with_schema_validation!(**symbolized_args)
308
327
 
309
328
  # Format and send the result
310
329
  send_formatted_result(result, id, metadata)
@@ -346,11 +365,19 @@ module FastMcp
346
365
 
347
366
  # Handle resources/list request
348
367
  def handle_resources_list(id)
349
- resources_list = @resources.values.map(&:metadata)
368
+ resources_list = @resources.select(&:non_templated?).map(&:metadata)
350
369
 
351
370
  send_result({ resources: resources_list }, id)
352
371
  end
353
372
 
373
+ # Handle resources/templates/list request
374
+ def handle_resources_templates_list(id)
375
+ # Collect templated resources
376
+ templated_resources_list = @resources.select(&:templated?).map(&:metadata)
377
+
378
+ send_result({ resourceTemplates: templated_resources_list }, id)
379
+ end
380
+
354
381
  # Handle resources/subscribe request
355
382
  def handle_resources_subscribe(params, id)
356
383
  return unless @client_initialized
@@ -362,11 +389,8 @@ module FastMcp
362
389
  return
363
390
  end
364
391
 
365
- resource = @resources[uri]
366
- unless resource
367
- send_error(-32_602, "Resource not found: #{uri}", id)
368
- return
369
- end
392
+ resource = @resources.find { |r| r.match(uri) }
393
+ return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
370
394
 
371
395
  # Add to subscriptions
372
396
  @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
data/lib/mcp/tool.rb CHANGED
@@ -94,6 +94,27 @@ module FastMcp
94
94
  class << self
95
95
  attr_accessor :server
96
96
 
97
+ # Add tagging support for tools
98
+ def tags(*tag_list)
99
+ if tag_list.empty?
100
+ @tags || []
101
+ else
102
+ @tags = tag_list.flatten.map(&:to_sym)
103
+ end
104
+ end
105
+
106
+ # Add metadata support for tools
107
+ def metadata(key = nil, value = nil)
108
+ @metadata ||= {}
109
+ if key.nil?
110
+ @metadata
111
+ elsif value.nil?
112
+ @metadata[key]
113
+ else
114
+ @metadata[key] = value
115
+ end
116
+ end
117
+
97
118
  def arguments(&block)
98
119
  @input_schema = Dry::Schema.JSON(&block)
99
120
  end
@@ -114,6 +135,11 @@ module FastMcp
114
135
  @description = description
115
136
  end
116
137
 
138
+ def authorize(&block)
139
+ @authorization_blocks ||= []
140
+ @authorization_blocks.push block
141
+ end
142
+
117
143
  def call(**args)
118
144
  raise NotImplementedError, 'Subclasses must implement the call method'
119
145
  end
@@ -126,11 +152,33 @@ module FastMcp
126
152
  end
127
153
  end
128
154
 
129
- def initialize
155
+ def initialize(headers: {})
130
156
  @_meta = {}
157
+ @headers = headers
158
+ end
159
+
160
+ def authorized?(**args)
161
+ auth_checks = self.class.ancestors.filter_map do |ancestor|
162
+ ancestor.ancestors.include?(FastMcp::Tool) &&
163
+ ancestor.instance_variable_get(:@authorization_blocks)
164
+ end.flatten
165
+
166
+ return true if auth_checks.empty?
167
+
168
+ arg_validation = self.class.input_schema.call(args)
169
+ raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any?
170
+
171
+ auth_checks.all? do |auth_check|
172
+ if auth_check.parameters.empty?
173
+ instance_exec(&auth_check)
174
+ else
175
+ instance_exec(**args, &auth_check)
176
+ end
177
+ end
131
178
  end
132
179
 
133
180
  attr_accessor :_meta
181
+ attr_reader :headers
134
182
 
135
183
  def notify_resource_updated(uri)
136
184
  self.class.server.notify_resource_updated(uri)
@@ -32,8 +32,8 @@ module FastMcp
32
32
 
33
33
  # Process an incoming message
34
34
  # This is a helper method that can be used by subclasses
35
- def process_message(message)
36
- server.handle_json_request(message)
35
+ def process_message(message, headers: {})
36
+ server.handle_request(message, headers: headers)
37
37
  end
38
38
  end
39
39
  end
@@ -13,6 +13,7 @@ module FastMcp
13
13
  DEFAULT_PATH_PREFIX = '/mcp'
14
14
  DEFAULT_ALLOWED_ORIGINS = ['localhost', '127.0.0.1', '[::1]'].freeze
15
15
  DEFAULT_ALLOWED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].freeze
16
+ SERVER_ENV_KEY = 'fast_mcp.server'
16
17
 
17
18
  SSE_HEADERS = {
18
19
  'Content-Type' => 'text/event-stream',
@@ -40,8 +41,10 @@ module FastMcp
40
41
  @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
41
42
  @localhost_only = options.fetch(:localhost_only, true) # Default to localhost-only mode
42
43
  @allowed_ips = options[:allowed_ips] || DEFAULT_ALLOWED_IPS
43
- @sse_clients = {}
44
+ @sse_clients = Concurrent::Hash.new
45
+ @sse_clients_mutex = Mutex.new
44
46
  @running = false
47
+ @filtered_servers_cache = {}
45
48
  end
46
49
 
47
50
  # Start the transport
@@ -57,12 +60,14 @@ module FastMcp
57
60
  @running = false
58
61
 
59
62
  # Close all SSE connections
60
- @sse_clients.each_value do |client|
61
- client[:stream].close if client[:stream].respond_to?(:close) && !client[:stream].closed?
62
- rescue StandardError => e
63
- @logger.error("Error closing SSE connection: #{e.message}")
63
+ @sse_clients_mutex.synchronize do
64
+ @sse_clients.each_value do |client|
65
+ client[:stream].close if client[:stream].respond_to?(:close) && !client[:stream].closed?
66
+ rescue StandardError => e
67
+ @logger.error("Error closing SSE connection: #{e.message}")
68
+ end
69
+ @sse_clients.clear
64
70
  end
65
- @sse_clients.clear
66
71
  end
67
72
 
68
73
  # Send a message to all connected SSE clients
@@ -71,21 +76,25 @@ module FastMcp
71
76
  @logger.debug("Broadcasting message to #{@sse_clients.size} SSE clients: #{json_message}")
72
77
 
73
78
  clients_to_remove = []
74
-
75
- @sse_clients.each do |client_id, client|
76
- stream = client[:stream]
77
- next if stream.nil? || (stream.respond_to?(:closed?) && stream.closed?)
78
-
79
- stream.write("data: #{json_message}\n\n")
80
- stream.flush if stream.respond_to?(:flush)
81
- rescue Errno::EPIPE, IOError => e
82
- # Broken pipe or IO error - client disconnected
83
- @logger.info("Client #{client_id} disconnected: #{e.message}")
84
- clients_to_remove << client_id
85
- rescue StandardError => e
86
- @logger.error("Error sending message to client #{client_id}: #{e.message}")
87
- # Remove the client if we can't send to it
88
- clients_to_remove << client_id
79
+ @sse_clients_mutex.synchronize do
80
+ @sse_clients.each do |client_id, client|
81
+ stream = client[:stream]
82
+ mutex = client[:mutex]
83
+ next if stream.nil? || (stream.respond_to?(:closed?) && stream.closed?) || mutex.nil?
84
+
85
+ begin
86
+ mutex.synchronize do
87
+ stream.write("data: #{json_message}\n\n")
88
+ stream.flush if stream.respond_to?(:flush)
89
+ end
90
+ rescue Errno::EPIPE, IOError => e
91
+ @logger.info("Client #{client_id} disconnected: #{e.message}")
92
+ clients_to_remove << client_id
93
+ rescue StandardError => e
94
+ @logger.error("Error sending message to client #{client_id}: #{e.message}")
95
+ clients_to_remove << client_id
96
+ end
97
+ end
89
98
  end
90
99
 
91
100
  # Remove disconnected clients outside the loop to avoid modifying the hash during iteration
@@ -93,15 +102,19 @@ module FastMcp
93
102
  end
94
103
 
95
104
  # Register a new SSE client
96
- def register_sse_client(client_id, stream)
97
- @logger.info("Registering SSE client: #{client_id}")
98
- @sse_clients[client_id] = { stream: stream, connected_at: Time.now }
105
+ def register_sse_client(client_id, stream, mutex = nil)
106
+ @sse_clients_mutex.synchronize do
107
+ @logger.info("Registering SSE client: #{client_id}")
108
+ @sse_clients[client_id] = { stream: stream, connected_at: Time.now, mutex: mutex || Mutex.new }
109
+ end
99
110
  end
100
111
 
101
112
  # Unregister an SSE client
102
113
  def unregister_sse_client(client_id)
103
- @logger.info("Unregistering SSE client: #{client_id}")
104
- @sse_clients.delete(client_id)
114
+ @sse_clients_mutex.synchronize do
115
+ @logger.info("Unregistering SSE client: #{client_id}")
116
+ @sse_clients.delete(client_id)
117
+ end
105
118
  end
106
119
 
107
120
  # Rack call method
@@ -196,19 +209,33 @@ module FastMcp
196
209
  # Validate Origin header to prevent DNS rebinding attacks
197
210
  return forbidden_response('Forbidden: Origin validation failed') unless validate_origin(request, env)
198
211
 
212
+ # Get the appropriate server for this request
213
+ request_server = get_server_for_request(request, env)
214
+
215
+ # Store the current transport temporarily if using a filtered server
216
+ if request_server != @server
217
+ original_transport = request_server.transport
218
+ request_server.transport = self
219
+ end
220
+
199
221
  subpath = request.path[@path_prefix.length..]
200
222
  @logger.debug("MCP request subpath: '#{subpath.inspect}'")
201
223
 
202
- case subpath
203
- when "/#{@sse_route}"
204
- handle_sse_request(request, env)
205
- when "/#{@messages_route}"
206
- handle_message_request(request)
207
- else
208
- @logger.error('Received unknown request')
209
- # Return 404 for unknown MCP endpoints
210
- endpoint_not_found_response
211
- end
224
+ result = case subpath
225
+ when "/#{@sse_route}"
226
+ handle_sse_request(request, env)
227
+ when "/#{@messages_route}"
228
+ handle_message_request_with_server(request, request_server)
229
+ else
230
+ @logger.error('Received unknown request')
231
+ # Return 404 for unknown MCP endpoints
232
+ endpoint_not_found_response
233
+ end
234
+
235
+ # Restore original transport if needed
236
+ request_server.transport = original_transport if request_server != @server && original_transport
237
+
238
+ result
212
239
  end
213
240
 
214
241
  def forbidden_response(message)
@@ -265,7 +292,7 @@ module FastMcp
265
292
  else
266
293
  # Fallback for servers that don't support streaming
267
294
  @logger.info('Falling back to default SSE')
268
- [200, headers, [":ok\n\n"]]
295
+ [200, SSE_HEADERS.dup, [":ok\n\n"]]
269
296
  end
270
297
  end
271
298
 
@@ -366,18 +393,24 @@ module FastMcp
366
393
 
367
394
  # Set up the SSE connection
368
395
  def setup_sse_connection(client_id, io, env)
396
+ # Handle for reconnection, if the client_id is already registered we reuse the mutex
397
+ # If not a reconnection, generate a new mutex used in registration
398
+ client = @sse_clients[client_id]
399
+ mutex = client ? client[:mutex] : Mutex.new
369
400
  # Send headers
370
401
  @logger.debug("Sending HTTP headers for SSE connection #{client_id}")
371
- io.write("HTTP/1.1 200 OK\r\n")
372
- SSE_HEADERS.each { |k, v| io.write("#{k}: #{v}\r\n") }
373
- io.write("\r\n")
374
- io.flush
402
+ mutex.synchronize do
403
+ io.write("HTTP/1.1 200 OK\r\n")
404
+ SSE_HEADERS.each { |k, v| io.write("#{k}: #{v}\r\n") }
405
+ io.write("\r\n")
406
+ io.flush
407
+ end
375
408
 
376
- # Register client
377
- register_sse_client(client_id, io)
409
+ # Register client (will overwrite if already present)
410
+ register_sse_client(client_id, io, mutex)
378
411
 
379
412
  # Send an initial comment to keep the connection alive
380
- io.write(": SSE connection established\n\n")
413
+ mutex.synchronize { io.write(": SSE connection established\n\n") }
381
414
 
382
415
  # Extract query parameters from the request
383
416
  query_string = env['QUERY_STRING']
@@ -386,12 +419,14 @@ module FastMcp
386
419
  endpoint = "#{@path_prefix}/#{@messages_route}"
387
420
  endpoint += "?#{query_string}" if query_string
388
421
  @logger.debug("Sending endpoint information to client #{client_id}: #{endpoint}")
389
- io.write("event: endpoint\ndata: #{endpoint}\n\n")
422
+ mutex.synchronize { io.write("event: endpoint\ndata: #{endpoint}\n\n") }
390
423
 
391
424
  # Send a retry directive with a very short reconnect time
392
425
  # This helps browsers reconnect quickly if the connection is lost
393
- io.write("retry: 100\n\n") # 100ms reconnect time
394
- io.flush
426
+ mutex.synchronize do
427
+ io.write("retry: 100\n\n")
428
+ io.flush
429
+ end
395
430
  rescue StandardError => e
396
431
  @logger.error("Error setting up SSE connection for client #{client_id}: #{e.message}")
397
432
  @logger.error(e.backtrace.join("\n")) if e.backtrace
@@ -417,11 +452,10 @@ module FastMcp
417
452
  ping_count = 0
418
453
  ping_interval = 1 # Send a ping every 1 second
419
454
  @running = true
420
-
455
+ mutex = @sse_clients[client_id] && @sse_clients[client_id][:mutex]
421
456
  while @running && !io.closed?
422
457
  begin
423
- ping_count = send_keep_alive_ping(io, client_id, ping_count)
424
-
458
+ mutex.synchronize { ping_count = send_keep_alive_ping(io, client_id, ping_count) }
425
459
  sleep ping_interval
426
460
  rescue Errno::EPIPE, IOError => e
427
461
  # Broken pipe or IO error - client disconnected
@@ -434,7 +468,6 @@ module FastMcp
434
468
  # Send a keep-alive ping and return the updated ping count
435
469
  def send_keep_alive_ping(io, client_id, ping_count)
436
470
  ping_count += 1
437
-
438
471
  # Send a comment before each ping to keep the connection alive
439
472
  io.write(": keep-alive #{ping_count}\n\n")
440
473
  io.flush
@@ -444,7 +477,6 @@ module FastMcp
444
477
  @logger.debug("Sending ping ##{ping_count} to SSE client #{client_id}")
445
478
  send_ping_event(io)
446
479
  end
447
-
448
480
  ping_count
449
481
  end
450
482
 
@@ -462,9 +494,14 @@ module FastMcp
462
494
  # Clean up SSE connection
463
495
  def cleanup_sse_connection(client_id, io)
464
496
  @logger.info("Cleaning up SSE connection for client #{client_id}")
497
+ mutex = @sse_clients[client_id] && @sse_clients[client_id][:mutex]
465
498
  unregister_sse_client(client_id)
466
499
  begin
467
- io.close unless io.closed?
500
+ if mutex
501
+ mutex.synchronize { io.close unless io.closed? }
502
+ else
503
+ io.close unless io.closed?
504
+ end
468
505
  @logger.info("Successfully closed IO for client #{client_id}")
469
506
  rescue StandardError => e
470
507
  @logger.error("Error closing IO for client #{client_id}: #{e.message}")
@@ -484,13 +521,13 @@ module FastMcp
484
521
  [200, SSE_HEADERS, []]
485
522
  end
486
523
 
487
- # Handle message POST request
488
- def handle_message_request(request)
524
+ # Handle message POST request with specific server
525
+ def handle_message_request_with_server(request, server)
489
526
  @logger.debug('Received message request')
490
527
  return method_not_allowed_response unless request.post?
491
528
 
492
529
  begin
493
- process_json_request(request)
530
+ process_json_request_with_server(request, server)
494
531
  rescue JSON::ParserError => e
495
532
  handle_parse_error(e)
496
533
  rescue StandardError => e
@@ -498,14 +535,19 @@ module FastMcp
498
535
  end
499
536
  end
500
537
 
501
- # Process a JSON-RPC request
502
- def process_json_request(request)
538
+ def process_json_request_with_server(request, server)
503
539
  # Parse the request body
504
540
  body = request.body.read
541
+ @logger.debug("Request body: #{body}")
542
+
543
+ # Extract headers that might be relevant
544
+ headers = request.env.select { |k, _v| k.start_with?('HTTP_') }
545
+ .transform_keys { |k| k.sub('HTTP_', '').downcase.tr('_', '-') }
505
546
 
506
- response = process_message(body) || []
507
- @logger.info("Response: #{response}")
547
+ # Let the specific server handle the JSON request directly
548
+ response = server.handle_request(body, headers: headers) || []
508
549
 
550
+ # Return the JSON response
509
551
  [200, { 'Content-Type' => 'application/json' }, response]
510
552
  end
511
553
 
@@ -536,6 +578,50 @@ module FastMcp
536
578
  }
537
579
  )]]
538
580
  end
581
+
582
+ # Get the appropriate server for this request
583
+ def get_server_for_request(request, env)
584
+ # 1. Check for explicit server in env (highest priority)
585
+ if env[SERVER_ENV_KEY]
586
+ @logger.debug("Using server from env[#{SERVER_ENV_KEY}]")
587
+ return env[SERVER_ENV_KEY]
588
+ end
589
+
590
+ # 2. Apply filters if configured
591
+ if @server.contains_filters?
592
+ @logger.debug('Server has filters, creating filtered copy')
593
+ # Cache filtered servers to avoid recreating them
594
+ cache_key = generate_cache_key(request)
595
+
596
+ @filtered_servers_cache[cache_key] ||= @server.create_filtered_copy(request)
597
+ return @filtered_servers_cache[cache_key]
598
+ end
599
+
600
+ # 3. Use the default server
601
+ @logger.debug('Using default server')
602
+ @server
603
+ end
604
+
605
+ # Generate a cache key based on filter-relevant request attributes
606
+ def generate_cache_key(request)
607
+ # Generate a cache key based on filter-relevant request attributes
608
+ # This is a simple example - real implementation would be more sophisticated
609
+ {
610
+ path: request.path,
611
+ params: request.params.sort.to_h,
612
+ headers: extract_relevant_headers(request)
613
+ }.hash
614
+ end
615
+
616
+ # Extract headers that might be relevant for filtering
617
+ def extract_relevant_headers(request)
618
+ relevant_headers = {}
619
+ ['X-User-Role', 'X-API-Version', 'X-Tenant-ID', 'Authorization'].each do |header|
620
+ header_key = "HTTP_#{header.upcase.tr('-', '_')}"
621
+ relevant_headers[header] = request.env[header_key] if request.env[header_key]
622
+ end
623
+ relevant_headers
624
+ end
539
625
  end
540
626
  end
541
627
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastMcp
4
- VERSION = '1.4.0'
4
+ VERSION = '1.5.0'
5
5
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yorick Jacquin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-05-10 00:00:00.000000000 Z
11
+ date: 2025-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: addressable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.8'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: base64
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -102,6 +116,7 @@ files:
102
116
  - lib/mcp/railtie.rb
103
117
  - lib/mcp/resource.rb
104
118
  - lib/mcp/server.rb
119
+ - lib/mcp/server_filtering.rb
105
120
  - lib/mcp/tool.rb
106
121
  - lib/mcp/transports/authenticated_rack_transport.rb
107
122
  - lib/mcp/transports/base_transport.rb