mathpix 0.1.0 → 0.1.2

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +115 -2
  4. data/SECURITY.md +1 -1
  5. data/bin/mathpix-mcp +55 -0
  6. data/lib/mathpix/batch.rb +7 -8
  7. data/lib/mathpix/batched_document_conversion.rb +238 -0
  8. data/lib/mathpix/client.rb +33 -27
  9. data/lib/mathpix/configuration.rb +5 -9
  10. data/lib/mathpix/conversion.rb +2 -6
  11. data/lib/mathpix/document.rb +47 -12
  12. data/lib/mathpix/document_batcher.rb +191 -0
  13. data/lib/mathpix/mcp/auth/oauth_provider.rb +8 -9
  14. data/lib/mathpix/mcp/base_tool.rb +8 -5
  15. data/lib/mathpix/mcp/elicitations/ambiguity_elicitation.rb +8 -11
  16. data/lib/mathpix/mcp/elicitations/base_elicitation.rb +2 -0
  17. data/lib/mathpix/mcp/elicitations/confidence_elicitation.rb +2 -1
  18. data/lib/mathpix/mcp/elicitations.rb +1 -1
  19. data/lib/mathpix/mcp/middleware/cors_middleware.rb +2 -6
  20. data/lib/mathpix/mcp/middleware/oauth_middleware.rb +2 -6
  21. data/lib/mathpix/mcp/middleware/rate_limiting_middleware.rb +19 -18
  22. data/lib/mathpix/mcp/resources/formats_list_resource.rb +54 -54
  23. data/lib/mathpix/mcp/resources/hierarchical_router.rb +9 -18
  24. data/lib/mathpix/mcp/resources/latest_snip_resource.rb +22 -22
  25. data/lib/mathpix/mcp/resources/recent_snips_resource.rb +11 -10
  26. data/lib/mathpix/mcp/resources/snip_stats_resource.rb +14 -12
  27. data/lib/mathpix/mcp/server.rb +18 -18
  28. data/lib/mathpix/mcp/tools/batch_convert_tool.rb +31 -37
  29. data/lib/mathpix/mcp/tools/check_document_status_tool.rb +5 -5
  30. data/lib/mathpix/mcp/tools/convert_document_tool.rb +15 -14
  31. data/lib/mathpix/mcp/tools/convert_image_tool.rb +15 -14
  32. data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +13 -13
  33. data/lib/mathpix/mcp/tools/get_account_info_tool.rb +1 -1
  34. data/lib/mathpix/mcp/tools/get_usage_tool.rb +5 -7
  35. data/lib/mathpix/mcp/tools/list_formats_tool.rb +30 -30
  36. data/lib/mathpix/mcp/tools/search_results_tool.rb +13 -14
  37. data/lib/mathpix/mcp/transports/http_streaming_transport.rb +129 -118
  38. data/lib/mathpix/mcp/transports/sse_stream_handler.rb +37 -35
  39. data/lib/mathpix/result.rb +3 -2
  40. data/lib/mathpix/version.rb +1 -1
  41. data/lib/mathpix.rb +3 -1
  42. metadata +75 -12
@@ -19,18 +19,14 @@ module Mathpix
19
19
  origin = env['HTTP_ORIGIN']
20
20
 
21
21
  # Handle preflight OPTIONS request
22
- if env['REQUEST_METHOD'] == 'OPTIONS'
23
- return preflight_response(origin)
24
- end
22
+ return preflight_response(origin) if env['REQUEST_METHOD'] == 'OPTIONS'
25
23
 
26
24
  # Call app and add CORS headers
27
25
  status, headers, body = @app.call(env)
28
26
 
29
27
  # For wildcard '*', always add CORS headers even without Origin
30
28
  # For specific origins, only add when Origin header is present and allowed
31
- if @allowed_origins.include?('*') || (origin && origin_allowed?(origin))
32
- add_cors_headers(headers, origin)
33
- end
29
+ add_cors_headers(headers, origin) if @allowed_origins.include?('*') || (origin && origin_allowed?(origin))
34
30
 
35
31
  [status, headers, body]
36
32
  end
@@ -18,9 +18,7 @@ module Mathpix
18
18
  # Extract and validate token
19
19
  token = extract_token(env)
20
20
 
21
- if token.nil?
22
- return unauthorized_response('missing_token')
23
- end
21
+ return unauthorized_response('missing_token') if token.nil?
24
22
 
25
23
  begin
26
24
  payload = @oauth_provider.validate_token(token)
@@ -39,9 +37,7 @@ module Mathpix
39
37
  def extract_token(env)
40
38
  # Try Bearer token
41
39
  auth_header = env['HTTP_AUTHORIZATION']
42
- if auth_header&.start_with?('Bearer ')
43
- return auth_header.sub('Bearer ', '')
44
- end
40
+ return auth_header.sub('Bearer ', '') if auth_header&.start_with?('Bearer ')
45
41
 
46
42
  # Try X-API-Key header
47
43
  env['HTTP_X_API_KEY']
@@ -27,15 +27,13 @@ module Mathpix
27
27
  @window = window
28
28
  @cleanup_thread = start_cleanup_thread unless ENV['RACK_ENV'] == 'test'
29
29
 
30
- $stderr.puts "[RATE LIMIT] Middleware initialized: object_id=#{object_id}" if ENV['RACK_ENV'] == 'test'
30
+ warn "[RATE LIMIT] Middleware initialized: object_id=#{object_id}" if ENV['RACK_ENV'] == 'test'
31
31
  end
32
32
 
33
33
  def call(env)
34
34
  # Exempt /health endpoint from rate limiting (monitoring endpoint)
35
35
  request_path = env['PATH_INFO'] || env['REQUEST_PATH']
36
- if request_path == '/health'
37
- return @app.call(env)
38
- end
36
+ return @app.call(env) if request_path == '/health'
39
37
 
40
38
  client_id = extract_client_id(env)
41
39
 
@@ -43,7 +41,9 @@ module Mathpix
43
41
  if rate_limited?(client_id)
44
42
  retry_after = time_until_reset(client_id)
45
43
  bucket = @@buckets[client_id]
46
- $stderr.puts "[RATE LIMIT] LIMITING client #{client_id}, Count: #{bucket[:count]}/#{@limit}" if ENV['RACK_ENV'] == 'test'
44
+ if ENV['RACK_ENV'] == 'test'
45
+ warn "[RATE LIMIT] LIMITING client #{client_id}, Count: #{bucket[:count]}/#{@limit}"
46
+ end
47
47
  return rate_limit_response(retry_after)
48
48
  end
49
49
 
@@ -53,7 +53,7 @@ module Mathpix
53
53
  # Debug: show count AFTER incrementing
54
54
  if ENV['RACK_ENV'] == 'test'
55
55
  bucket = @@buckets[client_id]
56
- $stderr.puts "[RATE LIMIT] Client: #{client_id}, Count: #{bucket[:count]}/#{@limit}"
56
+ warn "[RATE LIMIT] Client: #{client_id}, Count: #{bucket[:count]}/#{@limit}"
57
57
  end
58
58
 
59
59
  @app.call(env)
@@ -70,7 +70,8 @@ module Mathpix
70
70
 
71
71
  def rate_limited?(client_id)
72
72
  bucket = @@buckets[client_id]
73
- return false unless bucket # Not rate limited if no bucket yet
73
+ return false unless bucket # Not rate limited if no bucket yet
74
+
74
75
  bucket[:count] >= @limit
75
76
  end
76
77
 
@@ -83,7 +84,7 @@ module Mathpix
83
84
  bucket = @@buckets[client_id]
84
85
  old_count = bucket[:count]
85
86
  bucket[:count] += 1
86
- $stderr.puts "[RATE LIMIT] record_request: #{old_count} -> #{bucket[:count]}" if ENV['RACK_ENV'] == 'test'
87
+ warn "[RATE LIMIT] record_request: #{old_count} -> #{bucket[:count]}" if ENV['RACK_ENV'] == 'test'
87
88
  end
88
89
  end
89
90
 
@@ -104,10 +105,10 @@ module Mathpix
104
105
  'X-RateLimit-Reset' => (Time.now + retry_after).to_i.to_s
105
106
  },
106
107
  [JSON.generate({
107
- error: 'rate_limit_exceeded',
108
- message: 'Too many requests',
109
- retry_after: retry_after
110
- })]
108
+ error: 'rate_limit_exceeded',
109
+ message: 'Too many requests',
110
+ retry_after: retry_after
111
+ })]
111
112
  ]
112
113
  end
113
114
 
@@ -126,12 +127,12 @@ module Mathpix
126
127
 
127
128
  def cleanup_expired_buckets
128
129
  now = Time.now
129
- @buckets.each_pair do |client_id, bucket|
130
- if bucket[:reset_at] <= now
131
- # Reset the bucket instead of deleting
132
- bucket[:count] = 0
133
- bucket[:reset_at] = now + @window
134
- end
130
+ @buckets.each_pair do |_client_id, bucket|
131
+ next unless bucket[:reset_at] <= now
132
+
133
+ # Reset the bucket instead of deleting
134
+ bucket[:count] = 0
135
+ bucket[:reset_at] = now + @window
135
136
  end
136
137
  end
137
138
  end
@@ -14,97 +14,97 @@ module Mathpix
14
14
  #
15
15
  # @param client [Mathpix::Client] optional client (not used, kept for consistency)
16
16
  # @return [Hash] resource content
17
- def self.fetch(client = nil)
17
+ def self.fetch(_client = nil)
18
18
  formats = {
19
19
  image_formats: [
20
20
  {
21
- name: "latex_styled",
22
- description: "LaTeX with styling commands",
23
- use_case: "Typesetting, display"
21
+ name: 'latex_styled',
22
+ description: 'LaTeX with styling commands',
23
+ use_case: 'Typesetting, display'
24
24
  },
25
25
  {
26
- name: "text",
27
- description: "Plain text representation",
28
- use_case: "Simple text extraction"
26
+ name: 'text',
27
+ description: 'Plain text representation',
28
+ use_case: 'Simple text extraction'
29
29
  },
30
30
  {
31
- name: "latex_list",
32
- description: "Array of LaTeX expressions",
33
- use_case: "Multiple equations"
31
+ name: 'latex_list',
32
+ description: 'Array of LaTeX expressions',
33
+ use_case: 'Multiple equations'
34
34
  },
35
35
  {
36
- name: "mathml",
37
- description: "MathML markup language",
38
- use_case: "Web display, accessibility"
36
+ name: 'mathml',
37
+ description: 'MathML markup language',
38
+ use_case: 'Web display, accessibility'
39
39
  },
40
40
  {
41
- name: "asciimath",
42
- description: "AsciiMath notation",
43
- use_case: "Simple math notation"
41
+ name: 'asciimath',
42
+ description: 'AsciiMath notation',
43
+ use_case: 'Simple math notation'
44
44
  },
45
45
  {
46
- name: "text_display",
47
- description: "Display-style text",
48
- use_case: "Large equation display"
46
+ name: 'text_display',
47
+ description: 'Display-style text',
48
+ use_case: 'Large equation display'
49
49
  },
50
50
  {
51
- name: "latex_simplified",
52
- description: "Simplified LaTeX",
53
- use_case: "Basic LaTeX output"
51
+ name: 'latex_simplified',
52
+ description: 'Simplified LaTeX',
53
+ use_case: 'Basic LaTeX output'
54
54
  },
55
55
  {
56
- name: "data",
57
- description: "Full response with metadata",
58
- use_case: "Complete API response"
56
+ name: 'data',
57
+ description: 'Full response with metadata',
58
+ use_case: 'Complete API response'
59
59
  },
60
60
  {
61
- name: "html",
62
- description: "HTML markup",
63
- use_case: "Web integration"
61
+ name: 'html',
62
+ description: 'HTML markup',
63
+ use_case: 'Web integration'
64
64
  }
65
65
  ],
66
66
  document_formats: [
67
67
  {
68
- name: "markdown",
69
- description: "Markdown document format",
70
- use_case: "Note-taking, documentation"
68
+ name: 'markdown',
69
+ description: 'Markdown document format',
70
+ use_case: 'Note-taking, documentation'
71
71
  },
72
72
  {
73
- name: "latex",
74
- description: "LaTeX document format",
75
- use_case: "Academic papers, typesetting"
73
+ name: 'latex',
74
+ description: 'LaTeX document format',
75
+ use_case: 'Academic papers, typesetting'
76
76
  },
77
77
  {
78
- name: "html",
79
- description: "HTML document format",
80
- use_case: "Web publishing"
78
+ name: 'html',
79
+ description: 'HTML document format',
80
+ use_case: 'Web publishing'
81
81
  },
82
82
  {
83
- name: "docx",
84
- description: "Microsoft Word document",
85
- use_case: "Word processing"
83
+ name: 'docx',
84
+ description: 'Microsoft Word document',
85
+ use_case: 'Word processing'
86
86
  },
87
87
  {
88
- name: "tex.zip",
89
- description: "LaTeX with figures (zipped)",
90
- use_case: "Complete LaTeX projects"
88
+ name: 'tex.zip',
89
+ description: 'LaTeX with figures (zipped)',
90
+ use_case: 'Complete LaTeX projects'
91
91
  }
92
92
  ]
93
93
  }
94
94
 
95
95
  {
96
- uri: "mathpix://formats/list",
97
- mime_type: "application/json",
96
+ uri: 'mathpix://formats/list',
97
+ mime_type: 'application/json',
98
98
  content: JSON.pretty_generate({
99
- success: true,
100
- image_formats_count: formats[:image_formats].length,
101
- document_formats_count: formats[:document_formats].length,
102
- formats: formats,
103
- usage: {
104
- image_capture: "Use with Mathpix.snap() or ConvertImageTool",
105
- document_conversion: "Use with Mathpix.document() or ConvertDocumentTool"
106
- }
107
- })
99
+ success: true,
100
+ image_formats_count: formats[:image_formats].length,
101
+ document_formats_count: formats[:document_formats].length,
102
+ formats: formats,
103
+ usage: {
104
+ image_capture: 'Use with Mathpix.snap() or ConvertImageTool',
105
+ document_conversion: 'Use with Mathpix.document() or ConvertDocumentTool'
106
+ }
107
+ })
108
108
  }
109
109
  end
110
110
  end
@@ -84,7 +84,7 @@ module Mathpix
84
84
  links[:first] = build_link(path, limit, 0, query_params)
85
85
 
86
86
  # Previous link (if not on first page)
87
- if offset > 0
87
+ if offset.positive?
88
88
  prev_offset = [offset - limit, 0].max
89
89
  links[:prev] = build_link(path, limit, prev_offset, query_params)
90
90
  end
@@ -168,9 +168,7 @@ module Mathpix
168
168
  return false if value == 'false'
169
169
 
170
170
  # Array (comma-separated)
171
- if value.include?(',')
172
- return value.split(',').map(&:strip)
173
- end
171
+ return value.split(',').map(&:strip) if value.include?(',')
174
172
 
175
173
  # String
176
174
  value
@@ -179,9 +177,7 @@ module Mathpix
179
177
  def validate_params(params)
180
178
  # Validate limit
181
179
  if params[:limit]
182
- unless params[:limit].is_a?(Integer)
183
- raise InvalidParameterError, 'limit must be a number'
184
- end
180
+ raise InvalidParameterError, 'limit must be a number' unless params[:limit].is_a?(Integer)
185
181
 
186
182
  unless params[:limit].between?(MIN_LIMIT, MAX_LIMIT)
187
183
  raise InvalidParameterError, "limit must be between #{MIN_LIMIT} and #{MAX_LIMIT}"
@@ -189,18 +185,13 @@ module Mathpix
189
185
  end
190
186
 
191
187
  # Validate offset
192
- if params[:offset]
193
- unless params[:offset].is_a?(Integer)
194
- raise InvalidParameterError, 'offset must be a number'
195
- end
196
- end
188
+ raise InvalidParameterError, 'offset must be a number' if params[:offset] && !params[:offset].is_a?(Integer)
197
189
 
198
190
  # Validate ID format (if present)
199
- if params[:id]
200
- unless params[:id] =~ /^[a-zA-Z0-9_-]+$/
201
- raise InvalidParameterError, 'id must be alphanumeric'
202
- end
203
- end
191
+ return unless params[:id]
192
+ return if params[:id] =~ /^[a-zA-Z0-9_-]+$/
193
+
194
+ raise InvalidParameterError, 'id must be alphanumeric'
204
195
  end
205
196
 
206
197
  def build_link(path, limit, offset, query_params)
@@ -228,7 +219,7 @@ module Mathpix
228
219
  { id: params[:id], line_number: params[:line_number] }
229
220
  end
230
221
 
231
- def list_snips(params)
222
+ def list_snips(_params)
232
223
  []
233
224
  end
234
225
  end
@@ -19,39 +19,39 @@ module Mathpix
19
19
 
20
20
  if recent.empty?
21
21
  {
22
- uri: "mathpix://snip/recent/latest",
23
- mime_type: "application/json",
22
+ uri: 'mathpix://snip/recent/latest',
23
+ mime_type: 'application/json',
24
24
  content: JSON.pretty_generate({
25
- success: false,
26
- message: "No recent captures found"
27
- })
25
+ success: false,
26
+ message: 'No recent captures found'
27
+ })
28
28
  }
29
29
  else
30
30
  result = recent.first
31
31
  {
32
- uri: "mathpix://snip/recent/latest",
33
- mime_type: "application/json",
32
+ uri: 'mathpix://snip/recent/latest',
33
+ mime_type: 'application/json',
34
34
  content: JSON.pretty_generate({
35
- success: true,
36
- id: result.request_id,
37
- created_at: result.timestamp,
38
- latex: result.latex,
39
- text: result.text,
40
- confidence: result.confidence,
41
- is_printed: result.printed?,
42
- is_handwritten: result.handwritten?,
43
- position: result.position
44
- })
35
+ success: true,
36
+ id: result.request_id,
37
+ created_at: result.timestamp,
38
+ latex: result.latex,
39
+ text: result.text,
40
+ confidence: result.confidence,
41
+ is_printed: result.printed?,
42
+ is_handwritten: result.handwritten?,
43
+ position: result.position
44
+ })
45
45
  }
46
46
  end
47
47
  rescue Mathpix::Error => e
48
48
  {
49
- uri: "mathpix://snip/recent/latest",
50
- mime_type: "application/json",
49
+ uri: 'mathpix://snip/recent/latest',
50
+ mime_type: 'application/json',
51
51
  content: JSON.pretty_generate({
52
- success: false,
53
- error: e.message
54
- })
52
+ success: false,
53
+ error: e.message
54
+ })
55
55
  }
56
56
  end
57
57
  end
@@ -40,22 +40,22 @@ module Mathpix
40
40
 
41
41
  {
42
42
  uri: "mathpix://snip/recent?limit=#{limit}",
43
- mime_type: "application/json",
43
+ mime_type: 'application/json',
44
44
  content: JSON.pretty_generate({
45
- success: true,
46
- limit: limit,
47
- count: results.length,
48
- results: results
49
- })
45
+ success: true,
46
+ limit: limit,
47
+ count: results.length,
48
+ results: results
49
+ })
50
50
  }
51
51
  rescue Mathpix::Error => e
52
52
  {
53
53
  uri: "mathpix://snip/recent?limit=#{limit}",
54
- mime_type: "application/json",
54
+ mime_type: 'application/json',
55
55
  content: JSON.pretty_generate({
56
- success: false,
57
- error: e.message
58
- })
56
+ success: false,
57
+ error: e.message
58
+ })
59
59
  }
60
60
  end
61
61
 
@@ -67,6 +67,7 @@ module Mathpix
67
67
  def self.truncate(text, max_length)
68
68
  return nil unless text
69
69
  return text if text.length <= max_length
70
+
70
71
  "#{text[0...max_length]}..."
71
72
  end
72
73
  end
@@ -27,22 +27,22 @@ module Mathpix
27
27
  stats = compute_stats(recent)
28
28
 
29
29
  {
30
- uri: "mathpix://snip/stats",
31
- mime_type: "application/json",
30
+ uri: 'mathpix://snip/stats',
31
+ mime_type: 'application/json',
32
32
  content: JSON.pretty_generate({
33
- success: true,
34
- period: "last_100_captures",
35
- stats: stats
36
- })
33
+ success: true,
34
+ period: 'last_100_captures',
35
+ stats: stats
36
+ })
37
37
  }
38
38
  rescue Mathpix::Error => e
39
39
  {
40
- uri: "mathpix://snip/stats",
41
- mime_type: "application/json",
40
+ uri: 'mathpix://snip/stats',
41
+ mime_type: 'application/json',
42
42
  content: JSON.pretty_generate({
43
- success: false,
44
- error: e.message
45
- })
43
+ success: false,
44
+ error: e.message
45
+ })
46
46
  }
47
47
  end
48
48
 
@@ -67,7 +67,9 @@ module Mathpix
67
67
  average_confidence: avg_confidence.round(3),
68
68
  confidence_distribution: {
69
69
  high: confidences.count { |c| c >= Mathpix::Configuration::CONFIDENCE_HIGH },
70
- medium: confidences.count { |c| c >= Mathpix::Configuration::CONFIDENCE_MEDIUM && c < Mathpix::Configuration::CONFIDENCE_HIGH },
70
+ medium: confidences.count do |c|
71
+ c >= Mathpix::Configuration::CONFIDENCE_MEDIUM && c < Mathpix::Configuration::CONFIDENCE_HIGH
72
+ end,
71
73
  low: confidences.count { |c| c < Mathpix::Configuration::CONFIDENCE_MEDIUM }
72
74
  }
73
75
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  begin
4
4
  require 'mcp'
5
- require 'mcp/transports/stdio' # Transport classes not auto-loaded
5
+ require 'mcp/transports/stdio' # Transport classes not auto-loaded
6
6
  rescue LoadError
7
7
  raise LoadError, <<~ERROR
8
8
  The 'mcp' gem is required for MCP server functionality.
@@ -52,7 +52,7 @@ module Mathpix
52
52
  # @param name [String] server name
53
53
  # @param version [String] server version
54
54
  # @param mathpix_client [Mathpix::Client] optional client instance
55
- def initialize(name: "mathpix", version: Mathpix::VERSION, mathpix_client: nil)
55
+ def initialize(name: 'mathpix', version: Mathpix::VERSION, mathpix_client: nil)
56
56
  @name = name
57
57
  @version = version
58
58
  @mathpix_client = mathpix_client || Mathpix.client
@@ -144,28 +144,28 @@ module Mathpix
144
144
  def resource_specs
145
145
  [
146
146
  {
147
- uri: "mathpix://snip/recent/latest",
148
- name: "latest-snip",
149
- description: "Most recent capture result",
150
- mime_type: "application/json"
147
+ uri: 'mathpix://snip/recent/latest',
148
+ name: 'latest-snip',
149
+ description: 'Most recent capture result',
150
+ mime_type: 'application/json'
151
151
  },
152
152
  {
153
- uri: "mathpix://snip/stats",
154
- name: "snip-stats",
155
- description: "Overall capture statistics",
156
- mime_type: "application/json"
153
+ uri: 'mathpix://snip/stats',
154
+ name: 'snip-stats',
155
+ description: 'Overall capture statistics',
156
+ mime_type: 'application/json'
157
157
  },
158
158
  {
159
- uri: "mathpix://snip/recent?limit=10",
160
- name: "recent-snips",
161
- description: "Recent capture results (default limit 10)",
162
- mime_type: "application/json"
159
+ uri: 'mathpix://snip/recent?limit=10',
160
+ name: 'recent-snips',
161
+ description: 'Recent capture results (default limit 10)',
162
+ mime_type: 'application/json'
163
163
  },
164
164
  {
165
- uri: "mathpix://formats/list",
166
- name: "formats-list",
167
- description: "Available output formats",
168
- mime_type: "application/json"
165
+ uri: 'mathpix://formats/list',
166
+ name: 'formats-list',
167
+ description: 'Available output formats',
168
+ mime_type: 'application/json'
169
169
  }
170
170
  ]
171
171
  end