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,346 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'securerandom'
|
4
|
+
require 'json'
|
5
|
+
require 'base64'
|
6
|
+
require 'openssl'
|
7
|
+
require 'digest'
|
8
|
+
|
9
|
+
module Mathpix
|
10
|
+
module MCP
|
11
|
+
module Auth
|
12
|
+
# OAuth 2.0 Provider implementation
|
13
|
+
class OAuthProvider
|
14
|
+
AUTHORIZATION_CODE_EXPIRY = 600 # 10 minutes
|
15
|
+
ACCESS_TOKEN_EXPIRY = 3600 # 1 hour
|
16
|
+
REFRESH_TOKEN_EXPIRY = 2_592_000 # 30 days
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@clients = {}
|
20
|
+
@authorization_codes = {}
|
21
|
+
@access_tokens = {}
|
22
|
+
@refresh_tokens = {}
|
23
|
+
@revoked_tokens = {}
|
24
|
+
@jwt_secret = ENV['JWT_SECRET'] || SecureRandom.hex(32)
|
25
|
+
end
|
26
|
+
|
27
|
+
def register_client(client_id:, client_secret:, redirect_uri:, name: nil)
|
28
|
+
raise ArgumentError, 'client_id required' if client_id.nil?
|
29
|
+
raise ArgumentError, 'redirect_uri required' if redirect_uri.nil?
|
30
|
+
|
31
|
+
@clients[client_id] = {
|
32
|
+
client_secret: client_secret,
|
33
|
+
redirect_uri: redirect_uri,
|
34
|
+
name: name
|
35
|
+
}
|
36
|
+
end
|
37
|
+
|
38
|
+
def client_exists?(client_id)
|
39
|
+
@clients.key?(client_id)
|
40
|
+
end
|
41
|
+
|
42
|
+
def get_authorization(code)
|
43
|
+
@authorization_codes[code]
|
44
|
+
end
|
45
|
+
|
46
|
+
# Authorization code grant flow
|
47
|
+
def authorize(client_id:, redirect_uri:, scope:, user_id:, state: nil, code_challenge: nil, code_challenge_method: nil)
|
48
|
+
raise InvalidClientError, 'Unknown client' unless @clients.key?(client_id)
|
49
|
+
|
50
|
+
client = @clients[client_id]
|
51
|
+
raise InvalidRedirectUriError, 'Mismatched redirect_uri' unless client[:redirect_uri] == redirect_uri
|
52
|
+
|
53
|
+
code = SecureRandom.hex(32)
|
54
|
+
|
55
|
+
@authorization_codes[code] = {
|
56
|
+
client_id: client_id,
|
57
|
+
redirect_uri: redirect_uri,
|
58
|
+
scope: scope,
|
59
|
+
user_id: user_id,
|
60
|
+
state: state,
|
61
|
+
code_challenge: code_challenge,
|
62
|
+
code_challenge_method: code_challenge_method,
|
63
|
+
expires_at: Time.now + AUTHORIZATION_CODE_EXPIRY,
|
64
|
+
used: false
|
65
|
+
}
|
66
|
+
|
67
|
+
code
|
68
|
+
end
|
69
|
+
|
70
|
+
def exchange_code(code:, client_id:, client_secret:, redirect_uri:, code_verifier: nil)
|
71
|
+
auth = @authorization_codes[code]
|
72
|
+
|
73
|
+
raise InvalidGrantError, 'Invalid authorization code' if auth.nil?
|
74
|
+
raise InvalidGrantError, 'Authorization code already used' if auth[:used]
|
75
|
+
raise InvalidGrantError, 'Authorization code expired' if Time.now > auth[:expires_at]
|
76
|
+
raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
|
77
|
+
raise InvalidGrantError, 'Mismatched redirect_uri' unless auth[:redirect_uri] == redirect_uri
|
78
|
+
|
79
|
+
# PKCE verification
|
80
|
+
if auth[:code_challenge]
|
81
|
+
raise InvalidGrantError, 'Code verifier required' if code_verifier.nil?
|
82
|
+
unless verify_pkce(code_verifier, auth[:code_challenge], auth[:code_challenge_method])
|
83
|
+
raise InvalidGrantError, 'Invalid code verifier'
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Mark code as used
|
88
|
+
auth[:used] = true
|
89
|
+
|
90
|
+
# Generate tokens
|
91
|
+
access_token = generate_access_token(
|
92
|
+
user_id: auth[:user_id],
|
93
|
+
client_id: client_id,
|
94
|
+
scope: auth[:scope]
|
95
|
+
)
|
96
|
+
|
97
|
+
refresh_token = generate_refresh_token(
|
98
|
+
user_id: auth[:user_id],
|
99
|
+
client_id: client_id,
|
100
|
+
scope: auth[:scope]
|
101
|
+
)
|
102
|
+
|
103
|
+
{
|
104
|
+
access_token: access_token,
|
105
|
+
token_type: 'Bearer',
|
106
|
+
expires_in: ACCESS_TOKEN_EXPIRY,
|
107
|
+
refresh_token: refresh_token,
|
108
|
+
scope: auth[:scope]
|
109
|
+
}
|
110
|
+
end
|
111
|
+
|
112
|
+
def refresh_token(refresh_token:, client_id:, client_secret:)
|
113
|
+
raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
|
114
|
+
|
115
|
+
token_data = @refresh_tokens[refresh_token]
|
116
|
+
raise InvalidGrantError, 'Invalid refresh token' if token_data.nil?
|
117
|
+
raise InvalidGrantError, 'Refresh token expired' if Time.now > token_data[:expires_at]
|
118
|
+
raise InvalidGrantError, 'Refresh token revoked' if @revoked_tokens.key?(refresh_token)
|
119
|
+
|
120
|
+
# Invalidate old refresh token (rotation)
|
121
|
+
@refresh_tokens.delete(refresh_token)
|
122
|
+
@revoked_tokens[refresh_token] = Time.now
|
123
|
+
|
124
|
+
# Generate new tokens
|
125
|
+
access_token = generate_access_token(
|
126
|
+
user_id: token_data[:user_id],
|
127
|
+
client_id: client_id,
|
128
|
+
scope: token_data[:scope]
|
129
|
+
)
|
130
|
+
|
131
|
+
new_refresh_token = generate_refresh_token(
|
132
|
+
user_id: token_data[:user_id],
|
133
|
+
client_id: client_id,
|
134
|
+
scope: token_data[:scope]
|
135
|
+
)
|
136
|
+
|
137
|
+
{
|
138
|
+
access_token: access_token,
|
139
|
+
token_type: 'Bearer',
|
140
|
+
expires_in: ACCESS_TOKEN_EXPIRY,
|
141
|
+
refresh_token: new_refresh_token,
|
142
|
+
scope: token_data[:scope]
|
143
|
+
}
|
144
|
+
end
|
145
|
+
|
146
|
+
def validate_token(token)
|
147
|
+
raise InvalidTokenError, 'Token revoked' if @revoked_tokens.key?(token)
|
148
|
+
|
149
|
+
payload = decode_jwt(token)
|
150
|
+
raise InvalidTokenError, 'Token expired' if Time.now.to_i > payload['exp']
|
151
|
+
|
152
|
+
payload
|
153
|
+
rescue OpenSSL::OpenSSLError, StandardError => e
|
154
|
+
raise InvalidTokenError, "Invalid token signature: #{e.message}"
|
155
|
+
end
|
156
|
+
|
157
|
+
def validate_scope(token_scope, required_scope)
|
158
|
+
token_scopes = token_scope.split
|
159
|
+
required_scopes = required_scope.split
|
160
|
+
|
161
|
+
# Admin scope grants all
|
162
|
+
return true if token_scopes.include?('admin')
|
163
|
+
|
164
|
+
# Check if all required scopes are present
|
165
|
+
required_scopes.all? { |scope| token_scopes.include?(scope) }
|
166
|
+
end
|
167
|
+
|
168
|
+
def introspect_token(token, client_id: nil, client_secret: nil)
|
169
|
+
# Validate client if credentials provided
|
170
|
+
if client_id && client_secret
|
171
|
+
raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
|
172
|
+
end
|
173
|
+
|
174
|
+
begin
|
175
|
+
payload = validate_token(token)
|
176
|
+
|
177
|
+
{
|
178
|
+
active: true,
|
179
|
+
scope: payload['scope'],
|
180
|
+
client_id: payload['client_id'],
|
181
|
+
exp: payload['exp']
|
182
|
+
}
|
183
|
+
rescue InvalidTokenError
|
184
|
+
{ active: false }
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
def revoke_token(token, client_id: nil, client_secret: nil)
|
189
|
+
# Validate client if credentials provided
|
190
|
+
if client_id && client_secret
|
191
|
+
raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
|
192
|
+
end
|
193
|
+
|
194
|
+
@revoked_tokens[token] = Time.now
|
195
|
+
|
196
|
+
# If it's a refresh token, also revoke associated access tokens
|
197
|
+
if @refresh_tokens[token]
|
198
|
+
token_data = @refresh_tokens[token]
|
199
|
+
@refresh_tokens.delete(token)
|
200
|
+
|
201
|
+
# Revoke all access tokens for this user/client combination
|
202
|
+
@access_tokens.select do |access_token, data|
|
203
|
+
data[:user_id] == token_data[:user_id] && data[:client_id] == token_data[:client_id]
|
204
|
+
end.each_key do |access_token|
|
205
|
+
@revoked_tokens[access_token] = Time.now
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
true
|
210
|
+
end
|
211
|
+
|
212
|
+
def client_credentials(client_id:, client_secret:, scope:)
|
213
|
+
raise InvalidClientError, 'Invalid client' unless validate_client(client_id, client_secret)
|
214
|
+
|
215
|
+
access_token = generate_access_token(
|
216
|
+
user_id: nil, # No user for client credentials
|
217
|
+
client_id: client_id,
|
218
|
+
scope: scope
|
219
|
+
)
|
220
|
+
|
221
|
+
{
|
222
|
+
access_token: access_token,
|
223
|
+
token_type: 'Bearer',
|
224
|
+
expires_in: ACCESS_TOKEN_EXPIRY
|
225
|
+
# No refresh token for client credentials flow
|
226
|
+
}
|
227
|
+
end
|
228
|
+
alias client_credentials_grant client_credentials
|
229
|
+
|
230
|
+
# Public test helpers (used by step definitions)
|
231
|
+
def store_authorization_code(code:, client_id:, redirect_uri:, scope:)
|
232
|
+
@authorization_codes[code] = {
|
233
|
+
client_id: client_id,
|
234
|
+
redirect_uri: redirect_uri,
|
235
|
+
scope: scope,
|
236
|
+
user_id: 'test_user',
|
237
|
+
expires_at: Time.now + AUTHORIZATION_CODE_EXPIRY,
|
238
|
+
used: false
|
239
|
+
}
|
240
|
+
end
|
241
|
+
|
242
|
+
def decode_token(token)
|
243
|
+
decode_jwt(token)
|
244
|
+
end
|
245
|
+
|
246
|
+
private
|
247
|
+
|
248
|
+
def validate_client(client_id, client_secret)
|
249
|
+
client = @clients[client_id]
|
250
|
+
return false if client.nil?
|
251
|
+
|
252
|
+
client[:client_secret] == client_secret
|
253
|
+
end
|
254
|
+
|
255
|
+
def generate_access_token(user_id:, client_id:, scope:)
|
256
|
+
now = Time.now.to_i
|
257
|
+
|
258
|
+
payload = {
|
259
|
+
user_id: user_id,
|
260
|
+
client_id: client_id,
|
261
|
+
scope: scope,
|
262
|
+
iat: now,
|
263
|
+
exp: now + ACCESS_TOKEN_EXPIRY,
|
264
|
+
aud: 'mathpix-mcp-server',
|
265
|
+
iss: 'mathpix-oauth'
|
266
|
+
}
|
267
|
+
|
268
|
+
token = encode_jwt(payload)
|
269
|
+
|
270
|
+
@access_tokens[token] = {
|
271
|
+
user_id: user_id,
|
272
|
+
client_id: client_id,
|
273
|
+
scope: scope,
|
274
|
+
expires_at: Time.at(now + ACCESS_TOKEN_EXPIRY)
|
275
|
+
}
|
276
|
+
|
277
|
+
token
|
278
|
+
end
|
279
|
+
|
280
|
+
def generate_refresh_token(user_id:, client_id:, scope:)
|
281
|
+
token = SecureRandom.hex(32)
|
282
|
+
|
283
|
+
@refresh_tokens[token] = {
|
284
|
+
user_id: user_id,
|
285
|
+
client_id: client_id,
|
286
|
+
scope: scope,
|
287
|
+
expires_at: Time.now + REFRESH_TOKEN_EXPIRY
|
288
|
+
}
|
289
|
+
|
290
|
+
token
|
291
|
+
end
|
292
|
+
|
293
|
+
def encode_jwt(payload)
|
294
|
+
header = {
|
295
|
+
alg: 'HS256',
|
296
|
+
typ: 'JWT'
|
297
|
+
}
|
298
|
+
|
299
|
+
header_encoded = base64url_encode(JSON.generate(header))
|
300
|
+
payload_encoded = base64url_encode(JSON.generate(payload))
|
301
|
+
|
302
|
+
signature_input = "#{header_encoded}.#{payload_encoded}"
|
303
|
+
signature = OpenSSL::HMAC.digest('SHA256', @jwt_secret, signature_input)
|
304
|
+
signature_encoded = base64url_encode(signature)
|
305
|
+
|
306
|
+
"#{header_encoded}.#{payload_encoded}.#{signature_encoded}"
|
307
|
+
end
|
308
|
+
|
309
|
+
def decode_jwt(token)
|
310
|
+
header_encoded, payload_encoded, signature_encoded = token.split('.')
|
311
|
+
|
312
|
+
# Verify signature
|
313
|
+
signature_input = "#{header_encoded}.#{payload_encoded}"
|
314
|
+
expected_signature = OpenSSL::HMAC.digest('SHA256', @jwt_secret, signature_input)
|
315
|
+
expected_signature_encoded = base64url_encode(expected_signature)
|
316
|
+
|
317
|
+
unless signature_encoded == expected_signature_encoded
|
318
|
+
raise InvalidTokenError, 'Invalid signature'
|
319
|
+
end
|
320
|
+
|
321
|
+
JSON.parse(base64url_decode(payload_encoded))
|
322
|
+
end
|
323
|
+
|
324
|
+
def verify_pkce(code_verifier, code_challenge, method)
|
325
|
+
case method
|
326
|
+
when 'S256'
|
327
|
+
computed = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
328
|
+
computed == code_challenge
|
329
|
+
when 'plain'
|
330
|
+
code_verifier == code_challenge
|
331
|
+
else
|
332
|
+
false
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
def base64url_encode(data)
|
337
|
+
Base64.urlsafe_encode64(data, padding: false)
|
338
|
+
end
|
339
|
+
|
340
|
+
def base64url_decode(data)
|
341
|
+
Base64.urlsafe_decode64(data)
|
342
|
+
end
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mathpix
|
4
|
+
module MCP
|
5
|
+
module Auth
|
6
|
+
# Token storage and management
|
7
|
+
class TokenManager
|
8
|
+
def initialize
|
9
|
+
@tokens = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def store_token(token, data)
|
13
|
+
@tokens[token] = data
|
14
|
+
end
|
15
|
+
|
16
|
+
def get_token(token)
|
17
|
+
@tokens[token]
|
18
|
+
end
|
19
|
+
|
20
|
+
def revoke_token(token)
|
21
|
+
@tokens.delete(token)
|
22
|
+
end
|
23
|
+
|
24
|
+
def cleanup_expired
|
25
|
+
now = Time.now
|
26
|
+
@tokens.reject! { |_, data| data[:expires_at] && data[:expires_at] < now }
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mathpix
|
4
|
+
module MCP
|
5
|
+
module Auth
|
6
|
+
# Custom error classes
|
7
|
+
class AuthError < StandardError; end
|
8
|
+
class InvalidClientError < AuthError; end
|
9
|
+
class InvalidGrantError < AuthError; end
|
10
|
+
class InvalidTokenError < AuthError; end
|
11
|
+
class InvalidRedirectUriError < AuthError; end
|
12
|
+
class InsufficientScopeError < AuthError; end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
require_relative 'auth/oauth_provider'
|
18
|
+
require_relative 'auth/token_manager'
|
@@ -0,0 +1,117 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mathpix
|
4
|
+
module MCP
|
5
|
+
module Tools
|
6
|
+
# Base class for all Mathpix MCP tools
|
7
|
+
#
|
8
|
+
# Uses official Ruby MCP SDK (MCP::Tool)
|
9
|
+
# Provides common utilities for Mathpix-specific tools
|
10
|
+
#
|
11
|
+
# The geodesic path: official SDK base + Mathpix utilities
|
12
|
+
#
|
13
|
+
# @example Tool implementation
|
14
|
+
# class ExampleTool < BaseTool
|
15
|
+
# description "Example tool"
|
16
|
+
# input_schema(
|
17
|
+
# properties: { message: { type: "string" } },
|
18
|
+
# required: ["message"]
|
19
|
+
# )
|
20
|
+
#
|
21
|
+
# def self.call(message:, server_context:)
|
22
|
+
# client = server_context[:mathpix_client]
|
23
|
+
# # Use client to make API calls
|
24
|
+
# text_response("Result: #{message}")
|
25
|
+
# end
|
26
|
+
# end
|
27
|
+
class BaseTool < ::MCP::Tool
|
28
|
+
class << self
|
29
|
+
protected
|
30
|
+
|
31
|
+
# Get Mathpix client from server context
|
32
|
+
#
|
33
|
+
# @param server_context [Hash] MCP server context
|
34
|
+
# @return [Mathpix::Client] Mathpix API client
|
35
|
+
def mathpix_client(server_context)
|
36
|
+
server_context[:mathpix_client] || raise(ArgumentError, "mathpix_client not in server_context")
|
37
|
+
end
|
38
|
+
|
39
|
+
# Create text response (official MCP format)
|
40
|
+
#
|
41
|
+
# @param text [String] response text
|
42
|
+
# @return [::MCP::Tool::Response]
|
43
|
+
def text_response(text)
|
44
|
+
::MCP::Tool::Response.new([{
|
45
|
+
type: "text",
|
46
|
+
text: text
|
47
|
+
}])
|
48
|
+
end
|
49
|
+
|
50
|
+
# Create JSON response with text wrapper
|
51
|
+
#
|
52
|
+
# @param data [Hash] JSON data
|
53
|
+
# @return [::MCP::Tool::Response]
|
54
|
+
def json_response(data)
|
55
|
+
text_response(JSON.pretty_generate(data))
|
56
|
+
end
|
57
|
+
|
58
|
+
# Create error response
|
59
|
+
#
|
60
|
+
# @param error [StandardError, String] error object or message
|
61
|
+
# @return [::MCP::Tool::Response]
|
62
|
+
def error_response(error)
|
63
|
+
message = error.is_a?(StandardError) ? error.message : error.to_s
|
64
|
+
details = error.is_a?(Mathpix::Error) ? error.details : {}
|
65
|
+
|
66
|
+
error_data = {
|
67
|
+
error: true,
|
68
|
+
message: message,
|
69
|
+
type: error.class.name
|
70
|
+
}
|
71
|
+
error_data[:details] = details unless details.empty?
|
72
|
+
|
73
|
+
json_response(error_data)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Extract formats from arguments
|
77
|
+
#
|
78
|
+
# @param formats [Array, nil] format array
|
79
|
+
# @param client [Mathpix::Client] client for defaults
|
80
|
+
# @return [Array<Symbol>] format symbols
|
81
|
+
def extract_formats(formats, client)
|
82
|
+
return client.config.default_formats if formats.nil? || formats.empty?
|
83
|
+
Array(formats).map(&:to_sym)
|
84
|
+
end
|
85
|
+
|
86
|
+
# Normalize path (expand ~, resolve relative paths)
|
87
|
+
#
|
88
|
+
# @param path [String] file path
|
89
|
+
# @return [String] normalized path
|
90
|
+
def normalize_path(path)
|
91
|
+
File.expand_path(path) rescue path
|
92
|
+
end
|
93
|
+
|
94
|
+
# Check if path is a URL
|
95
|
+
#
|
96
|
+
# @param path [String] path or URL
|
97
|
+
# @return [Boolean]
|
98
|
+
def url?(path)
|
99
|
+
path.to_s.start_with?('http://', 'https://')
|
100
|
+
end
|
101
|
+
|
102
|
+
# Safe execute with error handling
|
103
|
+
#
|
104
|
+
# @yield Block to execute
|
105
|
+
# @return [::MCP::Tool::Response]
|
106
|
+
def safe_execute
|
107
|
+
yield
|
108
|
+
rescue Mathpix::Error => e
|
109
|
+
error_response(e)
|
110
|
+
rescue StandardError => e
|
111
|
+
error_response(e)
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
@@ -0,0 +1,162 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Mathpix
|
4
|
+
module MCP
|
5
|
+
module Elicitations
|
6
|
+
# Ambiguity elicitation for mathematical notation disambiguation
|
7
|
+
#
|
8
|
+
# When OCR detects ambiguous symbols, prompts user to clarify
|
9
|
+
# E.g., "0" vs "O", "1" vs "l", partial fractions, etc.
|
10
|
+
#
|
11
|
+
# @example Ambiguous symbol
|
12
|
+
# elicitation = AmbiguityElicitation.new(
|
13
|
+
# ambiguous_text: "O",
|
14
|
+
# context: "x + O = 5",
|
15
|
+
# alternatives: ["0 (zero)", "O (letter O)"]
|
16
|
+
# )
|
17
|
+
# response = elicitation.request_clarification
|
18
|
+
#
|
19
|
+
class AmbiguityElicitation
|
20
|
+
# Common ambiguous patterns in mathematical notation
|
21
|
+
AMBIGUOUS_PATTERNS = {
|
22
|
+
'O_or_0' => {
|
23
|
+
pattern: /[O0]/,
|
24
|
+
alternatives: ['0 (zero)', 'O (letter O)'],
|
25
|
+
description: 'Zero vs letter O ambiguity'
|
26
|
+
},
|
27
|
+
'1_or_l' => {
|
28
|
+
pattern: /[1lI]/,
|
29
|
+
alternatives: ['1 (one)', 'l (lowercase L)', 'I (uppercase i)'],
|
30
|
+
description: 'One vs lowercase L vs uppercase I'
|
31
|
+
},
|
32
|
+
'comma_or_decimal' => {
|
33
|
+
pattern: /\d[.,]\d/,
|
34
|
+
alternatives: ['comma (thousands separator)', 'decimal point'],
|
35
|
+
description: 'Comma vs decimal point in numbers'
|
36
|
+
},
|
37
|
+
'prime_or_apostrophe' => {
|
38
|
+
pattern: /[\'′]/,
|
39
|
+
alternatives: ["' (prime)", "' (apostrophe)"],
|
40
|
+
description: 'Prime notation vs apostrophe'
|
41
|
+
}
|
42
|
+
}.freeze
|
43
|
+
|
44
|
+
attr_reader :ambiguous_text, :context, :alternatives, :decision
|
45
|
+
|
46
|
+
# Initialize ambiguity elicitation
|
47
|
+
#
|
48
|
+
# @param ambiguous_text [String] the ambiguous symbol/text
|
49
|
+
# @param context [String] surrounding LaTeX for context
|
50
|
+
# @param alternatives [Array<String>] possible interpretations
|
51
|
+
# @param position [Hash] optional position data {x, y, width, height}
|
52
|
+
def initialize(ambiguous_text:, context:, alternatives: nil, position: nil)
|
53
|
+
@ambiguous_text = ambiguous_text
|
54
|
+
@context = context
|
55
|
+
@position = position
|
56
|
+
@alternatives = alternatives || detect_alternatives
|
57
|
+
@decision = nil
|
58
|
+
end
|
59
|
+
|
60
|
+
# Generate disambiguation prompt
|
61
|
+
#
|
62
|
+
# @return [String] user-facing prompt
|
63
|
+
def prompt
|
64
|
+
<<~PROMPT
|
65
|
+
🔍 Ambiguous Notation Detected
|
66
|
+
|
67
|
+
Ambiguous symbol: "#{@ambiguous_text}"
|
68
|
+
Context: #{@context}
|
69
|
+
|
70
|
+
Please select the correct interpretation:
|
71
|
+
PROMPT
|
72
|
+
end
|
73
|
+
|
74
|
+
# Generate options for clarification
|
75
|
+
#
|
76
|
+
# @return [Array<Hash>] option choices
|
77
|
+
def options
|
78
|
+
@alternatives.map.with_index do |alt, idx|
|
79
|
+
{
|
80
|
+
value: "option_#{idx}",
|
81
|
+
label: alt,
|
82
|
+
description: "Interpret as: #{alt}"
|
83
|
+
}
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Set user's clarification decision
|
88
|
+
#
|
89
|
+
# @param decision [String] selected option (option_0, option_1, etc)
|
90
|
+
def set_decision(decision)
|
91
|
+
index = decision.match(/option_(\d+)/)[1].to_i
|
92
|
+
unless index >= 0 && index < @alternatives.length
|
93
|
+
raise ArgumentError, "Invalid decision: #{decision}"
|
94
|
+
end
|
95
|
+
@decision = @alternatives[index]
|
96
|
+
end
|
97
|
+
|
98
|
+
# Apply clarification to LaTeX
|
99
|
+
#
|
100
|
+
# @return [String] corrected LaTeX
|
101
|
+
def apply_clarification
|
102
|
+
raise "No decision set" unless @decision
|
103
|
+
|
104
|
+
# Simple replacement (in real implementation, would be more sophisticated)
|
105
|
+
corrected = @context.dup
|
106
|
+
|
107
|
+
case @decision
|
108
|
+
when /0 \(zero\)/
|
109
|
+
corrected.gsub!(/O/, '0')
|
110
|
+
when /O \(letter O\)/
|
111
|
+
corrected.gsub!(/0/, 'O')
|
112
|
+
when /1 \(one\)/
|
113
|
+
corrected.gsub!(/[lI]/, '1')
|
114
|
+
when /l \(lowercase L\)/
|
115
|
+
corrected.gsub!(/[1I]/, 'l')
|
116
|
+
# Add more transformations as needed
|
117
|
+
end
|
118
|
+
|
119
|
+
corrected
|
120
|
+
end
|
121
|
+
|
122
|
+
# Convert to MCP elicitation request
|
123
|
+
#
|
124
|
+
# @return [Hash] MCP-compatible request
|
125
|
+
def to_mcp_request
|
126
|
+
{
|
127
|
+
type: 'select',
|
128
|
+
prompt: prompt,
|
129
|
+
field_name: 'ambiguity_clarification',
|
130
|
+
options: options.map { |o| { value: o[:value], label: o[:label] } },
|
131
|
+
metadata: {
|
132
|
+
ambiguous_text: @ambiguous_text,
|
133
|
+
context: @context,
|
134
|
+
position: @position
|
135
|
+
}
|
136
|
+
}
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
# Auto-detect possible alternatives based on ambiguous text
|
142
|
+
#
|
143
|
+
# @return [Array<String>] detected alternatives
|
144
|
+
def detect_alternatives
|
145
|
+
# Check common patterns
|
146
|
+
AMBIGUOUS_PATTERNS.each do |key, pattern_data|
|
147
|
+
if pattern_data[:pattern].match?(@ambiguous_text)
|
148
|
+
return pattern_data[:alternatives]
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
# Default: generic ambiguity
|
153
|
+
[
|
154
|
+
"Interpretation A: #{@ambiguous_text}",
|
155
|
+
"Interpretation B: similar symbol",
|
156
|
+
"Keep as-is: #{@ambiguous_text}"
|
157
|
+
]
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|