tsikol 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/CHANGELOG.md +22 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +579 -0
- data/Rakefile +12 -0
- data/docs/README.md +69 -0
- data/docs/api/middleware.md +721 -0
- data/docs/api/prompt.md +858 -0
- data/docs/api/resource.md +651 -0
- data/docs/api/server.md +509 -0
- data/docs/api/test-helpers.md +591 -0
- data/docs/api/tool.md +527 -0
- data/docs/cookbook/authentication.md +651 -0
- data/docs/cookbook/caching.md +877 -0
- data/docs/cookbook/dynamic-tools.md +970 -0
- data/docs/cookbook/error-handling.md +887 -0
- data/docs/cookbook/logging.md +1044 -0
- data/docs/cookbook/rate-limiting.md +717 -0
- data/docs/examples/code-assistant.md +922 -0
- data/docs/examples/complete-server.md +726 -0
- data/docs/examples/database-manager.md +1198 -0
- data/docs/examples/devops-tools.md +1382 -0
- data/docs/examples/echo-server.md +501 -0
- data/docs/examples/weather-service.md +822 -0
- data/docs/guides/completion.md +472 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/guides/middleware.md +823 -0
- data/docs/guides/project-structure.md +434 -0
- data/docs/guides/prompts.md +920 -0
- data/docs/guides/resources.md +720 -0
- data/docs/guides/sampling.md +804 -0
- data/docs/guides/testing.md +863 -0
- data/docs/guides/tools.md +627 -0
- data/examples/README.md +92 -0
- data/examples/advanced_features.rb +129 -0
- data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
- data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
- data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
- data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
- data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
- data/examples/basic-migrated/server.rb +25 -0
- data/examples/basic.rb +73 -0
- data/examples/full_featured.rb +175 -0
- data/examples/middleware_example.rb +112 -0
- data/examples/sampling_example.rb +104 -0
- data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
- data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
- data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
- data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
- data/examples/weather-service/server.rb +28 -0
- data/exe/tsikol +6 -0
- data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
- data/lib/tsikol/cli/templates/README.md.erb +38 -0
- data/lib/tsikol/cli/templates/gitignore.erb +49 -0
- data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
- data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
- data/lib/tsikol/cli/templates/server.rb.erb +24 -0
- data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
- data/lib/tsikol/cli.rb +203 -0
- data/lib/tsikol/error_handler.rb +141 -0
- data/lib/tsikol/health.rb +198 -0
- data/lib/tsikol/http_transport.rb +72 -0
- data/lib/tsikol/lifecycle.rb +149 -0
- data/lib/tsikol/middleware.rb +168 -0
- data/lib/tsikol/prompt.rb +101 -0
- data/lib/tsikol/resource.rb +53 -0
- data/lib/tsikol/router.rb +190 -0
- data/lib/tsikol/server.rb +660 -0
- data/lib/tsikol/stdio_transport.rb +108 -0
- data/lib/tsikol/test_helpers.rb +261 -0
- data/lib/tsikol/tool.rb +111 -0
- data/lib/tsikol/version.rb +5 -0
- data/lib/tsikol.rb +72 -0
- metadata +219 -0
@@ -0,0 +1,970 @@
|
|
1
|
+
# Dynamic Tools Recipe
|
2
|
+
|
3
|
+
This recipe shows how to create and register tools dynamically at runtime, enabling flexible and adaptable MCP servers.
|
4
|
+
|
5
|
+
## Basic Dynamic Tool Creation
|
6
|
+
|
7
|
+
### Runtime Tool Registration
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
class DynamicToolManager
|
11
|
+
def initialize(server)
|
12
|
+
@server = server
|
13
|
+
@dynamic_tools = {}
|
14
|
+
end
|
15
|
+
|
16
|
+
def create_tool(name, description, parameters, &block)
|
17
|
+
# Create a new tool class dynamically
|
18
|
+
tool_class = Class.new(Tsikol::Tool) do
|
19
|
+
# Set description
|
20
|
+
class_eval { description description }
|
21
|
+
|
22
|
+
# Define parameters
|
23
|
+
parameters.each do |param_name, param_config|
|
24
|
+
parameter param_name do
|
25
|
+
type param_config[:type]
|
26
|
+
param_config[:required] ? required : optional
|
27
|
+
default param_config[:default] if param_config[:default]
|
28
|
+
enum param_config[:enum] if param_config[:enum]
|
29
|
+
description param_config[:description]
|
30
|
+
|
31
|
+
if param_config[:complete]
|
32
|
+
complete(¶m_config[:complete])
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Define execute method
|
38
|
+
define_method(:execute, &block)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Set the tool name
|
42
|
+
tool_class.define_singleton_method(:name) { name }
|
43
|
+
|
44
|
+
# Register with server
|
45
|
+
@server.register_tool_class(tool_class)
|
46
|
+
@dynamic_tools[name] = tool_class
|
47
|
+
|
48
|
+
log :info, "Dynamic tool created", name: name
|
49
|
+
|
50
|
+
tool_class
|
51
|
+
end
|
52
|
+
|
53
|
+
def remove_tool(name)
|
54
|
+
if @dynamic_tools[name]
|
55
|
+
@server.unregister_tool(name)
|
56
|
+
@dynamic_tools.delete(name)
|
57
|
+
log :info, "Dynamic tool removed", name: name
|
58
|
+
true
|
59
|
+
else
|
60
|
+
false
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def list_dynamic_tools
|
65
|
+
@dynamic_tools.keys
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Usage
|
70
|
+
Tsikol.start(name: "dynamic-server") do
|
71
|
+
tool_manager = DynamicToolManager.new(self)
|
72
|
+
|
73
|
+
# Create a dynamic tool
|
74
|
+
tool_manager.create_tool(
|
75
|
+
"custom_calculator",
|
76
|
+
"Performs custom calculations",
|
77
|
+
{
|
78
|
+
expression: {
|
79
|
+
type: :string,
|
80
|
+
required: true,
|
81
|
+
description: "Mathematical expression"
|
82
|
+
},
|
83
|
+
variables: {
|
84
|
+
type: :object,
|
85
|
+
required: false,
|
86
|
+
default: {},
|
87
|
+
description: "Variable values"
|
88
|
+
}
|
89
|
+
}
|
90
|
+
) do |expression:, variables: {}|
|
91
|
+
# Tool implementation
|
92
|
+
evaluator = MathEvaluator.new(variables)
|
93
|
+
evaluator.evaluate(expression)
|
94
|
+
end
|
95
|
+
|
96
|
+
# Store tool manager for later use
|
97
|
+
@tool_manager = tool_manager
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
101
|
+
### Plugin-Based Tools
|
102
|
+
|
103
|
+
```ruby
|
104
|
+
class PluginSystem
|
105
|
+
def initialize(server, plugin_dir = "plugins")
|
106
|
+
@server = server
|
107
|
+
@plugin_dir = plugin_dir
|
108
|
+
@loaded_plugins = {}
|
109
|
+
end
|
110
|
+
|
111
|
+
def load_plugins
|
112
|
+
Dir.glob(File.join(@plugin_dir, "*.rb")).each do |plugin_file|
|
113
|
+
load_plugin(plugin_file)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
|
117
|
+
def load_plugin(plugin_file)
|
118
|
+
plugin_name = File.basename(plugin_file, ".rb")
|
119
|
+
|
120
|
+
begin
|
121
|
+
# Load plugin code
|
122
|
+
plugin_code = File.read(plugin_file)
|
123
|
+
plugin_module = Module.new
|
124
|
+
plugin_module.module_eval(plugin_code)
|
125
|
+
|
126
|
+
# Find tool classes in the plugin
|
127
|
+
tool_classes = plugin_module.constants.select do |const|
|
128
|
+
klass = plugin_module.const_get(const)
|
129
|
+
klass.is_a?(Class) && klass < Tsikol::Tool
|
130
|
+
end
|
131
|
+
|
132
|
+
# Register tools
|
133
|
+
tool_classes.each do |tool_const|
|
134
|
+
tool_class = plugin_module.const_get(tool_const)
|
135
|
+
@server.register_tool_class(tool_class)
|
136
|
+
|
137
|
+
@loaded_plugins[plugin_name] ||= []
|
138
|
+
@loaded_plugins[plugin_name] << tool_class
|
139
|
+
end
|
140
|
+
|
141
|
+
log :info, "Plugin loaded",
|
142
|
+
name: plugin_name,
|
143
|
+
tools: tool_classes.map(&:to_s)
|
144
|
+
|
145
|
+
true
|
146
|
+
|
147
|
+
rescue => e
|
148
|
+
log :error, "Failed to load plugin",
|
149
|
+
name: plugin_name,
|
150
|
+
error: e.message
|
151
|
+
false
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
def unload_plugin(plugin_name)
|
156
|
+
if tools = @loaded_plugins[plugin_name]
|
157
|
+
tools.each do |tool_class|
|
158
|
+
@server.unregister_tool(tool_class.name)
|
159
|
+
end
|
160
|
+
|
161
|
+
@loaded_plugins.delete(plugin_name)
|
162
|
+
log :info, "Plugin unloaded", name: plugin_name
|
163
|
+
true
|
164
|
+
else
|
165
|
+
false
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
def reload_plugin(plugin_name)
|
170
|
+
unload_plugin(plugin_name)
|
171
|
+
load_plugin(File.join(@plugin_dir, "#{plugin_name}.rb"))
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# Example plugin file: plugins/string_tools.rb
|
176
|
+
class StringReverse < Tsikol::Tool
|
177
|
+
description "Reverses a string"
|
178
|
+
|
179
|
+
parameter :text do
|
180
|
+
type :string
|
181
|
+
required
|
182
|
+
description "Text to reverse"
|
183
|
+
end
|
184
|
+
|
185
|
+
def execute(text:)
|
186
|
+
text.reverse
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
class StringCapitalize < Tsikol::Tool
|
191
|
+
description "Capitalizes words in a string"
|
192
|
+
|
193
|
+
parameter :text do
|
194
|
+
type :string
|
195
|
+
required
|
196
|
+
description "Text to capitalize"
|
197
|
+
end
|
198
|
+
|
199
|
+
parameter :style do
|
200
|
+
type :string
|
201
|
+
optional
|
202
|
+
default "title"
|
203
|
+
enum ["title", "sentence", "all"]
|
204
|
+
end
|
205
|
+
|
206
|
+
def execute(text:, style: "title")
|
207
|
+
case style
|
208
|
+
when "title"
|
209
|
+
text.split.map(&:capitalize).join(" ")
|
210
|
+
when "sentence"
|
211
|
+
text.capitalize
|
212
|
+
when "all"
|
213
|
+
text.upcase
|
214
|
+
end
|
215
|
+
end
|
216
|
+
end
|
217
|
+
```
|
218
|
+
|
219
|
+
## Configuration-Based Tools
|
220
|
+
|
221
|
+
### YAML Tool Definition
|
222
|
+
|
223
|
+
```ruby
|
224
|
+
class ConfigBasedToolLoader
|
225
|
+
def initialize(server)
|
226
|
+
@server = server
|
227
|
+
end
|
228
|
+
|
229
|
+
def load_from_yaml(yaml_file)
|
230
|
+
config = YAML.load_file(yaml_file)
|
231
|
+
|
232
|
+
config["tools"].each do |tool_config|
|
233
|
+
create_tool_from_config(tool_config)
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def create_tool_from_config(config)
|
238
|
+
tool_class = Class.new(Tsikol::Tool) do
|
239
|
+
description config["description"]
|
240
|
+
|
241
|
+
# Define parameters
|
242
|
+
config["parameters"].each do |param|
|
243
|
+
parameter param["name"].to_sym do
|
244
|
+
type param["type"].to_sym
|
245
|
+
param["required"] ? required : optional
|
246
|
+
default param["default"] if param["default"]
|
247
|
+
enum param["enum"] if param["enum"]
|
248
|
+
description param["description"]
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# Define execute method based on type
|
253
|
+
case config["type"]
|
254
|
+
when "command"
|
255
|
+
define_method(:execute) do |**params|
|
256
|
+
execute_command(config["command"], params)
|
257
|
+
end
|
258
|
+
when "http"
|
259
|
+
define_method(:execute) do |**params|
|
260
|
+
execute_http_request(config["endpoint"], config["method"], params)
|
261
|
+
end
|
262
|
+
when "script"
|
263
|
+
define_method(:execute) do |**params|
|
264
|
+
execute_script(config["script"], params)
|
265
|
+
end
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
# Set tool name
|
270
|
+
tool_class.define_singleton_method(:name) { config["name"] }
|
271
|
+
|
272
|
+
# Add helper methods
|
273
|
+
tool_class.class_eval do
|
274
|
+
def execute_command(command_template, params)
|
275
|
+
command = substitute_params(command_template, params)
|
276
|
+
|
277
|
+
log :info, "Executing command", command: command
|
278
|
+
|
279
|
+
output = `#{command} 2>&1`
|
280
|
+
success = $?.success?
|
281
|
+
|
282
|
+
if success
|
283
|
+
output
|
284
|
+
else
|
285
|
+
raise "Command failed: #{output}"
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
289
|
+
def execute_http_request(endpoint_template, method, params)
|
290
|
+
require 'net/http'
|
291
|
+
require 'uri'
|
292
|
+
|
293
|
+
endpoint = substitute_params(endpoint_template, params)
|
294
|
+
uri = URI.parse(endpoint)
|
295
|
+
|
296
|
+
response = case method.upcase
|
297
|
+
when "GET"
|
298
|
+
Net::HTTP.get_response(uri)
|
299
|
+
when "POST"
|
300
|
+
Net::HTTP.post_form(uri, params)
|
301
|
+
else
|
302
|
+
raise "Unsupported HTTP method: #{method}"
|
303
|
+
end
|
304
|
+
|
305
|
+
if response.is_a?(Net::HTTPSuccess)
|
306
|
+
response.body
|
307
|
+
else
|
308
|
+
raise "HTTP request failed: #{response.code} #{response.message}"
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
def execute_script(script_template, params)
|
313
|
+
script = substitute_params(script_template, params)
|
314
|
+
eval(script)
|
315
|
+
end
|
316
|
+
|
317
|
+
def substitute_params(template, params)
|
318
|
+
result = template.dup
|
319
|
+
params.each do |key, value|
|
320
|
+
result.gsub!("{{#{key}}}", value.to_s)
|
321
|
+
end
|
322
|
+
result
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
@server.register_tool_class(tool_class)
|
327
|
+
|
328
|
+
log :info, "Tool created from config", name: config["name"]
|
329
|
+
end
|
330
|
+
end
|
331
|
+
|
332
|
+
# Example YAML configuration: tools.yaml
|
333
|
+
# tools:
|
334
|
+
# - name: disk_usage
|
335
|
+
# description: Check disk usage
|
336
|
+
# type: command
|
337
|
+
# command: "df -h {{path}}"
|
338
|
+
# parameters:
|
339
|
+
# - name: path
|
340
|
+
# type: string
|
341
|
+
# required: false
|
342
|
+
# default: "/"
|
343
|
+
# description: "Path to check"
|
344
|
+
#
|
345
|
+
# - name: weather
|
346
|
+
# description: Get weather information
|
347
|
+
# type: http
|
348
|
+
# endpoint: "http://api.weather.com/v1/weather?city={{city}}"
|
349
|
+
# method: GET
|
350
|
+
# parameters:
|
351
|
+
# - name: city
|
352
|
+
# type: string
|
353
|
+
# required: true
|
354
|
+
# description: "City name"
|
355
|
+
```
|
356
|
+
|
357
|
+
### Database-Driven Tools
|
358
|
+
|
359
|
+
```ruby
|
360
|
+
class DatabaseToolManager
|
361
|
+
def initialize(server, database)
|
362
|
+
@server = server
|
363
|
+
@database = database
|
364
|
+
@loaded_tools = {}
|
365
|
+
end
|
366
|
+
|
367
|
+
def load_tools
|
368
|
+
tools = @database[:tools].where(enabled: true).all
|
369
|
+
|
370
|
+
tools.each do |tool_record|
|
371
|
+
create_tool_from_record(tool_record)
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
def create_tool_from_record(record)
|
376
|
+
tool_class = Class.new(Tsikol::Tool) do
|
377
|
+
description record[:description]
|
378
|
+
|
379
|
+
# Load parameters
|
380
|
+
parameters = JSON.parse(record[:parameters])
|
381
|
+
parameters.each do |param_name, param_config|
|
382
|
+
parameter param_name.to_sym do
|
383
|
+
type param_config["type"].to_sym
|
384
|
+
param_config["required"] ? required : optional
|
385
|
+
default param_config["default"] if param_config["default"]
|
386
|
+
description param_config["description"]
|
387
|
+
end
|
388
|
+
end
|
389
|
+
|
390
|
+
# Define execute method
|
391
|
+
implementation = record[:implementation]
|
392
|
+
implementation_type = record[:implementation_type]
|
393
|
+
|
394
|
+
case implementation_type
|
395
|
+
when "ruby"
|
396
|
+
# Direct Ruby code
|
397
|
+
class_eval <<~RUBY
|
398
|
+
def execute(**params)
|
399
|
+
#{implementation}
|
400
|
+
end
|
401
|
+
RUBY
|
402
|
+
when "sql"
|
403
|
+
# SQL query
|
404
|
+
define_method(:execute) do |**params|
|
405
|
+
execute_sql_query(implementation, params)
|
406
|
+
end
|
407
|
+
when "api"
|
408
|
+
# API call configuration
|
409
|
+
config = JSON.parse(implementation)
|
410
|
+
define_method(:execute) do |**params|
|
411
|
+
execute_api_call(config, params)
|
412
|
+
end
|
413
|
+
end
|
414
|
+
end
|
415
|
+
|
416
|
+
# Set tool name
|
417
|
+
tool_class.define_singleton_method(:name) { record[:name] }
|
418
|
+
tool_class.define_singleton_method(:tool_id) { record[:id] }
|
419
|
+
|
420
|
+
# Add database helper methods
|
421
|
+
tool_class.class_eval do
|
422
|
+
def execute_sql_query(query_template, params)
|
423
|
+
query = substitute_params(query_template, params)
|
424
|
+
|
425
|
+
log :info, "Executing SQL", query: query
|
426
|
+
|
427
|
+
results = @database.fetch(query).all
|
428
|
+
results.to_json
|
429
|
+
end
|
430
|
+
|
431
|
+
def execute_api_call(config, params)
|
432
|
+
# Implementation for API calls
|
433
|
+
# Similar to HTTP implementation above
|
434
|
+
end
|
435
|
+
end
|
436
|
+
|
437
|
+
@server.register_tool_class(tool_class)
|
438
|
+
@loaded_tools[record[:id]] = tool_class
|
439
|
+
|
440
|
+
log :info, "Tool loaded from database",
|
441
|
+
name: record[:name],
|
442
|
+
id: record[:id]
|
443
|
+
end
|
444
|
+
|
445
|
+
def reload_tool(tool_id)
|
446
|
+
# Remove old version
|
447
|
+
if old_tool = @loaded_tools[tool_id]
|
448
|
+
@server.unregister_tool(old_tool.name)
|
449
|
+
@loaded_tools.delete(tool_id)
|
450
|
+
end
|
451
|
+
|
452
|
+
# Load new version
|
453
|
+
if record = @database[:tools].where(id: tool_id, enabled: true).first
|
454
|
+
create_tool_from_record(record)
|
455
|
+
end
|
456
|
+
end
|
457
|
+
|
458
|
+
def sync_tools
|
459
|
+
# Get current tool IDs from database
|
460
|
+
db_tool_ids = @database[:tools].where(enabled: true).select_map(:id)
|
461
|
+
|
462
|
+
# Remove tools that no longer exist
|
463
|
+
(@loaded_tools.keys - db_tool_ids).each do |tool_id|
|
464
|
+
if tool = @loaded_tools[tool_id]
|
465
|
+
@server.unregister_tool(tool.name)
|
466
|
+
@loaded_tools.delete(tool_id)
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
# Add new tools
|
471
|
+
(db_tool_ids - @loaded_tools.keys).each do |tool_id|
|
472
|
+
if record = @database[:tools].where(id: tool_id).first
|
473
|
+
create_tool_from_record(record)
|
474
|
+
end
|
475
|
+
end
|
476
|
+
|
477
|
+
# Reload modified tools
|
478
|
+
@loaded_tools.each do |tool_id, tool_class|
|
479
|
+
record = @database[:tools].where(id: tool_id).first
|
480
|
+
if record && record[:updated_at] > tool_class.loaded_at
|
481
|
+
reload_tool(tool_id)
|
482
|
+
end
|
483
|
+
end
|
484
|
+
end
|
485
|
+
end
|
486
|
+
```
|
487
|
+
|
488
|
+
## Hot Reloading Tools
|
489
|
+
|
490
|
+
### File Watcher System
|
491
|
+
|
492
|
+
```ruby
|
493
|
+
require 'listen'
|
494
|
+
|
495
|
+
class HotReloadManager
|
496
|
+
def initialize(server, watch_dir = "app/tools")
|
497
|
+
@server = server
|
498
|
+
@watch_dir = watch_dir
|
499
|
+
@loaded_files = {}
|
500
|
+
end
|
501
|
+
|
502
|
+
def start
|
503
|
+
@listener = Listen.to(@watch_dir) do |modified, added, removed|
|
504
|
+
handle_file_changes(modified, added, removed)
|
505
|
+
end
|
506
|
+
|
507
|
+
# Load initial tools
|
508
|
+
load_all_tools
|
509
|
+
|
510
|
+
# Start watching
|
511
|
+
@listener.start
|
512
|
+
|
513
|
+
log :info, "Hot reload started", directory: @watch_dir
|
514
|
+
end
|
515
|
+
|
516
|
+
def stop
|
517
|
+
@listener&.stop
|
518
|
+
end
|
519
|
+
|
520
|
+
private
|
521
|
+
|
522
|
+
def handle_file_changes(modified, added, removed)
|
523
|
+
(modified + added).each do |file|
|
524
|
+
reload_tool_file(file)
|
525
|
+
end
|
526
|
+
|
527
|
+
removed.each do |file|
|
528
|
+
unload_tool_file(file)
|
529
|
+
end
|
530
|
+
end
|
531
|
+
|
532
|
+
def load_all_tools
|
533
|
+
Dir.glob(File.join(@watch_dir, "**/*.rb")).each do |file|
|
534
|
+
load_tool_file(file)
|
535
|
+
end
|
536
|
+
end
|
537
|
+
|
538
|
+
def reload_tool_file(file)
|
539
|
+
# Unload old version
|
540
|
+
unload_tool_file(file)
|
541
|
+
|
542
|
+
# Load new version
|
543
|
+
load_tool_file(file)
|
544
|
+
end
|
545
|
+
|
546
|
+
def load_tool_file(file)
|
547
|
+
begin
|
548
|
+
# Create isolated namespace
|
549
|
+
namespace = Module.new
|
550
|
+
|
551
|
+
# Load file in namespace
|
552
|
+
namespace.module_eval(File.read(file), file)
|
553
|
+
|
554
|
+
# Find tool classes
|
555
|
+
tool_classes = find_tool_classes(namespace)
|
556
|
+
|
557
|
+
# Register tools
|
558
|
+
tool_classes.each do |tool_class|
|
559
|
+
@server.register_tool_class(tool_class)
|
560
|
+
end
|
561
|
+
|
562
|
+
@loaded_files[file] = {
|
563
|
+
namespace: namespace,
|
564
|
+
tools: tool_classes
|
565
|
+
}
|
566
|
+
|
567
|
+
log :info, "Tools loaded",
|
568
|
+
file: file,
|
569
|
+
count: tool_classes.size
|
570
|
+
|
571
|
+
rescue => e
|
572
|
+
log :error, "Failed to load tool file",
|
573
|
+
file: file,
|
574
|
+
error: e.message
|
575
|
+
end
|
576
|
+
end
|
577
|
+
|
578
|
+
def unload_tool_file(file)
|
579
|
+
if loaded = @loaded_files[file]
|
580
|
+
loaded[:tools].each do |tool_class|
|
581
|
+
@server.unregister_tool(tool_class.name)
|
582
|
+
end
|
583
|
+
|
584
|
+
@loaded_files.delete(file)
|
585
|
+
|
586
|
+
log :info, "Tools unloaded", file: file
|
587
|
+
end
|
588
|
+
end
|
589
|
+
|
590
|
+
def find_tool_classes(namespace)
|
591
|
+
namespace.constants.map { |const|
|
592
|
+
namespace.const_get(const)
|
593
|
+
}.select { |const|
|
594
|
+
const.is_a?(Class) && const < Tsikol::Tool
|
595
|
+
}
|
596
|
+
end
|
597
|
+
end
|
598
|
+
|
599
|
+
# Usage
|
600
|
+
Tsikol.start(name: "hot-reload-server") do
|
601
|
+
# Enable hot reloading in development
|
602
|
+
if ENV['RACK_ENV'] == 'development'
|
603
|
+
hot_reload = HotReloadManager.new(self)
|
604
|
+
hot_reload.start
|
605
|
+
|
606
|
+
before_stop do
|
607
|
+
hot_reload.stop
|
608
|
+
end
|
609
|
+
else
|
610
|
+
# Load tools normally in production
|
611
|
+
Dir.glob('app/tools/**/*.rb').each { |f| require_relative f }
|
612
|
+
end
|
613
|
+
end
|
614
|
+
```
|
615
|
+
|
616
|
+
## AI-Generated Tools
|
617
|
+
|
618
|
+
### Tool Generation from Description
|
619
|
+
|
620
|
+
```ruby
|
621
|
+
class AiToolGenerator
|
622
|
+
def initialize(server)
|
623
|
+
@server = server
|
624
|
+
end
|
625
|
+
|
626
|
+
def generate_tool(description, examples = [])
|
627
|
+
# Use sampling to generate tool implementation
|
628
|
+
prompt = build_generation_prompt(description, examples)
|
629
|
+
|
630
|
+
response = @server.sample_text(
|
631
|
+
messages: [
|
632
|
+
{
|
633
|
+
role: "system",
|
634
|
+
content: {
|
635
|
+
type: "text",
|
636
|
+
text: SYSTEM_PROMPT
|
637
|
+
}
|
638
|
+
},
|
639
|
+
{
|
640
|
+
role: "user",
|
641
|
+
content: {
|
642
|
+
type: "text",
|
643
|
+
text: prompt
|
644
|
+
}
|
645
|
+
}
|
646
|
+
],
|
647
|
+
temperature: 0.3,
|
648
|
+
max_tokens: 2000
|
649
|
+
)
|
650
|
+
|
651
|
+
if response[:error]
|
652
|
+
raise "Failed to generate tool: #{response[:error]}"
|
653
|
+
end
|
654
|
+
|
655
|
+
# Parse generated code
|
656
|
+
tool_code = extract_tool_code(response[:text])
|
657
|
+
|
658
|
+
# Create and register tool
|
659
|
+
create_tool_from_code(tool_code)
|
660
|
+
end
|
661
|
+
|
662
|
+
private
|
663
|
+
|
664
|
+
SYSTEM_PROMPT = <<~PROMPT
|
665
|
+
You are an expert Ruby developer creating MCP tools.
|
666
|
+
Generate complete, working tool implementations following this pattern:
|
667
|
+
|
668
|
+
class ToolName < Tsikol::Tool
|
669
|
+
description "Clear description"
|
670
|
+
|
671
|
+
parameter :param_name do
|
672
|
+
type :string
|
673
|
+
required
|
674
|
+
description "Parameter description"
|
675
|
+
end
|
676
|
+
|
677
|
+
def execute(param_name:)
|
678
|
+
# Implementation
|
679
|
+
end
|
680
|
+
end
|
681
|
+
|
682
|
+
Ensure the code is safe, efficient, and handles errors properly.
|
683
|
+
PROMPT
|
684
|
+
|
685
|
+
def build_generation_prompt(description, examples)
|
686
|
+
prompt = "Create a tool with the following requirements:\n\n"
|
687
|
+
prompt += "Description: #{description}\n\n"
|
688
|
+
|
689
|
+
if examples.any?
|
690
|
+
prompt += "Examples:\n"
|
691
|
+
examples.each_with_index do |example, i|
|
692
|
+
prompt += "#{i + 1}. Input: #{example[:input]}\n"
|
693
|
+
prompt += " Output: #{example[:output]}\n"
|
694
|
+
end
|
695
|
+
end
|
696
|
+
|
697
|
+
prompt
|
698
|
+
end
|
699
|
+
|
700
|
+
def extract_tool_code(response)
|
701
|
+
# Extract Ruby code from response
|
702
|
+
if match = response.match(/```ruby\n(.*?)```/m)
|
703
|
+
match[1]
|
704
|
+
else
|
705
|
+
response
|
706
|
+
end
|
707
|
+
end
|
708
|
+
|
709
|
+
def create_tool_from_code(code)
|
710
|
+
# Create isolated namespace
|
711
|
+
namespace = Module.new
|
712
|
+
|
713
|
+
# Evaluate code
|
714
|
+
namespace.module_eval(code)
|
715
|
+
|
716
|
+
# Find tool class
|
717
|
+
tool_class = namespace.constants.map { |c|
|
718
|
+
namespace.const_get(c)
|
719
|
+
}.find { |c|
|
720
|
+
c.is_a?(Class) && c < Tsikol::Tool
|
721
|
+
}
|
722
|
+
|
723
|
+
if tool_class
|
724
|
+
@server.register_tool_class(tool_class)
|
725
|
+
log :info, "AI-generated tool registered", name: tool_class.name
|
726
|
+
tool_class
|
727
|
+
else
|
728
|
+
raise "No valid tool class found in generated code"
|
729
|
+
end
|
730
|
+
end
|
731
|
+
end
|
732
|
+
|
733
|
+
# Usage
|
734
|
+
generator = AiToolGenerator.new(server)
|
735
|
+
|
736
|
+
tool = generator.generate_tool(
|
737
|
+
"Create a tool that converts between different units of measurement",
|
738
|
+
[
|
739
|
+
{ input: { value: 100, from: "celsius", to: "fahrenheit" },
|
740
|
+
output: "212°F" },
|
741
|
+
{ input: { value: 1, from: "mile", to: "kilometer" },
|
742
|
+
output: "1.60934 km" }
|
743
|
+
]
|
744
|
+
)
|
745
|
+
```
|
746
|
+
|
747
|
+
## Tool Composition
|
748
|
+
|
749
|
+
### Composite Tools
|
750
|
+
|
751
|
+
```ruby
|
752
|
+
class CompositeToolBuilder
|
753
|
+
def initialize(server)
|
754
|
+
@server = server
|
755
|
+
end
|
756
|
+
|
757
|
+
def build_composite(name, description, steps)
|
758
|
+
tool_class = Class.new(Tsikol::Tool) do
|
759
|
+
description description
|
760
|
+
|
761
|
+
# Collect all parameters from component tools
|
762
|
+
all_params = {}
|
763
|
+
steps.each do |step|
|
764
|
+
tool = step[:tool]
|
765
|
+
step[:params].each do |param_name, source|
|
766
|
+
if source.is_a?(Symbol) && !all_params[source]
|
767
|
+
# This is an input parameter
|
768
|
+
all_params[source] = true
|
769
|
+
end
|
770
|
+
end
|
771
|
+
end
|
772
|
+
|
773
|
+
# Define parameters
|
774
|
+
all_params.keys.each do |param|
|
775
|
+
parameter param do
|
776
|
+
type :string # Default type
|
777
|
+
required
|
778
|
+
description "Input for #{param}"
|
779
|
+
end
|
780
|
+
end
|
781
|
+
|
782
|
+
# Define execute method
|
783
|
+
define_method(:execute) do |**params|
|
784
|
+
results = {}
|
785
|
+
|
786
|
+
steps.each_with_index do |step, index|
|
787
|
+
tool_name = step[:tool]
|
788
|
+
tool_params = {}
|
789
|
+
|
790
|
+
# Build parameters for this tool
|
791
|
+
step[:params].each do |param_name, source|
|
792
|
+
tool_params[param_name] = if source.is_a?(Symbol)
|
793
|
+
# Reference to input parameter or previous result
|
794
|
+
results[source] || params[source]
|
795
|
+
else
|
796
|
+
# Literal value
|
797
|
+
source
|
798
|
+
end
|
799
|
+
end
|
800
|
+
|
801
|
+
# Execute tool
|
802
|
+
log :debug, "Executing step #{index + 1}",
|
803
|
+
tool: tool_name,
|
804
|
+
params: tool_params
|
805
|
+
|
806
|
+
result = @server.execute_tool(tool_name, tool_params)
|
807
|
+
|
808
|
+
# Store result
|
809
|
+
results[step[:store_as] || :"step_#{index}"] = result
|
810
|
+
end
|
811
|
+
|
812
|
+
# Return final result
|
813
|
+
final_step = steps.last
|
814
|
+
results[final_step[:store_as] || :"step_#{steps.size - 1}"]
|
815
|
+
end
|
816
|
+
end
|
817
|
+
|
818
|
+
# Set tool name
|
819
|
+
tool_class.define_singleton_method(:name) { name }
|
820
|
+
|
821
|
+
@server.register_tool_class(tool_class)
|
822
|
+
tool_class
|
823
|
+
end
|
824
|
+
end
|
825
|
+
|
826
|
+
# Usage
|
827
|
+
builder = CompositeToolBuilder.new(server)
|
828
|
+
|
829
|
+
# Create a composite tool that:
|
830
|
+
# 1. Fetches data from an API
|
831
|
+
# 2. Processes the data
|
832
|
+
# 3. Generates a report
|
833
|
+
builder.build_composite(
|
834
|
+
"data_pipeline",
|
835
|
+
"Fetches and processes data to generate a report",
|
836
|
+
[
|
837
|
+
{
|
838
|
+
tool: "http_request",
|
839
|
+
params: {
|
840
|
+
url: :api_url, # From input parameter
|
841
|
+
method: "GET" # Literal value
|
842
|
+
},
|
843
|
+
store_as: :raw_data
|
844
|
+
},
|
845
|
+
{
|
846
|
+
tool: "json_parser",
|
847
|
+
params: {
|
848
|
+
json: :raw_data # From previous step
|
849
|
+
},
|
850
|
+
store_as: :parsed_data
|
851
|
+
},
|
852
|
+
{
|
853
|
+
tool: "report_generator",
|
854
|
+
params: {
|
855
|
+
data: :parsed_data, # From previous step
|
856
|
+
format: :report_format # From input parameter
|
857
|
+
},
|
858
|
+
store_as: :report
|
859
|
+
}
|
860
|
+
]
|
861
|
+
)
|
862
|
+
```
|
863
|
+
|
864
|
+
## Testing Dynamic Tools
|
865
|
+
|
866
|
+
```ruby
|
867
|
+
require 'minitest/autorun'
|
868
|
+
|
869
|
+
class DynamicToolTest < Minitest::Test
|
870
|
+
def setup
|
871
|
+
@server = Tsikol::Server.new(name: "test")
|
872
|
+
@manager = DynamicToolManager.new(@server)
|
873
|
+
@client = Tsikol::TestHelpers::TestClient.new(@server)
|
874
|
+
end
|
875
|
+
|
876
|
+
def test_creates_dynamic_tool
|
877
|
+
@manager.create_tool(
|
878
|
+
"test_adder",
|
879
|
+
"Adds two numbers",
|
880
|
+
{
|
881
|
+
a: { type: :number, required: true, description: "First number" },
|
882
|
+
b: { type: :number, required: true, description: "Second number" }
|
883
|
+
}
|
884
|
+
) do |a:, b:|
|
885
|
+
a + b
|
886
|
+
end
|
887
|
+
|
888
|
+
response = @client.call_tool("test_adder", { "a" => 5, "b" => 3 })
|
889
|
+
|
890
|
+
assert_successful_response(response)
|
891
|
+
assert_equal 8, response.dig(:result, :content, 0, :text)
|
892
|
+
end
|
893
|
+
|
894
|
+
def test_removes_dynamic_tool
|
895
|
+
@manager.create_tool("temporary", "Temp tool", {}) { "temp" }
|
896
|
+
|
897
|
+
# Tool exists
|
898
|
+
response = @client.call_tool("temporary", {})
|
899
|
+
assert_successful_response(response)
|
900
|
+
|
901
|
+
# Remove tool
|
902
|
+
@manager.remove_tool("temporary")
|
903
|
+
|
904
|
+
# Tool no longer exists
|
905
|
+
response = @client.call_tool("temporary", {})
|
906
|
+
assert_error_response(response, -32601) # Method not found
|
907
|
+
end
|
908
|
+
|
909
|
+
def test_hot_reload
|
910
|
+
# Create a temporary tool file
|
911
|
+
tool_file = "test_tool.rb"
|
912
|
+
File.write(tool_file, <<~RUBY)
|
913
|
+
class TestTool < Tsikol::Tool
|
914
|
+
description "Test tool v1"
|
915
|
+
|
916
|
+
def execute
|
917
|
+
"Version 1"
|
918
|
+
end
|
919
|
+
end
|
920
|
+
RUBY
|
921
|
+
|
922
|
+
# Load tool
|
923
|
+
loader = HotReloadManager.new(@server, ".")
|
924
|
+
loader.send(:load_tool_file, tool_file)
|
925
|
+
|
926
|
+
response = @client.call_tool("test_tool", {})
|
927
|
+
assert_equal "Version 1", response.dig(:result, :content, 0, :text)
|
928
|
+
|
929
|
+
# Modify tool
|
930
|
+
File.write(tool_file, <<~RUBY)
|
931
|
+
class TestTool < Tsikol::Tool
|
932
|
+
description "Test tool v2"
|
933
|
+
|
934
|
+
def execute
|
935
|
+
"Version 2"
|
936
|
+
end
|
937
|
+
end
|
938
|
+
RUBY
|
939
|
+
|
940
|
+
# Reload
|
941
|
+
loader.send(:reload_tool_file, tool_file)
|
942
|
+
|
943
|
+
response = @client.call_tool("test_tool", {})
|
944
|
+
assert_equal "Version 2", response.dig(:result, :content, 0, :text)
|
945
|
+
|
946
|
+
ensure
|
947
|
+
File.delete(tool_file) if File.exist?(tool_file)
|
948
|
+
end
|
949
|
+
end
|
950
|
+
```
|
951
|
+
|
952
|
+
## Best Practices
|
953
|
+
|
954
|
+
1. **Validate dynamic code** before execution
|
955
|
+
2. **Sandbox untrusted code** for security
|
956
|
+
3. **Version control** for dynamic tools
|
957
|
+
4. **Monitor performance** of generated tools
|
958
|
+
5. **Implement rollback** for tool updates
|
959
|
+
6. **Test thoroughly** especially for AI-generated tools
|
960
|
+
7. **Log all changes** for audit trail
|
961
|
+
8. **Set resource limits** for dynamic tools
|
962
|
+
9. **Use namespacing** to avoid conflicts
|
963
|
+
10. **Cache compiled tools** for performance
|
964
|
+
|
965
|
+
## Next Steps
|
966
|
+
|
967
|
+
- Implement [Security](security.md) for dynamic code execution
|
968
|
+
- Add [Monitoring](monitoring.md) for tool usage
|
969
|
+
- Set up [Testing](../guides/testing.md) for dynamic tools
|
970
|
+
- Review [Error Handling](error-handling.md) for tool failures
|