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 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,4 @@
1
+ ActiveMcp::Engine.routes.draw do
2
+ post "/", to: "base#index"
3
+ get "/health", to: proc { [200, {}, ["OK"]] }
4
+ end
@@ -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
@@ -0,0 +1,3 @@
1
+ module ActiveMcp
2
+ VERSION = "0.1.0"
3
+ 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: []