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,822 @@
|
|
1
|
+
# Weather Service Example
|
2
|
+
|
3
|
+
A comprehensive MCP server providing weather information with caching, rate limiting, and multiple data sources.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
This example demonstrates:
|
8
|
+
- External API integration
|
9
|
+
- Caching strategies
|
10
|
+
- Rate limiting
|
11
|
+
- Error handling and fallbacks
|
12
|
+
- Resource implementation
|
13
|
+
- Prompt templates
|
14
|
+
- Testing with mocked APIs
|
15
|
+
|
16
|
+
## Implementation
|
17
|
+
|
18
|
+
### server.rb
|
19
|
+
|
20
|
+
```ruby
|
21
|
+
#!/usr/bin/env ruby
|
22
|
+
|
23
|
+
require 'tsikol'
|
24
|
+
require 'net/http'
|
25
|
+
require 'json'
|
26
|
+
require 'redis'
|
27
|
+
|
28
|
+
# Weather API client
|
29
|
+
class WeatherClient
|
30
|
+
API_KEY = ENV['OPENWEATHER_API_KEY']
|
31
|
+
BASE_URL = 'https://api.openweathermap.org/data/2.5'
|
32
|
+
|
33
|
+
def initialize
|
34
|
+
@cache = {}
|
35
|
+
end
|
36
|
+
|
37
|
+
def current_weather(city)
|
38
|
+
fetch_data("weather", { q: city })
|
39
|
+
end
|
40
|
+
|
41
|
+
def forecast(city, days = 5)
|
42
|
+
data = fetch_data("forecast", { q: city, cnt: days * 8 })
|
43
|
+
parse_forecast(data)
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
def fetch_data(endpoint, params)
|
49
|
+
params[:appid] = API_KEY
|
50
|
+
params[:units] = 'metric'
|
51
|
+
|
52
|
+
uri = URI("#{BASE_URL}/#{endpoint}")
|
53
|
+
uri.query = URI.encode_www_form(params)
|
54
|
+
|
55
|
+
response = Net::HTTP.get_response(uri)
|
56
|
+
|
57
|
+
unless response.is_a?(Net::HTTPSuccess)
|
58
|
+
raise "Weather API error: #{response.code} #{response.message}"
|
59
|
+
end
|
60
|
+
|
61
|
+
JSON.parse(response.body)
|
62
|
+
end
|
63
|
+
|
64
|
+
def parse_forecast(data)
|
65
|
+
# Group forecast by day
|
66
|
+
daily_forecast = data['list'].group_by do |item|
|
67
|
+
Time.at(item['dt']).strftime('%Y-%m-%d')
|
68
|
+
end
|
69
|
+
|
70
|
+
daily_forecast.map do |date, items|
|
71
|
+
temps = items.map { |i| i['main']['temp'] }
|
72
|
+
{
|
73
|
+
date: date,
|
74
|
+
temp_min: temps.min,
|
75
|
+
temp_max: temps.max,
|
76
|
+
temp_avg: (temps.sum / temps.size).round(1),
|
77
|
+
conditions: items.map { |i| i['weather'][0]['main'] }.uniq
|
78
|
+
}
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Current weather tool
|
84
|
+
class CurrentWeatherTool < Tsikol::Tool
|
85
|
+
name "current_weather"
|
86
|
+
description "Get current weather for a city"
|
87
|
+
|
88
|
+
parameter :city do
|
89
|
+
type :string
|
90
|
+
required
|
91
|
+
description "City name (e.g., 'London', 'New York')"
|
92
|
+
|
93
|
+
complete do |partial|
|
94
|
+
# Suggest popular cities
|
95
|
+
cities = [
|
96
|
+
"London", "New York", "Tokyo", "Paris", "Sydney",
|
97
|
+
"Los Angeles", "Chicago", "Toronto", "Berlin", "Madrid"
|
98
|
+
]
|
99
|
+
cities.select { |c| c.downcase.start_with?(partial.downcase) }
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
parameter :units do
|
104
|
+
type :string
|
105
|
+
optional
|
106
|
+
default "celsius"
|
107
|
+
enum ["celsius", "fahrenheit"]
|
108
|
+
description "Temperature units"
|
109
|
+
end
|
110
|
+
|
111
|
+
def execute(city:, units: "celsius")
|
112
|
+
# Check cache first
|
113
|
+
cache_key = "weather:current:#{city.downcase}"
|
114
|
+
|
115
|
+
data = with_cache(cache_key, ttl: 600) do
|
116
|
+
weather_client.current_weather(city)
|
117
|
+
end
|
118
|
+
|
119
|
+
format_current_weather(data, units)
|
120
|
+
rescue => e
|
121
|
+
log :error, "Weather fetch failed", city: city, error: e.message
|
122
|
+
"Unable to fetch weather for #{city}: #{e.message}"
|
123
|
+
end
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
def format_current_weather(data, units)
|
128
|
+
temp = data['main']['temp']
|
129
|
+
feels_like = data['main']['feels_like']
|
130
|
+
|
131
|
+
if units == "fahrenheit"
|
132
|
+
temp = celsius_to_fahrenheit(temp)
|
133
|
+
feels_like = celsius_to_fahrenheit(feels_like)
|
134
|
+
unit_symbol = "�F"
|
135
|
+
else
|
136
|
+
unit_symbol = "�C"
|
137
|
+
end
|
138
|
+
|
139
|
+
<<~WEATHER
|
140
|
+
Weather in #{data['name']}, #{data['sys']['country']}:
|
141
|
+
|
142
|
+
Conditions: #{data['weather'][0]['description'].capitalize}
|
143
|
+
Temperature: #{temp.round(1)}#{unit_symbol}
|
144
|
+
Feels like: #{feels_like.round(1)}#{unit_symbol}
|
145
|
+
Humidity: #{data['main']['humidity']}%
|
146
|
+
Wind: #{data['wind']['speed']} m/s
|
147
|
+
Pressure: #{data['main']['pressure']} hPa
|
148
|
+
|
149
|
+
Last updated: #{Time.at(data['dt']).strftime('%Y-%m-%d %H:%M')}
|
150
|
+
WEATHER
|
151
|
+
end
|
152
|
+
|
153
|
+
def celsius_to_fahrenheit(celsius)
|
154
|
+
(celsius * 9/5) + 32
|
155
|
+
end
|
156
|
+
|
157
|
+
def weather_client
|
158
|
+
@weather_client ||= WeatherClient.new
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Weather forecast tool
|
163
|
+
class WeatherForecastTool < Tsikol::Tool
|
164
|
+
name "weather_forecast"
|
165
|
+
description "Get weather forecast for a city"
|
166
|
+
|
167
|
+
parameter :city do
|
168
|
+
type :string
|
169
|
+
required
|
170
|
+
description "City name"
|
171
|
+
end
|
172
|
+
|
173
|
+
parameter :days do
|
174
|
+
type :number
|
175
|
+
optional
|
176
|
+
default 5
|
177
|
+
description "Number of days (1-5)"
|
178
|
+
end
|
179
|
+
|
180
|
+
def execute(city:, days: 5)
|
181
|
+
days = [[1, days].max, 5].min # Clamp between 1 and 5
|
182
|
+
|
183
|
+
cache_key = "weather:forecast:#{city.downcase}:#{days}"
|
184
|
+
|
185
|
+
forecast = with_cache(cache_key, ttl: 3600) do
|
186
|
+
weather_client.forecast(city, days)
|
187
|
+
end
|
188
|
+
|
189
|
+
format_forecast(city, forecast)
|
190
|
+
rescue => e
|
191
|
+
log :error, "Forecast fetch failed", city: city, error: e.message
|
192
|
+
"Unable to fetch forecast for #{city}: #{e.message}"
|
193
|
+
end
|
194
|
+
|
195
|
+
private
|
196
|
+
|
197
|
+
def format_forecast(city, forecast)
|
198
|
+
lines = ["#{days}-day forecast for #{city}:", ""]
|
199
|
+
|
200
|
+
forecast.each do |day|
|
201
|
+
date = Date.parse(day[:date]).strftime('%A, %B %d')
|
202
|
+
temps = "#{day[:temp_min]}�C - #{day[:temp_max]}�C (avg: #{day[:temp_avg]}�C)"
|
203
|
+
conditions = day[:conditions].join(", ")
|
204
|
+
|
205
|
+
lines << "#{date}:"
|
206
|
+
lines << " Temperature: #{temps}"
|
207
|
+
lines << " Conditions: #{conditions}"
|
208
|
+
lines << ""
|
209
|
+
end
|
210
|
+
|
211
|
+
lines.join("\n")
|
212
|
+
end
|
213
|
+
|
214
|
+
def weather_client
|
215
|
+
@weather_client ||= WeatherClient.new
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Weather alerts resource
|
220
|
+
class WeatherAlertsResource < Tsikol::Resource
|
221
|
+
uri "weather/alerts/:region?"
|
222
|
+
description "Active weather alerts for a region"
|
223
|
+
|
224
|
+
def read
|
225
|
+
region = params[:region] || "global"
|
226
|
+
|
227
|
+
# In a real implementation, this would fetch from a weather alerts API
|
228
|
+
alerts = fetch_alerts(region)
|
229
|
+
|
230
|
+
{
|
231
|
+
region: region,
|
232
|
+
alerts: alerts,
|
233
|
+
last_updated: Time.now.iso8601,
|
234
|
+
severity_levels: ["info", "warning", "severe", "extreme"]
|
235
|
+
}.to_json
|
236
|
+
end
|
237
|
+
|
238
|
+
private
|
239
|
+
|
240
|
+
def fetch_alerts(region)
|
241
|
+
# Mock implementation - replace with real API
|
242
|
+
case region.downcase
|
243
|
+
when "california"
|
244
|
+
[
|
245
|
+
{
|
246
|
+
id: "CA-001",
|
247
|
+
type: "fire_weather",
|
248
|
+
severity: "warning",
|
249
|
+
title: "Red Flag Warning",
|
250
|
+
description: "High winds and low humidity",
|
251
|
+
areas: ["Los Angeles County", "Ventura County"],
|
252
|
+
valid_until: (Time.now + 86400).iso8601
|
253
|
+
}
|
254
|
+
]
|
255
|
+
when "florida"
|
256
|
+
[
|
257
|
+
{
|
258
|
+
id: "FL-001",
|
259
|
+
type: "thunderstorm",
|
260
|
+
severity: "warning",
|
261
|
+
title: "Severe Thunderstorm Warning",
|
262
|
+
description: "Possible tornadoes and damaging winds",
|
263
|
+
areas: ["Miami-Dade County"],
|
264
|
+
valid_until: (Time.now + 7200).iso8601
|
265
|
+
}
|
266
|
+
]
|
267
|
+
else
|
268
|
+
[]
|
269
|
+
end
|
270
|
+
end
|
271
|
+
end
|
272
|
+
|
273
|
+
# Historical weather data resource
|
274
|
+
class HistoricalWeatherResource < Tsikol::Resource
|
275
|
+
uri "weather/history/:city/:date?"
|
276
|
+
description "Historical weather data for a city"
|
277
|
+
|
278
|
+
def read
|
279
|
+
city = params[:city]
|
280
|
+
date = params[:date] || Date.today.to_s
|
281
|
+
|
282
|
+
# Check if date is valid and not in future
|
283
|
+
begin
|
284
|
+
parsed_date = Date.parse(date)
|
285
|
+
if parsed_date > Date.today
|
286
|
+
return error_response("Cannot get historical data for future dates")
|
287
|
+
end
|
288
|
+
rescue
|
289
|
+
return error_response("Invalid date format. Use YYYY-MM-DD")
|
290
|
+
end
|
291
|
+
|
292
|
+
# Fetch from cache or API
|
293
|
+
cache_key = "weather:history:#{city}:#{date}"
|
294
|
+
data = with_cache(cache_key, ttl: 86400) do # Cache for 24 hours
|
295
|
+
fetch_historical_data(city, parsed_date)
|
296
|
+
end
|
297
|
+
|
298
|
+
format_historical_data(city, date, data)
|
299
|
+
end
|
300
|
+
|
301
|
+
private
|
302
|
+
|
303
|
+
def fetch_historical_data(city, date)
|
304
|
+
# Mock implementation - replace with real historical weather API
|
305
|
+
{
|
306
|
+
temp_min: 10 + rand(5),
|
307
|
+
temp_max: 20 + rand(10),
|
308
|
+
temp_avg: 15 + rand(5),
|
309
|
+
precipitation: rand(20),
|
310
|
+
humidity: 50 + rand(30),
|
311
|
+
conditions: ["Partly Cloudy", "Sunny", "Overcast"].sample
|
312
|
+
}
|
313
|
+
end
|
314
|
+
|
315
|
+
def format_historical_data(city, date, data)
|
316
|
+
{
|
317
|
+
city: city,
|
318
|
+
date: date,
|
319
|
+
temperature: {
|
320
|
+
min: "#{data[:temp_min]}�C",
|
321
|
+
max: "#{data[:temp_max]}�C",
|
322
|
+
average: "#{data[:temp_avg]}�C"
|
323
|
+
},
|
324
|
+
precipitation: "#{data[:precipitation]}mm",
|
325
|
+
humidity: "#{data[:humidity]}%",
|
326
|
+
conditions: data[:conditions]
|
327
|
+
}.to_json
|
328
|
+
end
|
329
|
+
|
330
|
+
def error_response(message)
|
331
|
+
{ error: message }.to_json
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
# Weather assistant prompt
|
336
|
+
class WeatherAssistantPrompt < Tsikol::Prompt
|
337
|
+
name "weather_assistant"
|
338
|
+
description "Expert weather assistant with comprehensive knowledge"
|
339
|
+
|
340
|
+
argument :focus do
|
341
|
+
type :string
|
342
|
+
optional
|
343
|
+
enum ["current", "forecast", "alerts", "travel", "activities"]
|
344
|
+
description "Specific focus area for the assistant"
|
345
|
+
end
|
346
|
+
|
347
|
+
argument :location do
|
348
|
+
type :string
|
349
|
+
optional
|
350
|
+
description "Default location for weather queries"
|
351
|
+
end
|
352
|
+
|
353
|
+
def get_messages(focus: nil, location: nil)
|
354
|
+
system_message = build_system_message(focus, location)
|
355
|
+
|
356
|
+
[
|
357
|
+
{
|
358
|
+
role: "system",
|
359
|
+
content: {
|
360
|
+
type: "text",
|
361
|
+
text: system_message
|
362
|
+
}
|
363
|
+
},
|
364
|
+
{
|
365
|
+
role: "user",
|
366
|
+
content: {
|
367
|
+
type: "text",
|
368
|
+
text: "You are now active and ready to help with weather information."
|
369
|
+
}
|
370
|
+
},
|
371
|
+
{
|
372
|
+
role: "assistant",
|
373
|
+
content: {
|
374
|
+
type: "text",
|
375
|
+
text: "I'm ready to help you with weather information#{location ? " for #{location}" : ''}. I can provide current conditions, forecasts, weather alerts, and recommendations for weather-related activities. What would you like to know?"
|
376
|
+
}
|
377
|
+
}
|
378
|
+
]
|
379
|
+
end
|
380
|
+
|
381
|
+
private
|
382
|
+
|
383
|
+
def build_system_message(focus, location)
|
384
|
+
base = <<~SYSTEM
|
385
|
+
You are an expert weather assistant with access to comprehensive weather data and tools.
|
386
|
+
|
387
|
+
Available tools:
|
388
|
+
- current_weather: Get current weather conditions for any city
|
389
|
+
- weather_forecast: Get multi-day weather forecasts
|
390
|
+
|
391
|
+
Available resources:
|
392
|
+
- weather/alerts/{region}: Check active weather alerts
|
393
|
+
- weather/history/{city}/{date}: Access historical weather data
|
394
|
+
|
395
|
+
Key capabilities:
|
396
|
+
1. Provide accurate, up-to-date weather information
|
397
|
+
2. Interpret weather data in user-friendly terms
|
398
|
+
3. Give weather-based recommendations for activities
|
399
|
+
4. Explain weather patterns and phenomena
|
400
|
+
5. Provide travel weather advisories
|
401
|
+
SYSTEM
|
402
|
+
|
403
|
+
if focus
|
404
|
+
base += "\n\nSpecial focus: #{focus_description(focus)}"
|
405
|
+
end
|
406
|
+
|
407
|
+
if location
|
408
|
+
base += "\n\nDefault location: #{location} (but assist with any location requested)"
|
409
|
+
end
|
410
|
+
|
411
|
+
base
|
412
|
+
end
|
413
|
+
|
414
|
+
def focus_description(focus)
|
415
|
+
case focus
|
416
|
+
when "current"
|
417
|
+
"Emphasize current weather conditions and immediate concerns"
|
418
|
+
when "forecast"
|
419
|
+
"Focus on weather predictions and trends"
|
420
|
+
when "alerts"
|
421
|
+
"Prioritize weather warnings and safety information"
|
422
|
+
when "travel"
|
423
|
+
"Provide weather information relevant to travel planning"
|
424
|
+
when "activities"
|
425
|
+
"Suggest weather-appropriate activities and timing"
|
426
|
+
end
|
427
|
+
end
|
428
|
+
end
|
429
|
+
|
430
|
+
# Caching mixin
|
431
|
+
module Cacheable
|
432
|
+
def with_cache(key, ttl: 300)
|
433
|
+
if redis_available?
|
434
|
+
cached = redis.get(key)
|
435
|
+
return JSON.parse(cached) if cached
|
436
|
+
|
437
|
+
result = yield
|
438
|
+
redis.setex(key, ttl, result.to_json)
|
439
|
+
result
|
440
|
+
else
|
441
|
+
# Fallback to in-memory cache
|
442
|
+
@memory_cache ||= {}
|
443
|
+
if cached = @memory_cache[key]
|
444
|
+
return cached[:data] if Time.now < cached[:expires_at]
|
445
|
+
end
|
446
|
+
|
447
|
+
result = yield
|
448
|
+
@memory_cache[key] = {
|
449
|
+
data: result,
|
450
|
+
expires_at: Time.now + ttl
|
451
|
+
}
|
452
|
+
result
|
453
|
+
end
|
454
|
+
end
|
455
|
+
|
456
|
+
private
|
457
|
+
|
458
|
+
def redis
|
459
|
+
@redis ||= Redis.new
|
460
|
+
end
|
461
|
+
|
462
|
+
def redis_available?
|
463
|
+
return @redis_available if defined?(@redis_available)
|
464
|
+
@redis_available = begin
|
465
|
+
redis.ping == "PONG"
|
466
|
+
rescue
|
467
|
+
false
|
468
|
+
end
|
469
|
+
end
|
470
|
+
end
|
471
|
+
|
472
|
+
# Include caching in tools and resources
|
473
|
+
[CurrentWeatherTool, WeatherForecastTool, HistoricalWeatherResource].each do |klass|
|
474
|
+
klass.include Cacheable
|
475
|
+
end
|
476
|
+
|
477
|
+
# Start the server
|
478
|
+
Tsikol.start(
|
479
|
+
name: "weather-service",
|
480
|
+
version: "1.0.0",
|
481
|
+
description: "Comprehensive weather information service"
|
482
|
+
) do
|
483
|
+
# Enable capabilities
|
484
|
+
logging true
|
485
|
+
prompts true
|
486
|
+
|
487
|
+
# Middleware stack
|
488
|
+
use Tsikol::LoggingMiddleware, level: :info
|
489
|
+
use Tsikol::RateLimitMiddleware,
|
490
|
+
max_requests: 60,
|
491
|
+
window_seconds: 60
|
492
|
+
use Tsikol::CachingMiddleware,
|
493
|
+
cacheable_methods: ["resources/read"]
|
494
|
+
|
495
|
+
# Register components
|
496
|
+
tool CurrentWeatherTool
|
497
|
+
tool WeatherForecastTool
|
498
|
+
resource WeatherAlertsResource
|
499
|
+
resource HistoricalWeatherResource
|
500
|
+
prompt WeatherAssistantPrompt
|
501
|
+
|
502
|
+
# Service status resource
|
503
|
+
resource "status" do
|
504
|
+
description "Weather service status"
|
505
|
+
|
506
|
+
def read
|
507
|
+
{
|
508
|
+
status: "operational",
|
509
|
+
api_key_configured: !ENV['OPENWEATHER_API_KEY'].nil?,
|
510
|
+
cache_available: redis_available?,
|
511
|
+
components: {
|
512
|
+
current_weather: "operational",
|
513
|
+
forecast: "operational",
|
514
|
+
alerts: "operational",
|
515
|
+
historical: "operational"
|
516
|
+
},
|
517
|
+
rate_limits: {
|
518
|
+
requests_per_minute: 60,
|
519
|
+
burst_capacity: 10
|
520
|
+
}
|
521
|
+
}.to_json
|
522
|
+
end
|
523
|
+
|
524
|
+
def redis_available?
|
525
|
+
Redis.new.ping == "PONG" rescue false
|
526
|
+
end
|
527
|
+
end
|
528
|
+
end
|
529
|
+
```
|
530
|
+
|
531
|
+
### config.yml
|
532
|
+
|
533
|
+
```yaml
|
534
|
+
# Weather service configuration
|
535
|
+
weather_service:
|
536
|
+
# API configuration
|
537
|
+
openweather:
|
538
|
+
api_key: ${OPENWEATHER_API_KEY}
|
539
|
+
timeout: 10
|
540
|
+
retry_attempts: 3
|
541
|
+
|
542
|
+
# Caching
|
543
|
+
cache:
|
544
|
+
redis_url: ${REDIS_URL:-redis://localhost:6379}
|
545
|
+
ttl:
|
546
|
+
current_weather: 600 # 10 minutes
|
547
|
+
forecast: 3600 # 1 hour
|
548
|
+
alerts: 300 # 5 minutes
|
549
|
+
historical: 86400 # 24 hours
|
550
|
+
|
551
|
+
# Rate limiting per tier
|
552
|
+
rate_limits:
|
553
|
+
free:
|
554
|
+
requests_per_minute: 10
|
555
|
+
burst: 5
|
556
|
+
premium:
|
557
|
+
requests_per_minute: 60
|
558
|
+
burst: 20
|
559
|
+
enterprise:
|
560
|
+
requests_per_minute: 600
|
561
|
+
burst: 100
|
562
|
+
|
563
|
+
# Supported regions for alerts
|
564
|
+
alert_regions:
|
565
|
+
- global
|
566
|
+
- usa
|
567
|
+
- europe
|
568
|
+
- asia
|
569
|
+
- california
|
570
|
+
- florida
|
571
|
+
- texas
|
572
|
+
```
|
573
|
+
|
574
|
+
### Testing
|
575
|
+
|
576
|
+
```ruby
|
577
|
+
require 'minitest/autorun'
|
578
|
+
require 'tsikol/test_helpers'
|
579
|
+
require 'webmock/minitest'
|
580
|
+
|
581
|
+
class WeatherServiceTest < Minitest::Test
|
582
|
+
include Tsikol::TestHelpers
|
583
|
+
|
584
|
+
def setup
|
585
|
+
@server = create_test_server
|
586
|
+
@client = TestClient.new(@server)
|
587
|
+
|
588
|
+
# Mock weather API responses
|
589
|
+
stub_weather_api
|
590
|
+
end
|
591
|
+
|
592
|
+
def test_current_weather
|
593
|
+
response = @client.call_tool("current_weather", {
|
594
|
+
"city" => "London"
|
595
|
+
})
|
596
|
+
|
597
|
+
assert_successful_response(response)
|
598
|
+
result = response.dig(:result, :content, 0, :text)
|
599
|
+
assert_match /Weather in London/, result
|
600
|
+
assert_match /Temperature:/, result
|
601
|
+
assert_match /Humidity:/, result
|
602
|
+
end
|
603
|
+
|
604
|
+
def test_weather_forecast
|
605
|
+
response = @client.call_tool("weather_forecast", {
|
606
|
+
"city" => "New York",
|
607
|
+
"days" => 3
|
608
|
+
})
|
609
|
+
|
610
|
+
assert_successful_response(response)
|
611
|
+
result = response.dig(:result, :content, 0, :text)
|
612
|
+
assert_match /3-day forecast for New York/, result
|
613
|
+
end
|
614
|
+
|
615
|
+
def test_temperature_conversion
|
616
|
+
response = @client.call_tool("current_weather", {
|
617
|
+
"city" => "Miami",
|
618
|
+
"units" => "fahrenheit"
|
619
|
+
})
|
620
|
+
|
621
|
+
assert_successful_response(response)
|
622
|
+
result = response.dig(:result, :content, 0, :text)
|
623
|
+
assert_match /�F/, result
|
624
|
+
end
|
625
|
+
|
626
|
+
def test_weather_alerts
|
627
|
+
response = @client.read_resource("weather/alerts/california")
|
628
|
+
|
629
|
+
assert_successful_response(response)
|
630
|
+
data = JSON.parse(response.dig(:result, :contents, 0, :text))
|
631
|
+
assert_equal "california", data["region"]
|
632
|
+
assert_kind_of Array, data["alerts"]
|
633
|
+
end
|
634
|
+
|
635
|
+
def test_caching
|
636
|
+
# First request
|
637
|
+
response1 = @client.call_tool("current_weather", { "city" => "Paris" })
|
638
|
+
assert_successful_response(response1)
|
639
|
+
|
640
|
+
# Second request (should be cached)
|
641
|
+
response2 = @client.call_tool("current_weather", { "city" => "Paris" })
|
642
|
+
assert_successful_response(response2)
|
643
|
+
|
644
|
+
# Results should be identical
|
645
|
+
assert_equal response1[:result], response2[:result]
|
646
|
+
end
|
647
|
+
|
648
|
+
def test_error_handling
|
649
|
+
# Stub API error
|
650
|
+
stub_request(:get, /api.openweathermap.org/)
|
651
|
+
.to_return(status: 404, body: '{"message": "city not found"}')
|
652
|
+
|
653
|
+
response = @client.call_tool("current_weather", {
|
654
|
+
"city" => "InvalidCityName"
|
655
|
+
})
|
656
|
+
|
657
|
+
assert_successful_response(response)
|
658
|
+
result = response.dig(:result, :content, 0, :text)
|
659
|
+
assert_match /Unable to fetch weather/, result
|
660
|
+
end
|
661
|
+
|
662
|
+
private
|
663
|
+
|
664
|
+
def stub_weather_api
|
665
|
+
# Current weather
|
666
|
+
stub_request(:get, /api.openweathermap.org\/data\/2.5\/weather/)
|
667
|
+
.to_return(status: 200, body: {
|
668
|
+
name: "London",
|
669
|
+
sys: { country: "GB" },
|
670
|
+
main: {
|
671
|
+
temp: 15.5,
|
672
|
+
feels_like: 14.2,
|
673
|
+
humidity: 72,
|
674
|
+
pressure: 1013
|
675
|
+
},
|
676
|
+
weather: [{ description: "partly cloudy" }],
|
677
|
+
wind: { speed: 3.5 },
|
678
|
+
dt: Time.now.to_i
|
679
|
+
}.to_json)
|
680
|
+
|
681
|
+
# Forecast
|
682
|
+
stub_request(:get, /api.openweathermap.org\/data\/2.5\/forecast/)
|
683
|
+
.to_return(status: 200, body: {
|
684
|
+
list: Array.new(24) do |i|
|
685
|
+
{
|
686
|
+
dt: Time.now.to_i + (i * 3600 * 3),
|
687
|
+
main: { temp: 15 + rand(10) },
|
688
|
+
weather: [{ main: ["Clear", "Clouds", "Rain"].sample }]
|
689
|
+
}
|
690
|
+
end
|
691
|
+
}.to_json)
|
692
|
+
end
|
693
|
+
|
694
|
+
def create_test_server
|
695
|
+
# Create server without starting it
|
696
|
+
server = Tsikol::Server.new(name: "test-weather") do
|
697
|
+
tool CurrentWeatherTool
|
698
|
+
tool WeatherForecastTool
|
699
|
+
resource WeatherAlertsResource
|
700
|
+
resource HistoricalWeatherResource
|
701
|
+
end
|
702
|
+
|
703
|
+
# Inject test weather client
|
704
|
+
server.instance_eval do
|
705
|
+
@weather_client = MockWeatherClient.new
|
706
|
+
end
|
707
|
+
|
708
|
+
server
|
709
|
+
end
|
710
|
+
end
|
711
|
+
|
712
|
+
class MockWeatherClient < WeatherClient
|
713
|
+
def initialize
|
714
|
+
@responses = {}
|
715
|
+
end
|
716
|
+
|
717
|
+
def set_response(method, response)
|
718
|
+
@responses[method] = response
|
719
|
+
end
|
720
|
+
|
721
|
+
def current_weather(city)
|
722
|
+
@responses[:current] || super
|
723
|
+
end
|
724
|
+
|
725
|
+
def forecast(city, days)
|
726
|
+
@responses[:forecast] || super
|
727
|
+
end
|
728
|
+
end
|
729
|
+
```
|
730
|
+
|
731
|
+
## Deployment
|
732
|
+
|
733
|
+
### Docker Setup
|
734
|
+
|
735
|
+
```dockerfile
|
736
|
+
FROM ruby:3.2-slim
|
737
|
+
|
738
|
+
WORKDIR /app
|
739
|
+
|
740
|
+
# Install dependencies
|
741
|
+
RUN apt-get update && apt-get install -y \
|
742
|
+
build-essential \
|
743
|
+
redis-tools \
|
744
|
+
&& rm -rf /var/lib/apt/lists/*
|
745
|
+
|
746
|
+
# Copy application
|
747
|
+
COPY Gemfile Gemfile.lock ./
|
748
|
+
RUN bundle install
|
749
|
+
|
750
|
+
COPY . .
|
751
|
+
|
752
|
+
# Environment
|
753
|
+
ENV REDIS_URL=redis://redis:6379
|
754
|
+
|
755
|
+
# Run server
|
756
|
+
CMD ["ruby", "server.rb"]
|
757
|
+
```
|
758
|
+
|
759
|
+
### docker-compose.yml
|
760
|
+
|
761
|
+
```yaml
|
762
|
+
version: '3.8'
|
763
|
+
|
764
|
+
services:
|
765
|
+
weather-service:
|
766
|
+
build: .
|
767
|
+
ports:
|
768
|
+
- "3000:3000"
|
769
|
+
environment:
|
770
|
+
- OPENWEATHER_API_KEY=${OPENWEATHER_API_KEY}
|
771
|
+
- REDIS_URL=redis://redis:6379
|
772
|
+
depends_on:
|
773
|
+
- redis
|
774
|
+
restart: unless-stopped
|
775
|
+
|
776
|
+
redis:
|
777
|
+
image: redis:7-alpine
|
778
|
+
volumes:
|
779
|
+
- redis_data:/data
|
780
|
+
restart: unless-stopped
|
781
|
+
|
782
|
+
volumes:
|
783
|
+
redis_data:
|
784
|
+
```
|
785
|
+
|
786
|
+
## Best Practices Demonstrated
|
787
|
+
|
788
|
+
1. **External API Integration** with proper error handling
|
789
|
+
2. **Multi-level Caching** (Redis + in-memory fallback)
|
790
|
+
3. **Rate Limiting** to prevent API abuse
|
791
|
+
4. **Comprehensive Testing** with mocked APIs
|
792
|
+
5. **Configuration Management** with environment variables
|
793
|
+
6. **Docker Deployment** ready for production
|
794
|
+
7. **Graceful Degradation** when services are unavailable
|
795
|
+
8. **Structured Logging** for debugging
|
796
|
+
9. **Resource Organization** with clear URI patterns
|
797
|
+
10. **Prompt Engineering** for AI assistance
|
798
|
+
|
799
|
+
## Performance Optimizations
|
800
|
+
|
801
|
+
1. Cache API responses to reduce external calls
|
802
|
+
2. Use Redis for distributed caching
|
803
|
+
3. Implement request batching for multiple cities
|
804
|
+
4. Add connection pooling for HTTP requests
|
805
|
+
5. Use async processing for non-critical updates
|
806
|
+
|
807
|
+
## Security Considerations
|
808
|
+
|
809
|
+
1. Never expose API keys in logs or responses
|
810
|
+
2. Validate and sanitize city names
|
811
|
+
3. Implement rate limiting per API key
|
812
|
+
4. Use HTTPS for all external API calls
|
813
|
+
5. Rotate API keys regularly
|
814
|
+
|
815
|
+
## Next Steps
|
816
|
+
|
817
|
+
- Add more weather data sources for redundancy
|
818
|
+
- Implement weather maps and visualizations
|
819
|
+
- Add severe weather notifications
|
820
|
+
- Create mobile app integration
|
821
|
+
- Add machine learning for weather predictions
|
822
|
+
- Implement webhook support for alerts
|