mathpix-mcp 1.0.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.
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require_relative '../base_tool'
5
+
6
+ module Mathpix
7
+ module MCP
8
+ module Tools
9
+ # Convert Document Tool
10
+ #
11
+ # Converts documents (PDF, DOCX, PPTX) to Markdown, LaTeX, or other formats
12
+ # Thin delegate to Mathpix::Document
13
+ class ConvertDocumentTool < BaseTool
14
+ description 'Convert document (PDF, DOCX, PPTX) to Markdown, LaTeX, HTML, or other formats using Mathpix OCR'
15
+
16
+ # Above this many characters, returning the converted content inline
17
+ # would risk overflowing the LLM context window, so the result is
18
+ # written to a file and only a path + preview is returned.
19
+ DEFAULT_MAX_INLINE_CHARS = 50_000
20
+
21
+ # Characters of the converted markdown to include as a preview when the
22
+ # full content is written to a file instead of returned inline.
23
+ PREVIEW_CHARS = 2_000
24
+
25
+ input_schema(
26
+ properties: {
27
+ document_path: {
28
+ type: 'string',
29
+ description: 'Path to document file or URL (PDF, DOCX, or PPTX)'
30
+ },
31
+ formats: {
32
+ type: 'array',
33
+ items: { type: 'string' },
34
+ description: 'Output formats: markdown, latex, html, docx (default: markdown)'
35
+ },
36
+ include_tables: {
37
+ type: 'boolean',
38
+ description: 'Include table extraction as HTML'
39
+ },
40
+ output_path: {
41
+ type: 'string',
42
+ description: 'If set, write the converted output to this file (markdown). Other ' \
43
+ 'formats are written alongside with matching extensions. The response ' \
44
+ 'then returns file paths + a short preview instead of the full content.'
45
+ },
46
+ max_inline_chars: {
47
+ type: 'number',
48
+ description: 'Maximum characters to return inline before auto-saving to a file to ' \
49
+ "avoid exceeding the model context (default: #{DEFAULT_MAX_INLINE_CHARS}). " \
50
+ 'Ignored when output_path is set.'
51
+ },
52
+ max_wait: {
53
+ type: 'number',
54
+ description: 'Maximum wait time in seconds for conversion (default: 600)'
55
+ },
56
+ poll_interval: {
57
+ type: 'number',
58
+ description: 'Polling interval in seconds (default: 3.0)'
59
+ }
60
+ },
61
+ required: ['document_path']
62
+ )
63
+
64
+ def self.call(document_path:, server_context:, formats: nil, include_tables: false,
65
+ output_path: nil, max_inline_chars: DEFAULT_MAX_INLINE_CHARS,
66
+ max_wait: 600, poll_interval: 3.0)
67
+ safe_execute do
68
+ client = mathpix_client(server_context)
69
+
70
+ # Normalize path
71
+ document_path = normalize_path(document_path) unless url?(document_path)
72
+
73
+ # Extract formats or use defaults
74
+ output_formats = extract_formats(formats, client)
75
+
76
+ # Use Document class (new unified interface)
77
+ doc = Mathpix::Document.new(client, document_path)
78
+ doc.with_formats(*output_formats)
79
+ doc.with_tables if include_tables
80
+
81
+ # Start conversion and wait for completion
82
+ conversion = doc.convert
83
+ conversion.wait_until_complete(max_wait: max_wait, poll_interval: poll_interval)
84
+ result = conversion.result
85
+
86
+ contents = {
87
+ markdown: result.markdown,
88
+ latex: result.latex,
89
+ html: result.html
90
+ }.compact
91
+
92
+ response_data = {
93
+ success: true,
94
+ document_path: document_path,
95
+ formats: output_formats,
96
+ conversion_id: conversion.conversion_id,
97
+ metadata: {
98
+ document_type: conversion.document_type,
99
+ pages: result.page_count,
100
+ processing_time: result.processing_time
101
+ }
102
+ }
103
+
104
+ total_chars = contents.values.sum(&:length)
105
+
106
+ if output_path
107
+ # Explicit save requested.
108
+ response_data[:saved_files] = save_contents(contents, File.expand_path(output_path))
109
+ response_data[:preview] = preview_of(result.markdown)
110
+ elsif total_chars <= max_inline_chars
111
+ # Small enough to return inline.
112
+ response_data[:results] = contents
113
+ else
114
+ # Too large to inline safely — auto-save to a temp file so the
115
+ # model's context isn't blown out.
116
+ default_path = File.join(Dir.tmpdir, "mathpix_#{sanitize(conversion.conversion_id)}.md")
117
+ response_data[:saved_files] = save_contents(contents, default_path)
118
+ response_data[:preview] = preview_of(result.markdown)
119
+ response_data[:note] =
120
+ "Converted output is #{total_chars} characters, which exceeds max_inline_chars " \
121
+ "(#{max_inline_chars}); it was written to a file to avoid exceeding the model " \
122
+ 'context. Read the file at saved_files for the full content, pass output_path to ' \
123
+ 'choose the destination, or raise max_inline_chars to force inline output.'
124
+ end
125
+
126
+ json_response(response_data)
127
+ end
128
+ end
129
+
130
+ # Write each available format to disk, deriving sibling paths for the
131
+ # non-markdown formats from the markdown target's name.
132
+ #
133
+ # @param contents [Hash{Symbol=>String}] format => content
134
+ # @param markdown_path [String] target path for the markdown output
135
+ # @return [Hash{Symbol=>Hash}] format => { path:, bytes: }
136
+ def self.save_contents(contents, markdown_path)
137
+ ext_for = { markdown: nil, latex: 'tex', html: 'html' }
138
+ saved = {}
139
+
140
+ contents.each do |format, content|
141
+ path =
142
+ if format == :markdown
143
+ markdown_path
144
+ else
145
+ sibling_path(markdown_path, ext_for[format] || format.to_s)
146
+ end
147
+
148
+ File.write(path, content)
149
+ saved[format] = { path: path, bytes: content.bytesize }
150
+ end
151
+
152
+ saved
153
+ end
154
+
155
+ # Derive a sibling path with a different extension.
156
+ def self.sibling_path(base, ext)
157
+ dir = File.dirname(base)
158
+ stem = File.basename(base, File.extname(base))
159
+ File.join(dir, "#{stem}.#{ext}")
160
+ end
161
+
162
+ # First PREVIEW_CHARS characters of the content, if any.
163
+ def self.preview_of(content)
164
+ return nil unless content
165
+
166
+ content.length > PREVIEW_CHARS ? "#{content[0, PREVIEW_CHARS]}…" : content
167
+ end
168
+
169
+ # Make a conversion id safe to use in a filename.
170
+ def self.sanitize(value)
171
+ value.to_s.gsub(/[^a-zA-Z0-9_-]/, '_')
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+
5
+ module Mathpix
6
+ module MCP
7
+ module Tools
8
+ # Convert Image Tool
9
+ #
10
+ # Converts images (PNG, JPG, etc.) to LaTeX, text, or other formats
11
+ # Thin delegate to Mathpix::Client#snap
12
+ class ConvertImageTool < BaseTool
13
+ description 'Convert image (PNG, JPG, etc.) to LaTeX, text, or other formats using Mathpix OCR'
14
+
15
+ input_schema(
16
+ properties: {
17
+ image_path: {
18
+ type: 'string',
19
+ description: 'Path to image file or URL (http:// or https://)'
20
+ },
21
+ formats: {
22
+ type: 'array',
23
+ items: { type: 'string' },
24
+ description: 'Output formats: latex, text, mathml, asciimath, latex_styled, text_display, data, html (default: latex_styled, text)'
25
+ },
26
+ include_line_data: {
27
+ type: 'boolean',
28
+ description: 'Include line-level bounding boxes in response'
29
+ },
30
+ include_word_data: {
31
+ type: 'boolean',
32
+ description: 'Include word-level bounding boxes in response'
33
+ },
34
+ confidence_threshold: {
35
+ type: 'number',
36
+ description: 'Minimum confidence threshold (0.0-1.0)'
37
+ },
38
+ output_path: {
39
+ type: 'string',
40
+ description: 'Where to write the OCR result. The recognized content is always ' \
41
+ 'saved to a file (never returned inline); defaults to MATHPIX_OUTPUT_DIR ' \
42
+ 'or the system temp dir.'
43
+ }
44
+ },
45
+ required: ['image_path']
46
+ )
47
+
48
+ def self.call(image_path:, server_context:, formats: nil, include_line_data: false, include_word_data: false,
49
+ confidence_threshold: nil, output_path: nil)
50
+ safe_execute do
51
+ client = mathpix_client(server_context)
52
+
53
+ # Normalize path (expand ~, resolve relative paths)
54
+ image_path = normalize_path(image_path) unless url?(image_path)
55
+
56
+ # Extract formats or use defaults
57
+ output_formats = extract_formats(formats, client)
58
+
59
+ # Delegate to core gem
60
+ result = client.snap(image_path,
61
+ **snap_options(output_formats, include_line_data, include_word_data,
62
+ confidence_threshold))
63
+
64
+ # Write recognized content (and any requested bounding-box data) to
65
+ # files so it never enters the model context.
66
+ contents = artifact_contents(result, include_line_data, include_word_data)
67
+ stem = url?(image_path) ? 'image' : File.basename(image_path, File.extname(image_path))
68
+ base = output_path && !output_path.empty? ? output_path : default_artifact_path(stem, 'tex')
69
+
70
+ json_response(
71
+ success: true,
72
+ image_path: image_path,
73
+ formats: output_formats,
74
+ confidence: result.confidence,
75
+ is_printed: result.printed?,
76
+ is_handwritten: result.handwritten?,
77
+ saved_files: write_artifacts(contents, base),
78
+ preview: preview_of(result.latex || result.text)
79
+ )
80
+ end
81
+ end
82
+
83
+ # Build the options hash passed to Client#snap.
84
+ def self.snap_options(formats, include_line_data, include_word_data, confidence_threshold)
85
+ options = { formats: formats }
86
+ options[:include_line_data] = true if include_line_data
87
+ options[:include_word_data] = true if include_word_data
88
+ options[:confidence_threshold] = confidence_threshold if confidence_threshold
89
+ options
90
+ end
91
+
92
+ # Available OCR formats (and optional bounding-box data) as a
93
+ # format => content hash.
94
+ def self.artifact_contents(result, include_line_data, include_word_data)
95
+ contents = {
96
+ 'latex' => result.latex,
97
+ 'text' => result.text,
98
+ 'mathml' => result.mathml,
99
+ 'asciimath' => result.asciimath
100
+ }.compact
101
+ contents['line_data'] = result.line_data if include_line_data && !result.line_data.empty?
102
+ contents['word_data'] = result.word_data if include_word_data && !result.word_data.empty?
103
+ contents
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+
5
+ module Mathpix
6
+ module MCP
7
+ module Tools
8
+ # Convert Strokes Tool
9
+ #
10
+ # Converts handwritten strokes to LaTeX, text, or other formats
11
+ # Thin delegate to Mathpix::Client#snap with strokes format
12
+ class ConvertStrokesTool < BaseTool
13
+ description 'Convert handwritten strokes to LaTeX, text, or other formats using Mathpix OCR'
14
+
15
+ input_schema(
16
+ properties: {
17
+ strokes: {
18
+ type: 'array',
19
+ items: {
20
+ type: 'array',
21
+ items: {
22
+ type: 'array',
23
+ items: { type: 'number' }
24
+ }
25
+ },
26
+ description: 'Array of stroke arrays, where each stroke is an array of [x, y] coordinates'
27
+ },
28
+ formats: {
29
+ type: 'array',
30
+ items: { type: 'string' },
31
+ description: 'Output formats: latex, text, mathml, asciimath (default: latex_styled, text)'
32
+ },
33
+ width: {
34
+ type: 'number',
35
+ description: 'Canvas width for stroke normalization'
36
+ },
37
+ height: {
38
+ type: 'number',
39
+ description: 'Canvas height for stroke normalization'
40
+ },
41
+ output_path: {
42
+ type: 'string',
43
+ description: 'Where to write the OCR result. The recognized content is always ' \
44
+ 'saved to a file (never returned inline); defaults to MATHPIX_OUTPUT_DIR ' \
45
+ 'or the system temp dir.'
46
+ }
47
+ },
48
+ required: ['strokes']
49
+ )
50
+
51
+ def self.call(strokes:, server_context:, formats: nil, width: nil, height: nil, output_path: nil)
52
+ safe_execute do
53
+ client = mathpix_client(server_context)
54
+
55
+ # Extract formats or use defaults
56
+ output_formats = extract_formats(formats, client)
57
+
58
+ # Delegate to the strokes endpoint (transposes points internally)
59
+ options = { formats: output_formats }
60
+ options[:width] = width if width
61
+ options[:height] = height if height
62
+ result = client.convert_strokes(strokes, **options)
63
+
64
+ # Collect available formats and write them to disk so the recognized
65
+ # content never enters the model context.
66
+ contents = artifact_contents(result)
67
+ base = output_path && !output_path.empty? ? output_path : default_artifact_path('strokes', 'tex')
68
+ saved = write_artifacts(contents, base)
69
+
70
+ response_data = {
71
+ success: true,
72
+ input_type: 'strokes',
73
+ stroke_count: strokes.length,
74
+ formats: output_formats,
75
+ confidence: result.confidence,
76
+ is_handwritten: result.handwritten?,
77
+ saved_files: saved,
78
+ preview: preview_of(result.latex || result.text)
79
+ }
80
+
81
+ json_response(response_data)
82
+ end
83
+ end
84
+
85
+ # Available OCR formats as a format => content hash.
86
+ def self.artifact_contents(result)
87
+ {
88
+ 'latex' => result.latex,
89
+ 'text' => result.text,
90
+ 'mathml' => result.mathml,
91
+ 'asciimath' => result.asciimath
92
+ }.compact
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+
5
+ module Mathpix
6
+ module MCP
7
+ module Tools
8
+ # Get Account Info Tool
9
+ #
10
+ # Retrieves account information and plan details
11
+ # Thin delegate to Mathpix::Client (account endpoint)
12
+ class GetAccountInfoTool < BaseTool
13
+ description 'Get Mathpix account information, plan details, and limits'
14
+
15
+ input_schema(
16
+ properties: {},
17
+ required: []
18
+ )
19
+
20
+ def self.call(server_context:)
21
+ safe_execute do
22
+ client = mathpix_client(server_context)
23
+
24
+ # Mathpix's v3 API has no account/plan endpoint for app tokens
25
+ # (/v3/account returns 404). Derive the identifiers it does expose
26
+ # from /v3/ocr-usage and tell the caller where to find plan/limits.
27
+ rows = client.get('/ocr-usage', params: {})['ocr_usage'] || []
28
+ first = rows.first || {}
29
+
30
+ response_data = {
31
+ success: true,
32
+ account: {
33
+ app_id: first['app_id'] || client.config.app_id,
34
+ group_id: first['group_id']
35
+ },
36
+ note: 'Mathpix exposes no account/plan endpoint via the API; app_id/group_id are ' \
37
+ 'derived from /v3/ocr-usage. View plan, limits, and billing in the Mathpix ' \
38
+ 'console at https://console.mathpix.com.'
39
+ }
40
+
41
+ json_response(response_data)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+
5
+ module Mathpix
6
+ module MCP
7
+ module Tools
8
+ # Get Usage Tool
9
+ #
10
+ # Retrieves API usage statistics and account limits
11
+ # Thin delegate to Mathpix::Client (usage endpoint)
12
+ class GetUsageTool < BaseTool
13
+ description 'Get Mathpix API usage statistics and remaining credits'
14
+
15
+ input_schema(
16
+ properties: {
17
+ from_date: {
18
+ type: 'string',
19
+ description: 'ISO-8601 start date to report usage from (e.g. 2026-06-01T00:00:00.000Z). ' \
20
+ 'Omit to use the Mathpix default window.'
21
+ },
22
+ detailed: {
23
+ type: 'boolean',
24
+ description: 'Include the raw per-request usage rows (default: false)'
25
+ }
26
+ },
27
+ required: []
28
+ )
29
+
30
+ def self.call(server_context:, from_date: nil, detailed: false)
31
+ safe_execute do
32
+ client = mathpix_client(server_context)
33
+
34
+ # Mathpix exposes usage at /v3/ocr-usage; there is no /v3/usage.
35
+ params = {}
36
+ params[:from_date] = from_date if from_date && !from_date.empty?
37
+ usage_data = client.get('/ocr-usage', params: params)
38
+
39
+ rows = usage_data['ocr_usage'] || []
40
+ by_type = rows.each_with_object(Hash.new(0)) do |row, acc|
41
+ acc[row['usage_type']] += row['count'] || 0
42
+ end
43
+
44
+ response_data = {
45
+ success: true,
46
+ usage: {
47
+ app_id: rows.first&.fetch('app_id', nil),
48
+ group_id: rows.first&.fetch('group_id', nil),
49
+ total_requests: rows.sum { |row| row['count'] || 0 },
50
+ by_usage_type: by_type
51
+ }
52
+ }
53
+ response_data[:rows] = rows if detailed
54
+
55
+ json_response(response_data)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+
5
+ module Mathpix
6
+ module MCP
7
+ module Tools
8
+ # List Formats Tool
9
+ #
10
+ # Lists all available output formats for Mathpix OCR
11
+ # Static data from API documentation
12
+ class ListFormatsTool < BaseTool
13
+ description 'List all available output formats for Mathpix OCR operations'
14
+
15
+ input_schema(
16
+ properties: {
17
+ category: {
18
+ type: 'string',
19
+ description: 'Filter by category: image, document, or all (default: all)',
20
+ enum: %w[image document all]
21
+ }
22
+ },
23
+ required: []
24
+ )
25
+
26
+ def self.call(server_context:, category: 'all')
27
+ safe_execute do
28
+ # Static format definitions
29
+ image_formats = [
30
+ { name: 'latex_styled', description: 'LaTeX with styling', type: 'image' },
31
+ { name: 'text', description: 'Plain text', type: 'image' },
32
+ { name: 'latex_list', description: 'Array of LaTeX expressions', type: 'image' },
33
+ { name: 'mathml', description: 'MathML markup', type: 'image' },
34
+ { name: 'asciimath', description: 'AsciiMath notation', type: 'image' },
35
+ { name: 'text_display', description: 'Display-style text', type: 'image' },
36
+ { name: 'latex_simplified', description: 'Simplified LaTeX', type: 'image' },
37
+ { name: 'data', description: 'Full response data with metadata', type: 'image' },
38
+ { name: 'html', description: 'HTML markup', type: 'image' }
39
+ ]
40
+
41
+ document_formats = [
42
+ { name: 'markdown', description: 'Markdown format', type: 'document' },
43
+ { name: 'latex', description: 'LaTeX document', type: 'document' },
44
+ { name: 'html', description: 'HTML document', type: 'document' },
45
+ { name: 'docx', description: 'Microsoft Word document', type: 'document' },
46
+ { name: 'tex.zip', description: 'LaTeX with figures (zipped)', type: 'document' }
47
+ ]
48
+
49
+ # Filter by category
50
+ formats = case category
51
+ when 'image'
52
+ image_formats
53
+ when 'document'
54
+ document_formats
55
+ when 'all'
56
+ image_formats + document_formats
57
+ else
58
+ image_formats + document_formats
59
+ end
60
+
61
+ # Format response
62
+ response_data = {
63
+ success: true,
64
+ category: category,
65
+ count: formats.length,
66
+ formats: formats,
67
+ usage: {
68
+ image_capture: 'Use with snap() or ConvertImageTool',
69
+ document_conversion: 'Use with document() or ConvertDocumentTool'
70
+ }
71
+ }
72
+
73
+ json_response(response_data)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../base_tool'
4
+
5
+ module Mathpix
6
+ module MCP
7
+ module Tools
8
+ # Search Results Tool
9
+ #
10
+ # Searches recent capture results with optional filtering
11
+ # Thin delegate to Mathpix::Client#recent with search
12
+ class SearchResultsTool < BaseTool
13
+ description 'Search recent Mathpix capture results with optional text filtering'
14
+
15
+ input_schema(
16
+ properties: {
17
+ query: {
18
+ type: 'string',
19
+ description: 'Search query to filter results by LaTeX or text content'
20
+ },
21
+ limit: {
22
+ type: 'number',
23
+ description: 'Maximum number of results to return (default: 10, max: 100)'
24
+ },
25
+ offset: {
26
+ type: 'number',
27
+ description: 'Offset for pagination (default: 0)'
28
+ },
29
+ include_content: {
30
+ type: 'boolean',
31
+ description: 'Write each result\'s full LaTeX/text to a file and return its path ' \
32
+ '(default: false, previews only). Content is never returned inline.'
33
+ },
34
+ output_dir: {
35
+ type: 'string',
36
+ description: 'Directory for files written when include_content is true; defaults to ' \
37
+ 'MATHPIX_OUTPUT_DIR or the system temp dir.'
38
+ }
39
+ },
40
+ required: []
41
+ )
42
+
43
+ def self.call(server_context:, query: nil, limit: 10, offset: 0, include_content: false, output_dir: nil)
44
+ safe_execute do
45
+ client = mathpix_client(server_context)
46
+ dir = output_dir && !output_dir.empty? ? File.expand_path(output_dir) : artifact_dir
47
+
48
+ # Validate and constrain limit
49
+ limit = [[limit.to_i, 1].max, 100].min
50
+
51
+ # Get recent results from API
52
+ recent_results = client.recent(limit: limit + offset)
53
+
54
+ # Apply offset
55
+ results = recent_results.drop(offset)
56
+
57
+ # Apply search filter if query provided
58
+ if query && !query.empty?
59
+ query_lower = query.downcase
60
+ results = results.select do |result|
61
+ result.latex&.downcase&.include?(query_lower) ||
62
+ result.text&.downcase&.include?(query_lower)
63
+ end
64
+ end
65
+
66
+ # Limit after filtering
67
+ results = results.take(limit)
68
+
69
+ # Format results. Previews are always inline; full content (when
70
+ # requested) is written to a file so it never enters the context.
71
+ formatted_results = results.each_with_index.map do |result, index|
72
+ item = {
73
+ id: result.request_id,
74
+ created_at: result.timestamp,
75
+ confidence: result.confidence,
76
+ is_printed: result.printed?,
77
+ is_handwritten: result.handwritten?,
78
+ latex_preview: truncate(result.latex, 100),
79
+ text_preview: truncate(result.text, 100)
80
+ }
81
+
82
+ if include_content
83
+ contents = { 'latex' => result.latex, 'text' => result.text }.compact
84
+ unless contents.empty?
85
+ stem = result.request_id || "result_#{offset + index}"
86
+ item[:saved_files] = write_artifacts(contents, File.join(dir, "mathpix_#{sanitize(stem)}.tex"))
87
+ end
88
+ end
89
+
90
+ item
91
+ end
92
+
93
+ # Format response
94
+ response_data = {
95
+ success: true,
96
+ query: query,
97
+ limit: limit,
98
+ offset: offset,
99
+ count: formatted_results.length,
100
+ results: formatted_results
101
+ }
102
+
103
+ json_response(response_data)
104
+ end
105
+ end
106
+
107
+ def self.truncate(text, max_length)
108
+ return nil unless text
109
+ return text if text.length <= max_length
110
+
111
+ "#{text[0...max_length]}..."
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end