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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +44 -0
- data/LICENSE +22 -0
- data/README.md +403 -0
- data/bin/mathpix-mcp +46 -0
- data/bin/mathpix-mcp-http +39 -0
- data/config.ru +24 -0
- data/lib/mathpix/client.rb +534 -0
- data/lib/mathpix/configuration.rb +182 -0
- data/lib/mathpix/document.rb +345 -0
- data/lib/mathpix/errors.rb +78 -0
- data/lib/mathpix/mcp/base_tool.rb +225 -0
- data/lib/mathpix/mcp/http_app.rb +60 -0
- data/lib/mathpix/mcp/server.rb +124 -0
- data/lib/mathpix/mcp/tools/batch_convert_tool.rb +147 -0
- data/lib/mathpix/mcp/tools/check_document_status_tool.rb +70 -0
- data/lib/mathpix/mcp/tools/convert_document_tool.rb +176 -0
- data/lib/mathpix/mcp/tools/convert_image_tool.rb +108 -0
- data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +97 -0
- data/lib/mathpix/mcp/tools/get_account_info_tool.rb +47 -0
- data/lib/mathpix/mcp/tools/get_usage_tool.rb +61 -0
- data/lib/mathpix/mcp/tools/list_formats_tool.rb +79 -0
- data/lib/mathpix/mcp/tools/search_results_tool.rb +116 -0
- data/lib/mathpix/mcp.rb +31 -0
- data/lib/mathpix/result.rb +387 -0
- data/lib/mathpix/version.rb +5 -0
- data/lib/mathpix.rb +52 -0
- metadata +132 -0
|
@@ -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
|