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,149 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tsikol
|
4
|
+
# Lifecycle management for servers
|
5
|
+
module Lifecycle
|
6
|
+
def self.included(base)
|
7
|
+
base.extend(ClassMethods)
|
8
|
+
end
|
9
|
+
|
10
|
+
module ClassMethods
|
11
|
+
def before_start(&block)
|
12
|
+
@before_start_hooks ||= []
|
13
|
+
@before_start_hooks << block if block_given?
|
14
|
+
@before_start_hooks
|
15
|
+
end
|
16
|
+
|
17
|
+
def after_start(&block)
|
18
|
+
@after_start_hooks ||= []
|
19
|
+
@after_start_hooks << block if block_given?
|
20
|
+
@after_start_hooks
|
21
|
+
end
|
22
|
+
|
23
|
+
def before_stop(&block)
|
24
|
+
@before_stop_hooks ||= []
|
25
|
+
@before_stop_hooks << block if block_given?
|
26
|
+
@before_stop_hooks
|
27
|
+
end
|
28
|
+
|
29
|
+
def after_stop(&block)
|
30
|
+
@after_stop_hooks ||= []
|
31
|
+
@after_stop_hooks << block if block_given?
|
32
|
+
@after_stop_hooks
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Instance methods
|
37
|
+
def run_before_start_hooks
|
38
|
+
log :debug, "Running before_start hooks"
|
39
|
+
|
40
|
+
self.class.before_start.each do |hook|
|
41
|
+
instance_eval(&hook)
|
42
|
+
end
|
43
|
+
|
44
|
+
@before_start_hooks&.each do |hook|
|
45
|
+
instance_eval(&hook)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def run_after_start_hooks
|
50
|
+
log :debug, "Running after_start hooks"
|
51
|
+
|
52
|
+
self.class.after_start.each do |hook|
|
53
|
+
instance_eval(&hook)
|
54
|
+
end
|
55
|
+
|
56
|
+
@after_start_hooks&.each do |hook|
|
57
|
+
instance_eval(&hook)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def run_before_stop_hooks
|
62
|
+
log :debug, "Running before_stop hooks"
|
63
|
+
|
64
|
+
self.class.before_stop.each do |hook|
|
65
|
+
instance_eval(&hook)
|
66
|
+
end
|
67
|
+
|
68
|
+
@before_stop_hooks&.each do |hook|
|
69
|
+
instance_eval(&hook)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def run_after_stop_hooks
|
74
|
+
log :debug, "Running after_stop hooks"
|
75
|
+
|
76
|
+
self.class.after_stop.each do |hook|
|
77
|
+
instance_eval(&hook)
|
78
|
+
end
|
79
|
+
|
80
|
+
@after_stop_hooks&.each do |hook|
|
81
|
+
instance_eval(&hook)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# DSL methods for instance-level hooks
|
86
|
+
def before_start(&block)
|
87
|
+
@before_start_hooks ||= []
|
88
|
+
@before_start_hooks << block
|
89
|
+
end
|
90
|
+
|
91
|
+
def after_start(&block)
|
92
|
+
@after_start_hooks ||= []
|
93
|
+
@after_start_hooks << block
|
94
|
+
end
|
95
|
+
|
96
|
+
def before_stop(&block)
|
97
|
+
@before_stop_hooks ||= []
|
98
|
+
@before_stop_hooks << block
|
99
|
+
end
|
100
|
+
|
101
|
+
def after_stop(&block)
|
102
|
+
@after_stop_hooks ||= []
|
103
|
+
@after_stop_hooks << block
|
104
|
+
end
|
105
|
+
|
106
|
+
# Tool-level hooks
|
107
|
+
def before_tool(name = nil, &block)
|
108
|
+
@before_tool_hooks ||= {}
|
109
|
+
if name
|
110
|
+
@before_tool_hooks[name] = block
|
111
|
+
else
|
112
|
+
@before_tool_hooks[:all] = block
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def after_tool(name = nil, &block)
|
117
|
+
@after_tool_hooks ||= {}
|
118
|
+
if name
|
119
|
+
@after_tool_hooks[name] = block
|
120
|
+
else
|
121
|
+
@after_tool_hooks[:all] = block
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def run_before_tool_hook(tool_name, params)
|
126
|
+
# Run specific hook
|
127
|
+
if @before_tool_hooks&.[](tool_name)
|
128
|
+
@before_tool_hooks[tool_name].call(params)
|
129
|
+
end
|
130
|
+
|
131
|
+
# Run general hook
|
132
|
+
if @before_tool_hooks&.[](:all)
|
133
|
+
@before_tool_hooks[:all].call(tool_name, params)
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def run_after_tool_hook(tool_name, params, result)
|
138
|
+
# Run specific hook
|
139
|
+
if @after_tool_hooks&.[](tool_name)
|
140
|
+
@after_tool_hooks[tool_name].call(params, result)
|
141
|
+
end
|
142
|
+
|
143
|
+
# Run general hook
|
144
|
+
if @after_tool_hooks&.[](:all)
|
145
|
+
@after_tool_hooks[:all].call(tool_name, params, result)
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
@@ -0,0 +1,168 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tsikol
|
4
|
+
class Middleware
|
5
|
+
def initialize(app)
|
6
|
+
@app = app
|
7
|
+
end
|
8
|
+
|
9
|
+
# Called before processing a request
|
10
|
+
def before_request(message)
|
11
|
+
# Default: pass through
|
12
|
+
message
|
13
|
+
end
|
14
|
+
|
15
|
+
# Called after processing a response
|
16
|
+
def after_response(response, original_message)
|
17
|
+
# Default: pass through
|
18
|
+
response
|
19
|
+
end
|
20
|
+
|
21
|
+
# Called on error
|
22
|
+
def on_error(error, message)
|
23
|
+
# Default: re-raise
|
24
|
+
raise error
|
25
|
+
end
|
26
|
+
|
27
|
+
def call(message)
|
28
|
+
# Pre-process request
|
29
|
+
processed_message = before_request(message)
|
30
|
+
|
31
|
+
# Call next middleware or handler
|
32
|
+
response = @app.call(processed_message)
|
33
|
+
|
34
|
+
# Post-process response
|
35
|
+
after_response(response, message)
|
36
|
+
rescue => e
|
37
|
+
on_error(e, message)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Common middleware implementations
|
42
|
+
|
43
|
+
class LoggingMiddleware < Middleware
|
44
|
+
def initialize(app, logger: nil)
|
45
|
+
super(app)
|
46
|
+
@logger = logger
|
47
|
+
end
|
48
|
+
|
49
|
+
def before_request(message)
|
50
|
+
log :debug, "Request: #{message['method']}", data: { id: message['id'], params: message['params'] }
|
51
|
+
message
|
52
|
+
end
|
53
|
+
|
54
|
+
def after_response(response, original_message)
|
55
|
+
if response
|
56
|
+
status = response['error'] ? 'error' : 'success'
|
57
|
+
log :debug, "Response for #{original_message['method']} (#{status})",
|
58
|
+
data: { id: response['id'], error: response['error'] }
|
59
|
+
end
|
60
|
+
response
|
61
|
+
end
|
62
|
+
|
63
|
+
def on_error(error, message)
|
64
|
+
log :error, "Error processing #{message['method']}: #{error.message}",
|
65
|
+
data: { error: error.class.name, backtrace: error.backtrace.first(5) }
|
66
|
+
raise error
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def log(level, message, data: nil)
|
72
|
+
if @logger && @logger.respond_to?(:log)
|
73
|
+
@logger.log(level, message, data: data)
|
74
|
+
else
|
75
|
+
puts "[#{level.upcase}] #{message} #{data ? "- #{data.inspect}" : ""}"
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
class RateLimitMiddleware < Middleware
|
81
|
+
def initialize(app, max_requests: 100, window: 60)
|
82
|
+
super(app)
|
83
|
+
@max_requests = max_requests
|
84
|
+
@window = window
|
85
|
+
@requests = {}
|
86
|
+
end
|
87
|
+
|
88
|
+
def before_request(message)
|
89
|
+
client_id = message["id"] # In real usage, identify client better
|
90
|
+
now = Time.now.to_i
|
91
|
+
|
92
|
+
# Clean old entries
|
93
|
+
@requests.delete_if { |_, time| now - time > @window }
|
94
|
+
|
95
|
+
# Check rate limit
|
96
|
+
client_requests = @requests.select { |id, _| id.to_s.start_with?(client_id.to_s[0..8]) }
|
97
|
+
if client_requests.size >= @max_requests
|
98
|
+
raise "Rate limit exceeded: #{@max_requests} requests per #{@window} seconds"
|
99
|
+
end
|
100
|
+
|
101
|
+
# Track request
|
102
|
+
@requests[message["id"]] = now
|
103
|
+
|
104
|
+
message
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
class AuthenticationMiddleware < Middleware
|
109
|
+
def initialize(app, &auth_block)
|
110
|
+
super(app)
|
111
|
+
@auth_block = auth_block
|
112
|
+
end
|
113
|
+
|
114
|
+
def before_request(message)
|
115
|
+
# Extract auth info (this is simplified - real auth would be more complex)
|
116
|
+
auth_info = message["metadata"] || {}
|
117
|
+
|
118
|
+
unless @auth_block.call(auth_info, message)
|
119
|
+
raise "Authentication failed"
|
120
|
+
end
|
121
|
+
|
122
|
+
message
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
class ValidationMiddleware < Middleware
|
127
|
+
def before_request(message)
|
128
|
+
# Validate JSON-RPC structure
|
129
|
+
unless message["jsonrpc"] == "2.0"
|
130
|
+
raise "Invalid JSON-RPC version"
|
131
|
+
end
|
132
|
+
|
133
|
+
unless message["method"]
|
134
|
+
raise "Missing method"
|
135
|
+
end
|
136
|
+
|
137
|
+
message
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
141
|
+
# Middleware stack manager
|
142
|
+
class MiddlewareStack
|
143
|
+
def initialize(base_handler)
|
144
|
+
@base_handler = base_handler
|
145
|
+
@middlewares = []
|
146
|
+
end
|
147
|
+
|
148
|
+
def use(middleware_class, *args, **kwargs, &block)
|
149
|
+
@middlewares << [middleware_class, args, kwargs, block]
|
150
|
+
self
|
151
|
+
end
|
152
|
+
|
153
|
+
def build
|
154
|
+
# Build middleware chain from inside out
|
155
|
+
@middlewares.reverse.reduce(@base_handler) do |app, (klass, args, kwargs, block)|
|
156
|
+
if block
|
157
|
+
klass.new(app, *args, **kwargs, &block)
|
158
|
+
else
|
159
|
+
klass.new(app, *args, **kwargs)
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def call(message)
|
165
|
+
build.call(message)
|
166
|
+
end
|
167
|
+
end
|
168
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tsikol
|
4
|
+
class Prompt
|
5
|
+
class << self
|
6
|
+
attr_reader :prompt_description, :arguments_config
|
7
|
+
|
8
|
+
def description(desc)
|
9
|
+
@prompt_description = desc
|
10
|
+
end
|
11
|
+
|
12
|
+
def argument(name, &block)
|
13
|
+
@arguments_config ||= {}
|
14
|
+
arg = ArgumentBuilder.new(name)
|
15
|
+
arg.instance_eval(&block) if block_given?
|
16
|
+
@arguments_config[name] = arg.build
|
17
|
+
end
|
18
|
+
|
19
|
+
def prompt_name
|
20
|
+
# Convert class name to prompt name
|
21
|
+
# Weather::Chat -> weather:chat
|
22
|
+
name.gsub('::', ':').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def description
|
27
|
+
self.class.prompt_description || "Prompt: #{self.class.prompt_name}"
|
28
|
+
end
|
29
|
+
|
30
|
+
def arguments
|
31
|
+
self.class.arguments_config || {}
|
32
|
+
end
|
33
|
+
|
34
|
+
def generate(**args)
|
35
|
+
raise NotImplementedError, "Subclasses must implement generate method"
|
36
|
+
end
|
37
|
+
|
38
|
+
# Convert to MCP format
|
39
|
+
def to_mcp
|
40
|
+
{
|
41
|
+
name: self.class.prompt_name,
|
42
|
+
description: description,
|
43
|
+
arguments: build_arguments
|
44
|
+
}
|
45
|
+
end
|
46
|
+
|
47
|
+
# Get messages for MCP prompt
|
48
|
+
def get_messages(**args)
|
49
|
+
content = generate(**args)
|
50
|
+
|
51
|
+
[
|
52
|
+
{
|
53
|
+
role: "user",
|
54
|
+
content: {
|
55
|
+
type: "text",
|
56
|
+
text: content
|
57
|
+
}
|
58
|
+
}
|
59
|
+
]
|
60
|
+
end
|
61
|
+
|
62
|
+
private
|
63
|
+
|
64
|
+
def build_arguments
|
65
|
+
arguments.map do |name, config|
|
66
|
+
{
|
67
|
+
name: name.to_s,
|
68
|
+
description: config[:description] || "Argument: #{name}",
|
69
|
+
required: config[:required] || false
|
70
|
+
}
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
class ArgumentBuilder
|
76
|
+
def initialize(name)
|
77
|
+
@name = name
|
78
|
+
@config = { name: name }
|
79
|
+
end
|
80
|
+
|
81
|
+
def required(val = true)
|
82
|
+
@config[:required] = val
|
83
|
+
end
|
84
|
+
|
85
|
+
def optional
|
86
|
+
@config[:required] = false
|
87
|
+
end
|
88
|
+
|
89
|
+
def description(desc)
|
90
|
+
@config[:description] = desc
|
91
|
+
end
|
92
|
+
|
93
|
+
def complete(&block)
|
94
|
+
@config[:completion] = block
|
95
|
+
end
|
96
|
+
|
97
|
+
def build
|
98
|
+
@config
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tsikol
|
4
|
+
class Resource
|
5
|
+
class << self
|
6
|
+
attr_reader :resource_description, :resource_uri, :resource_mime_type
|
7
|
+
|
8
|
+
def uri(u)
|
9
|
+
@resource_uri = u
|
10
|
+
end
|
11
|
+
|
12
|
+
def description(desc)
|
13
|
+
@resource_description = desc
|
14
|
+
end
|
15
|
+
|
16
|
+
def mime_type(type)
|
17
|
+
@resource_mime_type = type
|
18
|
+
end
|
19
|
+
|
20
|
+
def resource_name
|
21
|
+
# Convert class name to resource name
|
22
|
+
# Weather::Alerts -> weather/alerts
|
23
|
+
name.gsub('::', '/').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def uri
|
28
|
+
self.class.resource_uri || self.class.resource_name
|
29
|
+
end
|
30
|
+
|
31
|
+
def description
|
32
|
+
self.class.resource_description || "Resource: #{uri}"
|
33
|
+
end
|
34
|
+
|
35
|
+
def mime_type
|
36
|
+
self.class.resource_mime_type || "text/plain"
|
37
|
+
end
|
38
|
+
|
39
|
+
def read
|
40
|
+
raise NotImplementedError, "Subclasses must implement read method"
|
41
|
+
end
|
42
|
+
|
43
|
+
# Convert to MCP format
|
44
|
+
def to_mcp
|
45
|
+
{
|
46
|
+
uri: uri,
|
47
|
+
name: uri,
|
48
|
+
description: description,
|
49
|
+
mimeType: mime_type
|
50
|
+
}
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
@@ -0,0 +1,190 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Tsikol
|
4
|
+
class Router
|
5
|
+
attr_reader :tools, :resources, :prompts, :server
|
6
|
+
|
7
|
+
def initialize(server)
|
8
|
+
@server = server
|
9
|
+
@tools = {}
|
10
|
+
@resources = {}
|
11
|
+
@prompts = {}
|
12
|
+
end
|
13
|
+
|
14
|
+
# DSL methods for routes.rb
|
15
|
+
def tool(name_or_class, from: nil, &block)
|
16
|
+
if name_or_class.is_a?(Class)
|
17
|
+
# Direct class reference: tool GetCurrentWeather
|
18
|
+
tool_instance = name_or_class.new
|
19
|
+
@server.register_tool_instance(tool_instance)
|
20
|
+
elsif from
|
21
|
+
# Load from file with explicit path
|
22
|
+
load_tool_from_file(name_or_class, from)
|
23
|
+
elsif name_or_class.is_a?(String) && !block_given?
|
24
|
+
# Auto-infer from name: tool "get_current_weather"
|
25
|
+
inferred_path = name_or_class.gsub('-', '_')
|
26
|
+
load_tool_from_file(name_or_class, inferred_path)
|
27
|
+
elsif block_given?
|
28
|
+
# Inline definition (backward compatibility)
|
29
|
+
@server.tool(name_or_class, &block)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def resource(uri_or_class, from: nil, &block)
|
34
|
+
if uri_or_class.is_a?(Class)
|
35
|
+
# Direct class reference
|
36
|
+
resource_instance = uri_or_class.new
|
37
|
+
@server.register_resource_instance(resource_instance)
|
38
|
+
elsif from
|
39
|
+
# Load from file with explicit path
|
40
|
+
load_resource_from_file(uri_or_class, from)
|
41
|
+
elsif uri_or_class.is_a?(String) && !block_given?
|
42
|
+
# Auto-infer from URI: resource "weather/alerts" -> weather_alerts.rb
|
43
|
+
inferred_path = uri_or_class.gsub('/', '_').gsub('-', '_')
|
44
|
+
load_resource_from_file(uri_or_class, inferred_path)
|
45
|
+
elsif block_given?
|
46
|
+
# Inline definition
|
47
|
+
@server.resource(uri_or_class, &block)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def prompt(name_or_class, from: nil, &block)
|
52
|
+
if name_or_class.is_a?(Class)
|
53
|
+
# Direct class reference
|
54
|
+
prompt_instance = name_or_class.new
|
55
|
+
@server.register_prompt_instance(prompt_instance)
|
56
|
+
elsif from
|
57
|
+
# Load from file with explicit path
|
58
|
+
load_prompt_from_file(name_or_class, from)
|
59
|
+
elsif name_or_class.is_a?(String) && !block_given?
|
60
|
+
# Auto-infer from name: prompt "weather_chat"
|
61
|
+
inferred_path = name_or_class.gsub('-', '_')
|
62
|
+
load_prompt_from_file(name_or_class, inferred_path)
|
63
|
+
elsif block_given?
|
64
|
+
# Inline definition
|
65
|
+
@server.prompt(name_or_class, &block)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
# Mount multiple items
|
70
|
+
def mount_tools(from:)
|
71
|
+
pattern = from.end_with?('/*') ? from : "#{from}/*"
|
72
|
+
base_path = "app/tools"
|
73
|
+
|
74
|
+
Dir.glob("#{base_path}/#{pattern}.rb").each do |file|
|
75
|
+
# Extract name from file path
|
76
|
+
name = file.sub("#{base_path}/", '').sub('.rb', '')
|
77
|
+
load_tool_from_file(nil, name)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def mount_resources(from:)
|
82
|
+
pattern = from.end_with?('/*') ? from : "#{from}/*"
|
83
|
+
base_path = "app/resources"
|
84
|
+
|
85
|
+
Dir.glob("#{base_path}/#{pattern}.rb").each do |file|
|
86
|
+
uri = file.sub("#{base_path}/", '').sub('.rb', '')
|
87
|
+
load_resource_from_file(nil, uri)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def mount_prompts(from:)
|
92
|
+
pattern = from.end_with?('/*') ? from : "#{from}/*"
|
93
|
+
base_path = "app/prompts"
|
94
|
+
|
95
|
+
Dir.glob("#{base_path}/#{pattern}.rb").each do |file|
|
96
|
+
name = file.sub("#{base_path}/", '').sub('.rb', '')
|
97
|
+
load_prompt_from_file(nil, name)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Auto-discover all components
|
102
|
+
def auto_discover!
|
103
|
+
mount_tools(from: "**/*") if Dir.exist?("app/tools")
|
104
|
+
mount_resources(from: "**/*") if Dir.exist?("app/resources")
|
105
|
+
mount_prompts(from: "**/*") if Dir.exist?("app/prompts")
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def load_tool_from_file(name, from)
|
111
|
+
file_path = "app/tools/#{from}.rb"
|
112
|
+
|
113
|
+
# Try to require the file
|
114
|
+
begin
|
115
|
+
require_relative "../../../#{file_path}"
|
116
|
+
rescue LoadError
|
117
|
+
# If relative path fails, try absolute
|
118
|
+
require File.expand_path(file_path)
|
119
|
+
end
|
120
|
+
|
121
|
+
# Convert file path to class name
|
122
|
+
# weather/get_current -> Weather::GetCurrent
|
123
|
+
class_name = from.split('/').map { |part|
|
124
|
+
part.split('_').map(&:capitalize).join
|
125
|
+
}.join('::')
|
126
|
+
|
127
|
+
# Try to get the class
|
128
|
+
klass = begin
|
129
|
+
Object.const_get(class_name)
|
130
|
+
rescue NameError
|
131
|
+
# If namespaced class not found, try without namespace
|
132
|
+
class_name.split('::').last.then { |n| Object.const_get(n) }
|
133
|
+
end
|
134
|
+
|
135
|
+
tool_instance = klass.new
|
136
|
+
|
137
|
+
# Register with server
|
138
|
+
@server.register_tool_instance(tool_instance, name: name)
|
139
|
+
end
|
140
|
+
|
141
|
+
def load_resource_from_file(uri, from)
|
142
|
+
file_path = "app/resources/#{from}.rb"
|
143
|
+
|
144
|
+
begin
|
145
|
+
require_relative "../../../#{file_path}"
|
146
|
+
rescue LoadError
|
147
|
+
require File.expand_path(file_path)
|
148
|
+
end
|
149
|
+
|
150
|
+
class_name = from.split('/').map { |part|
|
151
|
+
part.split('_').map(&:capitalize).join
|
152
|
+
}.join('::')
|
153
|
+
|
154
|
+
klass = begin
|
155
|
+
Object.const_get(class_name)
|
156
|
+
rescue NameError
|
157
|
+
class_name.split('::').last.then { |n| Object.const_get(n) }
|
158
|
+
end
|
159
|
+
|
160
|
+
resource_instance = klass.new
|
161
|
+
|
162
|
+
@server.register_resource_instance(resource_instance, uri: uri)
|
163
|
+
end
|
164
|
+
|
165
|
+
def load_prompt_from_file(name, from)
|
166
|
+
file_path = "app/prompts/#{from}.rb"
|
167
|
+
|
168
|
+
begin
|
169
|
+
require_relative "../../../#{file_path}"
|
170
|
+
rescue LoadError
|
171
|
+
require File.expand_path(file_path)
|
172
|
+
end
|
173
|
+
|
174
|
+
class_name = from.split('/').map { |part|
|
175
|
+
part.split('_').map(&:capitalize).join
|
176
|
+
}.join('::')
|
177
|
+
|
178
|
+
klass = begin
|
179
|
+
Object.const_get(class_name)
|
180
|
+
rescue NameError
|
181
|
+
class_name.split('::').last.then { |n| Object.const_get(n) }
|
182
|
+
end
|
183
|
+
|
184
|
+
prompt_instance = klass.new
|
185
|
+
|
186
|
+
@server.register_prompt_instance(prompt_instance, name: name)
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
end
|