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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE +21 -0
  4. data/README.md +171 -0
  5. data/SECURITY.md +137 -0
  6. data/lib/mathpix/balanced_ternary.rb +86 -0
  7. data/lib/mathpix/batch.rb +155 -0
  8. data/lib/mathpix/capture_builder.rb +142 -0
  9. data/lib/mathpix/chemistry.rb +69 -0
  10. data/lib/mathpix/client.rb +439 -0
  11. data/lib/mathpix/configuration.rb +187 -0
  12. data/lib/mathpix/configuration.rb.backup +125 -0
  13. data/lib/mathpix/conversion.rb +257 -0
  14. data/lib/mathpix/document.rb +320 -0
  15. data/lib/mathpix/errors.rb +78 -0
  16. data/lib/mathpix/mcp/auth/oauth_provider.rb +346 -0
  17. data/lib/mathpix/mcp/auth/token_manager.rb +31 -0
  18. data/lib/mathpix/mcp/auth.rb +18 -0
  19. data/lib/mathpix/mcp/base_tool.rb +117 -0
  20. data/lib/mathpix/mcp/elicitations/ambiguity_elicitation.rb +162 -0
  21. data/lib/mathpix/mcp/elicitations/base_elicitation.rb +141 -0
  22. data/lib/mathpix/mcp/elicitations/confidence_elicitation.rb +162 -0
  23. data/lib/mathpix/mcp/elicitations.rb +78 -0
  24. data/lib/mathpix/mcp/middleware/cors_middleware.rb +94 -0
  25. data/lib/mathpix/mcp/middleware/oauth_middleware.rb +72 -0
  26. data/lib/mathpix/mcp/middleware/rate_limiting_middleware.rb +140 -0
  27. data/lib/mathpix/mcp/middleware.rb +13 -0
  28. data/lib/mathpix/mcp/resources/formats_list_resource.rb +113 -0
  29. data/lib/mathpix/mcp/resources/hierarchical_router.rb +237 -0
  30. data/lib/mathpix/mcp/resources/latest_snip_resource.rb +60 -0
  31. data/lib/mathpix/mcp/resources/recent_snips_resource.rb +75 -0
  32. data/lib/mathpix/mcp/resources/snip_stats_resource.rb +78 -0
  33. data/lib/mathpix/mcp/resources.rb +15 -0
  34. data/lib/mathpix/mcp/server.rb +174 -0
  35. data/lib/mathpix/mcp/tools/batch_convert_tool.rb +106 -0
  36. data/lib/mathpix/mcp/tools/check_document_status_tool.rb +66 -0
  37. data/lib/mathpix/mcp/tools/convert_document_tool.rb +90 -0
  38. data/lib/mathpix/mcp/tools/convert_image_tool.rb +91 -0
  39. data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +82 -0
  40. data/lib/mathpix/mcp/tools/get_account_info_tool.rb +57 -0
  41. data/lib/mathpix/mcp/tools/get_usage_tool.rb +62 -0
  42. data/lib/mathpix/mcp/tools/list_formats_tool.rb +81 -0
  43. data/lib/mathpix/mcp/tools/search_results_tool.rb +111 -0
  44. data/lib/mathpix/mcp/transports/http_streaming_transport.rb +622 -0
  45. data/lib/mathpix/mcp/transports/sse_stream_handler.rb +236 -0
  46. data/lib/mathpix/mcp/transports.rb +12 -0
  47. data/lib/mathpix/mcp.rb +52 -0
  48. data/lib/mathpix/result.rb +364 -0
  49. data/lib/mathpix/version.rb +22 -0
  50. data/lib/mathpix.rb +229 -0
  51. metadata +283 -0
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Resources
6
+ # Formats List Resource Handler
7
+ #
8
+ # Returns available output formats for Mathpix OCR
9
+ # URI: mathpix://formats/list
10
+ #
11
+ # The geodesic path: resource handler + static data
12
+ module FormatsListResource
13
+ # Fetch formats list
14
+ #
15
+ # @param client [Mathpix::Client] optional client (not used, kept for consistency)
16
+ # @return [Hash] resource content
17
+ def self.fetch(client = nil)
18
+ formats = {
19
+ image_formats: [
20
+ {
21
+ name: "latex_styled",
22
+ description: "LaTeX with styling commands",
23
+ use_case: "Typesetting, display"
24
+ },
25
+ {
26
+ name: "text",
27
+ description: "Plain text representation",
28
+ use_case: "Simple text extraction"
29
+ },
30
+ {
31
+ name: "latex_list",
32
+ description: "Array of LaTeX expressions",
33
+ use_case: "Multiple equations"
34
+ },
35
+ {
36
+ name: "mathml",
37
+ description: "MathML markup language",
38
+ use_case: "Web display, accessibility"
39
+ },
40
+ {
41
+ name: "asciimath",
42
+ description: "AsciiMath notation",
43
+ use_case: "Simple math notation"
44
+ },
45
+ {
46
+ name: "text_display",
47
+ description: "Display-style text",
48
+ use_case: "Large equation display"
49
+ },
50
+ {
51
+ name: "latex_simplified",
52
+ description: "Simplified LaTeX",
53
+ use_case: "Basic LaTeX output"
54
+ },
55
+ {
56
+ name: "data",
57
+ description: "Full response with metadata",
58
+ use_case: "Complete API response"
59
+ },
60
+ {
61
+ name: "html",
62
+ description: "HTML markup",
63
+ use_case: "Web integration"
64
+ }
65
+ ],
66
+ document_formats: [
67
+ {
68
+ name: "markdown",
69
+ description: "Markdown document format",
70
+ use_case: "Note-taking, documentation"
71
+ },
72
+ {
73
+ name: "latex",
74
+ description: "LaTeX document format",
75
+ use_case: "Academic papers, typesetting"
76
+ },
77
+ {
78
+ name: "html",
79
+ description: "HTML document format",
80
+ use_case: "Web publishing"
81
+ },
82
+ {
83
+ name: "docx",
84
+ description: "Microsoft Word document",
85
+ use_case: "Word processing"
86
+ },
87
+ {
88
+ name: "tex.zip",
89
+ description: "LaTeX with figures (zipped)",
90
+ use_case: "Complete LaTeX projects"
91
+ }
92
+ ]
93
+ }
94
+
95
+ {
96
+ uri: "mathpix://formats/list",
97
+ mime_type: "application/json",
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
+ })
108
+ }
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Mathpix
6
+ module MCP
7
+ module Resources
8
+ # Hierarchical URI router with parameter extraction
9
+ class HierarchicalRouter
10
+ attr_reader :client, :routes
11
+
12
+ MIN_LIMIT = 1
13
+ MAX_LIMIT = 100
14
+
15
+ def initialize(client)
16
+ @client = client
17
+ @routes = {}
18
+ end
19
+
20
+ def register_route(pattern:, handler:, methods:, description: nil, params: nil)
21
+ @routes[pattern] = {
22
+ handler: handler,
23
+ methods: Array(methods),
24
+ description: description,
25
+ params: params
26
+ }
27
+ end
28
+
29
+ def match_route(path, method)
30
+ @routes.each do |pattern, route|
31
+ next unless route[:methods].include?(method)
32
+
33
+ params = extract_params(pattern, path)
34
+ next if params.nil?
35
+
36
+ return {
37
+ handler: route[:handler],
38
+ params: params,
39
+ route: route
40
+ }
41
+ end
42
+
43
+ nil
44
+ end
45
+
46
+ def route_request(path, method, query_params)
47
+ match = match_route(path, method)
48
+ raise RouteNotFoundError, "No route matches #{method.upcase} #{path}" if match.nil?
49
+
50
+ # Merge URI params with query params
51
+ all_params = match[:params].merge(query_params)
52
+
53
+ # Validate parameters
54
+ validate_params(all_params)
55
+
56
+ # Call handler method
57
+ send(match[:handler], all_params)
58
+ end
59
+
60
+ def parse_query_params(query_string)
61
+ return {} if query_string.nil? || query_string.empty?
62
+
63
+ params = {}
64
+
65
+ query_string.split('&').each do |param|
66
+ key, value = param.split('=', 2)
67
+ key = key.to_sym
68
+ value = CGI.unescape(value) if value
69
+
70
+ # Type coercion
71
+ params[key] = coerce_value(value)
72
+ end
73
+
74
+ params
75
+ end
76
+
77
+ def pagination_links(path:, limit:, offset:, total:, query_params:)
78
+ links = {}
79
+
80
+ # Self link
81
+ links[:self] = build_link(path, limit, offset, query_params)
82
+
83
+ # First link
84
+ links[:first] = build_link(path, limit, 0, query_params)
85
+
86
+ # Previous link (if not on first page)
87
+ if offset > 0
88
+ prev_offset = [offset - limit, 0].max
89
+ links[:prev] = build_link(path, limit, prev_offset, query_params)
90
+ end
91
+
92
+ # Next link (if not on last page)
93
+ if offset + limit < total
94
+ next_offset = offset + limit
95
+ links[:next] = build_link(path, limit, next_offset, query_params)
96
+ end
97
+
98
+ # Last link
99
+ last_offset = ((total - 1) / limit) * limit
100
+ links[:last] = build_link(path, limit, last_offset, query_params)
101
+
102
+ links
103
+ end
104
+
105
+ def select_fields(data, fields_param)
106
+ return data if fields_param.nil?
107
+
108
+ fields = fields_param.split(',').map(&:strip).map(&:to_sym)
109
+ data.select { |k, _| fields.include?(k) }
110
+ end
111
+
112
+ def relationship_links(resource)
113
+ links = {
114
+ self: "/snip/#{resource[:id]}"
115
+ }
116
+
117
+ links[:batch] = "/batch/#{resource[:batch_id]}" if resource[:batch_id]
118
+
119
+ links
120
+ end
121
+
122
+ def uri_templates
123
+ @routes.map do |pattern, route|
124
+ {
125
+ template: pattern.gsub(/:(\w+)/, '{\1}'),
126
+ description: route[:description],
127
+ methods: route[:methods],
128
+ params: route[:params]
129
+ }
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ def extract_params(pattern, path)
136
+ pattern_parts = pattern.split('/')
137
+ path_parts = path.split('/')
138
+
139
+ return nil unless pattern_parts.length == path_parts.length
140
+
141
+ params = {}
142
+
143
+ pattern_parts.each_with_index do |part, index|
144
+ if part.start_with?(':')
145
+ param_name = part[1..].to_sym
146
+ param_value = path_parts[index]
147
+
148
+ # Validate ID format (alphanumeric, underscores, hyphens)
149
+ return nil unless param_value =~ /^[a-zA-Z0-9_-]+$/
150
+
151
+ params[param_name] = param_value
152
+ else
153
+ return nil unless part == path_parts[index]
154
+ end
155
+ end
156
+
157
+ params
158
+ end
159
+
160
+ def coerce_value(value)
161
+ return nil if value.nil?
162
+
163
+ # Integer
164
+ return value.to_i if value =~ /^\d+$/
165
+
166
+ # Boolean
167
+ return true if value == 'true'
168
+ return false if value == 'false'
169
+
170
+ # Array (comma-separated)
171
+ if value.include?(',')
172
+ return value.split(',').map(&:strip)
173
+ end
174
+
175
+ # String
176
+ value
177
+ end
178
+
179
+ def validate_params(params)
180
+ # Validate limit
181
+ if params[:limit]
182
+ unless params[:limit].is_a?(Integer)
183
+ raise InvalidParameterError, 'limit must be a number'
184
+ end
185
+
186
+ unless params[:limit].between?(MIN_LIMIT, MAX_LIMIT)
187
+ raise InvalidParameterError, "limit must be between #{MIN_LIMIT} and #{MAX_LIMIT}"
188
+ end
189
+ end
190
+
191
+ # 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
197
+
198
+ # 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
204
+ end
205
+
206
+ def build_link(path, limit, offset, query_params)
207
+ params = query_params.dup
208
+ params[:limit] = limit
209
+ params[:offset] = offset
210
+
211
+ query_string = params.map { |k, v| "#{k}=#{v}" }.join('&')
212
+ "#{path}?#{query_string}"
213
+ end
214
+
215
+ # Placeholder handler methods
216
+ # These would be implemented based on actual resource handlers
217
+
218
+ def get_snip(params)
219
+ # Delegate to actual resource handler
220
+ { id: params[:id], placeholder: true }
221
+ end
222
+
223
+ def get_snip_metadata(params)
224
+ { id: params[:id], metadata: true }
225
+ end
226
+
227
+ def get_line(params)
228
+ { id: params[:id], line_number: params[:line_number] }
229
+ end
230
+
231
+ def list_snips(params)
232
+ []
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Resources
6
+ # Latest Snip Resource Handler
7
+ #
8
+ # Returns the most recent capture result
9
+ # URI: mathpix://snip/recent/latest
10
+ #
11
+ # The geodesic path: resource handler + client delegation
12
+ module LatestSnipResource
13
+ # Fetch latest snip data
14
+ #
15
+ # @param client [Mathpix::Client] Mathpix API client
16
+ # @return [Hash] resource content
17
+ def self.fetch(client)
18
+ recent = client.recent(limit: 1)
19
+
20
+ if recent.empty?
21
+ {
22
+ uri: "mathpix://snip/recent/latest",
23
+ mime_type: "application/json",
24
+ content: JSON.pretty_generate({
25
+ success: false,
26
+ message: "No recent captures found"
27
+ })
28
+ }
29
+ else
30
+ result = recent.first
31
+ {
32
+ uri: "mathpix://snip/recent/latest",
33
+ mime_type: "application/json",
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
+ })
45
+ }
46
+ end
47
+ rescue Mathpix::Error => e
48
+ {
49
+ uri: "mathpix://snip/recent/latest",
50
+ mime_type: "application/json",
51
+ content: JSON.pretty_generate({
52
+ success: false,
53
+ error: e.message
54
+ })
55
+ }
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Resources
6
+ # Recent Snips Resource Handler
7
+ #
8
+ # Returns recent capture results with pagination
9
+ # URI: mathpix://snip/recent?limit=N
10
+ #
11
+ # The geodesic path: resource handler + client delegation
12
+ module RecentSnipsResource
13
+ # Fetch recent snips data
14
+ #
15
+ # @param client [Mathpix::Client] Mathpix API client
16
+ # @param limit [Integer] number of results (default: 10, max: 100)
17
+ # @return [Hash] resource content
18
+ def self.fetch(client, limit: 10)
19
+ config = client.config
20
+
21
+ # Use configuration bounds instead of magic numbers
22
+ limit = config.sanitize_limit(limit)
23
+
24
+ # Log the request
25
+ config.log(:info, 'Fetching recent snips', { limit: limit })
26
+
27
+ recent = client.recent(limit: limit)
28
+
29
+ results = recent.map do |result|
30
+ {
31
+ id: result.request_id,
32
+ created_at: result.timestamp,
33
+ latex_preview: truncate(result.latex, 100),
34
+ text_preview: truncate(result.text, 100),
35
+ confidence: result.confidence,
36
+ is_printed: result.printed?,
37
+ is_handwritten: result.handwritten?
38
+ }
39
+ end
40
+
41
+ {
42
+ uri: "mathpix://snip/recent?limit=#{limit}",
43
+ mime_type: "application/json",
44
+ content: JSON.pretty_generate({
45
+ success: true,
46
+ limit: limit,
47
+ count: results.length,
48
+ results: results
49
+ })
50
+ }
51
+ rescue Mathpix::Error => e
52
+ {
53
+ uri: "mathpix://snip/recent?limit=#{limit}",
54
+ mime_type: "application/json",
55
+ content: JSON.pretty_generate({
56
+ success: false,
57
+ error: e.message
58
+ })
59
+ }
60
+ end
61
+
62
+ # Truncate text for preview
63
+ #
64
+ # @param text [String, nil] text to truncate
65
+ # @param max_length [Integer] maximum length
66
+ # @return [String, nil] truncated text
67
+ def self.truncate(text, max_length)
68
+ return nil unless text
69
+ return text if text.length <= max_length
70
+ "#{text[0...max_length]}..."
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Resources
6
+ # Snip Stats Resource Handler
7
+ #
8
+ # Returns overall capture statistics
9
+ # URI: mathpix://snip/stats
10
+ #
11
+ # The geodesic path: resource handler + client delegation
12
+ module SnipStatsResource
13
+ # Fetch snip statistics
14
+ #
15
+ # @param client [Mathpix::Client] Mathpix API client
16
+ # @return [Hash] resource content
17
+ def self.fetch(client)
18
+ config = client.config
19
+
20
+ # Log the request
21
+ config.log(:info, 'Fetching snip statistics', { limit: config.max_limit })
22
+
23
+ # Get recent captures to compute stats
24
+ recent = client.recent(limit: config.max_limit)
25
+
26
+ # Compute statistics
27
+ stats = compute_stats(recent)
28
+
29
+ {
30
+ uri: "mathpix://snip/stats",
31
+ mime_type: "application/json",
32
+ content: JSON.pretty_generate({
33
+ success: true,
34
+ period: "last_100_captures",
35
+ stats: stats
36
+ })
37
+ }
38
+ rescue Mathpix::Error => e
39
+ {
40
+ uri: "mathpix://snip/stats",
41
+ mime_type: "application/json",
42
+ content: JSON.pretty_generate({
43
+ success: false,
44
+ error: e.message
45
+ })
46
+ }
47
+ end
48
+
49
+ # Compute statistics from results
50
+ #
51
+ # @param results [Array<Mathpix::Result>] recent results
52
+ # @return [Hash] statistics
53
+ def self.compute_stats(results)
54
+ return { total: 0 } if results.empty?
55
+
56
+ total = results.length
57
+ printed_count = results.count(&:printed?)
58
+ handwritten_count = results.count(&:handwritten?)
59
+
60
+ confidences = results.map(&:confidence).compact
61
+ avg_confidence = confidences.empty? ? 0.0 : (confidences.sum / confidences.length.to_f)
62
+
63
+ {
64
+ total: total,
65
+ printed: printed_count,
66
+ handwritten: handwritten_count,
67
+ average_confidence: avg_confidence.round(3),
68
+ confidence_distribution: {
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 },
71
+ low: confidences.count { |c| c < Mathpix::Configuration::CONFIDENCE_MEDIUM }
72
+ }
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Resources
6
+ # Custom error classes
7
+ class ResourceError < StandardError; end
8
+ class RouteNotFoundError < ResourceError; end
9
+ class ResourceNotFoundError < ResourceError; end
10
+ class InvalidParameterError < ResourceError; end
11
+ end
12
+ end
13
+ end
14
+
15
+ require_relative 'resources/hierarchical_router'