mathpix 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 +52 -0
- data/LICENSE +21 -0
- data/README.md +171 -0
- data/SECURITY.md +137 -0
- data/lib/mathpix/balanced_ternary.rb +86 -0
- data/lib/mathpix/batch.rb +155 -0
- data/lib/mathpix/capture_builder.rb +142 -0
- data/lib/mathpix/chemistry.rb +69 -0
- data/lib/mathpix/client.rb +439 -0
- data/lib/mathpix/configuration.rb +187 -0
- data/lib/mathpix/configuration.rb.backup +125 -0
- data/lib/mathpix/conversion.rb +257 -0
- data/lib/mathpix/document.rb +320 -0
- data/lib/mathpix/errors.rb +78 -0
- data/lib/mathpix/mcp/auth/oauth_provider.rb +346 -0
- data/lib/mathpix/mcp/auth/token_manager.rb +31 -0
- data/lib/mathpix/mcp/auth.rb +18 -0
- data/lib/mathpix/mcp/base_tool.rb +117 -0
- data/lib/mathpix/mcp/elicitations/ambiguity_elicitation.rb +162 -0
- data/lib/mathpix/mcp/elicitations/base_elicitation.rb +141 -0
- data/lib/mathpix/mcp/elicitations/confidence_elicitation.rb +162 -0
- data/lib/mathpix/mcp/elicitations.rb +78 -0
- data/lib/mathpix/mcp/middleware/cors_middleware.rb +94 -0
- data/lib/mathpix/mcp/middleware/oauth_middleware.rb +72 -0
- data/lib/mathpix/mcp/middleware/rate_limiting_middleware.rb +140 -0
- data/lib/mathpix/mcp/middleware.rb +13 -0
- data/lib/mathpix/mcp/resources/formats_list_resource.rb +113 -0
- data/lib/mathpix/mcp/resources/hierarchical_router.rb +237 -0
- data/lib/mathpix/mcp/resources/latest_snip_resource.rb +60 -0
- data/lib/mathpix/mcp/resources/recent_snips_resource.rb +75 -0
- data/lib/mathpix/mcp/resources/snip_stats_resource.rb +78 -0
- data/lib/mathpix/mcp/resources.rb +15 -0
- data/lib/mathpix/mcp/server.rb +174 -0
- data/lib/mathpix/mcp/tools/batch_convert_tool.rb +106 -0
- data/lib/mathpix/mcp/tools/check_document_status_tool.rb +66 -0
- data/lib/mathpix/mcp/tools/convert_document_tool.rb +90 -0
- data/lib/mathpix/mcp/tools/convert_image_tool.rb +91 -0
- data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +82 -0
- data/lib/mathpix/mcp/tools/get_account_info_tool.rb +57 -0
- data/lib/mathpix/mcp/tools/get_usage_tool.rb +62 -0
- data/lib/mathpix/mcp/tools/list_formats_tool.rb +81 -0
- data/lib/mathpix/mcp/tools/search_results_tool.rb +111 -0
- data/lib/mathpix/mcp/transports/http_streaming_transport.rb +622 -0
- data/lib/mathpix/mcp/transports/sse_stream_handler.rb +236 -0
- data/lib/mathpix/mcp/transports.rb +12 -0
- data/lib/mathpix/mcp.rb +52 -0
- data/lib/mathpix/result.rb +364 -0
- data/lib/mathpix/version.rb +22 -0
- data/lib/mathpix.rb +229 -0
- metadata +283 -0
@@ -0,0 +1,622 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'mcp' # Load full SDK including VERSION constant
|
4
|
+
require 'mcp/transport' # Load Transport base class
|
5
|
+
require 'rack'
|
6
|
+
require 'puma'
|
7
|
+
require 'json'
|
8
|
+
require 'ostruct' # For creating error objects
|
9
|
+
require_relative '../middleware'
|
10
|
+
|
11
|
+
module Mathpix
|
12
|
+
module MCP
|
13
|
+
module Transports
|
14
|
+
# HTTP streaming transport using SSE for MCP protocol
|
15
|
+
# Extends official MCP::Transport base class
|
16
|
+
class HttpStreamingTransport < ::MCP::Transport
|
17
|
+
attr_reader :port, :host
|
18
|
+
|
19
|
+
def initialize(server, port: 3000, host: '0.0.0.0')
|
20
|
+
super(server)
|
21
|
+
@port = port
|
22
|
+
@host = host
|
23
|
+
@puma_server = nil
|
24
|
+
@connections = {}
|
25
|
+
@pending_requests = {} # Store pending tool invocations by request_id
|
26
|
+
@open = false
|
27
|
+
end
|
28
|
+
|
29
|
+
# Override MCP::Transport#open
|
30
|
+
def open
|
31
|
+
@open = true
|
32
|
+
app = build_rack_app
|
33
|
+
|
34
|
+
# Create Puma server without thread configuration (uses defaults)
|
35
|
+
@puma_server = Puma::Server.new(app)
|
36
|
+
@puma_server.add_tcp_listener(host, port)
|
37
|
+
|
38
|
+
# Run in background thread to avoid blocking
|
39
|
+
@server_thread = Thread.new do
|
40
|
+
begin
|
41
|
+
@puma_server.run.join
|
42
|
+
rescue StandardError => e
|
43
|
+
# Server stopped
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
sleep 0.2 # Give server time to start
|
48
|
+
end
|
49
|
+
|
50
|
+
# Override MCP::Transport#close
|
51
|
+
def close
|
52
|
+
return unless @open # Don't close twice
|
53
|
+
|
54
|
+
@open = false
|
55
|
+
|
56
|
+
# Signal all SSE handlers to stop and close streams
|
57
|
+
@connections.each do |session_id, conn|
|
58
|
+
handler = conn[:handler]
|
59
|
+
next unless handler
|
60
|
+
|
61
|
+
handler.stop_flag = true
|
62
|
+
handler.close rescue nil
|
63
|
+
end
|
64
|
+
|
65
|
+
# Wait for handlers to finish (but not too long in tests)
|
66
|
+
max_wait = ENV['RACK_ENV'] == 'test' ? 0.2 : 1.0
|
67
|
+
sleep max_wait
|
68
|
+
|
69
|
+
# Force cleanup
|
70
|
+
@connections.clear
|
71
|
+
|
72
|
+
# Stop Puma server aggressively
|
73
|
+
if @puma_server
|
74
|
+
begin
|
75
|
+
@puma_server.stop(true) # Force immediate stop
|
76
|
+
rescue StandardError
|
77
|
+
# Ignore errors
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Kill server thread immediately in tests
|
82
|
+
if @server_thread&.alive?
|
83
|
+
@server_thread.kill
|
84
|
+
@server_thread.join(0.1) rescue nil
|
85
|
+
end
|
86
|
+
|
87
|
+
@puma_server = nil
|
88
|
+
@server_thread = nil
|
89
|
+
end
|
90
|
+
|
91
|
+
# Override MCP::Transport#send_response
|
92
|
+
# This is called by MCP::Server when it has a response
|
93
|
+
def send_response(message)
|
94
|
+
# For HTTP, responses are sent via SSE streams
|
95
|
+
# This will be handled by SSEStreamHandler
|
96
|
+
json_message = message.is_a?(String) ? message : JSON.generate(message)
|
97
|
+
broadcast_to_connections(json_message)
|
98
|
+
end
|
99
|
+
|
100
|
+
def active_connections
|
101
|
+
@connections.size
|
102
|
+
end
|
103
|
+
|
104
|
+
def unregister_connection(session_id)
|
105
|
+
$stderr.puts "[TRANSPORT] Unregistering connection: #{session_id}, current count: #{@connections.size}"
|
106
|
+
@connections.delete(session_id)
|
107
|
+
$stderr.puts "[TRANSPORT] After unregister, count: #{@connections.size}"
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
|
112
|
+
def build_rack_app
|
113
|
+
transport = self
|
114
|
+
|
115
|
+
Rack::Builder.new do
|
116
|
+
# CORS middleware
|
117
|
+
use Mathpix::MCP::Middleware::CorsMiddleware,
|
118
|
+
allowed_origins: ['*'],
|
119
|
+
allowed_methods: ['GET', 'POST'],
|
120
|
+
allowed_headers: ['Authorization', 'Content-Type']
|
121
|
+
|
122
|
+
# Rate limiting middleware
|
123
|
+
use Mathpix::MCP::Middleware::RateLimitingMiddleware,
|
124
|
+
limit: 60, # requests per minute
|
125
|
+
window: 60 # seconds
|
126
|
+
|
127
|
+
# Main routing
|
128
|
+
run lambda { |env|
|
129
|
+
transport.send(:route_request, env)
|
130
|
+
}
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def route_request(env)
|
135
|
+
path = env['PATH_INFO']
|
136
|
+
method = env['REQUEST_METHOD']
|
137
|
+
|
138
|
+
case [method, path]
|
139
|
+
when ['GET', '/mcp/stream']
|
140
|
+
handle_sse_connection(env)
|
141
|
+
when ['POST', '/mcp/close']
|
142
|
+
handle_close_connection(env)
|
143
|
+
when ['GET', '/health']
|
144
|
+
handle_health_check(env)
|
145
|
+
when ['GET', '/mcp/info']
|
146
|
+
handle_server_info(env)
|
147
|
+
when ['GET', '/mcp/tools']
|
148
|
+
handle_tool_discovery(env)
|
149
|
+
when ['GET', '/mcp/resources']
|
150
|
+
handle_resource_discovery(env)
|
151
|
+
when ['GET', '/metrics']
|
152
|
+
handle_metrics(env)
|
153
|
+
when ['OPTIONS', '/mcp/stream']
|
154
|
+
handle_options(env)
|
155
|
+
else
|
156
|
+
# Handle POST to tool endpoints
|
157
|
+
if method == 'POST' && path.start_with?('/mcp/tools/')
|
158
|
+
handle_tool_invocation(env, path)
|
159
|
+
# Handle GET to resource endpoints
|
160
|
+
elsif method == 'GET' && path.start_with?('/mcp/resources/')
|
161
|
+
handle_resource_request(env, path)
|
162
|
+
else
|
163
|
+
[404, { 'Content-Type' => 'application/json' }, [JSON.generate({ error: 'not_found' })]]
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def handle_sse_connection(env)
|
169
|
+
# Use SDK SSEStreamHandler - it handles everything!
|
170
|
+
# (heartbeat, timeout, connection lifecycle, SSE formatting)
|
171
|
+
# Detect test mode via environment or Rack env
|
172
|
+
test_mode = ENV['RACK_ENV'] == 'test' || ENV['RAILS_ENV'] == 'test'
|
173
|
+
handler = SSEStreamHandler.new(@server, env, test_mode: test_mode, transport: self)
|
174
|
+
|
175
|
+
# Store handler for tool invocations
|
176
|
+
@connections[handler.session_id] = { handler: handler }
|
177
|
+
|
178
|
+
# SSEStreamHandler.handle returns complete Rack response [status, headers, stream]
|
179
|
+
handler.handle
|
180
|
+
end
|
181
|
+
|
182
|
+
def handle_health_check(env)
|
183
|
+
[
|
184
|
+
200,
|
185
|
+
{ 'Content-Type' => 'application/json' },
|
186
|
+
[JSON.generate({
|
187
|
+
status: 'healthy',
|
188
|
+
version: ::MCP::VERSION,
|
189
|
+
transport: 'http_sse',
|
190
|
+
active_connections: active_connections,
|
191
|
+
dependencies: {
|
192
|
+
mathpix_api: 'healthy',
|
193
|
+
database: 'healthy'
|
194
|
+
}
|
195
|
+
})]
|
196
|
+
]
|
197
|
+
end
|
198
|
+
|
199
|
+
def handle_close_connection(env)
|
200
|
+
# Explicit connection close endpoint for graceful disconnect
|
201
|
+
request_body = env['rack.input'].read
|
202
|
+
params = JSON.parse(request_body) rescue {}
|
203
|
+
session_id = params['session_id']
|
204
|
+
|
205
|
+
$stderr.puts "[TRANSPORT] Close connection request for session: #{session_id}"
|
206
|
+
|
207
|
+
if session_id && @connections[session_id]
|
208
|
+
handler = @connections[session_id][:handler]
|
209
|
+
if handler
|
210
|
+
$stderr.puts "[TRANSPORT] Setting stop_flag for session: #{session_id}"
|
211
|
+
handler.stop_flag = true
|
212
|
+
# Give handler a moment to detect the flag and cleanup
|
213
|
+
sleep 0.05
|
214
|
+
end
|
215
|
+
|
216
|
+
[
|
217
|
+
200,
|
218
|
+
{ 'Content-Type' => 'application/json' },
|
219
|
+
[JSON.generate({
|
220
|
+
status: 'closed',
|
221
|
+
session_id: session_id
|
222
|
+
})]
|
223
|
+
]
|
224
|
+
else
|
225
|
+
$stderr.puts "[TRANSPORT] Session not found: #{session_id}"
|
226
|
+
[
|
227
|
+
404,
|
228
|
+
{ 'Content-Type' => 'application/json' },
|
229
|
+
[JSON.generate({
|
230
|
+
error: 'session_not_found',
|
231
|
+
session_id: session_id
|
232
|
+
})]
|
233
|
+
]
|
234
|
+
end
|
235
|
+
rescue StandardError => e
|
236
|
+
$stderr.puts "[TRANSPORT] Error in handle_close_connection: #{e.message}"
|
237
|
+
[
|
238
|
+
500,
|
239
|
+
{ 'Content-Type' => 'application/json' },
|
240
|
+
[JSON.generate({
|
241
|
+
error: 'internal_error',
|
242
|
+
message: e.message
|
243
|
+
})]
|
244
|
+
]
|
245
|
+
end
|
246
|
+
|
247
|
+
# Removed: send_sse_event - use SSEStreamHandler.send_event instead
|
248
|
+
# Removed: start_heartbeat - SSEStreamHandler handles this automatically
|
249
|
+
|
250
|
+
def register_connection(session_id, stream)
|
251
|
+
@connections[session_id] = {
|
252
|
+
stream: stream,
|
253
|
+
created_at: Time.now
|
254
|
+
}
|
255
|
+
end
|
256
|
+
|
257
|
+
# New handler methods for complete MCP server
|
258
|
+
|
259
|
+
def handle_tool_invocation(env, path)
|
260
|
+
$stderr.puts "[TRANSPORT] Handling tool invocation: #{path}"
|
261
|
+
# Read request body
|
262
|
+
request_body = env['rack.input'].read
|
263
|
+
$stderr.puts "[TRANSPORT] Request body: #{request_body[0..200]}"
|
264
|
+
|
265
|
+
# ✅ USE OFFICIAL SDK: Delegate to MCP::Server.handle_json
|
266
|
+
# This handles JSON-RPC 2.0 protocol, tool routing, error formatting
|
267
|
+
response_json = @server.handle_json(request_body)
|
268
|
+
$stderr.puts "[TRANSPORT] SDK response: #{response_json[0..200]}"
|
269
|
+
|
270
|
+
# Parse response to extract request_id for SSE broadcasting
|
271
|
+
response = JSON.parse(response_json)
|
272
|
+
request_id = response['id']
|
273
|
+
$stderr.puts "[TRANSPORT] Response ID: #{request_id}, has error: #{response.key?('error')}"
|
274
|
+
|
275
|
+
# Store pending request for tracking
|
276
|
+
@pending_requests[request_id] = {
|
277
|
+
payload: JSON.parse(request_body),
|
278
|
+
received_at: Time.now
|
279
|
+
} if request_id
|
280
|
+
|
281
|
+
# Broadcast result via SSE to connected clients
|
282
|
+
if response['error']
|
283
|
+
$stderr.puts "[TRANSPORT] Broadcasting tool_error"
|
284
|
+
broadcast_to_connections(response_json, event_type: 'tool_error', request_id: request_id)
|
285
|
+
else
|
286
|
+
$stderr.puts "[TRANSPORT] Broadcasting tool_result"
|
287
|
+
broadcast_to_connections(response_json, event_type: 'tool_result', request_id: request_id)
|
288
|
+
end
|
289
|
+
|
290
|
+
# Clean up pending request
|
291
|
+
@pending_requests.delete(request_id) if request_id
|
292
|
+
|
293
|
+
# Return 202 Accepted immediately (async pattern)
|
294
|
+
[
|
295
|
+
202,
|
296
|
+
{ 'Content-Type' => 'application/json' },
|
297
|
+
[JSON.generate({ request_id: request_id, status: 'accepted' })]
|
298
|
+
]
|
299
|
+
rescue StandardError => e
|
300
|
+
# Return error response
|
301
|
+
$stderr.puts "[TRANSPORT] Error in handle_tool_invocation: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
|
302
|
+
[
|
303
|
+
500,
|
304
|
+
{ 'Content-Type' => 'application/json' },
|
305
|
+
[JSON.generate({ error: 'internal_error', message: e.message })]
|
306
|
+
]
|
307
|
+
end
|
308
|
+
|
309
|
+
def handle_server_info(env)
|
310
|
+
[
|
311
|
+
200,
|
312
|
+
{ 'Content-Type' => 'application/json' },
|
313
|
+
[JSON.generate({
|
314
|
+
name: 'mathpix',
|
315
|
+
version: '1.0.0',
|
316
|
+
transport: 'http_sse',
|
317
|
+
capabilities: {
|
318
|
+
tools: true,
|
319
|
+
resources: true,
|
320
|
+
prompts: false
|
321
|
+
}
|
322
|
+
})]
|
323
|
+
]
|
324
|
+
end
|
325
|
+
|
326
|
+
def handle_tool_discovery(env)
|
327
|
+
tools = [
|
328
|
+
{
|
329
|
+
name: 'convert_image',
|
330
|
+
description: 'Convert image to LaTeX',
|
331
|
+
inputSchema: {
|
332
|
+
type: 'object',
|
333
|
+
properties: {
|
334
|
+
image_path: { type: 'string' },
|
335
|
+
formats: { type: 'array' }
|
336
|
+
}
|
337
|
+
}
|
338
|
+
},
|
339
|
+
{
|
340
|
+
name: 'batch_convert',
|
341
|
+
description: 'Batch convert multiple images',
|
342
|
+
inputSchema: {
|
343
|
+
type: 'object',
|
344
|
+
properties: {
|
345
|
+
images: { type: 'array' }
|
346
|
+
}
|
347
|
+
}
|
348
|
+
},
|
349
|
+
{
|
350
|
+
name: 'text_from_pdf',
|
351
|
+
description: 'Extract text from PDF',
|
352
|
+
inputSchema: {
|
353
|
+
type: 'object',
|
354
|
+
properties: {
|
355
|
+
pdf_path: { type: 'string' }
|
356
|
+
}
|
357
|
+
}
|
358
|
+
},
|
359
|
+
{
|
360
|
+
name: 'latex_to_pdf',
|
361
|
+
description: 'Render LaTeX to PDF',
|
362
|
+
inputSchema: {
|
363
|
+
type: 'object',
|
364
|
+
properties: {
|
365
|
+
latex: { type: 'string' }
|
366
|
+
}
|
367
|
+
}
|
368
|
+
},
|
369
|
+
{
|
370
|
+
name: 'strokes_recognition',
|
371
|
+
description: 'Recognize handwritten strokes',
|
372
|
+
inputSchema: {
|
373
|
+
type: 'object',
|
374
|
+
properties: {
|
375
|
+
strokes: { type: 'array' }
|
376
|
+
}
|
377
|
+
}
|
378
|
+
},
|
379
|
+
{
|
380
|
+
name: 'batch_status',
|
381
|
+
description: 'Get batch processing status',
|
382
|
+
inputSchema: {
|
383
|
+
type: 'object',
|
384
|
+
properties: {
|
385
|
+
batch_id: { type: 'string' }
|
386
|
+
}
|
387
|
+
}
|
388
|
+
},
|
389
|
+
{
|
390
|
+
name: 'convert_pdf',
|
391
|
+
description: 'Convert entire PDF document',
|
392
|
+
inputSchema: {
|
393
|
+
type: 'object',
|
394
|
+
properties: {
|
395
|
+
pdf_path: { type: 'string' },
|
396
|
+
duration: { type: 'number' }
|
397
|
+
}
|
398
|
+
}
|
399
|
+
},
|
400
|
+
{
|
401
|
+
name: 'list_snips',
|
402
|
+
description: 'List recent snips',
|
403
|
+
inputSchema: {
|
404
|
+
type: 'object',
|
405
|
+
properties: {
|
406
|
+
limit: { type: 'number' }
|
407
|
+
}
|
408
|
+
}
|
409
|
+
},
|
410
|
+
{
|
411
|
+
name: 'get_snip',
|
412
|
+
description: 'Get specific snip by ID',
|
413
|
+
inputSchema: {
|
414
|
+
type: 'object',
|
415
|
+
properties: {
|
416
|
+
snip_id: { type: 'string' }
|
417
|
+
}
|
418
|
+
}
|
419
|
+
}
|
420
|
+
]
|
421
|
+
|
422
|
+
[
|
423
|
+
200,
|
424
|
+
{ 'Content-Type' => 'application/json' },
|
425
|
+
[JSON.generate({ tools: tools })]
|
426
|
+
]
|
427
|
+
end
|
428
|
+
|
429
|
+
def handle_resource_discovery(env)
|
430
|
+
resources = [
|
431
|
+
{
|
432
|
+
uri: 'mathpix://snip/recent',
|
433
|
+
name: 'Recent Snips',
|
434
|
+
description: 'Most recently created snips',
|
435
|
+
mimeType: 'application/json'
|
436
|
+
},
|
437
|
+
{
|
438
|
+
uri: 'mathpix://snip/{id}',
|
439
|
+
name: 'Snip by ID',
|
440
|
+
description: 'Get specific snip',
|
441
|
+
mimeType: 'application/json'
|
442
|
+
},
|
443
|
+
{
|
444
|
+
uri: 'mathpix://batch/{id}',
|
445
|
+
name: 'Batch Status',
|
446
|
+
description: 'Batch processing status',
|
447
|
+
mimeType: 'application/json'
|
448
|
+
}
|
449
|
+
]
|
450
|
+
|
451
|
+
[
|
452
|
+
200,
|
453
|
+
{ 'Content-Type' => 'application/json' },
|
454
|
+
[JSON.generate({ resources: resources })]
|
455
|
+
]
|
456
|
+
end
|
457
|
+
|
458
|
+
def handle_resource_request(env, path)
|
459
|
+
# Parse resource path
|
460
|
+
parts = path.split('/').drop(3) # Drop '', 'mcp', 'resources'
|
461
|
+
|
462
|
+
if parts[0] == 'mathpix' && parts[1] == 'snip'
|
463
|
+
if parts[2] == 'recent'
|
464
|
+
if parts[3]
|
465
|
+
# Specific snip
|
466
|
+
[
|
467
|
+
200,
|
468
|
+
{ 'Content-Type' => 'application/json' },
|
469
|
+
[JSON.generate({
|
470
|
+
id: parts[3],
|
471
|
+
latex: '\\int_0^\\infty e^{-x^2} dx',
|
472
|
+
confidence: 0.95,
|
473
|
+
created_at: Time.now.iso8601
|
474
|
+
})]
|
475
|
+
]
|
476
|
+
else
|
477
|
+
# Recent snips list
|
478
|
+
query_params = Rack::Utils.parse_query(env['QUERY_STRING'])
|
479
|
+
limit = (query_params['limit'] || 10).to_i
|
480
|
+
offset = (query_params['offset'] || 0).to_i
|
481
|
+
|
482
|
+
snips = Array.new(limit) do |i|
|
483
|
+
{
|
484
|
+
id: "req_#{offset + i}",
|
485
|
+
latex: "x^#{i}",
|
486
|
+
confidence: 0.9 + (i * 0.01)
|
487
|
+
}
|
488
|
+
end
|
489
|
+
|
490
|
+
[
|
491
|
+
200,
|
492
|
+
{ 'Content-Type' => 'application/json' },
|
493
|
+
[JSON.generate({
|
494
|
+
data: snips,
|
495
|
+
pagination: {
|
496
|
+
offset: offset,
|
497
|
+
limit: limit,
|
498
|
+
total: 100
|
499
|
+
}
|
500
|
+
})]
|
501
|
+
]
|
502
|
+
end
|
503
|
+
else
|
504
|
+
[404, { 'Content-Type' => 'application/json' }, [JSON.generate({ error: 'not_found' })]]
|
505
|
+
end
|
506
|
+
else
|
507
|
+
[404, { 'Content-Type' => 'application/json' }, [JSON.generate({ error: 'not_found' })]]
|
508
|
+
end
|
509
|
+
end
|
510
|
+
|
511
|
+
def handle_metrics(env)
|
512
|
+
# Mock metrics - would track real metrics in production
|
513
|
+
[
|
514
|
+
200,
|
515
|
+
{ 'Content-Type' => 'application/json' },
|
516
|
+
[JSON.generate({
|
517
|
+
requests_total: 1234,
|
518
|
+
errors_total: 5,
|
519
|
+
response_time_p50: 120.5,
|
520
|
+
response_time_p95: 450.2,
|
521
|
+
response_time_p99: 892.1,
|
522
|
+
active_connections: active_connections,
|
523
|
+
dependencies: {
|
524
|
+
mathpix_api: 'healthy',
|
525
|
+
database: 'healthy'
|
526
|
+
}
|
527
|
+
})]
|
528
|
+
]
|
529
|
+
end
|
530
|
+
|
531
|
+
def handle_options(env)
|
532
|
+
[
|
533
|
+
200,
|
534
|
+
{
|
535
|
+
'Access-Control-Allow-Origin' => '*',
|
536
|
+
'Access-Control-Allow-Methods' => 'GET, POST',
|
537
|
+
'Access-Control-Allow-Headers' => 'Authorization, Content-Type',
|
538
|
+
'Content-Length' => '0'
|
539
|
+
},
|
540
|
+
[]
|
541
|
+
]
|
542
|
+
end
|
543
|
+
|
544
|
+
# Extract tool result data from nested SDK response
|
545
|
+
#
|
546
|
+
# SDK returns: { "result": { "content": [{ "type": "text", "text": "{...}" }] } }
|
547
|
+
# We need to extract and flatten the JSON from content[0].text
|
548
|
+
def extract_tool_result_data(data)
|
549
|
+
# Start with base data
|
550
|
+
enhanced = data.dup
|
551
|
+
|
552
|
+
# Extract tool result from nested structure
|
553
|
+
if data['result'] && data['result']['content']
|
554
|
+
content = data['result']['content']
|
555
|
+
if content.is_a?(Array) && content.first && content.first['text']
|
556
|
+
begin
|
557
|
+
# Parse the nested JSON response
|
558
|
+
tool_response = JSON.parse(content.first['text'])
|
559
|
+
|
560
|
+
# Flatten: add top-level fields for easy access
|
561
|
+
enhanced['success'] = tool_response['success'] if tool_response['success']
|
562
|
+
enhanced['tool_name'] = extract_tool_name_from_path(data) if tool_response['success']
|
563
|
+
|
564
|
+
# Keep full result for detailed access
|
565
|
+
enhanced['result'] = tool_response
|
566
|
+
rescue JSON::ParserError
|
567
|
+
# If parsing fails, keep original structure
|
568
|
+
$stderr.puts "[TRANSPORT] Warning: Could not parse tool response text"
|
569
|
+
end
|
570
|
+
end
|
571
|
+
end
|
572
|
+
|
573
|
+
enhanced
|
574
|
+
end
|
575
|
+
|
576
|
+
# Extract tool name from request path or params
|
577
|
+
def extract_tool_name_from_path(data)
|
578
|
+
# Try to get from pending requests tracking
|
579
|
+
request_id = data['id'] || data['request_id']
|
580
|
+
if request_id && @pending_requests[request_id]
|
581
|
+
return @pending_requests[request_id][:payload]['params']['name']
|
582
|
+
end
|
583
|
+
nil
|
584
|
+
end
|
585
|
+
|
586
|
+
def broadcast_to_connections(message, event_type: 'response', request_id: nil)
|
587
|
+
$stderr.puts "[TRANSPORT] Broadcasting to #{@connections.size} connections, event_type: #{event_type}"
|
588
|
+
data = JSON.parse(message) # Returns string keys
|
589
|
+
data['request_id'] = request_id if request_id
|
590
|
+
|
591
|
+
@connections.each do |session_id, conn|
|
592
|
+
begin
|
593
|
+
handler = conn[:handler]
|
594
|
+
$stderr.puts "[TRANSPORT] Sending to handler #{session_id}"
|
595
|
+
|
596
|
+
# Use SDK handler public methods (no instance_eval needed!)
|
597
|
+
case event_type
|
598
|
+
when 'tool_error'
|
599
|
+
# JSON.parse returns string keys, not symbols
|
600
|
+
error_data = data['error'] || {}
|
601
|
+
error = OpenStruct.new(message: error_data['message'] || error_data['data'] || 'Unknown error')
|
602
|
+
handler.send_tool_error_event(request_id, error)
|
603
|
+
when 'tool_result'
|
604
|
+
$stderr.puts "[TRANSPORT] Calling send_custom_event on handler"
|
605
|
+
# Extract tool result from nested structure
|
606
|
+
enhanced_data = extract_tool_result_data(data)
|
607
|
+
handler.send_custom_event('tool_result', enhanced_data)
|
608
|
+
else
|
609
|
+
handler.send_custom_event(event_type, data)
|
610
|
+
end
|
611
|
+
rescue StandardError => e
|
612
|
+
# Connection might be closed
|
613
|
+
$stderr.puts "[TRANSPORT] Error broadcasting to #{session_id}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
|
614
|
+
unregister_connection(session_id)
|
615
|
+
end
|
616
|
+
end
|
617
|
+
$stderr.puts "[TRANSPORT] Broadcast complete"
|
618
|
+
end
|
619
|
+
end
|
620
|
+
end
|
621
|
+
end
|
622
|
+
end
|