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 +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +52 -5
- data/lib/mcp/resource.rb +86 -29
- data/lib/mcp/server.rb +84 -60
- data/lib/mcp/server_filtering.rb +80 -0
- data/lib/mcp/tool.rb +49 -1
- data/lib/mcp/transports/base_transport.rb +2 -2
- data/lib/mcp/transports/rack_transport.rb +146 -60
- data/lib/mcp/version.rb +1 -1
- metadata +17 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 1a99f230c0ff73568287d0812e53c266dffca1bd46de3c0308000d5658b5b9a4
|
4
|
+
data.tar.gz: 38fb4275c6cf123691ed02145687c70f5251be28e784f71b42a00c85b5fbee3d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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/
|
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 "
|
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.
|
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.
|
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 '
|
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
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
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
|
-
|
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.
|
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
|
70
|
-
|
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
|
-
|
81
|
-
|
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.
|
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.
|
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.
|
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
|
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
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
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
|
-
|
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.
|
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
|
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.
|
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
|
-
@
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
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
|
-
@
|
98
|
-
|
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
|
-
@
|
104
|
-
|
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
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
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,
|
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
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
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
|
-
|
394
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
507
|
-
|
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
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
|
+
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-
|
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
|