rails_mcp_engine 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/README.md +134 -0
- data/Rakefile +10 -0
- data/app/controllers/rails_mcp_engine/application_controller.rb +5 -0
- data/app/controllers/rails_mcp_engine/chat_controller.rb +110 -0
- data/app/controllers/rails_mcp_engine/playground_controller.rb +183 -0
- data/app/lib/tool_meta.rb +91 -0
- data/app/lib/tool_schema/builder.rb +71 -0
- data/app/lib/tool_schema/fast_mcp_builder.rb +66 -0
- data/app/lib/tool_schema/fast_mcp_factory.rb +49 -0
- data/app/lib/tool_schema/ruby_llm_builder.rb +80 -0
- data/app/lib/tool_schema/ruby_llm_factory.rb +48 -0
- data/app/lib/tool_schema/sorbet_type_mapper.rb +137 -0
- data/app/services/tools/meta_tool_service.rb +165 -0
- data/app/views/rails_mcp_engine/chat/show.html.erb +478 -0
- data/app/views/rails_mcp_engine/playground/show.html.erb +138 -0
- data/config/routes.rb +9 -0
- data/lib/rails_mcp_engine/engine.rb +42 -0
- data/lib/rails_mcp_engine/version.rb +3 -0
- data/lib/rails_mcp_engine.rb +6 -0
- metadata +126 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 53d474e90f159408325d95bf24d519e93a5f82b2b00258dc756300dd6ae04365
|
|
4
|
+
data.tar.gz: f38095e894a2ae6b3aba6744aadfaf529d8aa9029f7f1b0f0c4992516e2beb93
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 67040a2d30ec66489b8dd99f7d995f7f93e36f353e935384d8205b51d8d4103438f3ea83f8ead1397ef8afd0f54b823f76f513f3d5fe4613a0316de380d8b5a4
|
|
7
|
+
data.tar.gz: 75e1d06bb502e402beb4f8aa85c5ede2ab741017526cd7ed413130ab6c12edeb20a77ce3fdb7cccaf859103de82d24389d029a041271b90bf79fa8938835217b
|
data/README.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# Rails MCP Engine
|
|
2
|
+
|
|
3
|
+
Rails MCP Engine provides a unified tool-definition pipeline for Rails 8 applications. Service classes declare Sorbet-typed signatures and metadata once, and the engine auto-generates both RubyLLM and FastMCP tool classes at boot.
|
|
4
|
+
|
|
5
|
+
## Core Dependencies
|
|
6
|
+
|
|
7
|
+
This engine is built on top of these powerful libraries:
|
|
8
|
+
|
|
9
|
+
- **[RubyLLM](https://github.com/crmne/ruby_llm)**: For generating LLM-compatible tool definitions.
|
|
10
|
+
- **[FastMCP](https://github.com/yjacquin/fast-mcp)**: For creating Model Context Protocol (MCP) servers.
|
|
11
|
+
- **[Sorbet](https://github.com/sorbet/sorbet)**: For static type checking and signature definitions.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
Add this line to your application's Gemfile:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'rails_mcp_engine'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
And then execute:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
- **Service classes** live under the `Tools::` namespace, extend `ToolMeta`, and expose a single Sorbet-signed entrypoint (defaults to `#call`).
|
|
31
|
+
- **ToolMeta DSL** captures names, descriptions, and parameter metadata, while Sorbet signatures provide the type source of truth.
|
|
32
|
+
- The **ToolSchema pipeline** merges Sorbet type information with metadata, producing a unified schema AST.
|
|
33
|
+
- **Factories** (`ToolSchema::RubyLlmFactory` and `ToolSchema::FastMcpFactory`) transform the AST into RubyLLM tools and FastMCP `ApplicationTool` subclasses, respectively.
|
|
34
|
+
- The **Engine** automatically iterates through registered service classes and generates both tool types during Rails initialization (`to_prepare`).
|
|
35
|
+
|
|
36
|
+
## Defining a tool service
|
|
37
|
+
|
|
38
|
+
Create a service that extends `ToolMeta` and uses Sorbet for the entrypoint signature. Only business logic belongs here; tool wrappers are generated.
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
# app/services/tools/book_meeting_service.rb
|
|
42
|
+
class Tools::BookMeetingService
|
|
43
|
+
extend T::Sig
|
|
44
|
+
extend ToolMeta
|
|
45
|
+
|
|
46
|
+
tool_name "book_meeting"
|
|
47
|
+
tool_description "Books a meeting."
|
|
48
|
+
tool_param :window, description: "Start/finish window"
|
|
49
|
+
tool_param :participants, description: "Email recipients"
|
|
50
|
+
|
|
51
|
+
sig do
|
|
52
|
+
params(
|
|
53
|
+
window: T::Hash[Symbol, String],
|
|
54
|
+
participants: T::Array[String]
|
|
55
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
56
|
+
end
|
|
57
|
+
def call(window:, participants:)
|
|
58
|
+
# ... business logic ...
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
On boot, the engine generates:
|
|
64
|
+
- `Tools::BookMeeting < RubyLLM::Tool` with a matching `params` block.
|
|
65
|
+
- `Mcp::BookMeetingTool < ApplicationTool` with a matching `arguments` block.
|
|
66
|
+
|
|
67
|
+
## Default meta tool
|
|
68
|
+
|
|
69
|
+
`Tools::MetaToolService` is included by default to explore and execute registered tools at runtime. It exposes a single `action` argument with supporting keywords:
|
|
70
|
+
|
|
71
|
+
- `list`: return full tool details (name, description, params, return type).
|
|
72
|
+
- `list_summary`: return only names and descriptions.
|
|
73
|
+
- `search`: provide `query` to fuzzy-match name/description.
|
|
74
|
+
- `get`: provide `tool_name` to fetch a full schema payload.
|
|
75
|
+
- `run`: provide `tool_name` and `arguments` to invoke a tool through its service class.
|
|
76
|
+
|
|
77
|
+
> **Note:** The `register` action is not available via the tool interface for security reasons. Developers can manually register tools using `Tools::MetaToolService.new.register_tool("ClassName")` in their code.
|
|
78
|
+
|
|
79
|
+
Example invocation from a console:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
Tools::MetaToolService.new.call(action: 'run', tool_name: 'book_meeting', arguments: { window: { start: '...', finish: '...' }, participants: ['a@example.com'] })
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Tool Registration Hooks
|
|
86
|
+
|
|
87
|
+
You can attach `before_call` and `after_call` hooks when manually registering tools. These hooks are useful for logging, tracing, or other side effects.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
Tools::MetaToolService.new.register_tool(
|
|
91
|
+
'Tools::BookMeetingService',
|
|
92
|
+
before_call: ->(args) { Rails.logger.info("Calling tool with #{args}") },
|
|
93
|
+
after_call: ->(result) { Rails.logger.info("Tool returned #{result}") }
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- `before_call`: A `Proc` that receives the arguments hash.
|
|
98
|
+
- `after_call`: A `Proc` that receives the result.
|
|
99
|
+
|
|
100
|
+
These hooks are executed around the tool's entrypoint method for both RubyLLM and FastMCP wrappers.
|
|
101
|
+
|
|
102
|
+
## Development
|
|
103
|
+
|
|
104
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rails test` to run the tests.
|
|
105
|
+
|
|
106
|
+
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
107
|
+
|
|
108
|
+
### Playground & Chat
|
|
109
|
+
|
|
110
|
+
The engine includes a built-in playground and chat interface for testing your tools.
|
|
111
|
+
|
|
112
|
+
1. **Mount the engine**: Ensure the engine is mounted in your `config/routes.rb` (e.g., `mount RailsMcpEngine::Engine => '/rails_mcp_engine'`).
|
|
113
|
+
2. **Access the Playground**: Navigate to `/rails_mcp_engine/playground` to register and test tools individually.
|
|
114
|
+
3. **Access the Chat**: Navigate to `/rails_mcp_engine/chat` to test tools within a conversational interface using OpenAI models.
|
|
115
|
+
|
|
116
|
+
The playground allows you to:
|
|
117
|
+
- Register tool services dynamically by pasting Ruby code.
|
|
118
|
+
- Run registered tools with JSON arguments.
|
|
119
|
+
- View tool schemas and details.
|
|
120
|
+
|
|
121
|
+
The chat interface allows you to:
|
|
122
|
+
- Chat with OpenAI models (e.g., gpt-4o).
|
|
123
|
+
- Automatically invoke registered tools during the conversation.
|
|
124
|
+
- View tool calls and results within the chat history.
|
|
125
|
+
|
|
126
|
+
Screenshots:
|
|
127
|
+
|
|
128
|
+
| Playground | Chat |
|
|
129
|
+
| --- | --- |
|
|
130
|
+
|  |  |
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
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,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RailsMcpEngine
|
|
6
|
+
class ChatController < ApplicationController
|
|
7
|
+
def show
|
|
8
|
+
@tools = schemas
|
|
9
|
+
@models = [
|
|
10
|
+
# OpenAI
|
|
11
|
+
'gpt-5-nano',
|
|
12
|
+
'gpt-4.1',
|
|
13
|
+
'gpt-4o',
|
|
14
|
+
'gpt-4o-mini',
|
|
15
|
+
# Google
|
|
16
|
+
'gemini-2.5-pro',
|
|
17
|
+
'gemini-2.0-pro-exp',
|
|
18
|
+
# Anthropic
|
|
19
|
+
'claude-sonnet-4-5',
|
|
20
|
+
'claude-3-7-sonnet-20250219',
|
|
21
|
+
'claude-3-5-haiku-20241022',
|
|
22
|
+
'claude-3-haiku-20240307'
|
|
23
|
+
]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def send_message
|
|
27
|
+
user_message = params[:message].to_s
|
|
28
|
+
model = params[:model].to_s
|
|
29
|
+
conversation_history = JSON.parse(params[:conversation_history] || '[]', symbolize_names: true)
|
|
30
|
+
|
|
31
|
+
if user_message.strip.empty?
|
|
32
|
+
render json: { error: 'Message is required' }, status: :bad_request
|
|
33
|
+
return
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
provider = if model.start_with?('gemini')
|
|
37
|
+
:gemini
|
|
38
|
+
elsif model.start_with?('claude')
|
|
39
|
+
:anthropic
|
|
40
|
+
else
|
|
41
|
+
:openai
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Create RubyLLM chat instance with configuration
|
|
45
|
+
chat = RubyLLM.chat(
|
|
46
|
+
provider: provider,
|
|
47
|
+
model: model
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Register all available tools
|
|
51
|
+
tool_classes = get_tool_classes
|
|
52
|
+
chat = tool_classes.reduce(chat) { |c, tool_class| c.with_tool(tool_class) }
|
|
53
|
+
|
|
54
|
+
# Prepare the message with conversation history context
|
|
55
|
+
if conversation_history.empty?
|
|
56
|
+
# First message: just send as-is
|
|
57
|
+
full_message = user_message
|
|
58
|
+
else
|
|
59
|
+
# Include conversation history for context
|
|
60
|
+
context_parts = ['Previous conversation:']
|
|
61
|
+
conversation_history.each do |msg|
|
|
62
|
+
role_label = msg[:role] == 'user' ? 'User' : 'Assistant'
|
|
63
|
+
context_parts << "#{role_label}: #{msg[:content]}"
|
|
64
|
+
end
|
|
65
|
+
context_parts << "\nCurrent question:"
|
|
66
|
+
context_parts << user_message
|
|
67
|
+
full_message = context_parts.join("\n\n")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Ask the question and capture response
|
|
71
|
+
begin
|
|
72
|
+
# Ruby LLM handles tool calling automatically
|
|
73
|
+
response = chat.ask(full_message)
|
|
74
|
+
assistant_content = response.content
|
|
75
|
+
|
|
76
|
+
# Build conversation history manually since Ruby LLM manages it internally
|
|
77
|
+
# Add user message
|
|
78
|
+
conversation_history << { role: 'user', content: user_message }
|
|
79
|
+
# Add assistant response
|
|
80
|
+
conversation_history << { role: 'assistant', content: assistant_content }
|
|
81
|
+
|
|
82
|
+
# Tool results are handled transparently by Ruby LLM
|
|
83
|
+
# We don't have direct access to them, so return empty array
|
|
84
|
+
tool_results = []
|
|
85
|
+
|
|
86
|
+
render json: {
|
|
87
|
+
conversation_history: conversation_history,
|
|
88
|
+
tool_results: tool_results
|
|
89
|
+
}
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
render json: { error: e.message }, status: :bad_request
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def schemas
|
|
98
|
+
ToolMeta.registry.map { |service_class| ToolSchema::Builder.build(service_class) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def get_tool_classes
|
|
102
|
+
# Get all RubyLLM tool classes that were generated
|
|
103
|
+
schemas.map do |schema|
|
|
104
|
+
tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(schema[:service_class])
|
|
105
|
+
# Accessing Tools from global namespace
|
|
106
|
+
::Tools.const_get(tool_constant)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RailsMcpEngine
|
|
6
|
+
class PlaygroundController < ApplicationController
|
|
7
|
+
def show
|
|
8
|
+
@register_result = flash[:register_result]
|
|
9
|
+
@test_result = flash[:test_result]
|
|
10
|
+
@tools = schemas
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def register
|
|
14
|
+
source = params[:source].to_s
|
|
15
|
+
class_name = extract_class_name(source)
|
|
16
|
+
|
|
17
|
+
result = if source.strip.empty?
|
|
18
|
+
{ error: 'Tool source code is required' }
|
|
19
|
+
elsif class_name.nil?
|
|
20
|
+
{ error: 'Could not infer class name from the provided source' }
|
|
21
|
+
else
|
|
22
|
+
register_source(source, class_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
flash[:register_result] = result
|
|
26
|
+
redirect_to playground_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
tool_name = params[:tool_name].to_s
|
|
31
|
+
parsed_arguments = parse_arguments(params[:arguments])
|
|
32
|
+
schema = schemas.find { |s| s[:name] == tool_name }
|
|
33
|
+
|
|
34
|
+
result = if schema.nil?
|
|
35
|
+
{ error: "Tool not found: #{tool_name}" }
|
|
36
|
+
elsif parsed_arguments.is_a?(String)
|
|
37
|
+
{ error: parsed_arguments }
|
|
38
|
+
else
|
|
39
|
+
invoke_tool(schema, parsed_arguments)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
flash[:test_result] = result
|
|
43
|
+
redirect_to playground_path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def delete_tool
|
|
47
|
+
tool_name = params[:tool_name].to_s
|
|
48
|
+
schema = schemas.find { |s| s[:name] == tool_name }
|
|
49
|
+
|
|
50
|
+
result = if schema.nil?
|
|
51
|
+
{ error: "Tool not found: #{tool_name}" }
|
|
52
|
+
else
|
|
53
|
+
delete_tool_from_registry(schema[:service_class])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
flash[:register_result] = result
|
|
57
|
+
redirect_to playground_path
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def schemas
|
|
63
|
+
ToolMeta.registry.map { |service_class| ToolSchema::Builder.build(service_class) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def extract_class_name(source)
|
|
67
|
+
require 'ripper'
|
|
68
|
+
sexp = Ripper.sexp(source)
|
|
69
|
+
return nil unless sexp
|
|
70
|
+
|
|
71
|
+
# sexp is [:program, statements]
|
|
72
|
+
statements = sexp[1]
|
|
73
|
+
find_class(statements, [])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def find_class(statements, namespace)
|
|
77
|
+
return nil unless statements.is_a?(Array)
|
|
78
|
+
|
|
79
|
+
statements.each do |stmt|
|
|
80
|
+
next unless stmt.is_a?(Array)
|
|
81
|
+
|
|
82
|
+
case stmt.first
|
|
83
|
+
when :module
|
|
84
|
+
# [:module, const_ref, body]
|
|
85
|
+
# body is [:bodystmt, statements, ...]
|
|
86
|
+
const_node = stmt[1]
|
|
87
|
+
const_name = get_const_name(const_node)
|
|
88
|
+
|
|
89
|
+
body_stmt = stmt[2]
|
|
90
|
+
inner_statements = body_stmt[1]
|
|
91
|
+
|
|
92
|
+
result = find_class(inner_statements, namespace + [const_name])
|
|
93
|
+
return result if result
|
|
94
|
+
when :class
|
|
95
|
+
# [:class, const_ref, superclass, body]
|
|
96
|
+
const_node = stmt[1]
|
|
97
|
+
const_name = get_const_name(const_node)
|
|
98
|
+
|
|
99
|
+
return (namespace + [const_name]).join('::')
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def get_const_name(node)
|
|
106
|
+
return nil unless node.is_a?(Array)
|
|
107
|
+
|
|
108
|
+
type = node.first
|
|
109
|
+
if type == :const_ref
|
|
110
|
+
# [:const_ref, [:@const, "Name", ...]]
|
|
111
|
+
node[1][1]
|
|
112
|
+
elsif type == :const_path_ref
|
|
113
|
+
# [:const_path_ref, parent, child]
|
|
114
|
+
parent = node[1]
|
|
115
|
+
child = node[2] # [:@const, "Name", ...]
|
|
116
|
+
|
|
117
|
+
parent_name = if parent.first == :var_ref
|
|
118
|
+
parent[1][1]
|
|
119
|
+
else
|
|
120
|
+
get_const_name(parent)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
"#{parent_name}::#{child[1]}"
|
|
124
|
+
else
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def register_source(source, class_name)
|
|
130
|
+
Object.class_eval(source)
|
|
131
|
+
# Use the engine's namespace or ensure Tools is available.
|
|
132
|
+
# Assuming Tools module is defined in the host app or globally.
|
|
133
|
+
# If Tools is not defined, we might need to define it or use a different namespace.
|
|
134
|
+
# For now, keeping it as is, assuming host app environment.
|
|
135
|
+
|
|
136
|
+
# However, since we are in an engine, we should check if we need to be more careful.
|
|
137
|
+
# The original code used Tools::MetaToolService.
|
|
138
|
+
# Let's check if Tools is defined in the engine or expected from host.
|
|
139
|
+
# The engine.rb defines ApplicationTool.
|
|
140
|
+
|
|
141
|
+
# Re-using the logic from ManualController but adapting for Engine.
|
|
142
|
+
::Tools::MetaToolService.new.register_tool(
|
|
143
|
+
class_name,
|
|
144
|
+
before_call: ->(args) { Rails.logger.info(" [MCP] Request #{class_name}: #{args.inspect}") },
|
|
145
|
+
after_call: ->(result) { Rails.logger.info(" [MCP] Response #{class_name}: #{result.inspect}") }
|
|
146
|
+
)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
{ error: e.message }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def parse_arguments(raw_value)
|
|
152
|
+
return {} if raw_value.to_s.strip.empty?
|
|
153
|
+
|
|
154
|
+
JSON.parse(raw_value, symbolize_names: true)
|
|
155
|
+
rescue JSON::ParserError => e
|
|
156
|
+
e.message
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def invoke_tool(schema, arguments)
|
|
160
|
+
tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(schema[:service_class])
|
|
161
|
+
# Accessing Tools from global namespace
|
|
162
|
+
tool_class = ::Tools.const_get(tool_constant)
|
|
163
|
+
result = tool_class.new.execute(**arguments.symbolize_keys)
|
|
164
|
+
|
|
165
|
+
{ tool: { name: schema[:name], description: schema[:description] }, result: result }
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
Rails.logger.error("Error invoking tool #{schema[:name]}: #{e.message}")
|
|
168
|
+
{ error: e.message }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def delete_tool_from_registry(service_class)
|
|
172
|
+
ToolMeta.registry.delete(service_class)
|
|
173
|
+
|
|
174
|
+
# Also remove the RubyLLM tool class constant
|
|
175
|
+
tool_constant = ToolSchema::RubyLlmFactory.tool_class_name(service_class)
|
|
176
|
+
::Tools.send(:remove_const, tool_constant) if ::Tools.const_defined?(tool_constant, false)
|
|
177
|
+
|
|
178
|
+
{ success: 'Tool deleted successfully' }
|
|
179
|
+
rescue StandardError => e
|
|
180
|
+
{ error: e.message }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
# ToolMeta defines a DSL for annotating service classes that represent tools.
|
|
7
|
+
# The metadata collected here becomes the source of truth for non-type
|
|
8
|
+
# attributes such as names, descriptions, and examples.
|
|
9
|
+
module ToolMeta
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
ParamMetadata = T.type_alias do
|
|
13
|
+
T::Hash[Symbol, T.untyped]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class MissingSignatureError < StandardError; end
|
|
17
|
+
|
|
18
|
+
sig { params(base: T.class_of(Object)).void }
|
|
19
|
+
def self.extended(base)
|
|
20
|
+
registry << base unless registry.include?(base)
|
|
21
|
+
base.instance_variable_set(:@tool_params, T.let([], T::Array[ParamMetadata]))
|
|
22
|
+
base.instance_variable_set(:@tool_name, nil)
|
|
23
|
+
base.instance_variable_set(:@tool_description, nil)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
sig { returns(T::Array[T.class_of(Object)]) }
|
|
27
|
+
def self.registry
|
|
28
|
+
@registry ||= []
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
sig { void }
|
|
32
|
+
def self.clear_registry
|
|
33
|
+
@registry = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig { params(name: String).void }
|
|
37
|
+
def tool_name(name)
|
|
38
|
+
@tool_name = name
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { params(description: String).void }
|
|
42
|
+
def tool_description(description)
|
|
43
|
+
@tool_description = description
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig do
|
|
47
|
+
params(
|
|
48
|
+
name: T.any(String, Symbol),
|
|
49
|
+
description: T.nilable(String),
|
|
50
|
+
required: T::Boolean,
|
|
51
|
+
example: T.untyped,
|
|
52
|
+
enum: T.nilable(T::Array[T.untyped])
|
|
53
|
+
).void
|
|
54
|
+
end
|
|
55
|
+
def tool_param(name, description: nil, required: true, example: nil, enum: nil)
|
|
56
|
+
@tool_params << {
|
|
57
|
+
name: name.to_sym,
|
|
58
|
+
description: description,
|
|
59
|
+
required: required,
|
|
60
|
+
example: example,
|
|
61
|
+
enum: enum
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
sig { returns(String) }
|
|
66
|
+
def tool_entrypoint
|
|
67
|
+
'call'
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
sig { returns(T::Hash[Symbol, T.untyped]) }
|
|
71
|
+
def tool_metadata
|
|
72
|
+
{
|
|
73
|
+
name: @tool_name || default_tool_name,
|
|
74
|
+
description: @tool_description || '',
|
|
75
|
+
params: @tool_params,
|
|
76
|
+
entrypoint: tool_entrypoint
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
sig { returns(String) }
|
|
83
|
+
def default_tool_name
|
|
84
|
+
return '' unless respond_to?(:name) && name
|
|
85
|
+
|
|
86
|
+
name.split('::').last
|
|
87
|
+
.gsub(/Service$/, '')
|
|
88
|
+
.gsub(/([a-z0-9])([A-Z])/, '\\1_\\2')
|
|
89
|
+
.downcase
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
require_relative '../tool_meta'
|
|
6
|
+
require_relative 'sorbet_type_mapper'
|
|
7
|
+
|
|
8
|
+
module ToolSchema
|
|
9
|
+
class Builder
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
SchemaAst = T.type_alias do
|
|
13
|
+
T::Hash[Symbol, T.untyped]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
sig { params(service_class: T.class_of(Object)).returns(SchemaAst) }
|
|
17
|
+
def self.build(service_class)
|
|
18
|
+
metadata = T.let(service_class.tool_metadata, T::Hash[Symbol, T.untyped])
|
|
19
|
+
entrypoint = metadata[:entrypoint]&.to_sym || :call
|
|
20
|
+
method = service_class.instance_method(entrypoint)
|
|
21
|
+
type_info = SorbetTypeMapper.map_signature(method)
|
|
22
|
+
|
|
23
|
+
params_ast = type_info[:params].map do |param_ast|
|
|
24
|
+
merge_param(param_ast, metadata[:params])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
{
|
|
28
|
+
name: metadata[:name],
|
|
29
|
+
description: metadata[:description],
|
|
30
|
+
params: params_ast,
|
|
31
|
+
return_type: type_info[:return_type],
|
|
32
|
+
entrypoint: entrypoint,
|
|
33
|
+
service_class: service_class
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
sig do
|
|
38
|
+
params(
|
|
39
|
+
param_ast: T::Hash[Symbol, T.untyped],
|
|
40
|
+
metadata_params: T.nilable(T::Array[T::Hash[Symbol, T.untyped]])
|
|
41
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
42
|
+
end
|
|
43
|
+
def self.merge_param(param_ast, metadata_params)
|
|
44
|
+
meta = metadata_params&.find { |p| p[:name].to_sym == param_ast[:name].to_sym }
|
|
45
|
+
description = meta ? meta[:description] : nil
|
|
46
|
+
example = meta ? meta[:example] : nil
|
|
47
|
+
enum = meta ? meta[:enum] : nil
|
|
48
|
+
required_from_meta = meta.nil? ? true : (meta[:required].nil? ? true : meta[:required])
|
|
49
|
+
required = param_ast[:required] && required_from_meta
|
|
50
|
+
|
|
51
|
+
param_ast.merge(
|
|
52
|
+
description: description,
|
|
53
|
+
example: example,
|
|
54
|
+
enum: enum,
|
|
55
|
+
required: required,
|
|
56
|
+
children: merge_children(param_ast[:children], metadata_params)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig do
|
|
61
|
+
params(children: T.untyped, metadata_params: T.nilable(T::Array[T::Hash[Symbol, T.untyped]])).returns(T.untyped)
|
|
62
|
+
end
|
|
63
|
+
def self.merge_children(children, metadata_params)
|
|
64
|
+
return [] unless children.is_a?(Array)
|
|
65
|
+
|
|
66
|
+
children.map do |child|
|
|
67
|
+
merge_param(child, metadata_params)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
module ToolSchema
|
|
7
|
+
module FastMcpBuilder
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
sig { params(params_ast: T::Array[T::Hash[Symbol, T.untyped]]).returns(Proc) }
|
|
11
|
+
def self.arguments_block(params_ast)
|
|
12
|
+
proc do
|
|
13
|
+
params_ast.each do |param|
|
|
14
|
+
FastMcpBuilder.build_param(self, param)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { params(ctx: BasicObject, param: T::Hash[Symbol, T.untyped]).void }
|
|
20
|
+
def self.build_param(ctx, param)
|
|
21
|
+
wrapper = param[:required] ? ctx.required(param[:name]) : ctx.optional(param[:name])
|
|
22
|
+
|
|
23
|
+
node = case param[:type]
|
|
24
|
+
when :object
|
|
25
|
+
wrapper.hash do
|
|
26
|
+
(param[:children] || []).each do |child|
|
|
27
|
+
FastMcpBuilder.build_param(self, child)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
when :array
|
|
31
|
+
item = param[:item_type]
|
|
32
|
+
if item && scalar_type?(item[:type])
|
|
33
|
+
wrapper.array(scalar_symbol(item[:type]))
|
|
34
|
+
elsif item&.dig(:type) == :object
|
|
35
|
+
wrapper.array(:hash) do
|
|
36
|
+
(item[:children] || []).each do |child|
|
|
37
|
+
FastMcpBuilder.build_param(self, child)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
wrapper.array(:any)
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
wrapper.value(scalar_symbol(param[:type]))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
node.description(param[:description]) if param[:description]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
|
51
|
+
def self.scalar_type?(type)
|
|
52
|
+
%i[string integer float boolean any].include?(type)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
sig { params(type: T.untyped).returns(Symbol) }
|
|
56
|
+
def self.scalar_symbol(type)
|
|
57
|
+
case type
|
|
58
|
+
when :string then :string
|
|
59
|
+
when :integer then :integer
|
|
60
|
+
when :float then :float
|
|
61
|
+
when :boolean then :bool
|
|
62
|
+
else :any
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|