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,651 @@
1
+ # Authentication Recipe
2
+
3
+ This recipe shows how to add authentication to your MCP server to secure tools and resources.
4
+
5
+ ## Basic Token Authentication
6
+
7
+ ### Simple Token Middleware
8
+
9
+ ```ruby
10
+ class TokenAuthMiddleware < Tsikol::Middleware
11
+ def initialize(app, options = {})
12
+ @app = app
13
+ @token = options[:token] || ENV['MCP_AUTH_TOKEN']
14
+ @protected_methods = options[:protected_methods] || ["tools/call", "resources/read"]
15
+ end
16
+
17
+ def call(request)
18
+ method = request["method"]
19
+
20
+ # Skip auth for unprotected methods
21
+ return @app.call(request) unless @protected_methods.include?(method)
22
+
23
+ # Extract token from request
24
+ provided_token = extract_token(request)
25
+
26
+ # Validate token
27
+ unless valid_token?(provided_token)
28
+ return unauthorized_response(request["id"])
29
+ end
30
+
31
+ # Continue with request
32
+ @app.call(request)
33
+ end
34
+
35
+ private
36
+
37
+ def extract_token(request)
38
+ # Look in multiple places
39
+ request.dig("params", "_token") ||
40
+ request.dig("params", "authorization") ||
41
+ request.dig("meta", "authorization")
42
+ end
43
+
44
+ def valid_token?(token)
45
+ return false unless token
46
+ # Constant-time comparison
47
+ ActiveSupport::SecurityUtils.secure_compare(token, @token)
48
+ rescue
49
+ # Fallback for non-Rails environments
50
+ token == @token
51
+ end
52
+
53
+ def unauthorized_response(id)
54
+ {
55
+ jsonrpc: "2.0",
56
+ id: id,
57
+ error: {
58
+ code: -32001,
59
+ message: "Unauthorized",
60
+ data: { details: "Invalid or missing authentication token" }
61
+ }
62
+ }
63
+ end
64
+ end
65
+
66
+ # Usage in server.rb
67
+ Tsikol.start(name: "secure-server") do
68
+ use TokenAuthMiddleware, token: ENV['AUTH_TOKEN']
69
+
70
+ tool SecureTool
71
+ resource SecureResource
72
+ end
73
+ ```
74
+
75
+ ## JWT Authentication
76
+
77
+ ### JWT Middleware with User Context
78
+
79
+ ```ruby
80
+ require 'jwt'
81
+
82
+ class JWTAuthMiddleware < Tsikol::Middleware
83
+ def initialize(app, options = {})
84
+ @app = app
85
+ @secret = options[:secret] || ENV['JWT_SECRET']
86
+ @algorithm = options[:algorithm] || 'HS256'
87
+ @skip_methods = options[:skip_methods] || ["initialize"]
88
+ end
89
+
90
+ def call(request)
91
+ method = request["method"]
92
+
93
+ # Skip auth for certain methods
94
+ return @app.call(request) if @skip_methods.include?(method)
95
+
96
+ # Extract and verify JWT
97
+ token = extract_bearer_token(request)
98
+
99
+ begin
100
+ payload = JWT.decode(token, @secret, true, algorithm: @algorithm)[0]
101
+
102
+ # Check expiration
103
+ if payload['exp'] && Time.now.to_i > payload['exp']
104
+ return token_expired_response(request["id"])
105
+ end
106
+
107
+ # Add user context to request
108
+ request["authenticated_user"] = payload
109
+
110
+ # Continue with request
111
+ @app.call(request)
112
+
113
+ rescue JWT::DecodeError => e
114
+ invalid_token_response(request["id"], e.message)
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def extract_bearer_token(request)
121
+ auth_header = request.dig("params", "authorization") ||
122
+ request.dig("meta", "authorization") || ""
123
+
124
+ # Extract token from "Bearer <token>" format
125
+ auth_header.sub(/^Bearer /i, '')
126
+ end
127
+
128
+ def token_expired_response(id)
129
+ {
130
+ jsonrpc: "2.0",
131
+ id: id,
132
+ error: {
133
+ code: -32002,
134
+ message: "Token expired",
135
+ data: { details: "Authentication token has expired" }
136
+ }
137
+ }
138
+ end
139
+
140
+ def invalid_token_response(id, details)
141
+ {
142
+ jsonrpc: "2.0",
143
+ id: id,
144
+ error: {
145
+ code: -32001,
146
+ message: "Invalid token",
147
+ data: { details: details }
148
+ }
149
+ }
150
+ end
151
+ end
152
+ ```
153
+
154
+ ### Using User Context in Tools
155
+
156
+ ```ruby
157
+ class UserAwareTool < Tsikol::Tool
158
+ description "Tool that uses authenticated user information"
159
+
160
+ parameter :action do
161
+ type :string
162
+ required
163
+ end
164
+
165
+ def execute(action:)
166
+ # Access authenticated user from request context
167
+ user = Thread.current[:mcp_request]&.dig("authenticated_user")
168
+
169
+ unless user
170
+ return "Authentication required"
171
+ end
172
+
173
+ case action
174
+ when "profile"
175
+ get_user_profile(user)
176
+ when "preferences"
177
+ get_user_preferences(user)
178
+ else
179
+ "Unknown action"
180
+ end
181
+ end
182
+
183
+ private
184
+
185
+ def get_user_profile(user)
186
+ {
187
+ id: user['sub'],
188
+ email: user['email'],
189
+ name: user['name'],
190
+ roles: user['roles'] || []
191
+ }.to_json
192
+ end
193
+
194
+ def get_user_preferences(user)
195
+ # Load preferences based on user ID
196
+ preferences = load_preferences(user['sub'])
197
+ preferences.to_json
198
+ end
199
+ end
200
+ ```
201
+
202
+ ## API Key Authentication
203
+
204
+ ### Multi-Tenant API Key System
205
+
206
+ ```ruby
207
+ class APIKeyAuthMiddleware < Tsikol::Middleware
208
+ def initialize(app, options = {})
209
+ @app = app
210
+ @key_store = options[:key_store] || APIKeyStore.new
211
+ @rate_limiter = options[:rate_limiter]
212
+ end
213
+
214
+ def call(request)
215
+ api_key = extract_api_key(request)
216
+
217
+ unless api_key
218
+ return missing_key_response(request["id"])
219
+ end
220
+
221
+ # Validate API key
222
+ key_info = @key_store.validate(api_key)
223
+
224
+ unless key_info
225
+ return invalid_key_response(request["id"])
226
+ end
227
+
228
+ # Check if key is active
229
+ unless key_info[:active]
230
+ return inactive_key_response(request["id"])
231
+ end
232
+
233
+ # Apply rate limiting per API key
234
+ if @rate_limiter && @rate_limiter.exceeded?(api_key)
235
+ return rate_limit_response(request["id"])
236
+ end
237
+
238
+ # Add tenant context
239
+ request["tenant"] = key_info[:tenant]
240
+ request["api_key_id"] = key_info[:id]
241
+ request["permissions"] = key_info[:permissions]
242
+
243
+ # Log API key usage
244
+ log_usage(api_key, request["method"])
245
+
246
+ @app.call(request)
247
+ end
248
+
249
+ private
250
+
251
+ def extract_api_key(request)
252
+ request.dig("params", "api_key") ||
253
+ request.dig("meta", "x-api-key")
254
+ end
255
+
256
+ def log_usage(api_key, method)
257
+ # Log API key usage for analytics
258
+ Thread.new do
259
+ @key_store.log_usage(api_key, method, Time.now)
260
+ end
261
+ end
262
+ end
263
+
264
+ class APIKeyStore
265
+ def initialize
266
+ @keys = load_keys_from_database
267
+ end
268
+
269
+ def validate(api_key)
270
+ key_hash = hash_key(api_key)
271
+ @keys[key_hash]
272
+ end
273
+
274
+ def log_usage(api_key, method, timestamp)
275
+ # Store usage metrics
276
+ end
277
+
278
+ private
279
+
280
+ def hash_key(api_key)
281
+ # Use secure hashing
282
+ Digest::SHA256.hexdigest(api_key)
283
+ end
284
+
285
+ def load_keys_from_database
286
+ # Load from your database
287
+ # Example structure:
288
+ {
289
+ "hashed_key_1" => {
290
+ id: "key_123",
291
+ tenant: "customer_1",
292
+ active: true,
293
+ permissions: ["read", "write"],
294
+ created_at: Time.now - 86400 * 30
295
+ }
296
+ }
297
+ end
298
+ end
299
+ ```
300
+
301
+ ## OAuth 2.0 Integration
302
+
303
+ ### OAuth Middleware
304
+
305
+ ```ruby
306
+ require 'oauth2'
307
+
308
+ class OAuthMiddleware < Tsikol::Middleware
309
+ def initialize(app, options = {})
310
+ @app = app
311
+ @client_id = options[:client_id] || ENV['OAUTH_CLIENT_ID']
312
+ @client_secret = options[:client_secret] || ENV['OAUTH_CLIENT_SECRET']
313
+ @site = options[:site] || 'https://auth.example.com'
314
+ @token_url = options[:token_url] || '/oauth/token'
315
+ @authorize_url = options[:authorize_url] || '/oauth/authorize'
316
+ end
317
+
318
+ def call(request)
319
+ token = extract_oauth_token(request)
320
+
321
+ unless token
322
+ return unauthorized_response(request["id"], "Missing OAuth token")
323
+ end
324
+
325
+ # Validate token with OAuth provider
326
+ user_info = validate_token(token)
327
+
328
+ unless user_info
329
+ return unauthorized_response(request["id"], "Invalid OAuth token")
330
+ end
331
+
332
+ # Add user info to request
333
+ request["oauth_user"] = user_info
334
+
335
+ @app.call(request)
336
+ rescue OAuth2::Error => e
337
+ unauthorized_response(request["id"], e.message)
338
+ end
339
+
340
+ private
341
+
342
+ def oauth_client
343
+ @oauth_client ||= OAuth2::Client.new(
344
+ @client_id,
345
+ @client_secret,
346
+ site: @site,
347
+ token_url: @token_url,
348
+ authorize_url: @authorize_url
349
+ )
350
+ end
351
+
352
+ def validate_token(token_string)
353
+ token = OAuth2::AccessToken.new(oauth_client, token_string)
354
+
355
+ # Validate token and get user info
356
+ response = token.get('/api/userinfo')
357
+
358
+ if response.status == 200
359
+ JSON.parse(response.body)
360
+ else
361
+ nil
362
+ end
363
+ end
364
+
365
+ def extract_oauth_token(request)
366
+ auth_header = request.dig("params", "authorization") || ""
367
+ auth_header.sub(/^Bearer /i, '')
368
+ end
369
+ end
370
+ ```
371
+
372
+ ## Permission-Based Access Control
373
+
374
+ ### Role-Based Access Control (RBAC)
375
+
376
+ ```ruby
377
+ class RBACMiddleware < Tsikol::Middleware
378
+ def initialize(app, options = {})
379
+ @app = app
380
+ @permissions = options[:permissions] || default_permissions
381
+ end
382
+
383
+ def call(request)
384
+ method = request["method"]
385
+
386
+ # Get required permission for method
387
+ required_permission = get_required_permission(method)
388
+
389
+ # No permission required
390
+ return @app.call(request) unless required_permission
391
+
392
+ # Get user from previous auth middleware
393
+ user = request["authenticated_user"]
394
+
395
+ unless user
396
+ return unauthorized_response(request["id"], "Authentication required")
397
+ end
398
+
399
+ # Check user permissions
400
+ user_permissions = get_user_permissions(user)
401
+
402
+ unless has_permission?(user_permissions, required_permission)
403
+ return forbidden_response(request["id"], required_permission)
404
+ end
405
+
406
+ @app.call(request)
407
+ end
408
+
409
+ private
410
+
411
+ def default_permissions
412
+ {
413
+ "tools/list" => "tools:read",
414
+ "tools/call" => "tools:execute",
415
+ "resources/list" => "resources:read",
416
+ "resources/read" => "resources:read",
417
+ "prompts/list" => "prompts:read",
418
+ "prompts/get" => "prompts:use"
419
+ }
420
+ end
421
+
422
+ def get_required_permission(method)
423
+ # Check exact match first
424
+ return @permissions[method] if @permissions[method]
425
+
426
+ # Check wildcard patterns
427
+ @permissions.each do |pattern, permission|
428
+ if pattern.include?("*") && method.match?(pattern.gsub("*", ".*"))
429
+ return permission
430
+ end
431
+ end
432
+
433
+ nil
434
+ end
435
+
436
+ def get_user_permissions(user)
437
+ # Get permissions from user object or load from database
438
+ permissions = user["permissions"] || []
439
+
440
+ # Add role-based permissions
441
+ if user["roles"]
442
+ user["roles"].each do |role|
443
+ permissions.concat(role_permissions(role))
444
+ end
445
+ end
446
+
447
+ permissions.uniq
448
+ end
449
+
450
+ def role_permissions(role)
451
+ # Define role-based permissions
452
+ case role
453
+ when "admin"
454
+ ["tools:*", "resources:*", "prompts:*"]
455
+ when "developer"
456
+ ["tools:execute", "resources:read", "prompts:use"]
457
+ when "viewer"
458
+ ["tools:read", "resources:read", "prompts:read"]
459
+ else
460
+ []
461
+ end
462
+ end
463
+
464
+ def has_permission?(user_permissions, required)
465
+ user_permissions.any? do |perm|
466
+ # Exact match
467
+ perm == required ||
468
+ # Wildcard match (e.g., "tools:*" matches "tools:execute")
469
+ (perm.end_with?("*") && required.start_with?(perm[0..-2]))
470
+ end
471
+ end
472
+
473
+ def forbidden_response(id, permission)
474
+ {
475
+ jsonrpc: "2.0",
476
+ id: id,
477
+ error: {
478
+ code: -32003,
479
+ message: "Forbidden",
480
+ data: {
481
+ details: "Insufficient permissions",
482
+ required: permission
483
+ }
484
+ }
485
+ }
486
+ end
487
+ end
488
+ ```
489
+
490
+ ## Secure Configuration
491
+
492
+ ### Environment-Based Security
493
+
494
+ ```ruby
495
+ # config/security.rb
496
+ module Security
497
+ class Config
498
+ def self.load
499
+ {
500
+ auth_enabled: ENV.fetch('MCP_AUTH_ENABLED', 'true') == 'true',
501
+ auth_type: ENV.fetch('MCP_AUTH_TYPE', 'jwt'),
502
+ jwt_secret: ENV.fetch('JWT_SECRET') { raise "JWT_SECRET required" },
503
+ jwt_algorithm: ENV.fetch('JWT_ALGORITHM', 'HS256'),
504
+ token_expiry: ENV.fetch('TOKEN_EXPIRY', '3600').to_i,
505
+ require_https: ENV.fetch('REQUIRE_HTTPS', 'true') == 'true',
506
+ allowed_origins: ENV.fetch('ALLOWED_ORIGINS', '*').split(','),
507
+ rate_limit: {
508
+ enabled: ENV.fetch('RATE_LIMIT_ENABLED', 'true') == 'true',
509
+ max_requests: ENV.fetch('RATE_LIMIT_MAX', '100').to_i,
510
+ window_seconds: ENV.fetch('RATE_LIMIT_WINDOW', '60').to_i
511
+ }
512
+ }
513
+ end
514
+ end
515
+ end
516
+
517
+ # server.rb
518
+ require_relative 'config/security'
519
+
520
+ Tsikol.start(name: "secure-server") do
521
+ config = Security::Config.load
522
+
523
+ if config[:auth_enabled]
524
+ case config[:auth_type]
525
+ when 'jwt'
526
+ use JWTAuthMiddleware,
527
+ secret: config[:jwt_secret],
528
+ algorithm: config[:jwt_algorithm]
529
+ when 'api_key'
530
+ use APIKeyAuthMiddleware
531
+ when 'oauth'
532
+ use OAuthMiddleware
533
+ end
534
+
535
+ # Add RBAC after authentication
536
+ use RBACMiddleware
537
+ end
538
+
539
+ if config[:rate_limit][:enabled]
540
+ use Tsikol::RateLimitMiddleware,
541
+ max_requests: config[:rate_limit][:max_requests],
542
+ window_seconds: config[:rate_limit][:window_seconds]
543
+ end
544
+
545
+ # Your tools and resources
546
+ tool SecureTool
547
+ resource SecureResource
548
+ end
549
+ ```
550
+
551
+ ## Testing Authentication
552
+
553
+ ```ruby
554
+ require 'minitest/autorun'
555
+ require 'jwt'
556
+
557
+ class AuthenticationTest < Minitest::Test
558
+ def setup
559
+ @server = create_secure_server
560
+ @client = Tsikol::TestHelpers::TestClient.new(@server)
561
+ end
562
+
563
+ def test_requires_authentication
564
+ response = @client.call_tool("secure_tool", {})
565
+ assert_error_response(response, -32001)
566
+ assert_match /unauthorized/i, response[:error][:message]
567
+ end
568
+
569
+ def test_accepts_valid_token
570
+ token = generate_valid_jwt
571
+
572
+ response = @client.call_tool("secure_tool", {
573
+ "_token" => token,
574
+ "action" => "test"
575
+ })
576
+
577
+ assert_successful_response(response)
578
+ end
579
+
580
+ def test_rejects_expired_token
581
+ token = generate_expired_jwt
582
+
583
+ response = @client.call_tool("secure_tool", {
584
+ "_token" => token,
585
+ "action" => "test"
586
+ })
587
+
588
+ assert_error_response(response, -32002)
589
+ assert_match /expired/i, response[:error][:message]
590
+ end
591
+
592
+ def test_enforces_permissions
593
+ # User with limited permissions
594
+ token = generate_jwt_with_permissions(["tools:read"])
595
+
596
+ response = @client.call_tool("secure_tool", {
597
+ "_token" => token,
598
+ "action" => "write" # Requires tools:execute
599
+ })
600
+
601
+ assert_error_response(response, -32003)
602
+ assert_match /forbidden/i, response[:error][:message]
603
+ end
604
+
605
+ private
606
+
607
+ def create_secure_server
608
+ server = Tsikol::Server.new(name: "test")
609
+ server.use JWTAuthMiddleware, secret: "test_secret"
610
+ server.use RBACMiddleware
611
+ server.register_tool_instance(SecureTool.new)
612
+ server
613
+ end
614
+
615
+ def generate_valid_jwt
616
+ payload = {
617
+ sub: "user123",
618
+ email: "user@example.com",
619
+ permissions: ["tools:execute"],
620
+ exp: Time.now.to_i + 3600
621
+ }
622
+ JWT.encode(payload, "test_secret", 'HS256')
623
+ end
624
+
625
+ def generate_expired_jwt
626
+ payload = {
627
+ sub: "user123",
628
+ exp: Time.now.to_i - 3600 # Expired 1 hour ago
629
+ }
630
+ JWT.encode(payload, "test_secret", 'HS256')
631
+ end
632
+ end
633
+ ```
634
+
635
+ ## Security Best Practices
636
+
637
+ 1. **Always use HTTPS** in production
638
+ 2. **Store secrets securely** using environment variables or secret management systems
639
+ 3. **Implement rate limiting** to prevent abuse
640
+ 4. **Log authentication attempts** for security monitoring
641
+ 5. **Use secure token storage** with proper expiration
642
+ 6. **Implement CSRF protection** for web-based clients
643
+ 7. **Regular security audits** of your authentication system
644
+ 8. **Principle of least privilege** for permissions
645
+
646
+ ## Next Steps
647
+
648
+ - Implement [Rate Limiting](rate-limiting.md)
649
+ - Add [Logging](logging.md) for security events
650
+ - Set up [Error Handling](error-handling.md)
651
+ - Review [Testing Guide](../guides/testing.md)