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,823 @@
|
|
1
|
+
# Middleware Guide
|
2
|
+
|
3
|
+
Middleware in Tsikol provides a powerful way to add cross-cutting concerns to your MCP server. From logging and authentication to rate limiting and error handling, middleware helps keep your tools and resources focused on their core functionality.
|
4
|
+
|
5
|
+
## Table of Contents
|
6
|
+
|
7
|
+
1. [What is Middleware?](#what-is-middleware)
|
8
|
+
2. [Built-in Middleware](#built-in-middleware)
|
9
|
+
3. [Creating Custom Middleware](#creating-custom-middleware)
|
10
|
+
4. [Middleware Chain](#middleware-chain)
|
11
|
+
5. [Request/Response Manipulation](#requestresponse-manipulation)
|
12
|
+
6. [Advanced Patterns](#advanced-patterns)
|
13
|
+
7. [Testing Middleware](#testing-middleware)
|
14
|
+
|
15
|
+
## What is Middleware?
|
16
|
+
|
17
|
+
Middleware components:
|
18
|
+
- Intercept requests before they reach tools/resources
|
19
|
+
- Process responses before returning to the client
|
20
|
+
- Handle cross-cutting concerns (logging, auth, etc.)
|
21
|
+
- Can short-circuit the request chain
|
22
|
+
- Execute in a defined order
|
23
|
+
|
24
|
+
## Built-in Middleware
|
25
|
+
|
26
|
+
### Logging Middleware
|
27
|
+
|
28
|
+
Log all requests and responses:
|
29
|
+
|
30
|
+
```ruby
|
31
|
+
Tsikol.server "my-server" do
|
32
|
+
use Tsikol::LoggingMiddleware
|
33
|
+
|
34
|
+
# Or with options
|
35
|
+
use Tsikol::LoggingMiddleware,
|
36
|
+
level: :debug,
|
37
|
+
include_params: true,
|
38
|
+
include_response: true
|
39
|
+
end
|
40
|
+
```
|
41
|
+
|
42
|
+
### Rate Limiting Middleware
|
43
|
+
|
44
|
+
Prevent abuse with rate limiting:
|
45
|
+
|
46
|
+
```ruby
|
47
|
+
Tsikol.server "my-server" do
|
48
|
+
use Tsikol::RateLimitMiddleware,
|
49
|
+
max_requests: 100, # Per window
|
50
|
+
window_seconds: 60, # Time window
|
51
|
+
by: :client_id # Group by client
|
52
|
+
end
|
53
|
+
```
|
54
|
+
|
55
|
+
### Error Handling Middleware
|
56
|
+
|
57
|
+
Consistent error handling:
|
58
|
+
|
59
|
+
```ruby
|
60
|
+
Tsikol.server "my-server" do
|
61
|
+
use Tsikol::ErrorHandlingMiddleware,
|
62
|
+
log_errors: true,
|
63
|
+
include_backtrace: false,
|
64
|
+
error_handler: ->(error) { notify_error_service(error) }
|
65
|
+
end
|
66
|
+
```
|
67
|
+
|
68
|
+
### Metrics Middleware
|
69
|
+
|
70
|
+
Collect performance metrics:
|
71
|
+
|
72
|
+
```ruby
|
73
|
+
Tsikol.server "my-server" do
|
74
|
+
use Tsikol::MetricsMiddleware,
|
75
|
+
include_histogram: true,
|
76
|
+
track_errors: true,
|
77
|
+
custom_tags: { environment: "production" }
|
78
|
+
end
|
79
|
+
```
|
80
|
+
|
81
|
+
## Creating Custom Middleware
|
82
|
+
|
83
|
+
### Basic Middleware Structure
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
class MyMiddleware < Tsikol::Middleware
|
87
|
+
def initialize(app, options = {})
|
88
|
+
@app = app
|
89
|
+
@options = options
|
90
|
+
end
|
91
|
+
|
92
|
+
def call(request)
|
93
|
+
# Before request processing
|
94
|
+
log(:debug, "Processing request", method: request["method"])
|
95
|
+
|
96
|
+
# Call the next middleware or handler
|
97
|
+
response = @app.call(request)
|
98
|
+
|
99
|
+
# After response processing
|
100
|
+
log(:debug, "Request completed", status: response[:result] ? "success" : "error")
|
101
|
+
|
102
|
+
response
|
103
|
+
end
|
104
|
+
end
|
105
|
+
```
|
106
|
+
|
107
|
+
### Authentication Middleware
|
108
|
+
|
109
|
+
```ruby
|
110
|
+
class AuthenticationMiddleware < Tsikol::Middleware
|
111
|
+
def initialize(app, options = {})
|
112
|
+
super
|
113
|
+
@secret_key = options[:secret_key] || ENV['MCP_SECRET_KEY']
|
114
|
+
@required_methods = options[:required_methods] || []
|
115
|
+
end
|
116
|
+
|
117
|
+
def call(request)
|
118
|
+
method = request["method"]
|
119
|
+
|
120
|
+
# Skip auth for certain methods
|
121
|
+
unless requires_auth?(method)
|
122
|
+
return @app.call(request)
|
123
|
+
end
|
124
|
+
|
125
|
+
# Extract and verify token
|
126
|
+
token = extract_token(request)
|
127
|
+
|
128
|
+
unless valid_token?(token)
|
129
|
+
return error_response(request["id"], -32001, "Unauthorized")
|
130
|
+
end
|
131
|
+
|
132
|
+
# Add user context
|
133
|
+
request["authenticated_user"] = decode_token(token)
|
134
|
+
|
135
|
+
@app.call(request)
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def requires_auth?(method)
|
141
|
+
return true if @required_methods.empty?
|
142
|
+
@required_methods.include?(method)
|
143
|
+
end
|
144
|
+
|
145
|
+
def extract_token(request)
|
146
|
+
# Look for token in various places
|
147
|
+
request.dig("params", "_token") ||
|
148
|
+
request.dig("params", "authorization") ||
|
149
|
+
request.dig("meta", "authorization")
|
150
|
+
end
|
151
|
+
|
152
|
+
def valid_token?(token)
|
153
|
+
return false unless token
|
154
|
+
|
155
|
+
begin
|
156
|
+
payload = JWT.decode(token, @secret_key, true, algorithm: 'HS256')
|
157
|
+
!expired?(payload[0])
|
158
|
+
rescue JWT::DecodeError
|
159
|
+
false
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
def expired?(payload)
|
164
|
+
return false unless payload["exp"]
|
165
|
+
Time.now.to_i > payload["exp"]
|
166
|
+
end
|
167
|
+
|
168
|
+
def decode_token(token)
|
169
|
+
JWT.decode(token, @secret_key, true, algorithm: 'HS256')[0]
|
170
|
+
rescue
|
171
|
+
nil
|
172
|
+
end
|
173
|
+
|
174
|
+
def error_response(id, code, message)
|
175
|
+
{
|
176
|
+
jsonrpc: "2.0",
|
177
|
+
id: id,
|
178
|
+
error: {
|
179
|
+
code: code,
|
180
|
+
message: message
|
181
|
+
}
|
182
|
+
}
|
183
|
+
end
|
184
|
+
end
|
185
|
+
```
|
186
|
+
|
187
|
+
### Request Validation Middleware
|
188
|
+
|
189
|
+
```ruby
|
190
|
+
class ValidationMiddleware < Tsikol::Middleware
|
191
|
+
def initialize(app, options = {})
|
192
|
+
super
|
193
|
+
@max_request_size = options[:max_request_size] || 1_048_576 # 1MB
|
194
|
+
@allowed_methods = options[:allowed_methods] || nil
|
195
|
+
end
|
196
|
+
|
197
|
+
def call(request)
|
198
|
+
# Validate request structure
|
199
|
+
unless valid_jsonrpc?(request)
|
200
|
+
return error_response(request["id"], -32600, "Invalid Request")
|
201
|
+
end
|
202
|
+
|
203
|
+
# Check request size
|
204
|
+
if request.to_json.bytesize > @max_request_size
|
205
|
+
return error_response(request["id"], -32600, "Request too large")
|
206
|
+
end
|
207
|
+
|
208
|
+
# Check allowed methods
|
209
|
+
if @allowed_methods && !@allowed_methods.include?(request["method"])
|
210
|
+
return error_response(request["id"], -32601, "Method not found")
|
211
|
+
end
|
212
|
+
|
213
|
+
@app.call(request)
|
214
|
+
end
|
215
|
+
|
216
|
+
private
|
217
|
+
|
218
|
+
def valid_jsonrpc?(request)
|
219
|
+
request.is_a?(Hash) &&
|
220
|
+
request["jsonrpc"] == "2.0" &&
|
221
|
+
request["id"] &&
|
222
|
+
request["method"].is_a?(String)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
```
|
226
|
+
|
227
|
+
### Caching Middleware
|
228
|
+
|
229
|
+
```ruby
|
230
|
+
class CachingMiddleware < Tsikol::Middleware
|
231
|
+
def initialize(app, options = {})
|
232
|
+
super
|
233
|
+
@cache = {}
|
234
|
+
@ttl = options[:ttl] || 300 # 5 minutes
|
235
|
+
@cacheable_methods = options[:cacheable_methods] || ["resources/read"]
|
236
|
+
end
|
237
|
+
|
238
|
+
def call(request)
|
239
|
+
method = request["method"]
|
240
|
+
|
241
|
+
# Only cache certain methods
|
242
|
+
unless cacheable?(method)
|
243
|
+
return @app.call(request)
|
244
|
+
end
|
245
|
+
|
246
|
+
# Generate cache key
|
247
|
+
cache_key = generate_cache_key(request)
|
248
|
+
|
249
|
+
# Check cache
|
250
|
+
if cached = get_cached(cache_key)
|
251
|
+
log(:debug, "Cache hit", key: cache_key)
|
252
|
+
return cached
|
253
|
+
end
|
254
|
+
|
255
|
+
# Process request
|
256
|
+
response = @app.call(request)
|
257
|
+
|
258
|
+
# Cache successful responses
|
259
|
+
if response[:result]
|
260
|
+
set_cached(cache_key, response)
|
261
|
+
end
|
262
|
+
|
263
|
+
response
|
264
|
+
end
|
265
|
+
|
266
|
+
private
|
267
|
+
|
268
|
+
def cacheable?(method)
|
269
|
+
@cacheable_methods.include?(method)
|
270
|
+
end
|
271
|
+
|
272
|
+
def generate_cache_key(request)
|
273
|
+
params = request["params"] || {}
|
274
|
+
"#{request['method']}:#{params.sort.to_h.hash}"
|
275
|
+
end
|
276
|
+
|
277
|
+
def get_cached(key)
|
278
|
+
entry = @cache[key]
|
279
|
+
return nil unless entry
|
280
|
+
|
281
|
+
if Time.now - entry[:timestamp] > @ttl
|
282
|
+
@cache.delete(key)
|
283
|
+
return nil
|
284
|
+
end
|
285
|
+
|
286
|
+
entry[:response]
|
287
|
+
end
|
288
|
+
|
289
|
+
def set_cached(key, response)
|
290
|
+
@cache[key] = {
|
291
|
+
response: response,
|
292
|
+
timestamp: Time.now
|
293
|
+
}
|
294
|
+
|
295
|
+
# Limit cache size
|
296
|
+
cleanup_cache if @cache.size > 1000
|
297
|
+
end
|
298
|
+
|
299
|
+
def cleanup_cache
|
300
|
+
# Remove oldest entries
|
301
|
+
sorted = @cache.sort_by { |_, v| v[:timestamp] }
|
302
|
+
sorted.first(@cache.size - 800).each { |k, _| @cache.delete(k) }
|
303
|
+
end
|
304
|
+
end
|
305
|
+
```
|
306
|
+
|
307
|
+
## Middleware Chain
|
308
|
+
|
309
|
+
### Ordering Middleware
|
310
|
+
|
311
|
+
Middleware executes in the order it's defined:
|
312
|
+
|
313
|
+
```ruby
|
314
|
+
Tsikol.server "my-server" do
|
315
|
+
# Executes first -> last for requests
|
316
|
+
# Executes last -> first for responses
|
317
|
+
|
318
|
+
use Tsikol::LoggingMiddleware # 1. Logs request
|
319
|
+
use AuthenticationMiddleware # 2. Checks auth
|
320
|
+
use ValidationMiddleware # 3. Validates
|
321
|
+
use Tsikol::RateLimitMiddleware # 4. Rate limits
|
322
|
+
use CachingMiddleware # 5. Checks cache
|
323
|
+
use Tsikol::ErrorHandlingMiddleware # 6. Handles errors
|
324
|
+
|
325
|
+
# Your tools and resources handle the request here
|
326
|
+
|
327
|
+
# Response travels back through middleware in reverse
|
328
|
+
end
|
329
|
+
```
|
330
|
+
|
331
|
+
### Conditional Middleware
|
332
|
+
|
333
|
+
Apply middleware conditionally:
|
334
|
+
|
335
|
+
```ruby
|
336
|
+
class ConditionalMiddleware < Tsikol::Middleware
|
337
|
+
def initialize(app, options = {})
|
338
|
+
super
|
339
|
+
@condition = options[:condition] || ->(_) { true }
|
340
|
+
@wrapped_middleware = options[:middleware].new(app, options[:middleware_options] || {})
|
341
|
+
end
|
342
|
+
|
343
|
+
def call(request)
|
344
|
+
if @condition.call(request)
|
345
|
+
@wrapped_middleware.call(request)
|
346
|
+
else
|
347
|
+
@app.call(request)
|
348
|
+
end
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
# Usage
|
353
|
+
Tsikol.server "my-server" do
|
354
|
+
use ConditionalMiddleware,
|
355
|
+
condition: ->(req) { req["method"].start_with?("tools/") },
|
356
|
+
middleware: AuthenticationMiddleware,
|
357
|
+
middleware_options: { secret_key: "secret" }
|
358
|
+
end
|
359
|
+
```
|
360
|
+
|
361
|
+
## Request/Response Manipulation
|
362
|
+
|
363
|
+
### Modifying Requests
|
364
|
+
|
365
|
+
```ruby
|
366
|
+
class RequestEnrichmentMiddleware < Tsikol::Middleware
|
367
|
+
def call(request)
|
368
|
+
# Add metadata to request
|
369
|
+
request["meta"] ||= {}
|
370
|
+
request["meta"]["timestamp"] = Time.now.iso8601
|
371
|
+
request["meta"]["request_id"] = SecureRandom.uuid
|
372
|
+
|
373
|
+
# Add default parameters
|
374
|
+
if request["method"] == "tools/call"
|
375
|
+
request["params"] ||= {}
|
376
|
+
request["params"]["_metadata"] = {
|
377
|
+
server_version: Tsikol::VERSION,
|
378
|
+
ruby_version: RUBY_VERSION
|
379
|
+
}
|
380
|
+
end
|
381
|
+
|
382
|
+
@app.call(request)
|
383
|
+
end
|
384
|
+
end
|
385
|
+
```
|
386
|
+
|
387
|
+
### Modifying Responses
|
388
|
+
|
389
|
+
```ruby
|
390
|
+
class ResponseFormattingMiddleware < Tsikol::Middleware
|
391
|
+
def call(request)
|
392
|
+
response = @app.call(request)
|
393
|
+
|
394
|
+
# Add metadata to all responses
|
395
|
+
if response[:result]
|
396
|
+
response[:result] = {
|
397
|
+
data: response[:result],
|
398
|
+
meta: {
|
399
|
+
timestamp: Time.now.iso8601,
|
400
|
+
duration_ms: calculate_duration(request),
|
401
|
+
server: @server.name
|
402
|
+
}
|
403
|
+
}
|
404
|
+
end
|
405
|
+
|
406
|
+
# Format error responses
|
407
|
+
if response[:error]
|
408
|
+
response[:error][:data] ||= {}
|
409
|
+
response[:error][:data][:request_id] = request.dig("meta", "request_id")
|
410
|
+
end
|
411
|
+
|
412
|
+
response
|
413
|
+
end
|
414
|
+
|
415
|
+
private
|
416
|
+
|
417
|
+
def calculate_duration(request)
|
418
|
+
start_time = request.dig("meta", "_start_time")
|
419
|
+
return nil unless start_time
|
420
|
+
((Time.now - start_time) * 1000).round(2)
|
421
|
+
end
|
422
|
+
end
|
423
|
+
```
|
424
|
+
|
425
|
+
### Short-Circuit Responses
|
426
|
+
|
427
|
+
```ruby
|
428
|
+
class CircuitBreakerMiddleware < Tsikol::Middleware
|
429
|
+
def initialize(app, options = {})
|
430
|
+
super
|
431
|
+
@failure_threshold = options[:failure_threshold] || 5
|
432
|
+
@timeout = options[:timeout] || 60
|
433
|
+
@failure_counts = Hash.new(0)
|
434
|
+
@circuit_opened_at = {}
|
435
|
+
end
|
436
|
+
|
437
|
+
def call(request)
|
438
|
+
key = circuit_key(request)
|
439
|
+
|
440
|
+
# Check if circuit is open
|
441
|
+
if circuit_open?(key)
|
442
|
+
if should_attempt_reset?(key)
|
443
|
+
# Try one request
|
444
|
+
@circuit_opened_at.delete(key)
|
445
|
+
else
|
446
|
+
# Short-circuit
|
447
|
+
return error_response(
|
448
|
+
request["id"],
|
449
|
+
-32003,
|
450
|
+
"Service temporarily unavailable"
|
451
|
+
)
|
452
|
+
end
|
453
|
+
end
|
454
|
+
|
455
|
+
# Process request
|
456
|
+
response = @app.call(request)
|
457
|
+
|
458
|
+
# Track failures
|
459
|
+
if response[:error]
|
460
|
+
@failure_counts[key] += 1
|
461
|
+
|
462
|
+
if @failure_counts[key] >= @failure_threshold
|
463
|
+
open_circuit(key)
|
464
|
+
end
|
465
|
+
else
|
466
|
+
# Reset on success
|
467
|
+
@failure_counts[key] = 0
|
468
|
+
end
|
469
|
+
|
470
|
+
response
|
471
|
+
end
|
472
|
+
|
473
|
+
private
|
474
|
+
|
475
|
+
def circuit_key(request)
|
476
|
+
request["method"]
|
477
|
+
end
|
478
|
+
|
479
|
+
def circuit_open?(key)
|
480
|
+
@circuit_opened_at.key?(key)
|
481
|
+
end
|
482
|
+
|
483
|
+
def should_attempt_reset?(key)
|
484
|
+
Time.now - @circuit_opened_at[key] > @timeout
|
485
|
+
end
|
486
|
+
|
487
|
+
def open_circuit(key)
|
488
|
+
@circuit_opened_at[key] = Time.now
|
489
|
+
log(:warning, "Circuit breaker opened", method: key)
|
490
|
+
end
|
491
|
+
end
|
492
|
+
```
|
493
|
+
|
494
|
+
## Advanced Patterns
|
495
|
+
|
496
|
+
### Async Middleware
|
497
|
+
|
498
|
+
Handle async operations:
|
499
|
+
|
500
|
+
```ruby
|
501
|
+
class AsyncMiddleware < Tsikol::Middleware
|
502
|
+
def initialize(app, options = {})
|
503
|
+
super
|
504
|
+
@thread_pool = options[:thread_pool] || default_thread_pool
|
505
|
+
end
|
506
|
+
|
507
|
+
def call(request)
|
508
|
+
if async_method?(request["method"])
|
509
|
+
handle_async(request)
|
510
|
+
else
|
511
|
+
@app.call(request)
|
512
|
+
end
|
513
|
+
end
|
514
|
+
|
515
|
+
private
|
516
|
+
|
517
|
+
def async_method?(method)
|
518
|
+
method.end_with?("_async")
|
519
|
+
end
|
520
|
+
|
521
|
+
def handle_async(request)
|
522
|
+
job_id = SecureRandom.uuid
|
523
|
+
|
524
|
+
# Start async processing
|
525
|
+
@thread_pool.post do
|
526
|
+
begin
|
527
|
+
result = @app.call(request)
|
528
|
+
store_result(job_id, result)
|
529
|
+
rescue => e
|
530
|
+
store_error(job_id, e)
|
531
|
+
end
|
532
|
+
end
|
533
|
+
|
534
|
+
# Return immediately with job ID
|
535
|
+
{
|
536
|
+
jsonrpc: "2.0",
|
537
|
+
id: request["id"],
|
538
|
+
result: {
|
539
|
+
job_id: job_id,
|
540
|
+
status: "processing",
|
541
|
+
check_url: "/jobs/#{job_id}"
|
542
|
+
}
|
543
|
+
}
|
544
|
+
end
|
545
|
+
|
546
|
+
def default_thread_pool
|
547
|
+
Concurrent::ThreadPoolExecutor.new(
|
548
|
+
min_threads: 2,
|
549
|
+
max_threads: 10,
|
550
|
+
max_queue: 100
|
551
|
+
)
|
552
|
+
end
|
553
|
+
end
|
554
|
+
```
|
555
|
+
|
556
|
+
### Middleware Composition
|
557
|
+
|
558
|
+
Combine multiple middleware behaviors:
|
559
|
+
|
560
|
+
```ruby
|
561
|
+
class CompositeMiddleware < Tsikol::Middleware
|
562
|
+
def initialize(app, *middlewares)
|
563
|
+
@app = app
|
564
|
+
@middleware_chain = build_chain(middlewares)
|
565
|
+
end
|
566
|
+
|
567
|
+
def call(request)
|
568
|
+
@middleware_chain.call(request)
|
569
|
+
end
|
570
|
+
|
571
|
+
private
|
572
|
+
|
573
|
+
def build_chain(middlewares)
|
574
|
+
middlewares.reverse.reduce(@app) do |app, (middleware_class, options)|
|
575
|
+
middleware_class.new(app, options || {})
|
576
|
+
end
|
577
|
+
end
|
578
|
+
end
|
579
|
+
|
580
|
+
# Usage
|
581
|
+
Tsikol.server "my-server" do
|
582
|
+
use CompositeMiddleware,
|
583
|
+
[Tsikol::LoggingMiddleware, { level: :debug }],
|
584
|
+
[AuthenticationMiddleware, { secret_key: "secret" }],
|
585
|
+
[Tsikol::RateLimitMiddleware, { max_requests: 100 }]
|
586
|
+
end
|
587
|
+
```
|
588
|
+
|
589
|
+
### Context Propagation
|
590
|
+
|
591
|
+
Pass context through the middleware chain:
|
592
|
+
|
593
|
+
```ruby
|
594
|
+
class ContextMiddleware < Tsikol::Middleware
|
595
|
+
def call(request)
|
596
|
+
# Initialize context
|
597
|
+
context = Thread.current[:mcp_context] = {
|
598
|
+
request_id: SecureRandom.uuid,
|
599
|
+
start_time: Time.now,
|
600
|
+
user: nil,
|
601
|
+
metadata: {}
|
602
|
+
}
|
603
|
+
|
604
|
+
begin
|
605
|
+
response = @app.call(request)
|
606
|
+
|
607
|
+
# Add context to response
|
608
|
+
if response[:result] && context[:metadata].any?
|
609
|
+
response[:result][:_context] = context[:metadata]
|
610
|
+
end
|
611
|
+
|
612
|
+
response
|
613
|
+
ensure
|
614
|
+
# Clean up context
|
615
|
+
Thread.current[:mcp_context] = nil
|
616
|
+
end
|
617
|
+
end
|
618
|
+
end
|
619
|
+
|
620
|
+
# Other middleware can access context
|
621
|
+
class LoggingMiddleware < Tsikol::Middleware
|
622
|
+
def call(request)
|
623
|
+
context = Thread.current[:mcp_context]
|
624
|
+
|
625
|
+
log(:info, "Request received",
|
626
|
+
request_id: context[:request_id],
|
627
|
+
method: request["method"])
|
628
|
+
|
629
|
+
@app.call(request)
|
630
|
+
end
|
631
|
+
end
|
632
|
+
```
|
633
|
+
|
634
|
+
## Testing Middleware
|
635
|
+
|
636
|
+
### Basic Middleware Test
|
637
|
+
|
638
|
+
```ruby
|
639
|
+
require 'minitest/autorun'
|
640
|
+
|
641
|
+
class MiddlewareTest < Minitest::Test
|
642
|
+
def setup
|
643
|
+
@app = ->(_request) { { jsonrpc: "2.0", id: 1, result: "success" } }
|
644
|
+
@middleware = MyMiddleware.new(@app)
|
645
|
+
end
|
646
|
+
|
647
|
+
def test_passes_request_through
|
648
|
+
request = { "jsonrpc" => "2.0", "id" => 1, "method" => "test" }
|
649
|
+
response = @middleware.call(request)
|
650
|
+
|
651
|
+
assert_equal "success", response[:result]
|
652
|
+
end
|
653
|
+
|
654
|
+
def test_modifies_request
|
655
|
+
# Test that middleware modifies request as expected
|
656
|
+
end
|
657
|
+
|
658
|
+
def test_modifies_response
|
659
|
+
# Test that middleware modifies response as expected
|
660
|
+
end
|
661
|
+
end
|
662
|
+
```
|
663
|
+
|
664
|
+
### Testing Authentication
|
665
|
+
|
666
|
+
```ruby
|
667
|
+
class AuthMiddlewareTest < Minitest::Test
|
668
|
+
def setup
|
669
|
+
@app = ->(_request) { { jsonrpc: "2.0", id: 1, result: "success" } }
|
670
|
+
@middleware = AuthenticationMiddleware.new(@app,
|
671
|
+
secret_key: "test_secret",
|
672
|
+
required_methods: ["tools/call"])
|
673
|
+
end
|
674
|
+
|
675
|
+
def test_allows_unauthenticated_methods
|
676
|
+
request = {
|
677
|
+
"jsonrpc" => "2.0",
|
678
|
+
"id" => 1,
|
679
|
+
"method" => "initialize"
|
680
|
+
}
|
681
|
+
|
682
|
+
response = @middleware.call(request)
|
683
|
+
assert_equal "success", response[:result]
|
684
|
+
end
|
685
|
+
|
686
|
+
def test_requires_auth_for_tools
|
687
|
+
request = {
|
688
|
+
"jsonrpc" => "2.0",
|
689
|
+
"id" => 1,
|
690
|
+
"method" => "tools/call"
|
691
|
+
}
|
692
|
+
|
693
|
+
response = @middleware.call(request)
|
694
|
+
assert_equal -32001, response[:error][:code]
|
695
|
+
assert_equal "Unauthorized", response[:error][:message]
|
696
|
+
end
|
697
|
+
|
698
|
+
def test_accepts_valid_token
|
699
|
+
token = generate_valid_token
|
700
|
+
request = {
|
701
|
+
"jsonrpc" => "2.0",
|
702
|
+
"id" => 1,
|
703
|
+
"method" => "tools/call",
|
704
|
+
"params" => { "_token" => token }
|
705
|
+
}
|
706
|
+
|
707
|
+
response = @middleware.call(request)
|
708
|
+
assert_equal "success", response[:result]
|
709
|
+
end
|
710
|
+
|
711
|
+
private
|
712
|
+
|
713
|
+
def generate_valid_token
|
714
|
+
payload = {
|
715
|
+
user_id: 123,
|
716
|
+
exp: Time.now.to_i + 3600
|
717
|
+
}
|
718
|
+
JWT.encode(payload, "test_secret", 'HS256')
|
719
|
+
end
|
720
|
+
end
|
721
|
+
```
|
722
|
+
|
723
|
+
### Testing Rate Limiting
|
724
|
+
|
725
|
+
```ruby
|
726
|
+
class RateLimitTest < Minitest::Test
|
727
|
+
def setup
|
728
|
+
@app = ->(_request) { { jsonrpc: "2.0", id: 1, result: "success" } }
|
729
|
+
@middleware = Tsikol::RateLimitMiddleware.new(@app,
|
730
|
+
max_requests: 3,
|
731
|
+
window_seconds: 1)
|
732
|
+
end
|
733
|
+
|
734
|
+
def test_allows_requests_under_limit
|
735
|
+
3.times do |i|
|
736
|
+
request = { "jsonrpc" => "2.0", "id" => i, "method" => "test" }
|
737
|
+
response = @middleware.call(request)
|
738
|
+
assert_equal "success", response[:result]
|
739
|
+
end
|
740
|
+
end
|
741
|
+
|
742
|
+
def test_blocks_requests_over_limit
|
743
|
+
# Make max requests
|
744
|
+
3.times do |i|
|
745
|
+
request = { "jsonrpc" => "2.0", "id" => i, "method" => "test" }
|
746
|
+
@middleware.call(request)
|
747
|
+
end
|
748
|
+
|
749
|
+
# Next request should be rate limited
|
750
|
+
request = { "jsonrpc" => "2.0", "id" => 4, "method" => "test" }
|
751
|
+
response = @middleware.call(request)
|
752
|
+
|
753
|
+
assert response[:error]
|
754
|
+
assert_match /rate limit/i, response[:error][:message]
|
755
|
+
end
|
756
|
+
|
757
|
+
def test_resets_after_window
|
758
|
+
# Make max requests
|
759
|
+
3.times { |i| @middleware.call(base_request(i)) }
|
760
|
+
|
761
|
+
# Wait for window to reset
|
762
|
+
sleep(1.1)
|
763
|
+
|
764
|
+
# Should allow new requests
|
765
|
+
response = @middleware.call(base_request(4))
|
766
|
+
assert_equal "success", response[:result]
|
767
|
+
end
|
768
|
+
|
769
|
+
private
|
770
|
+
|
771
|
+
def base_request(id)
|
772
|
+
{ "jsonrpc" => "2.0", "id" => id, "method" => "test" }
|
773
|
+
end
|
774
|
+
end
|
775
|
+
```
|
776
|
+
|
777
|
+
## Best Practices
|
778
|
+
|
779
|
+
1. **Keep Middleware Focused**: Each middleware should have one responsibility
|
780
|
+
2. **Order Matters**: Place auth before rate limiting, logging first/last
|
781
|
+
3. **Handle Errors**: Don't let middleware errors crash the server
|
782
|
+
4. **Performance**: Middleware runs on every request, keep it fast
|
783
|
+
5. **Thread Safety**: Ensure middleware is thread-safe
|
784
|
+
6. **Configuration**: Make middleware configurable via options
|
785
|
+
7. **Testing**: Test middleware in isolation and integration
|
786
|
+
|
787
|
+
## Common Middleware Stack
|
788
|
+
|
789
|
+
A typical production middleware stack:
|
790
|
+
|
791
|
+
```ruby
|
792
|
+
Tsikol.server "production-server" do
|
793
|
+
# Observability
|
794
|
+
use Tsikol::LoggingMiddleware, level: :info
|
795
|
+
use Tsikol::MetricsMiddleware
|
796
|
+
|
797
|
+
# Security
|
798
|
+
use ValidationMiddleware, max_request_size: 5_242_880 # 5MB
|
799
|
+
use AuthenticationMiddleware, secret_key: ENV['SECRET_KEY']
|
800
|
+
|
801
|
+
# Protection
|
802
|
+
use Tsikol::RateLimitMiddleware, max_requests: 1000, window_seconds: 60
|
803
|
+
use CircuitBreakerMiddleware, failure_threshold: 10
|
804
|
+
|
805
|
+
# Performance
|
806
|
+
use CachingMiddleware, ttl: 300
|
807
|
+
|
808
|
+
# Error handling (last)
|
809
|
+
use Tsikol::ErrorHandlingMiddleware, log_errors: true
|
810
|
+
|
811
|
+
# Your application
|
812
|
+
tool FileManager
|
813
|
+
resource SystemStatus
|
814
|
+
prompt Assistant
|
815
|
+
end
|
816
|
+
```
|
817
|
+
|
818
|
+
## Next Steps
|
819
|
+
|
820
|
+
- Learn about [Testing](testing.md)
|
821
|
+
- Implement [Authentication](../cookbook/authentication.md)
|
822
|
+
- Add [Rate Limiting](../cookbook/rate-limiting.md)
|
823
|
+
- Set up [Logging](../cookbook/logging.md)
|