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.
Files changed (75) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/CONTRIBUTING.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +579 -0
  6. data/Rakefile +12 -0
  7. data/docs/README.md +69 -0
  8. data/docs/api/middleware.md +721 -0
  9. data/docs/api/prompt.md +858 -0
  10. data/docs/api/resource.md +651 -0
  11. data/docs/api/server.md +509 -0
  12. data/docs/api/test-helpers.md +591 -0
  13. data/docs/api/tool.md +527 -0
  14. data/docs/cookbook/authentication.md +651 -0
  15. data/docs/cookbook/caching.md +877 -0
  16. data/docs/cookbook/dynamic-tools.md +970 -0
  17. data/docs/cookbook/error-handling.md +887 -0
  18. data/docs/cookbook/logging.md +1044 -0
  19. data/docs/cookbook/rate-limiting.md +717 -0
  20. data/docs/examples/code-assistant.md +922 -0
  21. data/docs/examples/complete-server.md +726 -0
  22. data/docs/examples/database-manager.md +1198 -0
  23. data/docs/examples/devops-tools.md +1382 -0
  24. data/docs/examples/echo-server.md +501 -0
  25. data/docs/examples/weather-service.md +822 -0
  26. data/docs/guides/completion.md +472 -0
  27. data/docs/guides/getting-started.md +462 -0
  28. data/docs/guides/middleware.md +823 -0
  29. data/docs/guides/project-structure.md +434 -0
  30. data/docs/guides/prompts.md +920 -0
  31. data/docs/guides/resources.md +720 -0
  32. data/docs/guides/sampling.md +804 -0
  33. data/docs/guides/testing.md +863 -0
  34. data/docs/guides/tools.md +627 -0
  35. data/examples/README.md +92 -0
  36. data/examples/advanced_features.rb +129 -0
  37. data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
  38. data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
  39. data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
  40. data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
  41. data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
  42. data/examples/basic-migrated/server.rb +25 -0
  43. data/examples/basic.rb +73 -0
  44. data/examples/full_featured.rb +175 -0
  45. data/examples/middleware_example.rb +112 -0
  46. data/examples/sampling_example.rb +104 -0
  47. data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
  48. data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
  49. data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
  50. data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
  51. data/examples/weather-service/server.rb +28 -0
  52. data/exe/tsikol +6 -0
  53. data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
  54. data/lib/tsikol/cli/templates/README.md.erb +38 -0
  55. data/lib/tsikol/cli/templates/gitignore.erb +49 -0
  56. data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
  57. data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
  58. data/lib/tsikol/cli/templates/server.rb.erb +24 -0
  59. data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
  60. data/lib/tsikol/cli.rb +203 -0
  61. data/lib/tsikol/error_handler.rb +141 -0
  62. data/lib/tsikol/health.rb +198 -0
  63. data/lib/tsikol/http_transport.rb +72 -0
  64. data/lib/tsikol/lifecycle.rb +149 -0
  65. data/lib/tsikol/middleware.rb +168 -0
  66. data/lib/tsikol/prompt.rb +101 -0
  67. data/lib/tsikol/resource.rb +53 -0
  68. data/lib/tsikol/router.rb +190 -0
  69. data/lib/tsikol/server.rb +660 -0
  70. data/lib/tsikol/stdio_transport.rb +108 -0
  71. data/lib/tsikol/test_helpers.rb +261 -0
  72. data/lib/tsikol/tool.rb +111 -0
  73. data/lib/tsikol/version.rb +5 -0
  74. data/lib/tsikol.rb +72 -0
  75. 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