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,660 @@
|
|
1
|
+
module Tsikol
|
2
|
+
class Server
|
3
|
+
include Lifecycle
|
4
|
+
include Health
|
5
|
+
|
6
|
+
attr_reader :name, :version
|
7
|
+
|
8
|
+
def initialize(name:, version: "1.0.0")
|
9
|
+
@name = name
|
10
|
+
@version = version
|
11
|
+
@tools = {}
|
12
|
+
@resources = {}
|
13
|
+
@prompts = {}
|
14
|
+
@tool_instances = {}
|
15
|
+
@resource_instances = {}
|
16
|
+
@prompt_instances = {}
|
17
|
+
@server_capabilities = {}
|
18
|
+
@log_level = :info
|
19
|
+
@stdio_transport = nil
|
20
|
+
@sampling_handler = nil
|
21
|
+
@middleware_stack = nil
|
22
|
+
@error_handler = ErrorHandler.new(self)
|
23
|
+
|
24
|
+
# Initialize health monitoring
|
25
|
+
initialize_health_monitoring
|
26
|
+
end
|
27
|
+
|
28
|
+
# Store transport reference for sending notifications
|
29
|
+
def set_transport(transport)
|
30
|
+
@stdio_transport = transport
|
31
|
+
end
|
32
|
+
|
33
|
+
def capabilities(&block)
|
34
|
+
instance_eval(&block) if block_given?
|
35
|
+
end
|
36
|
+
|
37
|
+
def logging(enabled = true)
|
38
|
+
@server_capabilities[:logging] = {} if enabled
|
39
|
+
end
|
40
|
+
|
41
|
+
def completion(enabled = true)
|
42
|
+
@server_capabilities[:completion] = {} if enabled
|
43
|
+
end
|
44
|
+
|
45
|
+
def sampling(enabled = true, &block)
|
46
|
+
@server_capabilities[:sampling] = {} if enabled
|
47
|
+
@sampling_handler = block if block_given?
|
48
|
+
end
|
49
|
+
|
50
|
+
# Alternative method name for clarity
|
51
|
+
def on_sampling(&block)
|
52
|
+
sampling(true, &block)
|
53
|
+
end
|
54
|
+
|
55
|
+
# Middleware support
|
56
|
+
def use(middleware_class, *args, **kwargs, &block)
|
57
|
+
@middleware_stack ||= MiddlewareStack.new(method(:handle_message_direct))
|
58
|
+
@middleware_stack.use(middleware_class, *args, **kwargs, &block)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Register class-based instances
|
62
|
+
def register_tool_instance(tool_instance, name: nil)
|
63
|
+
tool_name = name || tool_instance.class.tool_name
|
64
|
+
@tool_instances[tool_name] = tool_instance
|
65
|
+
|
66
|
+
# Extract completions if any
|
67
|
+
tool_instance.parameters.each do |param_name, config|
|
68
|
+
if config[:completion]
|
69
|
+
@completions ||= {}
|
70
|
+
@completions["tool:#{tool_name}:#{param_name}"] = config[:completion]
|
71
|
+
@server_capabilities[:completion] ||= {}
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
def register_resource_instance(resource_instance, uri: nil)
|
77
|
+
resource_uri = uri || resource_instance.uri
|
78
|
+
@resource_instances[resource_uri] = resource_instance
|
79
|
+
end
|
80
|
+
|
81
|
+
def register_prompt_instance(prompt_instance, name: nil)
|
82
|
+
prompt_name = name || prompt_instance.class.prompt_name
|
83
|
+
@prompt_instances[prompt_name] = prompt_instance
|
84
|
+
|
85
|
+
# Extract completions if any
|
86
|
+
prompt_instance.arguments.each do |arg_name, config|
|
87
|
+
if config[:completion]
|
88
|
+
@completions ||= {}
|
89
|
+
@completions["prompt:#{prompt_name}:#{arg_name}"] = config[:completion]
|
90
|
+
@server_capabilities[:completion] ||= {}
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def tool(name, description: nil, &block)
|
96
|
+
properties, required = extract_parameters_as_schema(block)
|
97
|
+
|
98
|
+
@tools[name] = {
|
99
|
+
description: description || "Tool: #{name}",
|
100
|
+
handler: block,
|
101
|
+
properties: properties,
|
102
|
+
required: required
|
103
|
+
}
|
104
|
+
end
|
105
|
+
|
106
|
+
def resource(uri, description: nil, &block)
|
107
|
+
@resources[uri] = {
|
108
|
+
description: description || "Resource: #{uri}",
|
109
|
+
handler: block
|
110
|
+
}
|
111
|
+
end
|
112
|
+
|
113
|
+
def prompt(name, description: nil, &block)
|
114
|
+
arguments = extract_parameters_as_arguments(block)
|
115
|
+
|
116
|
+
@prompts[name] = {
|
117
|
+
description: description || "Prompt: #{name}",
|
118
|
+
handler: block,
|
119
|
+
arguments: arguments
|
120
|
+
}
|
121
|
+
end
|
122
|
+
|
123
|
+
# Define completions for prompts or resources
|
124
|
+
def completion_for(type, name, argument = nil, &block)
|
125
|
+
@completions ||= {}
|
126
|
+
key = "#{type}:#{name}"
|
127
|
+
key += ":#{argument}" if argument
|
128
|
+
@completions[key] = block
|
129
|
+
|
130
|
+
# Auto-enable completion capability
|
131
|
+
@server_capabilities[:completion] ||= {}
|
132
|
+
end
|
133
|
+
|
134
|
+
# Logging DSL method
|
135
|
+
def log(level, message, data: nil, logger: nil)
|
136
|
+
# Auto-enable logging capability when first used
|
137
|
+
@server_capabilities[:logging] ||= {}
|
138
|
+
# Define log level hierarchy
|
139
|
+
levels = [:debug, :info, :notice, :warning, :error, :critical, :alert, :emergency]
|
140
|
+
current_level_index = levels.index(@log_level) || 1
|
141
|
+
message_level_index = levels.index(level) || 1
|
142
|
+
|
143
|
+
# Only log if message level is >= current log level
|
144
|
+
return if message_level_index < current_level_index
|
145
|
+
|
146
|
+
# Send notification if transport is available
|
147
|
+
if @stdio_transport
|
148
|
+
notification = {
|
149
|
+
jsonrpc: "2.0",
|
150
|
+
method: "notifications/message",
|
151
|
+
params: {
|
152
|
+
level: level.to_s,
|
153
|
+
message: message
|
154
|
+
}
|
155
|
+
}
|
156
|
+
|
157
|
+
notification[:params][:data] = data if data
|
158
|
+
notification[:params][:logger] = logger if logger
|
159
|
+
|
160
|
+
@stdio_transport.send_notification(notification)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def handle_message(raw_message)
|
165
|
+
message = JSON.parse(raw_message)
|
166
|
+
|
167
|
+
# Use middleware stack if configured
|
168
|
+
if @middleware_stack
|
169
|
+
@middleware_stack.call(message)
|
170
|
+
else
|
171
|
+
handle_message_direct(message)
|
172
|
+
end
|
173
|
+
rescue JSON::ParserError => e
|
174
|
+
# For parse errors, we can't send a response without an ID
|
175
|
+
log :error, "Parse error: #{e.message}"
|
176
|
+
nil
|
177
|
+
rescue => e
|
178
|
+
# Try to send error response if we have an ID
|
179
|
+
if message && message["id"]
|
180
|
+
error_response(message["id"], :internal_error, "Internal error: #{e.message}")
|
181
|
+
else
|
182
|
+
log :error, "Internal error without request ID: #{e.message}"
|
183
|
+
nil
|
184
|
+
end
|
185
|
+
end
|
186
|
+
|
187
|
+
# Direct message handler (without middleware)
|
188
|
+
def handle_message_direct(message)
|
189
|
+
start_time = Time.now
|
190
|
+
@metrics.increment(:requests_total)
|
191
|
+
@metrics.set(:active_requests, @metrics.get(:active_requests) + 1)
|
192
|
+
|
193
|
+
response = case message["method"]
|
194
|
+
when "initialize"
|
195
|
+
handle_initialize(message)
|
196
|
+
when "tools/list"
|
197
|
+
handle_tools_list(message)
|
198
|
+
when "tools/call"
|
199
|
+
handle_tool_call(message)
|
200
|
+
when "resources/list"
|
201
|
+
handle_resources_list(message)
|
202
|
+
when "resources/read"
|
203
|
+
handle_resource_read(message)
|
204
|
+
when "prompts/list"
|
205
|
+
handle_prompts_list(message)
|
206
|
+
when "prompts/get"
|
207
|
+
handle_prompt_get(message)
|
208
|
+
when "logging/setLevel"
|
209
|
+
handle_logging_set_level(message)
|
210
|
+
when "completion/complete"
|
211
|
+
handle_completion_complete(message)
|
212
|
+
when "sampling/createMessage"
|
213
|
+
handle_sampling_create_message(message)
|
214
|
+
when "ping"
|
215
|
+
handle_ping(message)
|
216
|
+
else
|
217
|
+
error_response(message["id"], :method_not_found, "Method not found: #{message['method']}")
|
218
|
+
end
|
219
|
+
|
220
|
+
# Track metrics
|
221
|
+
duration = (Time.now - start_time) * 1000 # Convert to ms
|
222
|
+
@metrics.record(:response_time, duration)
|
223
|
+
@metrics.set(:active_requests, @metrics.get(:active_requests) - 1)
|
224
|
+
|
225
|
+
if response && response[:error]
|
226
|
+
@metrics.increment(:errors_total)
|
227
|
+
end
|
228
|
+
|
229
|
+
response
|
230
|
+
end
|
231
|
+
|
232
|
+
private
|
233
|
+
|
234
|
+
# Extract parameters for tool schemas (properties format)
|
235
|
+
def extract_parameters_as_schema(block)
|
236
|
+
properties = {}
|
237
|
+
required = []
|
238
|
+
|
239
|
+
block.parameters.each do |type, param_name|
|
240
|
+
if type == :keyreq # required keyword argument
|
241
|
+
properties[param_name.to_s] = {
|
242
|
+
type: "string",
|
243
|
+
description: "Required parameter: #{param_name}"
|
244
|
+
}
|
245
|
+
required << param_name.to_s
|
246
|
+
elsif type == :key # optional keyword argument
|
247
|
+
properties[param_name.to_s] = {
|
248
|
+
type: "string",
|
249
|
+
description: "Optional parameter: #{param_name}"
|
250
|
+
}
|
251
|
+
end
|
252
|
+
end
|
253
|
+
|
254
|
+
[properties, required]
|
255
|
+
end
|
256
|
+
|
257
|
+
# Extract parameters for prompt arguments format
|
258
|
+
def extract_parameters_as_arguments(block)
|
259
|
+
block.parameters.map do |type, param_name|
|
260
|
+
if type == :keyreq || type == :key
|
261
|
+
{
|
262
|
+
name: param_name.to_s,
|
263
|
+
description: "#{type == :keyreq ? 'Required' : 'Optional'} parameter: #{param_name}",
|
264
|
+
required: type == :keyreq
|
265
|
+
}
|
266
|
+
end
|
267
|
+
end.compact
|
268
|
+
end
|
269
|
+
|
270
|
+
def handle_initialize(message)
|
271
|
+
# Build capabilities based on what's available
|
272
|
+
capabilities = {}
|
273
|
+
capabilities[:tools] = {} if @tools.any?
|
274
|
+
capabilities[:resources] = {} if @resources.any?
|
275
|
+
capabilities[:prompts] = {} if @prompts.any?
|
276
|
+
|
277
|
+
# Add declared server capabilities
|
278
|
+
capabilities.merge!(@server_capabilities)
|
279
|
+
|
280
|
+
{
|
281
|
+
jsonrpc: "2.0",
|
282
|
+
id: message["id"],
|
283
|
+
result: {
|
284
|
+
protocolVersion: Tsikol::PROTOCOL_VERSION,
|
285
|
+
capabilities: capabilities,
|
286
|
+
serverInfo: {
|
287
|
+
name: @name,
|
288
|
+
version: @version
|
289
|
+
}
|
290
|
+
}
|
291
|
+
}
|
292
|
+
end
|
293
|
+
|
294
|
+
def handle_tools_list(message)
|
295
|
+
# Combine both DSL tools and class-based tools
|
296
|
+
tools = []
|
297
|
+
|
298
|
+
# DSL-style tools
|
299
|
+
@tools.each do |name, tool|
|
300
|
+
tools << {
|
301
|
+
name: name,
|
302
|
+
description: tool[:description],
|
303
|
+
inputSchema: {
|
304
|
+
type: "object",
|
305
|
+
properties: tool[:properties],
|
306
|
+
required: tool[:required]
|
307
|
+
}
|
308
|
+
}
|
309
|
+
end
|
310
|
+
|
311
|
+
# Class-based tools
|
312
|
+
@tool_instances.each do |name, instance|
|
313
|
+
tools << instance.to_mcp
|
314
|
+
end
|
315
|
+
|
316
|
+
{
|
317
|
+
jsonrpc: "2.0",
|
318
|
+
id: message["id"],
|
319
|
+
result: { tools: tools }
|
320
|
+
}
|
321
|
+
end
|
322
|
+
|
323
|
+
def handle_tool_call(message)
|
324
|
+
tool_name = message.dig("params", "name")
|
325
|
+
arguments = message.dig("params", "arguments") || {}
|
326
|
+
|
327
|
+
# Run before tool hook
|
328
|
+
run_before_tool_hook(tool_name, arguments) if respond_to?(:run_before_tool_hook)
|
329
|
+
|
330
|
+
# Track tool metrics
|
331
|
+
@metrics.increment("tools:#{tool_name}:calls")
|
332
|
+
start_time = Time.now
|
333
|
+
|
334
|
+
# Try class-based tool first
|
335
|
+
if (tool_instance = @tool_instances[tool_name])
|
336
|
+
begin
|
337
|
+
kwargs = arguments.transform_keys(&:to_sym)
|
338
|
+
result = @error_handler.wrap_tool_execution(tool_name) do
|
339
|
+
tool_instance.execute(**kwargs)
|
340
|
+
end
|
341
|
+
rescue => e
|
342
|
+
@metrics.increment("tools:#{tool_name}:errors")
|
343
|
+
return error_response(message["id"], :internal_error, "Tool error: #{e.message}")
|
344
|
+
end
|
345
|
+
elsif (tool = @tools[tool_name])
|
346
|
+
# Fall back to DSL-style tool
|
347
|
+
begin
|
348
|
+
result = @error_handler.wrap_tool_execution(tool_name) do
|
349
|
+
if tool[:handler].parameters.any?
|
350
|
+
kwargs = arguments.transform_keys(&:to_sym)
|
351
|
+
tool[:handler].call(**kwargs)
|
352
|
+
else
|
353
|
+
tool[:handler].call
|
354
|
+
end
|
355
|
+
end
|
356
|
+
rescue => e
|
357
|
+
@metrics.increment("tools:#{tool_name}:errors")
|
358
|
+
return error_response(message["id"], :internal_error, "Tool error: #{e.message}")
|
359
|
+
end
|
360
|
+
else
|
361
|
+
return error_response(message["id"], :invalid_params, "Unknown tool: #{tool_name}")
|
362
|
+
end
|
363
|
+
|
364
|
+
# Track execution time
|
365
|
+
duration = (Time.now - start_time) * 1000
|
366
|
+
@metrics.record("tools:#{tool_name}:duration", duration)
|
367
|
+
|
368
|
+
# Run after tool hook
|
369
|
+
run_after_tool_hook(tool_name, arguments, result) if respond_to?(:run_after_tool_hook)
|
370
|
+
|
371
|
+
{
|
372
|
+
jsonrpc: "2.0",
|
373
|
+
id: message["id"],
|
374
|
+
result: {
|
375
|
+
content: [
|
376
|
+
{
|
377
|
+
type: "text",
|
378
|
+
text: result.to_s
|
379
|
+
}
|
380
|
+
]
|
381
|
+
}
|
382
|
+
}
|
383
|
+
end
|
384
|
+
|
385
|
+
def handle_resources_list(message)
|
386
|
+
resources = []
|
387
|
+
|
388
|
+
# DSL-style resources
|
389
|
+
@resources.each do |uri, resource|
|
390
|
+
resources << {
|
391
|
+
uri: uri,
|
392
|
+
name: uri,
|
393
|
+
description: resource[:description]
|
394
|
+
}
|
395
|
+
end
|
396
|
+
|
397
|
+
# Class-based resources
|
398
|
+
@resource_instances.each do |uri, instance|
|
399
|
+
resources << instance.to_mcp
|
400
|
+
end
|
401
|
+
|
402
|
+
{
|
403
|
+
jsonrpc: "2.0",
|
404
|
+
id: message["id"],
|
405
|
+
result: { resources: resources }
|
406
|
+
}
|
407
|
+
end
|
408
|
+
|
409
|
+
def handle_resource_read(message)
|
410
|
+
uri = message.dig("params", "uri")
|
411
|
+
|
412
|
+
# Try class-based resource first
|
413
|
+
if (resource_instance = @resource_instances[uri])
|
414
|
+
content = resource_instance.read
|
415
|
+
elsif (resource = @resources[uri])
|
416
|
+
# Fall back to DSL-style resource
|
417
|
+
content = resource[:handler].call
|
418
|
+
else
|
419
|
+
return error_response(message["id"], :invalid_params, "Unknown resource: #{uri}")
|
420
|
+
end
|
421
|
+
|
422
|
+
{
|
423
|
+
jsonrpc: "2.0",
|
424
|
+
id: message["id"],
|
425
|
+
result: {
|
426
|
+
contents: [
|
427
|
+
{
|
428
|
+
uri: uri,
|
429
|
+
mimeType: "text/plain",
|
430
|
+
text: content.to_s
|
431
|
+
}
|
432
|
+
]
|
433
|
+
}
|
434
|
+
}
|
435
|
+
end
|
436
|
+
|
437
|
+
def handle_prompts_list(message)
|
438
|
+
prompts = []
|
439
|
+
|
440
|
+
# DSL-style prompts
|
441
|
+
@prompts.each do |name, prompt|
|
442
|
+
prompts << {
|
443
|
+
name: name,
|
444
|
+
description: prompt[:description],
|
445
|
+
arguments: prompt[:arguments]
|
446
|
+
}
|
447
|
+
end
|
448
|
+
|
449
|
+
# Class-based prompts
|
450
|
+
@prompt_instances.each do |name, instance|
|
451
|
+
prompts << instance.to_mcp
|
452
|
+
end
|
453
|
+
|
454
|
+
{
|
455
|
+
jsonrpc: "2.0",
|
456
|
+
id: message["id"],
|
457
|
+
result: { prompts: prompts }
|
458
|
+
}
|
459
|
+
end
|
460
|
+
|
461
|
+
def handle_prompt_get(message)
|
462
|
+
name = message.dig("params", "name")
|
463
|
+
arguments = message.dig("params", "arguments") || {}
|
464
|
+
|
465
|
+
# Try class-based prompt first
|
466
|
+
if (prompt_instance = @prompt_instances[name])
|
467
|
+
kwargs = arguments.transform_keys(&:to_sym)
|
468
|
+
messages = prompt_instance.get_messages(**kwargs)
|
469
|
+
|
470
|
+
return {
|
471
|
+
jsonrpc: "2.0",
|
472
|
+
id: message["id"],
|
473
|
+
result: {
|
474
|
+
messages: messages
|
475
|
+
}
|
476
|
+
}
|
477
|
+
elsif (prompt = @prompts[name])
|
478
|
+
# Fall back to DSL-style prompt
|
479
|
+
kwargs = arguments.transform_keys(&:to_sym)
|
480
|
+
text = prompt[:handler].call(**kwargs)
|
481
|
+
else
|
482
|
+
return error_response(message["id"], :invalid_params, "Unknown prompt: #{name}")
|
483
|
+
end
|
484
|
+
|
485
|
+
{
|
486
|
+
jsonrpc: "2.0",
|
487
|
+
id: message["id"],
|
488
|
+
result: {
|
489
|
+
messages: [
|
490
|
+
{
|
491
|
+
role: "user",
|
492
|
+
content: {
|
493
|
+
type: "text",
|
494
|
+
text: text
|
495
|
+
}
|
496
|
+
}
|
497
|
+
]
|
498
|
+
}
|
499
|
+
}
|
500
|
+
end
|
501
|
+
|
502
|
+
def handle_logging_set_level(message)
|
503
|
+
level = message.dig("params", "level")
|
504
|
+
|
505
|
+
# Validate level
|
506
|
+
valid_levels = %w[debug info notice warning error critical alert emergency]
|
507
|
+
unless valid_levels.include?(level)
|
508
|
+
return error_response(message["id"], :invalid_params, "Invalid log level: #{level}")
|
509
|
+
end
|
510
|
+
|
511
|
+
@log_level = level.to_sym
|
512
|
+
|
513
|
+
{
|
514
|
+
jsonrpc: "2.0",
|
515
|
+
id: message["id"],
|
516
|
+
result: {}
|
517
|
+
}
|
518
|
+
end
|
519
|
+
|
520
|
+
def handle_completion_complete(message)
|
521
|
+
params = message["params"] || {}
|
522
|
+
ref = params["ref"]
|
523
|
+
argument = params["argument"]
|
524
|
+
|
525
|
+
unless ref
|
526
|
+
return error_response(message["id"], :invalid_params, "Missing ref parameter")
|
527
|
+
end
|
528
|
+
|
529
|
+
# Build completion key
|
530
|
+
completion_key = case ref["type"]
|
531
|
+
when "ref/prompt"
|
532
|
+
if argument
|
533
|
+
"prompt:#{ref['name']}:#{argument['name']}"
|
534
|
+
else
|
535
|
+
"prompt:#{ref['name']}"
|
536
|
+
end
|
537
|
+
when "ref/resource"
|
538
|
+
"resource:#{ref['uri']}"
|
539
|
+
else
|
540
|
+
return error_response(message["id"], :invalid_params, "Unknown ref type: #{ref['type']}")
|
541
|
+
end
|
542
|
+
|
543
|
+
# Get completion handler
|
544
|
+
handler = @completions&.[](completion_key)
|
545
|
+
|
546
|
+
# Get completions
|
547
|
+
values = if handler
|
548
|
+
begin
|
549
|
+
# Call handler with current value
|
550
|
+
current_value = argument&.dig("value") || ""
|
551
|
+
result = handler.call(current_value)
|
552
|
+
Array(result)
|
553
|
+
rescue => e
|
554
|
+
log :error, "Completion handler error", data: { error: e.message }
|
555
|
+
[]
|
556
|
+
end
|
557
|
+
else
|
558
|
+
[]
|
559
|
+
end
|
560
|
+
|
561
|
+
# Limit to 100 results as per spec
|
562
|
+
values = values.first(100)
|
563
|
+
|
564
|
+
{
|
565
|
+
jsonrpc: "2.0",
|
566
|
+
id: message["id"],
|
567
|
+
result: {
|
568
|
+
completion: {
|
569
|
+
values: values,
|
570
|
+
total: values.size,
|
571
|
+
hasMore: false
|
572
|
+
}
|
573
|
+
}
|
574
|
+
}
|
575
|
+
end
|
576
|
+
|
577
|
+
def handle_sampling_create_message(message)
|
578
|
+
params = message["params"] || {}
|
579
|
+
|
580
|
+
# Validate required parameters
|
581
|
+
messages = params["messages"]
|
582
|
+
unless messages && messages.is_a?(Array)
|
583
|
+
return error_response(message["id"], :invalid_params, "Missing or invalid messages parameter")
|
584
|
+
end
|
585
|
+
|
586
|
+
# Extract parameters
|
587
|
+
model_preferences = params["modelPreferences"] || {}
|
588
|
+
system_prompt = params["systemPrompt"]
|
589
|
+
max_tokens = params["maxTokens"]
|
590
|
+
include_context = params["includeContext"] || "none"
|
591
|
+
temperature = params["temperature"]
|
592
|
+
metadata = params["metadata"] || {}
|
593
|
+
|
594
|
+
# Build sampling request data
|
595
|
+
sampling_data = {
|
596
|
+
messages: messages,
|
597
|
+
model_preferences: model_preferences,
|
598
|
+
system_prompt: system_prompt,
|
599
|
+
max_tokens: max_tokens,
|
600
|
+
temperature: temperature,
|
601
|
+
metadata: metadata
|
602
|
+
}
|
603
|
+
|
604
|
+
# Call registered sampling handler if any
|
605
|
+
if @sampling_handler
|
606
|
+
begin
|
607
|
+
result = @sampling_handler.call(sampling_data)
|
608
|
+
|
609
|
+
# Result should have role and content
|
610
|
+
unless result.is_a?(Hash) && result[:role] && result[:content]
|
611
|
+
raise "Sampling handler must return hash with :role and :content"
|
612
|
+
end
|
613
|
+
|
614
|
+
{
|
615
|
+
jsonrpc: "2.0",
|
616
|
+
id: message["id"],
|
617
|
+
result: result
|
618
|
+
}
|
619
|
+
rescue => e
|
620
|
+
error_response(message["id"], :internal_error, "Sampling error: #{e.message}")
|
621
|
+
end
|
622
|
+
else
|
623
|
+
# No sampling handler registered
|
624
|
+
error_response(message["id"], :internal_error, "No sampling handler registered. Call 'on_sampling' to register a handler.")
|
625
|
+
end
|
626
|
+
end
|
627
|
+
|
628
|
+
def handle_ping(message)
|
629
|
+
# Simple ping/pong response
|
630
|
+
{
|
631
|
+
jsonrpc: "2.0",
|
632
|
+
id: message["id"],
|
633
|
+
result: {}
|
634
|
+
}
|
635
|
+
end
|
636
|
+
|
637
|
+
def error_response(id, code_key, message)
|
638
|
+
# If no ID, we can't send a proper error response
|
639
|
+
return nil if id.nil?
|
640
|
+
|
641
|
+
code = case code_key
|
642
|
+
when :parse_error then Errors::PARSE_ERROR
|
643
|
+
when :invalid_request then Errors::INVALID_REQUEST
|
644
|
+
when :method_not_found then Errors::METHOD_NOT_FOUND
|
645
|
+
when :invalid_params then Errors::INVALID_PARAMS
|
646
|
+
when :internal_error then Errors::INTERNAL_ERROR
|
647
|
+
else Errors::INTERNAL_ERROR
|
648
|
+
end
|
649
|
+
|
650
|
+
{
|
651
|
+
jsonrpc: "2.0",
|
652
|
+
id: id,
|
653
|
+
error: {
|
654
|
+
code: code,
|
655
|
+
message: message
|
656
|
+
}
|
657
|
+
}
|
658
|
+
end
|
659
|
+
end
|
660
|
+
end
|