active_mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +309 -0
- data/Rakefile +31 -0
- data/app/controllers/active_mcp/base_controller.rb +87 -0
- data/config/routes.rb +4 -0
- data/lib/active_mcp/engine.rb +14 -0
- data/lib/active_mcp/server/error_codes.rb +14 -0
- data/lib/active_mcp/server/methods.rb +14 -0
- data/lib/active_mcp/server/protocol_handler.rb +167 -0
- data/lib/active_mcp/server/stdio_connection.rb +21 -0
- data/lib/active_mcp/server/tool_manager.rb +114 -0
- data/lib/active_mcp/server.rb +55 -0
- data/lib/active_mcp/tool.rb +54 -0
- data/lib/active_mcp/version.rb +3 -0
- data/lib/active_mcp.rb +14 -0
- data/lib/generators/active_mcp/tool/templates/tool.rb.erb +18 -0
- data/lib/generators/active_mcp/tool/tool_generator.rb +17 -0
- metadata +91 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 9f18994ffb431e43b365012a42a156596c09d0b1fef51352c7280363df3baddc
|
4
|
+
data.tar.gz: ab39f6b59b7a14510d1f3bc1c18b8f8e854de8ec6055d7592671b7fa1e586382
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b9cf091830ad128b958570361b252a396d9778d6eeaec596bbc22e88dd53bd16770726320928600c8d5e6ec7adc1adf436ab574a6355ec21a276ee15e4b6ccc1
|
7
|
+
data.tar.gz: e7f65722c824d17de80903e899efa552733e747b64ca5abfdc8c44dc73e37ae74d50d7481974dc543c3daed0315050a761115956233f78ccfc1fd55f7eb9a23c
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2024 Moeki Kawakami
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,309 @@
|
|
1
|
+
# Active Context
|
2
|
+
|
3
|
+
A Ruby on Rails engine that provides [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) capabilities to Rails applications. This gem allows you to easily create and expose MCP-compatible tools from your Rails application.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'active_mcp'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
```bash
|
16
|
+
$ bundle install
|
17
|
+
```
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
```bash
|
22
|
+
$ gem install active_mcp
|
23
|
+
```
|
24
|
+
|
25
|
+
## Setup
|
26
|
+
|
27
|
+
1. Mount the ActiveMcp engine in your `config/routes.rb`:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
Rails.application.routes.draw do
|
31
|
+
mount ActiveMcp::Engine, at: "/mcp"
|
32
|
+
|
33
|
+
# Your other routes
|
34
|
+
end
|
35
|
+
```
|
36
|
+
|
37
|
+
2. Create a tool by inheriting from `ActiveMcp::Tool`:
|
38
|
+
|
39
|
+
```ruby
|
40
|
+
class CreateNoteTool < ActiveMcp::Tool
|
41
|
+
description "Create Note!!"
|
42
|
+
|
43
|
+
property :title, :string
|
44
|
+
property :content, :string
|
45
|
+
|
46
|
+
def call(title:, content:)
|
47
|
+
Note.create(title:, content:)
|
48
|
+
|
49
|
+
"Created!"
|
50
|
+
end
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
54
|
+
3. Start the MCP server:
|
55
|
+
|
56
|
+
```ruby
|
57
|
+
server = ActiveMcp::Server.new(
|
58
|
+
name: "ActiveMcp DEMO",
|
59
|
+
uri: 'https://your-app.example.com/mcp'
|
60
|
+
)
|
61
|
+
server.start
|
62
|
+
```
|
63
|
+
|
64
|
+
## Rails Generators
|
65
|
+
|
66
|
+
MCP Rails provides generators to help you quickly create new MCP tools:
|
67
|
+
|
68
|
+
```bash
|
69
|
+
# Generate a new MCP tool
|
70
|
+
$ rails generate active_mcp:tool search_users
|
71
|
+
```
|
72
|
+
|
73
|
+
This creates a new tool file at `app/models/tools/search_users_tool.rb` with the following starter code:
|
74
|
+
|
75
|
+
```ruby
|
76
|
+
class SearchUsersTool < ActiveMcp::Tool
|
77
|
+
description 'Search users'
|
78
|
+
|
79
|
+
property :param1, :string, required: true, description: 'First parameter description'
|
80
|
+
property :param2, :string, required: false, description: 'Second parameter description'
|
81
|
+
# Add more parameters as needed
|
82
|
+
|
83
|
+
def call(param1:, param2: nil, auth_info: nil, **args)
|
84
|
+
# auth_info = { type: :bearer, token: 'xxx', header: 'Bearer xxx' }
|
85
|
+
|
86
|
+
# Implement your tool logic here
|
87
|
+
"Tool executed successfully with #{param1}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
```
|
91
|
+
|
92
|
+
You can then customize the generated tool to fit your needs.
|
93
|
+
|
94
|
+
## Input Schema
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
property :name, :string, required: true, description: 'User name'
|
98
|
+
property :age, :integer, required: false, description: 'User age'
|
99
|
+
property :addresses, :array, required: false, description: 'User addresses'
|
100
|
+
property :preferences, :object, required: false, description: 'User preferences'
|
101
|
+
```
|
102
|
+
|
103
|
+
Supported types include:
|
104
|
+
|
105
|
+
- `:string`
|
106
|
+
- `:integer`
|
107
|
+
- `:number` (float/decimal)
|
108
|
+
- `:boolean`
|
109
|
+
- `:array`
|
110
|
+
- `:object` (hash/dictionary)
|
111
|
+
- `:null`
|
112
|
+
|
113
|
+
## Using with MCP Clients
|
114
|
+
|
115
|
+
Any MCP-compatible client can connect to your server. The most common way is to provide the MCP server URL:
|
116
|
+
|
117
|
+
```
|
118
|
+
http://your-app.example.com/mcp
|
119
|
+
```
|
120
|
+
|
121
|
+
Clients will discover the available tools and their input schemas automatically through the MCP protocol.
|
122
|
+
|
123
|
+
## Authentication Flow
|
124
|
+
|
125
|
+
ActiveMcp supports receiving authentication credentials from MCP clients and forwarding them to your Rails application. There are two ways to handle authentication:
|
126
|
+
|
127
|
+
### 1. Using Server Configuration
|
128
|
+
|
129
|
+
When creating your MCP server, you can pass authentication options that will be included in every request:
|
130
|
+
|
131
|
+
```ruby
|
132
|
+
server = ActiveMcp::Server.new(
|
133
|
+
name: "ActiveMcp DEMO",
|
134
|
+
uri: 'http://localhost:3000/mcp',
|
135
|
+
auth: {
|
136
|
+
type: :bearer, # or :basic
|
137
|
+
token: ENV[:ACCESS_TOKEN]
|
138
|
+
}
|
139
|
+
)
|
140
|
+
server.start
|
141
|
+
```
|
142
|
+
|
143
|
+
### 2. Custom Controller with Auth Handling
|
144
|
+
|
145
|
+
For more advanced authentication, create a custom controller that handles the authentication flow:
|
146
|
+
|
147
|
+
```ruby
|
148
|
+
class CustomController < ActiveMcpController
|
149
|
+
before_action :authenticate
|
150
|
+
|
151
|
+
private
|
152
|
+
|
153
|
+
def authenticate
|
154
|
+
# Extract auth from MCP request
|
155
|
+
auth_header = request.headers['Authorization']
|
156
|
+
|
157
|
+
if auth_header.present?
|
158
|
+
# Process the auth header (Bearer token, etc.)
|
159
|
+
token = auth_header.split(' ').last
|
160
|
+
|
161
|
+
# Validate the token against your auth system
|
162
|
+
user = User.find_by_token(token)
|
163
|
+
|
164
|
+
unless user
|
165
|
+
render_error(-32600, "Authentication failed")
|
166
|
+
return false
|
167
|
+
end
|
168
|
+
|
169
|
+
# Set current user for tool access
|
170
|
+
Current.user = user
|
171
|
+
else
|
172
|
+
render_error(-32600, "Authentication required")
|
173
|
+
return false
|
174
|
+
end
|
175
|
+
end
|
176
|
+
end
|
177
|
+
```
|
178
|
+
|
179
|
+
### 3. Using Auth in Tools
|
180
|
+
|
181
|
+
Authentication information is automatically passed to your tools through the `auth_info` parameter:
|
182
|
+
|
183
|
+
```ruby
|
184
|
+
class SecuredDataTool < ActiveMcp::Tool
|
185
|
+
description 'Access secured data'
|
186
|
+
|
187
|
+
property :resource_id, :string, required: true, description: 'ID of the resource to access'
|
188
|
+
|
189
|
+
def call(resource_id:, auth_info: nil, **args)
|
190
|
+
# Check if auth info exists
|
191
|
+
unless auth_info.present?
|
192
|
+
raise "Authentication required to access this resource"
|
193
|
+
end
|
194
|
+
|
195
|
+
# Extract token from auth info
|
196
|
+
token = auth_info[:token]
|
197
|
+
|
198
|
+
# Validate token and get user
|
199
|
+
user = User.authenticate_with_token(token)
|
200
|
+
|
201
|
+
unless user
|
202
|
+
raise "Invalid authentication token"
|
203
|
+
end
|
204
|
+
|
205
|
+
# Check if user has access to the resource
|
206
|
+
resource = Resource.find(resource_id)
|
207
|
+
|
208
|
+
if resource.user_id != user.id
|
209
|
+
raise "Access denied to this resource"
|
210
|
+
end
|
211
|
+
|
212
|
+
# Return the secured data
|
213
|
+
{
|
214
|
+
type: "text",
|
215
|
+
content: resource.to_json
|
216
|
+
}
|
217
|
+
end
|
218
|
+
end
|
219
|
+
```
|
220
|
+
|
221
|
+
## Advanced Configuration
|
222
|
+
|
223
|
+
### Custom Controller
|
224
|
+
|
225
|
+
If you need to customize the MCP controller behavior, you can create your own controller that inherits from `ActiveMcpController`:
|
226
|
+
|
227
|
+
```ruby
|
228
|
+
class CustomController < ActiveContexController
|
229
|
+
# Add custom behavior, authentication, etc.
|
230
|
+
end
|
231
|
+
```
|
232
|
+
|
233
|
+
And update your routes:
|
234
|
+
|
235
|
+
```ruby
|
236
|
+
Rails.application.routes.draw do
|
237
|
+
post "/mcp", to: "custom_mcp#index"
|
238
|
+
end
|
239
|
+
```
|
240
|
+
|
241
|
+
## Best Practices
|
242
|
+
|
243
|
+
### Create a Tool for Each Model
|
244
|
+
|
245
|
+
For security reasons, it's recommended to create specific tools for each model rather than generic tools that dynamically determine the model class. This approach:
|
246
|
+
|
247
|
+
1. Increases security by avoiding dynamic class loading
|
248
|
+
2. Makes your tools more explicit and easier to understand
|
249
|
+
3. Provides better validation and error handling specific to each model
|
250
|
+
|
251
|
+
For example, instead of creating a generic search tool, create specific search tools for each model:
|
252
|
+
|
253
|
+
```ruby
|
254
|
+
# Good: Specific tool for searching users
|
255
|
+
class SearchUsersTool < ActiveMcp::Tool
|
256
|
+
description 'Search users by criteria'
|
257
|
+
|
258
|
+
property :email, :string, required: false, description: 'Email to search for'
|
259
|
+
property :name, :string, required: false, description: 'Name to search for'
|
260
|
+
property :limit, :integer, required: false, description: 'Maximum number of records to return'
|
261
|
+
|
262
|
+
def call(email: nil, name: nil, limit: 10)
|
263
|
+
criteria = {}
|
264
|
+
criteria[:email] = email if email.present?
|
265
|
+
criteria[:name] = name if name.present?
|
266
|
+
|
267
|
+
users = User.where(criteria).limit(limit)
|
268
|
+
|
269
|
+
{
|
270
|
+
type: "text",
|
271
|
+
content: users.to_json(only: [:id, :name, :email, :created_at])
|
272
|
+
}
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
# Good: Specific tool for searching posts
|
277
|
+
class SearchPostsTool < ActiveMcp::Tool
|
278
|
+
description 'Search posts by criteria'
|
279
|
+
|
280
|
+
property :title, :string, required: false, description: 'Title to search for'
|
281
|
+
property :author_id, :integer, required: false, description: 'Author ID to filter by'
|
282
|
+
property :limit, :integer, required: false, description: 'Maximum number of records to return'
|
283
|
+
|
284
|
+
def call(title: nil, author_id: nil, limit: 10)
|
285
|
+
criteria = {}
|
286
|
+
criteria[:title] = title if title.present?
|
287
|
+
criteria[:author_id] = author_id if author_id.present?
|
288
|
+
|
289
|
+
posts = Post.where(criteria).limit(limit)
|
290
|
+
|
291
|
+
{
|
292
|
+
type: "text",
|
293
|
+
content: posts.to_json(only: [:id, :title, :author_id, :created_at])
|
294
|
+
}
|
295
|
+
end
|
296
|
+
end
|
297
|
+
```
|
298
|
+
|
299
|
+
## Development
|
300
|
+
|
301
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
302
|
+
|
303
|
+
## Contributing
|
304
|
+
|
305
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/kawakamimoeki/active_mcp. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/kawakamimoeki/active_mcp/blob/main/CODE_OF_CONDUCT.md).
|
306
|
+
|
307
|
+
## License
|
308
|
+
|
309
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
begin
|
2
|
+
require "bundler/setup"
|
3
|
+
rescue LoadError
|
4
|
+
puts "You must `gem install bundler` and `bundle install` to run rake tasks"
|
5
|
+
end
|
6
|
+
|
7
|
+
require "rdoc/task"
|
8
|
+
|
9
|
+
RDoc::Task.new(:rdoc) do |rdoc|
|
10
|
+
rdoc.rdoc_dir = "rdoc"
|
11
|
+
rdoc.title = "ActiveMcp"
|
12
|
+
rdoc.options << "--line-numbers"
|
13
|
+
rdoc.rdoc_files.include("README.md")
|
14
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
15
|
+
end
|
16
|
+
|
17
|
+
require "bundler/gem_tasks"
|
18
|
+
|
19
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
20
|
+
load "rails/tasks/engine.rake"
|
21
|
+
load "rails/tasks/statistics.rake"
|
22
|
+
|
23
|
+
require "rake/testtask"
|
24
|
+
|
25
|
+
Rake::TestTask.new(:test) do |t|
|
26
|
+
t.libs << "test"
|
27
|
+
t.pattern = "test/**/*_test.rb"
|
28
|
+
t.verbose = false
|
29
|
+
end
|
30
|
+
|
31
|
+
task default: :test
|
@@ -0,0 +1,87 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveMcp
|
4
|
+
class BaseController < ActionController::Base
|
5
|
+
protect_from_forgery with: :null_session
|
6
|
+
skip_before_action :verify_authenticity_token
|
7
|
+
before_action :process_authentication, only: [:index]
|
8
|
+
|
9
|
+
def index
|
10
|
+
case params[:method]
|
11
|
+
when Method::TOOLS_LIST
|
12
|
+
render_tools_list
|
13
|
+
when Method::TOOLS_CALL
|
14
|
+
call_tool(params)
|
15
|
+
else
|
16
|
+
render json: {error: "Method not found: #{params[:method]}"}, status: 404
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def process_authentication
|
23
|
+
auth_header = request.headers["Authorization"]
|
24
|
+
if auth_header.present?
|
25
|
+
@auth_info = {
|
26
|
+
header: auth_header,
|
27
|
+
type: if auth_header.start_with?("Bearer ")
|
28
|
+
:bearer
|
29
|
+
elsif auth_header.start_with?("Basic ")
|
30
|
+
:basic
|
31
|
+
else
|
32
|
+
:unknown
|
33
|
+
end,
|
34
|
+
token: auth_header.split(" ").last
|
35
|
+
}
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def render_tools_list
|
40
|
+
tools = Tool.registered_tools.map do |tool_class|
|
41
|
+
{
|
42
|
+
name: tool_class.tool_name,
|
43
|
+
description: tool_class.desc,
|
44
|
+
inputSchema: tool_class.schema
|
45
|
+
}
|
46
|
+
end
|
47
|
+
|
48
|
+
render json: {result: tools}
|
49
|
+
end
|
50
|
+
|
51
|
+
def call_tool(params)
|
52
|
+
tool_name = params[:name]
|
53
|
+
arguments = JSON.parse(params[:arguments] || "{}")
|
54
|
+
|
55
|
+
unless tool_name
|
56
|
+
render json: {error: "Invalid params: missing tool name"}, status: 422
|
57
|
+
return
|
58
|
+
end
|
59
|
+
|
60
|
+
tool_class = Tool.registered_tools.find do |tc|
|
61
|
+
tc.tool_name == tool_name
|
62
|
+
end
|
63
|
+
|
64
|
+
unless tool_class
|
65
|
+
render json: {error: "Tool not found: #{tool_name}"}, status: 404
|
66
|
+
return
|
67
|
+
end
|
68
|
+
|
69
|
+
tool = tool_class.new
|
70
|
+
validation_result = tool.validate_arguments(arguments)
|
71
|
+
|
72
|
+
if validation_result.is_a?(Hash) && validation_result[:error]
|
73
|
+
render json: {result: validation_result[:error]}
|
74
|
+
return
|
75
|
+
end
|
76
|
+
|
77
|
+
begin
|
78
|
+
arguments[:auth_info] = @auth_info if @auth_info.present?
|
79
|
+
|
80
|
+
result = tool.call(**arguments.symbolize_keys)
|
81
|
+
render json: {result: result}
|
82
|
+
rescue => e
|
83
|
+
render json: {error: "Error: #{e.message}"}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
data/config/routes.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
module ActiveMcp
|
2
|
+
class Engine < ::Rails::Engine
|
3
|
+
isolate_namespace ActiveMcp
|
4
|
+
|
5
|
+
initializer "active_mcp.eager_load_tools" do |app|
|
6
|
+
tools_path = Rails.root.join("app", "tools")
|
7
|
+
if Dir.exist?(tools_path)
|
8
|
+
Dir[tools_path.join("*.rb")].sort.each do |file|
|
9
|
+
require_dependency file
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveMcp
|
4
|
+
module ErrorCode
|
5
|
+
NOT_INITIALIZED = -32_002
|
6
|
+
ALREADY_INITIALIZED = -32_002
|
7
|
+
|
8
|
+
PARSE_ERROR = -32_700
|
9
|
+
INVALID_REQUEST = -32_600
|
10
|
+
METHOD_NOT_FOUND = -32_601
|
11
|
+
INVALID_PARAMS = -32_602
|
12
|
+
INTERNAL_ERROR = -32_603
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveMcp
|
4
|
+
module Method
|
5
|
+
INITIALIZE = "initialize"
|
6
|
+
INITIALIZED = "notifications/initialized"
|
7
|
+
PING = "ping"
|
8
|
+
TOOLS_LIST = "tools/list"
|
9
|
+
TOOLS_CALL = "tools/call"
|
10
|
+
RESOURCES_LIST = "resources/list"
|
11
|
+
RESOURCES_READ = "resources/read"
|
12
|
+
RESOURCES_TEMPLATES_LIST = "resources/list"
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,167 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module ActiveMcp
|
4
|
+
class Server
|
5
|
+
class ProtocolHandler
|
6
|
+
attr_reader :initialized
|
7
|
+
|
8
|
+
def initialize(server)
|
9
|
+
@server = server
|
10
|
+
@initialized = false
|
11
|
+
@supported_protocol_versions = [PROTOCOL_VERSION]
|
12
|
+
end
|
13
|
+
|
14
|
+
def process_message(message)
|
15
|
+
message = message.to_s.force_encoding("UTF-8")
|
16
|
+
result = begin
|
17
|
+
request = JSON.parse(message, symbolize_names: true)
|
18
|
+
handle_request(request)
|
19
|
+
rescue JSON::ParserError => e
|
20
|
+
error_response(nil, ErrorCode::PARSE_ERROR, "Invalid JSON: #{e.message}")
|
21
|
+
rescue => e
|
22
|
+
error_response(nil, ErrorCode::INTERNAL_ERROR, e.message)
|
23
|
+
end
|
24
|
+
|
25
|
+
json_result = JSON.generate(result).force_encoding("UTF-8") if result
|
26
|
+
json_result
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def handle_request(request)
|
32
|
+
allowed_methods = [
|
33
|
+
Method::INITIALIZE,
|
34
|
+
Method::INITIALIZED,
|
35
|
+
Method::PING
|
36
|
+
]
|
37
|
+
|
38
|
+
if !@initialized && !allowed_methods.include?(request[:method])
|
39
|
+
return error_response(request[:id], ErrorCode::NOT_INITIALIZED, "Server not initialized")
|
40
|
+
end
|
41
|
+
|
42
|
+
case request[:method]
|
43
|
+
when Method::INITIALIZE
|
44
|
+
handle_initialize(request)
|
45
|
+
when Method::INITIALIZED
|
46
|
+
handle_initialized(request)
|
47
|
+
when Method::PING
|
48
|
+
handle_ping(request)
|
49
|
+
when Method::TOOLS_LIST
|
50
|
+
handle_list_tools(request)
|
51
|
+
when Method::TOOLS_CALL
|
52
|
+
handle_use_tool(request)
|
53
|
+
when Method::RESOURCES_LIST
|
54
|
+
handle_list_resources(request)
|
55
|
+
when Method::RESOURCES_READ
|
56
|
+
handle_read_resource(request)
|
57
|
+
else
|
58
|
+
error_response(request[:id], ErrorCode::METHOD_NOT_FOUND, "Unknown method: #{request[:method]}")
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def handle_initialize(request)
|
63
|
+
return error_response(request[:id], ErrorCode::ALREADY_INITIALIZED, "Server already initialized") if @initialized
|
64
|
+
|
65
|
+
client_version = request.dig(:params, :protocolVersion)
|
66
|
+
|
67
|
+
unless @supported_protocol_versions.include?(client_version)
|
68
|
+
return error_response(
|
69
|
+
request[:id],
|
70
|
+
ErrorCode::INVALID_PARAMS,
|
71
|
+
"Unsupported protocol version",
|
72
|
+
{
|
73
|
+
supported: @supported_protocol_versions,
|
74
|
+
requested: client_version
|
75
|
+
}
|
76
|
+
)
|
77
|
+
end
|
78
|
+
|
79
|
+
response = {
|
80
|
+
jsonrpc: JSON_RPC_VERSION,
|
81
|
+
id: request[:id],
|
82
|
+
result: {
|
83
|
+
protocolVersion: PROTOCOL_VERSION,
|
84
|
+
capabilities: {
|
85
|
+
resources: {
|
86
|
+
subscribe: false,
|
87
|
+
listChanged: false
|
88
|
+
},
|
89
|
+
tools: {
|
90
|
+
listChanged: false
|
91
|
+
}
|
92
|
+
},
|
93
|
+
serverInfo: {
|
94
|
+
name: @server.name,
|
95
|
+
version: @server.version
|
96
|
+
}
|
97
|
+
}
|
98
|
+
}
|
99
|
+
|
100
|
+
@initialized = true
|
101
|
+
response
|
102
|
+
end
|
103
|
+
|
104
|
+
def handle_initialized(request)
|
105
|
+
@initialized = true
|
106
|
+
nil
|
107
|
+
end
|
108
|
+
|
109
|
+
def handle_ping(request)
|
110
|
+
success_response(request[:id], {})
|
111
|
+
end
|
112
|
+
|
113
|
+
def handle_list_tools(request)
|
114
|
+
success_response(request[:id], {tools: @server.tool_manager.tools})
|
115
|
+
end
|
116
|
+
|
117
|
+
def handle_use_tool(request)
|
118
|
+
name = request.dig(:params, :name)
|
119
|
+
arguments = request.dig(:params, :arguments) || {}
|
120
|
+
|
121
|
+
begin
|
122
|
+
result = @server.tool_manager.call_tool(name, arguments)
|
123
|
+
|
124
|
+
success_response(request[:id], result)
|
125
|
+
rescue => e
|
126
|
+
error_response(request[:id], ErrorCode::INTERNAL_ERROR, e.message)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def handle_list_resources(request)
|
131
|
+
success_response(
|
132
|
+
request[:id],
|
133
|
+
{
|
134
|
+
resources: [],
|
135
|
+
nextCursor: "0"
|
136
|
+
}
|
137
|
+
)
|
138
|
+
end
|
139
|
+
|
140
|
+
def handle_read_resource(request)
|
141
|
+
uri = request.dig(:params, :uri)
|
142
|
+
error_response(request[:id], ErrorCode::INVALID_REQUEST, "Resource not found", {uri: uri})
|
143
|
+
end
|
144
|
+
|
145
|
+
def success_response(id, result)
|
146
|
+
{
|
147
|
+
jsonrpc: JSON_RPC_VERSION,
|
148
|
+
id: id,
|
149
|
+
result: result
|
150
|
+
}
|
151
|
+
end
|
152
|
+
|
153
|
+
def error_response(id, code, message, data = nil)
|
154
|
+
response = {
|
155
|
+
jsonrpc: JSON_RPC_VERSION,
|
156
|
+
id: id || 0,
|
157
|
+
error: {
|
158
|
+
code: code,
|
159
|
+
message: message
|
160
|
+
}
|
161
|
+
}
|
162
|
+
response[:error][:data] = data if data
|
163
|
+
response
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActiveMcp
|
4
|
+
class StdioConnection
|
5
|
+
def initialize
|
6
|
+
$stdout.sync = true
|
7
|
+
end
|
8
|
+
|
9
|
+
def read_next_message
|
10
|
+
message = $stdin.gets&.chomp
|
11
|
+
message.to_s.force_encoding("UTF-8")
|
12
|
+
end
|
13
|
+
|
14
|
+
def send_message(message)
|
15
|
+
message = message.to_s.force_encoding("UTF-8")
|
16
|
+
$stdout.binmode
|
17
|
+
$stdout.write(message + "\n")
|
18
|
+
$stdout.flush
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
require "json"
|
2
|
+
|
3
|
+
module ActiveMcp
|
4
|
+
class Server
|
5
|
+
class ToolManager
|
6
|
+
attr_reader :tools
|
7
|
+
|
8
|
+
def initialize(uri: nil, auth: nil)
|
9
|
+
@tools = {}
|
10
|
+
@uri = uri
|
11
|
+
|
12
|
+
if auth
|
13
|
+
@auth_header = "#{auth[:type] == :bearer ? "Bearer" : "Basic"} #{auth[:token]}"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def call_tool(name, arguments = {})
|
18
|
+
tool_info = @tools.find { _1[:name] == name }
|
19
|
+
|
20
|
+
unless tool_info
|
21
|
+
return {
|
22
|
+
isError: true,
|
23
|
+
content: [{type: "text", text: "Tool not found: #{name}"}]
|
24
|
+
}
|
25
|
+
end
|
26
|
+
|
27
|
+
invoke_tool(name, arguments)
|
28
|
+
end
|
29
|
+
|
30
|
+
def load_registered_tools
|
31
|
+
fetch_tools
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def invoke_tool(name, arguments)
|
37
|
+
require "net/http"
|
38
|
+
uri = URI.parse(@uri.to_s)
|
39
|
+
request = Net::HTTP::Post.new(uri)
|
40
|
+
request.body = JSON.generate({
|
41
|
+
method: "tools/call",
|
42
|
+
name:,
|
43
|
+
arguments: arguments.to_json
|
44
|
+
})
|
45
|
+
request["Content-Type"] = "application/json"
|
46
|
+
request["Authorization"] = @auth_header
|
47
|
+
|
48
|
+
begin
|
49
|
+
response = Net::HTTP.start(uri.hostname, uri.port) do |http|
|
50
|
+
http.request(request)
|
51
|
+
end
|
52
|
+
|
53
|
+
if response.code == "200"
|
54
|
+
body = JSON.parse(response.body, symbolize_names: true)
|
55
|
+
if body[:error]
|
56
|
+
{
|
57
|
+
isError: true,
|
58
|
+
content: [{type: "text", text: body[:error]}]
|
59
|
+
}
|
60
|
+
else
|
61
|
+
format_result(body[:result])
|
62
|
+
end
|
63
|
+
else
|
64
|
+
{
|
65
|
+
isError: true,
|
66
|
+
content: [{type: "text", text: "HTTP Error: #{response.code}"}]
|
67
|
+
}
|
68
|
+
end
|
69
|
+
rescue => e
|
70
|
+
{
|
71
|
+
isError: true,
|
72
|
+
content: [{type: "text", text: "Error calling tool: #{e.message}"}]
|
73
|
+
}
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def fetch_tools
|
78
|
+
return unless @uri
|
79
|
+
|
80
|
+
require "net/http"
|
81
|
+
uri = URI.parse(@uri.to_s)
|
82
|
+
request = Net::HTTP::Post.new(uri)
|
83
|
+
request.body = JSON.generate({
|
84
|
+
method: "tools/list",
|
85
|
+
arguments: "{}"
|
86
|
+
})
|
87
|
+
request["Content-Type"] = "application/json"
|
88
|
+
request["Authorization"] = @auth_header
|
89
|
+
|
90
|
+
begin
|
91
|
+
response = Net::HTTP.start(uri.hostname, uri.port) do |http|
|
92
|
+
http.request(request)
|
93
|
+
end
|
94
|
+
|
95
|
+
result = JSON.parse(response.body, symbolize_names: true)
|
96
|
+
@tools = result[:result]
|
97
|
+
rescue
|
98
|
+
@tools = []
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def format_result(result)
|
103
|
+
case result
|
104
|
+
when String
|
105
|
+
{content: [{type: "text", text: result}]}
|
106
|
+
when Hash
|
107
|
+
{content: [{type: "text", text: result.to_json}]}
|
108
|
+
else
|
109
|
+
{content: [{type: "text", text: result.to_s}]}
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require "json"
|
2
|
+
require "English"
|
3
|
+
require_relative "server/methods"
|
4
|
+
require_relative "server/error_codes"
|
5
|
+
require_relative "server/stdio_connection"
|
6
|
+
require_relative "server/tool_manager"
|
7
|
+
require_relative "server/protocol_handler"
|
8
|
+
|
9
|
+
module ActiveMcp
|
10
|
+
class Server
|
11
|
+
attr_reader :name, :version, :uri, :tool_manager, :protocol_handler
|
12
|
+
|
13
|
+
def initialize(
|
14
|
+
version: ActiveMcp::VERSION,
|
15
|
+
name: "ActiveMcp",
|
16
|
+
uri: nil,
|
17
|
+
auth: nil
|
18
|
+
)
|
19
|
+
@name = name
|
20
|
+
@version = version
|
21
|
+
@uri = uri
|
22
|
+
@tool_manager = ToolManager.new(uri: uri, auth:)
|
23
|
+
@protocol_handler = ProtocolHandler.new(self)
|
24
|
+
@tool_manager.load_registered_tools
|
25
|
+
end
|
26
|
+
|
27
|
+
def start
|
28
|
+
serve_stdio
|
29
|
+
end
|
30
|
+
|
31
|
+
def serve_stdio
|
32
|
+
serve(StdioConnection.new)
|
33
|
+
end
|
34
|
+
|
35
|
+
def serve(connection)
|
36
|
+
loop do
|
37
|
+
message = connection.read_next_message
|
38
|
+
break if message.nil?
|
39
|
+
|
40
|
+
response = @protocol_handler.process_message(message)
|
41
|
+
next if response.nil?
|
42
|
+
|
43
|
+
connection.send_message(response)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def initialized
|
48
|
+
@protocol_handler.initialized
|
49
|
+
end
|
50
|
+
|
51
|
+
def tools
|
52
|
+
@tool_manager.tools
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require "json-schema"
|
2
|
+
|
3
|
+
module ActiveMcp
|
4
|
+
class Tool
|
5
|
+
class << self
|
6
|
+
attr_reader :desc, :schema
|
7
|
+
|
8
|
+
def tool_name
|
9
|
+
name ? name.underscore.sub(/_tool$/, "") : ""
|
10
|
+
end
|
11
|
+
|
12
|
+
def description(value)
|
13
|
+
@desc = value
|
14
|
+
end
|
15
|
+
|
16
|
+
def property(name, type, required: false, description: nil)
|
17
|
+
@schema ||= {
|
18
|
+
"type" => "object",
|
19
|
+
"properties" => {},
|
20
|
+
"required" => []
|
21
|
+
}
|
22
|
+
|
23
|
+
@schema["properties"][name.to_s] = {"type" => type.to_s}
|
24
|
+
@schema["properties"][name.to_s]["description"] = description if description
|
25
|
+
@schema["required"] << name.to_s if required
|
26
|
+
end
|
27
|
+
|
28
|
+
def registered_tools
|
29
|
+
@registered_tools ||= []
|
30
|
+
end
|
31
|
+
|
32
|
+
attr_writer :registered_tools
|
33
|
+
|
34
|
+
def inherited(subclass)
|
35
|
+
registered_tools << subclass
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def initialize
|
40
|
+
end
|
41
|
+
|
42
|
+
def call(**args)
|
43
|
+
raise NotImplementedError, "#{self.class.name}#call must be implemented"
|
44
|
+
end
|
45
|
+
|
46
|
+
def validate_arguments(args)
|
47
|
+
return true unless self.class.schema
|
48
|
+
|
49
|
+
JSON::Validator.validate!(self.class.schema, args)
|
50
|
+
rescue JSON::Schema::ValidationError => e
|
51
|
+
{error: e.message}
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
data/lib/active_mcp.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative "active_mcp/version"
|
4
|
+
require_relative "active_mcp/tool"
|
5
|
+
require_relative "active_mcp/server"
|
6
|
+
|
7
|
+
if defined? ::Rails
|
8
|
+
require_relative "active_mcp/engine"
|
9
|
+
end
|
10
|
+
|
11
|
+
module ActiveMcp
|
12
|
+
JSON_RPC_VERSION = "2.0"
|
13
|
+
PROTOCOL_VERSION = "2024-11-05"
|
14
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class <%= class_name %> < ActiveMcp::Tool
|
2
|
+
description "<%= file_name.humanize %>"
|
3
|
+
|
4
|
+
property :param1, :string, required: true, description: "First parameter description"
|
5
|
+
property :param2, :string, required: false, description: "Second parameter description"
|
6
|
+
# Add more parameters as needed
|
7
|
+
|
8
|
+
def call(param1:, param2: nil, auth_info: nil, **args)
|
9
|
+
# Authentication information can be accessed via _auth_info parameter
|
10
|
+
# auth_info = { type: :bearer, token: "xxx", header: "Bearer xxx" }
|
11
|
+
# or { type: :basic, token: "base64encoded", header: "Basic base64encoded" }
|
12
|
+
|
13
|
+
# Implement tool logic here
|
14
|
+
|
15
|
+
# Return a string, hash, or any JSON-serializable object
|
16
|
+
"Tool executed successfully with #{param1}"
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module ActiveMcp
|
2
|
+
module Generators
|
3
|
+
class ToolGenerator < Rails::Generators::NamedBase
|
4
|
+
source_root File.expand_path("templates", __dir__)
|
5
|
+
|
6
|
+
def create_tool_file
|
7
|
+
template "tool.rb.erb", File.join("app/tools", "#{file_name}_tool.rb")
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def class_name
|
13
|
+
"#{file_name.camelize}Tool"
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
metadata
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: active_mcp
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Your Name
|
8
|
+
bindir: bin
|
9
|
+
cert_chain: []
|
10
|
+
date: 2025-04-02 00:00:00.000000000 Z
|
11
|
+
dependencies:
|
12
|
+
- !ruby/object:Gem::Dependency
|
13
|
+
name: rails
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
15
|
+
requirements:
|
16
|
+
- - ">="
|
17
|
+
- !ruby/object:Gem::Version
|
18
|
+
version: 6.0.0
|
19
|
+
- - "<"
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: 8.0.0
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
requirements:
|
26
|
+
- - ">="
|
27
|
+
- !ruby/object:Gem::Version
|
28
|
+
version: 6.0.0
|
29
|
+
- - "<"
|
30
|
+
- !ruby/object:Gem::Version
|
31
|
+
version: 8.0.0
|
32
|
+
- !ruby/object:Gem::Dependency
|
33
|
+
name: json-schema
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
35
|
+
requirements:
|
36
|
+
- - ">="
|
37
|
+
- !ruby/object:Gem::Version
|
38
|
+
version: '0'
|
39
|
+
type: :runtime
|
40
|
+
prerelease: false
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
42
|
+
requirements:
|
43
|
+
- - ">="
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '0'
|
46
|
+
description: A Rails engine that provides MCP capabilities to your Rails application
|
47
|
+
email:
|
48
|
+
- your.email@example.com
|
49
|
+
executables: []
|
50
|
+
extensions: []
|
51
|
+
extra_rdoc_files: []
|
52
|
+
files:
|
53
|
+
- MIT-LICENSE
|
54
|
+
- README.md
|
55
|
+
- Rakefile
|
56
|
+
- app/controllers/active_mcp/base_controller.rb
|
57
|
+
- config/routes.rb
|
58
|
+
- lib/active_mcp.rb
|
59
|
+
- lib/active_mcp/engine.rb
|
60
|
+
- lib/active_mcp/server.rb
|
61
|
+
- lib/active_mcp/server/error_codes.rb
|
62
|
+
- lib/active_mcp/server/methods.rb
|
63
|
+
- lib/active_mcp/server/protocol_handler.rb
|
64
|
+
- lib/active_mcp/server/stdio_connection.rb
|
65
|
+
- lib/active_mcp/server/tool_manager.rb
|
66
|
+
- lib/active_mcp/tool.rb
|
67
|
+
- lib/active_mcp/version.rb
|
68
|
+
- lib/generators/active_mcp/tool/templates/tool.rb.erb
|
69
|
+
- lib/generators/active_mcp/tool/tool_generator.rb
|
70
|
+
homepage: https://github.com/moekiorg/active_mcp
|
71
|
+
licenses:
|
72
|
+
- MIT
|
73
|
+
metadata: {}
|
74
|
+
rdoc_options: []
|
75
|
+
require_paths:
|
76
|
+
- lib
|
77
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
78
|
+
requirements:
|
79
|
+
- - ">="
|
80
|
+
- !ruby/object:Gem::Version
|
81
|
+
version: 2.7.0
|
82
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
83
|
+
requirements:
|
84
|
+
- - ">="
|
85
|
+
- !ruby/object:Gem::Version
|
86
|
+
version: '0'
|
87
|
+
requirements: []
|
88
|
+
rubygems_version: 3.6.6
|
89
|
+
specification_version: 4
|
90
|
+
summary: Rails engine for the Model Context Protocol (MCP)
|
91
|
+
test_files: []
|