differino-mcp 0.1.0 → 0.2.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.
- package/.next/trace +1 -0
- package/dist/index.js +26 -377
- package/package.json +2 -3
- package/src/index.ts +36 -452
package/.next/trace
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"name":"generate-buildid","duration":115,"timestamp":148958595929,"id":4,"parentId":1,"tags":{},"startTime":1774429523967,"traceId":"be39ecf3904fcb99"},{"name":"load-custom-routes","duration":119,"timestamp":148958596120,"id":5,"parentId":1,"tags":{},"startTime":1774429523967,"traceId":"be39ecf3904fcb99"},{"name":"next-build","duration":82517,"timestamp":148958517288,"id":1,"tags":{"buildMode":"default","isTurboBuild":"false","version":"14.1.0","isTurbopack":false},"startTime":1774429523888,"traceId":"be39ecf3904fcb99"}]
|
package/dist/index.js
CHANGED
|
@@ -37,95 +37,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
37
37
|
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
38
38
|
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
39
39
|
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
40
|
-
const supabase_js_1 = require("@supabase/supabase-js");
|
|
41
40
|
const fs = __importStar(require("fs"));
|
|
42
41
|
const path = __importStar(require("path"));
|
|
43
42
|
// ---------------------------------------------------------------------------
|
|
44
43
|
// Configuration
|
|
45
44
|
// ---------------------------------------------------------------------------
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
'';
|
|
49
|
-
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ||
|
|
50
|
-
process.env.DIFFERINO_SUPABASE_ANON_KEY ||
|
|
51
|
-
'';
|
|
52
|
-
const BASE_URL = process.env.DIFFERINO_URL || 'https://differino.com';
|
|
53
|
-
const EMAIL = process.env.DIFFERINO_EMAIL || '';
|
|
54
|
-
const PASSWORD = process.env.DIFFERINO_PASSWORD || '';
|
|
55
|
-
let accessToken = null;
|
|
56
|
-
let refreshToken = null;
|
|
57
|
-
let supabase = null;
|
|
45
|
+
const API_KEY = process.env.DIFFERINO_API_KEY || '';
|
|
46
|
+
const BASE_URL = process.env.DIFFERINO_URL || 'https://www.differino.com';
|
|
58
47
|
// ---------------------------------------------------------------------------
|
|
59
|
-
//
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
async function authenticate() {
|
|
62
|
-
supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
63
|
-
const { data, error } = await supabase.auth.signInWithPassword({
|
|
64
|
-
email: EMAIL,
|
|
65
|
-
password: PASSWORD,
|
|
66
|
-
});
|
|
67
|
-
if (error)
|
|
68
|
-
throw new Error(`Authentication failed: ${error.message}`);
|
|
69
|
-
accessToken = data.session.access_token;
|
|
70
|
-
refreshToken = data.session.refresh_token;
|
|
71
|
-
}
|
|
72
|
-
async function ensureAuth() {
|
|
73
|
-
if (!supabase || !accessToken) {
|
|
74
|
-
await authenticate();
|
|
75
|
-
}
|
|
76
|
-
return supabase;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Build the cookie header that the Next.js Supabase SSR client expects.
|
|
80
|
-
*
|
|
81
|
-
* @supabase/ssr stores the session in a cookie whose name follows the pattern
|
|
82
|
-
* `sb-<project-ref>-auth-token`. The value is a base64url-encoded JSON string
|
|
83
|
-
* containing `access_token`, `refresh_token`, etc.
|
|
84
|
-
*
|
|
85
|
-
* For large tokens the library may chunk the cookie across multiple numbered
|
|
86
|
-
* cookies (sb-<ref>-auth-token.0, .1, ...). We produce a single cookie here
|
|
87
|
-
* which works for typical token sizes.
|
|
88
|
-
*/
|
|
89
|
-
function buildAuthCookie() {
|
|
90
|
-
const projectRef = SUPABASE_URL.match(/https:\/\/([^.]+)\./)?.[1] || 'ref';
|
|
91
|
-
const cookieName = `sb-${projectRef}-auth-token`;
|
|
92
|
-
const cookieValue = JSON.stringify({
|
|
93
|
-
access_token: accessToken,
|
|
94
|
-
refresh_token: refreshToken,
|
|
95
|
-
token_type: 'bearer',
|
|
96
|
-
});
|
|
97
|
-
// The SSR helper may also check chunked variants (.0, .1, …).
|
|
98
|
-
// Providing a single non-chunked cookie is accepted when the value fits in
|
|
99
|
-
// one cookie (< 3180 bytes). For very long JWTs we split into chunks.
|
|
100
|
-
const encoded = cookieValue;
|
|
101
|
-
const CHUNK_SIZE = 3180;
|
|
102
|
-
if (encoded.length <= CHUNK_SIZE) {
|
|
103
|
-
return `${cookieName}=${encodeURIComponent(encoded)}`;
|
|
104
|
-
}
|
|
105
|
-
// Chunked cookies
|
|
106
|
-
const chunks = [];
|
|
107
|
-
for (let i = 0; i * CHUNK_SIZE < encoded.length; i++) {
|
|
108
|
-
const slice = encoded.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
109
|
-
chunks.push(`${cookieName}.${i}=${encodeURIComponent(slice)}`);
|
|
110
|
-
}
|
|
111
|
-
return chunks.join('; ');
|
|
112
|
-
}
|
|
113
|
-
// ---------------------------------------------------------------------------
|
|
114
|
-
// HTTP helpers (calls to the Differino Next.js API)
|
|
48
|
+
// HTTP helpers
|
|
115
49
|
// ---------------------------------------------------------------------------
|
|
116
50
|
async function apiCall(endpoint, options = {}) {
|
|
117
51
|
const url = `${BASE_URL}${endpoint}`;
|
|
118
52
|
const headers = {
|
|
53
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
119
54
|
...(options.headers || {}),
|
|
120
55
|
};
|
|
121
|
-
|
|
122
|
-
headers['Cookie'] = buildAuthCookie();
|
|
123
|
-
}
|
|
124
|
-
const res = await fetch(url, { ...options, headers, redirect: 'manual' });
|
|
125
|
-
// Handle redirects (auth middleware may redirect to /login)
|
|
126
|
-
if (res.status >= 300 && res.status < 400) {
|
|
127
|
-
throw new Error(`API redirected (${res.status}) — session may have expired. Location: ${res.headers.get('location')}`);
|
|
128
|
-
}
|
|
56
|
+
const res = await fetch(url, { ...options, headers });
|
|
129
57
|
const text = await res.text();
|
|
130
58
|
let json;
|
|
131
59
|
try {
|
|
@@ -162,9 +90,8 @@ function getMimeType(ext) {
|
|
|
162
90
|
// Tool implementations
|
|
163
91
|
// ---------------------------------------------------------------------------
|
|
164
92
|
async function compareDocuments(args) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// --- Validate files ---
|
|
93
|
+
const { file_a_path, file_b_path } = args;
|
|
94
|
+
// Validate files exist
|
|
168
95
|
for (const fp of [file_a_path, file_b_path]) {
|
|
169
96
|
if (!fs.existsSync(fp)) {
|
|
170
97
|
return `Error: File not found: ${fp}`;
|
|
@@ -180,258 +107,41 @@ async function compareDocuments(args) {
|
|
|
180
107
|
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
181
108
|
return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
|
|
182
109
|
}
|
|
183
|
-
//
|
|
110
|
+
// Build multipart form
|
|
111
|
+
const form = new FormData();
|
|
184
112
|
const fileABuffer = fs.readFileSync(file_a_path);
|
|
185
|
-
const fileASize = fileABuffer.byteLength;
|
|
186
|
-
// Use init/complete flow for files > 4MB, direct upload for smaller ones
|
|
187
|
-
let uploadA;
|
|
188
|
-
let uploadB;
|
|
189
|
-
if (fileASize < 4 * 1024 * 1024) {
|
|
190
|
-
// Direct upload via /api/upload
|
|
191
|
-
const formA = new FormData();
|
|
192
|
-
formA.append('file', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
|
|
193
|
-
formA.append('name', nameA);
|
|
194
|
-
uploadA = await apiCall('/api/upload', {
|
|
195
|
-
method: 'POST',
|
|
196
|
-
body: formA,
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
uploadA = await uploadLargeFile(file_a_path, nameA, fileABuffer, extA);
|
|
201
|
-
}
|
|
202
|
-
// --- Upload file B ---
|
|
203
113
|
const fileBBuffer = fs.readFileSync(file_b_path);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
formB.append('name', nameB);
|
|
209
|
-
uploadB = await apiCall('/api/upload', {
|
|
210
|
-
method: 'POST',
|
|
211
|
-
body: formB,
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
uploadB = await uploadLargeFile(file_b_path, nameB, fileBBuffer, extB);
|
|
216
|
-
}
|
|
217
|
-
// --- Wait for text extraction ---
|
|
218
|
-
const MAX_WAIT_MS = 120_000;
|
|
219
|
-
const POLL_MS = 2_000;
|
|
220
|
-
for (const vId of [uploadA.versionId, uploadB.versionId]) {
|
|
221
|
-
const deadline = Date.now() + MAX_WAIT_MS;
|
|
222
|
-
while (Date.now() < deadline) {
|
|
223
|
-
const { data } = await supabase
|
|
224
|
-
.from('versions')
|
|
225
|
-
.select('status')
|
|
226
|
-
.eq('id', vId)
|
|
227
|
-
.single();
|
|
228
|
-
if (data?.status === 'ready')
|
|
229
|
-
break;
|
|
230
|
-
if (data?.status === 'failed') {
|
|
231
|
-
return `Error: Text extraction failed for version ${vId}.`;
|
|
232
|
-
}
|
|
233
|
-
await sleep(POLL_MS);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
// --- Create comparison ---
|
|
237
|
-
const comparison = await apiCall('/api/comparisons', {
|
|
114
|
+
form.append('file_a', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
|
|
115
|
+
form.append('file_b', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
|
|
116
|
+
// Call the REST API — it handles upload, extraction, comparison, and polling
|
|
117
|
+
const result = await apiCall('/api/v1/compare', {
|
|
238
118
|
method: 'POST',
|
|
239
|
-
|
|
240
|
-
body: JSON.stringify({
|
|
241
|
-
versionAId: uploadA.versionId,
|
|
242
|
-
versionBId: uploadB.versionId,
|
|
243
|
-
accuracyMode: accuracy_mode,
|
|
244
|
-
}),
|
|
119
|
+
body: form,
|
|
245
120
|
});
|
|
246
|
-
|
|
247
|
-
const compDeadline = Date.now() + MAX_WAIT_MS;
|
|
248
|
-
while (Date.now() < compDeadline) {
|
|
249
|
-
const { data } = await supabase
|
|
250
|
-
.from('comparisons')
|
|
251
|
-
.select('status, summary, error_message')
|
|
252
|
-
.eq('id', comparison.id)
|
|
253
|
-
.single();
|
|
254
|
-
if (data?.status === 'ready') {
|
|
255
|
-
const summary = data.summary;
|
|
256
|
-
return JSON.stringify({
|
|
257
|
-
status: 'ready',
|
|
258
|
-
comparison_id: comparison.id,
|
|
259
|
-
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
260
|
-
summary: {
|
|
261
|
-
added_blocks: summary?.added_blocks ?? 0,
|
|
262
|
-
removed_blocks: summary?.removed_blocks ?? 0,
|
|
263
|
-
modified_blocks: summary?.modified_blocks ?? 0,
|
|
264
|
-
unchanged_blocks: summary?.unchanged_blocks ?? 0,
|
|
265
|
-
},
|
|
266
|
-
}, null, 2);
|
|
267
|
-
}
|
|
268
|
-
if (data?.status === 'failed') {
|
|
269
|
-
return `Error: Comparison failed — ${data.error_message || 'unknown error'}`;
|
|
270
|
-
}
|
|
271
|
-
await sleep(POLL_MS);
|
|
272
|
-
}
|
|
273
|
-
return JSON.stringify({
|
|
274
|
-
status: 'processing',
|
|
275
|
-
comparison_id: comparison.id,
|
|
276
|
-
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
277
|
-
message: 'Comparison is still processing. Check the URL for results.',
|
|
278
|
-
}, null, 2);
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Upload a large file (>4 MB) via the init + signed-URL + complete flow,
|
|
282
|
-
* which bypasses Vercel's 4.5 MB request body limit.
|
|
283
|
-
*/
|
|
284
|
-
async function uploadLargeFile(filePath, filename, buffer, ext) {
|
|
285
|
-
const mime = getMimeType(ext);
|
|
286
|
-
// 1. Init — creates version record and returns a signed upload URL
|
|
287
|
-
const init = await apiCall('/api/upload/init', {
|
|
288
|
-
method: 'POST',
|
|
289
|
-
headers: { 'Content-Type': 'application/json' },
|
|
290
|
-
body: JSON.stringify({
|
|
291
|
-
filename,
|
|
292
|
-
contentType: mime,
|
|
293
|
-
fileSize: buffer.byteLength,
|
|
294
|
-
documentName: filename,
|
|
295
|
-
}),
|
|
296
|
-
});
|
|
297
|
-
// 2. Upload directly to Supabase Storage via the signed URL
|
|
298
|
-
const uploadRes = await fetch(init.signedUrl, {
|
|
299
|
-
method: 'PUT',
|
|
300
|
-
headers: { 'Content-Type': mime },
|
|
301
|
-
body: new Uint8Array(buffer),
|
|
302
|
-
});
|
|
303
|
-
if (!uploadRes.ok) {
|
|
304
|
-
const text = await uploadRes.text();
|
|
305
|
-
throw new Error(`Storage upload failed (${uploadRes.status}): ${text.slice(0, 200)}`);
|
|
306
|
-
}
|
|
307
|
-
// 3. Complete — enqueues extraction job
|
|
308
|
-
const complete = await apiCall('/api/upload/complete', {
|
|
309
|
-
method: 'POST',
|
|
310
|
-
headers: { 'Content-Type': 'application/json' },
|
|
311
|
-
body: JSON.stringify({ versionId: init.versionId }),
|
|
312
|
-
});
|
|
313
|
-
return {
|
|
314
|
-
versionId: init.versionId,
|
|
315
|
-
documentId: init.documentId,
|
|
316
|
-
};
|
|
121
|
+
return JSON.stringify(result, null, 2);
|
|
317
122
|
}
|
|
318
123
|
async function getComparison(args) {
|
|
319
|
-
await
|
|
320
|
-
|
|
321
|
-
.from('comparisons')
|
|
322
|
-
.select(`
|
|
323
|
-
id, status, summary, error_message, created_at, completed_at,
|
|
324
|
-
version_a:versions!version_a_id ( id, document_id, documents ( name ) ),
|
|
325
|
-
version_b:versions!version_b_id ( id, document_id, documents ( name ) )
|
|
326
|
-
`)
|
|
327
|
-
.eq('id', args.comparison_id)
|
|
328
|
-
.single();
|
|
329
|
-
if (error || !data)
|
|
330
|
-
return 'Error: Comparison not found.';
|
|
331
|
-
const rec = data;
|
|
332
|
-
return JSON.stringify({
|
|
333
|
-
comparison_id: rec.id,
|
|
334
|
-
status: rec.status,
|
|
335
|
-
url: `${BASE_URL}/compare?id=${rec.id}`,
|
|
336
|
-
file_a: rec.version_a?.documents?.name ?? 'Unknown',
|
|
337
|
-
file_b: rec.version_b?.documents?.name ?? 'Unknown',
|
|
338
|
-
summary: rec.summary ?? null,
|
|
339
|
-
error: rec.error_message ?? null,
|
|
340
|
-
created_at: rec.created_at,
|
|
341
|
-
completed_at: rec.completed_at,
|
|
342
|
-
}, null, 2);
|
|
124
|
+
const result = await apiCall(`/api/v1/comparisons/${args.comparison_id}`);
|
|
125
|
+
return JSON.stringify(result, null, 2);
|
|
343
126
|
}
|
|
344
127
|
async function listComparisons(args) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const { data: workspace } = await supabase
|
|
348
|
-
.from('workspaces')
|
|
349
|
-
.select('id')
|
|
350
|
-
.single();
|
|
351
|
-
if (!workspace)
|
|
352
|
-
return 'Error: No workspace found.';
|
|
353
|
-
const { data, error } = await supabase
|
|
354
|
-
.from('comparisons')
|
|
355
|
-
.select(`
|
|
356
|
-
id, status, created_at,
|
|
357
|
-
version_a:versions!version_a_id ( documents ( name ) ),
|
|
358
|
-
version_b:versions!version_b_id ( documents ( name ) )
|
|
359
|
-
`)
|
|
360
|
-
.eq('workspace_id', workspace.id)
|
|
361
|
-
.order('created_at', { ascending: false })
|
|
362
|
-
.limit(limit);
|
|
363
|
-
if (error)
|
|
364
|
-
return `Error: ${error.message}`;
|
|
365
|
-
const comparisons = (data || []).map((c) => ({
|
|
366
|
-
id: c.id,
|
|
367
|
-
status: c.status,
|
|
368
|
-
file_a: c.version_a?.documents?.name ?? 'Unknown',
|
|
369
|
-
file_b: c.version_b?.documents?.name ?? 'Unknown',
|
|
370
|
-
created_at: c.created_at,
|
|
371
|
-
url: `${BASE_URL}/compare?id=${c.id}`,
|
|
372
|
-
}));
|
|
373
|
-
return JSON.stringify(comparisons, null, 2);
|
|
374
|
-
}
|
|
375
|
-
async function listDocuments(args) {
|
|
376
|
-
await ensureAuth();
|
|
377
|
-
const { data: workspace } = await supabase
|
|
378
|
-
.from('workspaces')
|
|
379
|
-
.select('id')
|
|
380
|
-
.single();
|
|
381
|
-
if (!workspace)
|
|
382
|
-
return 'Error: No workspace found.';
|
|
383
|
-
const { data, error } = await supabase
|
|
384
|
-
.from('documents')
|
|
385
|
-
.select(`
|
|
386
|
-
id, name, updated_at,
|
|
387
|
-
versions ( id, version_number, status, created_at )
|
|
388
|
-
`)
|
|
389
|
-
.eq('workspace_id', workspace.id)
|
|
390
|
-
.order('updated_at', { ascending: false })
|
|
391
|
-
.limit(args.limit || 20);
|
|
392
|
-
if (error)
|
|
393
|
-
return `Error: ${error.message}`;
|
|
394
|
-
return JSON.stringify(data, null, 2);
|
|
395
|
-
}
|
|
396
|
-
async function getCredits() {
|
|
397
|
-
await ensureAuth();
|
|
398
|
-
const { data: workspace } = await supabase
|
|
399
|
-
.from('workspaces')
|
|
400
|
-
.select('credits, plan, pro_expires_at')
|
|
401
|
-
.single();
|
|
402
|
-
if (!workspace)
|
|
403
|
-
return 'Error: No workspace found.';
|
|
404
|
-
const now = new Date();
|
|
405
|
-
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
|
406
|
-
const { data: usage } = await supabase
|
|
407
|
-
.from('usage')
|
|
408
|
-
.select('comparison_count')
|
|
409
|
-
.eq('month', monthKey)
|
|
410
|
-
.single();
|
|
128
|
+
// Use the internal session-less API — falls through to REST
|
|
129
|
+
// For now, this tool is simplified to just report the endpoint
|
|
411
130
|
return JSON.stringify({
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
pro_expires_at: workspace.pro_expires_at,
|
|
415
|
-
free_comparisons_used: usage?.comparison_count ?? 0,
|
|
416
|
-
free_comparisons_limit: 3,
|
|
131
|
+
message: 'Use compare_documents to create comparisons, and get_comparison to check results.',
|
|
132
|
+
api_docs: `${BASE_URL}/mcp`,
|
|
417
133
|
}, null, 2);
|
|
418
134
|
}
|
|
419
135
|
// ---------------------------------------------------------------------------
|
|
420
|
-
// Utility
|
|
421
|
-
// ---------------------------------------------------------------------------
|
|
422
|
-
function sleep(ms) {
|
|
423
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
424
|
-
}
|
|
425
|
-
// ---------------------------------------------------------------------------
|
|
426
136
|
// MCP Server
|
|
427
137
|
// ---------------------------------------------------------------------------
|
|
428
|
-
const server = new index_js_1.Server({ name: 'differino', version: '0.
|
|
138
|
+
const server = new index_js_1.Server({ name: 'differino', version: '0.2.0' }, { capabilities: { tools: {} } });
|
|
429
139
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
430
140
|
tools: [
|
|
431
141
|
{
|
|
432
142
|
name: 'compare_documents',
|
|
433
143
|
description: 'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
|
|
434
|
-
'Reads files from local disk, uploads them to Differino, and returns a diff summary.',
|
|
144
|
+
'Reads files from local disk, uploads them to Differino, and returns a diff summary with a URL to view the full comparison.',
|
|
435
145
|
inputSchema: {
|
|
436
146
|
type: 'object',
|
|
437
147
|
properties: {
|
|
@@ -443,11 +153,6 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
|
443
153
|
type: 'string',
|
|
444
154
|
description: 'Absolute path to the modified document',
|
|
445
155
|
},
|
|
446
|
-
accuracy_mode: {
|
|
447
|
-
type: 'string',
|
|
448
|
-
enum: ['fast', 'balanced', 'thorough'],
|
|
449
|
-
description: 'Comparison accuracy mode (default: balanced)',
|
|
450
|
-
},
|
|
451
156
|
},
|
|
452
157
|
required: ['file_a_path', 'file_b_path'],
|
|
453
158
|
},
|
|
@@ -466,40 +171,6 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
|
466
171
|
required: ['comparison_id'],
|
|
467
172
|
},
|
|
468
173
|
},
|
|
469
|
-
{
|
|
470
|
-
name: 'list_comparisons',
|
|
471
|
-
description: 'List recent document comparisons in the user\'s workspace.',
|
|
472
|
-
inputSchema: {
|
|
473
|
-
type: 'object',
|
|
474
|
-
properties: {
|
|
475
|
-
limit: {
|
|
476
|
-
type: 'number',
|
|
477
|
-
description: 'Maximum number of comparisons to return (default: 10)',
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
},
|
|
481
|
-
},
|
|
482
|
-
{
|
|
483
|
-
name: 'list_documents',
|
|
484
|
-
description: 'List documents in the user\'s Differino library.',
|
|
485
|
-
inputSchema: {
|
|
486
|
-
type: 'object',
|
|
487
|
-
properties: {
|
|
488
|
-
limit: {
|
|
489
|
-
type: 'number',
|
|
490
|
-
description: 'Maximum number of documents to return (default: 20)',
|
|
491
|
-
},
|
|
492
|
-
},
|
|
493
|
-
},
|
|
494
|
-
},
|
|
495
|
-
{
|
|
496
|
-
name: 'get_credits',
|
|
497
|
-
description: 'Check the user\'s credit balance, plan status, and free comparison usage for the current month.',
|
|
498
|
-
inputSchema: {
|
|
499
|
-
type: 'object',
|
|
500
|
-
properties: {},
|
|
501
|
-
},
|
|
502
|
-
},
|
|
503
174
|
],
|
|
504
175
|
}));
|
|
505
176
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
@@ -513,15 +184,6 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
513
184
|
case 'get_comparison':
|
|
514
185
|
result = await getComparison(args);
|
|
515
186
|
break;
|
|
516
|
-
case 'list_comparisons':
|
|
517
|
-
result = await listComparisons(args);
|
|
518
|
-
break;
|
|
519
|
-
case 'list_documents':
|
|
520
|
-
result = await listDocuments(args);
|
|
521
|
-
break;
|
|
522
|
-
case 'get_credits':
|
|
523
|
-
result = await getCredits();
|
|
524
|
-
break;
|
|
525
187
|
default:
|
|
526
188
|
result = `Unknown tool: ${name}`;
|
|
527
189
|
}
|
|
@@ -541,22 +203,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
541
203
|
// Entry point
|
|
542
204
|
// ---------------------------------------------------------------------------
|
|
543
205
|
async function main() {
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
process.exit(1);
|
|
548
|
-
}
|
|
549
|
-
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
|
550
|
-
console.error('Error: SUPABASE_URL and SUPABASE_ANON_KEY environment variables are required.');
|
|
551
|
-
process.exit(1);
|
|
552
|
-
}
|
|
553
|
-
// Authenticate on startup so we fail fast if credentials are wrong
|
|
554
|
-
try {
|
|
555
|
-
await authenticate();
|
|
556
|
-
}
|
|
557
|
-
catch (err) {
|
|
558
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
559
|
-
console.error(message);
|
|
206
|
+
if (!API_KEY) {
|
|
207
|
+
console.error('Error: DIFFERINO_API_KEY environment variable is required.\n' +
|
|
208
|
+
'Generate one at https://www.differino.com/settings');
|
|
560
209
|
process.exit(1);
|
|
561
210
|
}
|
|
562
211
|
const transport = new stdio_js_1.StdioServerTransport();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "differino-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for Differino document comparison",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
"start": "node dist/index.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
15
|
-
"@supabase/supabase-js": "^2.39.0"
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.0.0"
|
|
16
15
|
},
|
|
17
16
|
"devDependencies": {
|
|
18
17
|
"typescript": "^5.3.0",
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,6 @@ import {
|
|
|
6
6
|
CallToolRequestSchema,
|
|
7
7
|
ListToolsRequestSchema,
|
|
8
8
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
-
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
|
10
9
|
import * as fs from 'fs';
|
|
11
10
|
import * as path from 'path';
|
|
12
11
|
|
|
@@ -14,85 +13,11 @@ import * as path from 'path';
|
|
|
14
13
|
// Configuration
|
|
15
14
|
// ---------------------------------------------------------------------------
|
|
16
15
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
process.env.DIFFERINO_SUPABASE_URL ||
|
|
20
|
-
'';
|
|
21
|
-
const SUPABASE_ANON_KEY =
|
|
22
|
-
process.env.SUPABASE_ANON_KEY ||
|
|
23
|
-
process.env.DIFFERINO_SUPABASE_ANON_KEY ||
|
|
24
|
-
'';
|
|
25
|
-
const BASE_URL = process.env.DIFFERINO_URL || 'https://differino.com';
|
|
26
|
-
const EMAIL = process.env.DIFFERINO_EMAIL || '';
|
|
27
|
-
const PASSWORD = process.env.DIFFERINO_PASSWORD || '';
|
|
28
|
-
|
|
29
|
-
let accessToken: string | null = null;
|
|
30
|
-
let refreshToken: string | null = null;
|
|
31
|
-
let supabase: SupabaseClient | null = null;
|
|
16
|
+
const API_KEY = process.env.DIFFERINO_API_KEY || '';
|
|
17
|
+
const BASE_URL = process.env.DIFFERINO_URL || 'https://www.differino.com';
|
|
32
18
|
|
|
33
19
|
// ---------------------------------------------------------------------------
|
|
34
|
-
//
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
async function authenticate(): Promise<void> {
|
|
38
|
-
supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
39
|
-
const { data, error } = await supabase.auth.signInWithPassword({
|
|
40
|
-
email: EMAIL,
|
|
41
|
-
password: PASSWORD,
|
|
42
|
-
});
|
|
43
|
-
if (error) throw new Error(`Authentication failed: ${error.message}`);
|
|
44
|
-
accessToken = data.session.access_token;
|
|
45
|
-
refreshToken = data.session.refresh_token;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function ensureAuth(): Promise<SupabaseClient> {
|
|
49
|
-
if (!supabase || !accessToken) {
|
|
50
|
-
await authenticate();
|
|
51
|
-
}
|
|
52
|
-
return supabase!;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Build the cookie header that the Next.js Supabase SSR client expects.
|
|
57
|
-
*
|
|
58
|
-
* @supabase/ssr stores the session in a cookie whose name follows the pattern
|
|
59
|
-
* `sb-<project-ref>-auth-token`. The value is a base64url-encoded JSON string
|
|
60
|
-
* containing `access_token`, `refresh_token`, etc.
|
|
61
|
-
*
|
|
62
|
-
* For large tokens the library may chunk the cookie across multiple numbered
|
|
63
|
-
* cookies (sb-<ref>-auth-token.0, .1, ...). We produce a single cookie here
|
|
64
|
-
* which works for typical token sizes.
|
|
65
|
-
*/
|
|
66
|
-
function buildAuthCookie(): string {
|
|
67
|
-
const projectRef = SUPABASE_URL.match(/https:\/\/([^.]+)\./)?.[1] || 'ref';
|
|
68
|
-
const cookieName = `sb-${projectRef}-auth-token`;
|
|
69
|
-
const cookieValue = JSON.stringify({
|
|
70
|
-
access_token: accessToken,
|
|
71
|
-
refresh_token: refreshToken,
|
|
72
|
-
token_type: 'bearer',
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
// The SSR helper may also check chunked variants (.0, .1, …).
|
|
76
|
-
// Providing a single non-chunked cookie is accepted when the value fits in
|
|
77
|
-
// one cookie (< 3180 bytes). For very long JWTs we split into chunks.
|
|
78
|
-
const encoded = cookieValue;
|
|
79
|
-
const CHUNK_SIZE = 3180;
|
|
80
|
-
|
|
81
|
-
if (encoded.length <= CHUNK_SIZE) {
|
|
82
|
-
return `${cookieName}=${encodeURIComponent(encoded)}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Chunked cookies
|
|
86
|
-
const chunks: string[] = [];
|
|
87
|
-
for (let i = 0; i * CHUNK_SIZE < encoded.length; i++) {
|
|
88
|
-
const slice = encoded.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
89
|
-
chunks.push(`${cookieName}.${i}=${encodeURIComponent(slice)}`);
|
|
90
|
-
}
|
|
91
|
-
return chunks.join('; ');
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ---------------------------------------------------------------------------
|
|
95
|
-
// HTTP helpers (calls to the Differino Next.js API)
|
|
20
|
+
// HTTP helpers
|
|
96
21
|
// ---------------------------------------------------------------------------
|
|
97
22
|
|
|
98
23
|
async function apiCall(
|
|
@@ -101,21 +26,11 @@ async function apiCall(
|
|
|
101
26
|
): Promise<any> {
|
|
102
27
|
const url = `${BASE_URL}${endpoint}`;
|
|
103
28
|
const headers: Record<string, string> = {
|
|
29
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
104
30
|
...(options.headers as Record<string, string> || {}),
|
|
105
31
|
};
|
|
106
32
|
|
|
107
|
-
|
|
108
|
-
headers['Cookie'] = buildAuthCookie();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const res = await fetch(url, { ...options, headers, redirect: 'manual' });
|
|
112
|
-
|
|
113
|
-
// Handle redirects (auth middleware may redirect to /login)
|
|
114
|
-
if (res.status >= 300 && res.status < 400) {
|
|
115
|
-
throw new Error(
|
|
116
|
-
`API redirected (${res.status}) — session may have expired. Location: ${res.headers.get('location')}`,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
33
|
+
const res = await fetch(url, { ...options, headers });
|
|
119
34
|
|
|
120
35
|
const text = await res.text();
|
|
121
36
|
let json: any;
|
|
@@ -162,13 +77,10 @@ function getMimeType(ext: string): string {
|
|
|
162
77
|
async function compareDocuments(args: {
|
|
163
78
|
file_a_path: string;
|
|
164
79
|
file_b_path: string;
|
|
165
|
-
accuracy_mode?: string;
|
|
166
80
|
}): Promise<string> {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const { file_a_path, file_b_path, accuracy_mode = 'balanced' } = args;
|
|
81
|
+
const { file_a_path, file_b_path } = args;
|
|
170
82
|
|
|
171
|
-
//
|
|
83
|
+
// Validate files exist
|
|
172
84
|
for (const fp of [file_a_path, file_b_path]) {
|
|
173
85
|
if (!fs.existsSync(fp)) {
|
|
174
86
|
return `Error: File not found: ${fp}`;
|
|
@@ -187,321 +99,56 @@ async function compareDocuments(args: {
|
|
|
187
99
|
return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
|
|
188
100
|
}
|
|
189
101
|
|
|
190
|
-
//
|
|
102
|
+
// Build multipart form
|
|
103
|
+
const form = new FormData();
|
|
191
104
|
const fileABuffer = fs.readFileSync(file_a_path);
|
|
192
|
-
const fileASize = fileABuffer.byteLength;
|
|
193
|
-
|
|
194
|
-
// Use init/complete flow for files > 4MB, direct upload for smaller ones
|
|
195
|
-
let uploadA: { versionId: string; documentId: string };
|
|
196
|
-
let uploadB: { versionId: string; documentId: string };
|
|
197
|
-
|
|
198
|
-
if (fileASize < 4 * 1024 * 1024) {
|
|
199
|
-
// Direct upload via /api/upload
|
|
200
|
-
const formA = new FormData();
|
|
201
|
-
formA.append('file', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
|
|
202
|
-
formA.append('name', nameA);
|
|
203
|
-
|
|
204
|
-
uploadA = await apiCall('/api/upload', {
|
|
205
|
-
method: 'POST',
|
|
206
|
-
body: formA,
|
|
207
|
-
});
|
|
208
|
-
} else {
|
|
209
|
-
uploadA = await uploadLargeFile(file_a_path, nameA, fileABuffer, extA);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// --- Upload file B ---
|
|
213
105
|
const fileBBuffer = fs.readFileSync(file_b_path);
|
|
214
|
-
const fileBSize = fileBBuffer.byteLength;
|
|
215
|
-
|
|
216
|
-
if (fileBSize < 4 * 1024 * 1024) {
|
|
217
|
-
const formB = new FormData();
|
|
218
|
-
formB.append('file', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
|
|
219
|
-
formB.append('name', nameB);
|
|
220
|
-
|
|
221
|
-
uploadB = await apiCall('/api/upload', {
|
|
222
|
-
method: 'POST',
|
|
223
|
-
body: formB,
|
|
224
|
-
});
|
|
225
|
-
} else {
|
|
226
|
-
uploadB = await uploadLargeFile(file_b_path, nameB, fileBBuffer, extB);
|
|
227
|
-
}
|
|
228
106
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
.select('status')
|
|
239
|
-
.eq('id', vId)
|
|
240
|
-
.single();
|
|
241
|
-
|
|
242
|
-
if (data?.status === 'ready') break;
|
|
243
|
-
if (data?.status === 'failed') {
|
|
244
|
-
return `Error: Text extraction failed for version ${vId}.`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
await sleep(POLL_MS);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// --- Create comparison ---
|
|
252
|
-
const comparison = await apiCall('/api/comparisons', {
|
|
253
|
-
method: 'POST',
|
|
254
|
-
headers: { 'Content-Type': 'application/json' },
|
|
255
|
-
body: JSON.stringify({
|
|
256
|
-
versionAId: uploadA.versionId,
|
|
257
|
-
versionBId: uploadB.versionId,
|
|
258
|
-
accuracyMode: accuracy_mode,
|
|
259
|
-
}),
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
// --- Poll for comparison result ---
|
|
263
|
-
const compDeadline = Date.now() + MAX_WAIT_MS;
|
|
264
|
-
while (Date.now() < compDeadline) {
|
|
265
|
-
const { data } = await supabase!
|
|
266
|
-
.from('comparisons')
|
|
267
|
-
.select('status, summary, error_message')
|
|
268
|
-
.eq('id', comparison.id)
|
|
269
|
-
.single();
|
|
270
|
-
|
|
271
|
-
if (data?.status === 'ready') {
|
|
272
|
-
const summary = data.summary as Record<string, number> | null;
|
|
273
|
-
return JSON.stringify(
|
|
274
|
-
{
|
|
275
|
-
status: 'ready',
|
|
276
|
-
comparison_id: comparison.id,
|
|
277
|
-
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
278
|
-
summary: {
|
|
279
|
-
added_blocks: summary?.added_blocks ?? 0,
|
|
280
|
-
removed_blocks: summary?.removed_blocks ?? 0,
|
|
281
|
-
modified_blocks: summary?.modified_blocks ?? 0,
|
|
282
|
-
unchanged_blocks: summary?.unchanged_blocks ?? 0,
|
|
283
|
-
},
|
|
284
|
-
},
|
|
285
|
-
null,
|
|
286
|
-
2,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
if (data?.status === 'failed') {
|
|
291
|
-
return `Error: Comparison failed — ${data.error_message || 'unknown error'}`;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
await sleep(POLL_MS);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
return JSON.stringify(
|
|
298
|
-
{
|
|
299
|
-
status: 'processing',
|
|
300
|
-
comparison_id: comparison.id,
|
|
301
|
-
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
302
|
-
message: 'Comparison is still processing. Check the URL for results.',
|
|
303
|
-
},
|
|
304
|
-
null,
|
|
305
|
-
2,
|
|
107
|
+
form.append(
|
|
108
|
+
'file_a',
|
|
109
|
+
new Blob([fileABuffer], { type: getMimeType(extA) }),
|
|
110
|
+
nameA,
|
|
111
|
+
);
|
|
112
|
+
form.append(
|
|
113
|
+
'file_b',
|
|
114
|
+
new Blob([fileBBuffer], { type: getMimeType(extB) }),
|
|
115
|
+
nameB,
|
|
306
116
|
);
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* Upload a large file (>4 MB) via the init + signed-URL + complete flow,
|
|
311
|
-
* which bypasses Vercel's 4.5 MB request body limit.
|
|
312
|
-
*/
|
|
313
|
-
async function uploadLargeFile(
|
|
314
|
-
filePath: string,
|
|
315
|
-
filename: string,
|
|
316
|
-
buffer: Buffer,
|
|
317
|
-
ext: string,
|
|
318
|
-
): Promise<{ versionId: string; documentId: string }> {
|
|
319
|
-
const mime = getMimeType(ext);
|
|
320
|
-
|
|
321
|
-
// 1. Init — creates version record and returns a signed upload URL
|
|
322
|
-
const init = await apiCall('/api/upload/init', {
|
|
323
|
-
method: 'POST',
|
|
324
|
-
headers: { 'Content-Type': 'application/json' },
|
|
325
|
-
body: JSON.stringify({
|
|
326
|
-
filename,
|
|
327
|
-
contentType: mime,
|
|
328
|
-
fileSize: buffer.byteLength,
|
|
329
|
-
documentName: filename,
|
|
330
|
-
}),
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
// 2. Upload directly to Supabase Storage via the signed URL
|
|
334
|
-
const uploadRes = await fetch(init.signedUrl, {
|
|
335
|
-
method: 'PUT',
|
|
336
|
-
headers: { 'Content-Type': mime },
|
|
337
|
-
body: new Uint8Array(buffer),
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
if (!uploadRes.ok) {
|
|
341
|
-
const text = await uploadRes.text();
|
|
342
|
-
throw new Error(`Storage upload failed (${uploadRes.status}): ${text.slice(0, 200)}`);
|
|
343
|
-
}
|
|
344
117
|
|
|
345
|
-
//
|
|
346
|
-
const
|
|
118
|
+
// Call the REST API — it handles upload, extraction, comparison, and polling
|
|
119
|
+
const result = await apiCall('/api/v1/compare', {
|
|
347
120
|
method: 'POST',
|
|
348
|
-
|
|
349
|
-
body: JSON.stringify({ versionId: init.versionId }),
|
|
121
|
+
body: form,
|
|
350
122
|
});
|
|
351
123
|
|
|
352
|
-
return
|
|
353
|
-
versionId: init.versionId,
|
|
354
|
-
documentId: init.documentId,
|
|
355
|
-
};
|
|
124
|
+
return JSON.stringify(result, null, 2);
|
|
356
125
|
}
|
|
357
126
|
|
|
358
127
|
async function getComparison(args: { comparison_id: string }): Promise<string> {
|
|
359
|
-
await
|
|
360
|
-
|
|
361
|
-
const { data, error } = await supabase!
|
|
362
|
-
.from('comparisons')
|
|
363
|
-
.select(
|
|
364
|
-
`
|
|
365
|
-
id, status, summary, error_message, created_at, completed_at,
|
|
366
|
-
version_a:versions!version_a_id ( id, document_id, documents ( name ) ),
|
|
367
|
-
version_b:versions!version_b_id ( id, document_id, documents ( name ) )
|
|
368
|
-
`,
|
|
369
|
-
)
|
|
370
|
-
.eq('id', args.comparison_id)
|
|
371
|
-
.single();
|
|
372
|
-
|
|
373
|
-
if (error || !data) return 'Error: Comparison not found.';
|
|
374
|
-
|
|
375
|
-
const rec = data as any;
|
|
376
|
-
return JSON.stringify(
|
|
377
|
-
{
|
|
378
|
-
comparison_id: rec.id,
|
|
379
|
-
status: rec.status,
|
|
380
|
-
url: `${BASE_URL}/compare?id=${rec.id}`,
|
|
381
|
-
file_a: rec.version_a?.documents?.name ?? 'Unknown',
|
|
382
|
-
file_b: rec.version_b?.documents?.name ?? 'Unknown',
|
|
383
|
-
summary: rec.summary ?? null,
|
|
384
|
-
error: rec.error_message ?? null,
|
|
385
|
-
created_at: rec.created_at,
|
|
386
|
-
completed_at: rec.completed_at,
|
|
387
|
-
},
|
|
388
|
-
null,
|
|
389
|
-
2,
|
|
390
|
-
);
|
|
128
|
+
const result = await apiCall(`/api/v1/comparisons/${args.comparison_id}`);
|
|
129
|
+
return JSON.stringify(result, null, 2);
|
|
391
130
|
}
|
|
392
131
|
|
|
393
132
|
async function listComparisons(args: { limit?: number }): Promise<string> {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const limit = args.limit || 10;
|
|
397
|
-
|
|
398
|
-
const { data: workspace } = await supabase!
|
|
399
|
-
.from('workspaces')
|
|
400
|
-
.select('id')
|
|
401
|
-
.single();
|
|
402
|
-
|
|
403
|
-
if (!workspace) return 'Error: No workspace found.';
|
|
404
|
-
|
|
405
|
-
const { data, error } = await supabase!
|
|
406
|
-
.from('comparisons')
|
|
407
|
-
.select(
|
|
408
|
-
`
|
|
409
|
-
id, status, created_at,
|
|
410
|
-
version_a:versions!version_a_id ( documents ( name ) ),
|
|
411
|
-
version_b:versions!version_b_id ( documents ( name ) )
|
|
412
|
-
`,
|
|
413
|
-
)
|
|
414
|
-
.eq('workspace_id', workspace.id)
|
|
415
|
-
.order('created_at', { ascending: false })
|
|
416
|
-
.limit(limit);
|
|
417
|
-
|
|
418
|
-
if (error) return `Error: ${error.message}`;
|
|
419
|
-
|
|
420
|
-
const comparisons = (data || []).map((c: any) => ({
|
|
421
|
-
id: c.id,
|
|
422
|
-
status: c.status,
|
|
423
|
-
file_a: c.version_a?.documents?.name ?? 'Unknown',
|
|
424
|
-
file_b: c.version_b?.documents?.name ?? 'Unknown',
|
|
425
|
-
created_at: c.created_at,
|
|
426
|
-
url: `${BASE_URL}/compare?id=${c.id}`,
|
|
427
|
-
}));
|
|
428
|
-
|
|
429
|
-
return JSON.stringify(comparisons, null, 2);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
async function listDocuments(args: { limit?: number }): Promise<string> {
|
|
433
|
-
await ensureAuth();
|
|
434
|
-
|
|
435
|
-
const { data: workspace } = await supabase!
|
|
436
|
-
.from('workspaces')
|
|
437
|
-
.select('id')
|
|
438
|
-
.single();
|
|
439
|
-
|
|
440
|
-
if (!workspace) return 'Error: No workspace found.';
|
|
441
|
-
|
|
442
|
-
const { data, error } = await supabase!
|
|
443
|
-
.from('documents')
|
|
444
|
-
.select(
|
|
445
|
-
`
|
|
446
|
-
id, name, updated_at,
|
|
447
|
-
versions ( id, version_number, status, created_at )
|
|
448
|
-
`,
|
|
449
|
-
)
|
|
450
|
-
.eq('workspace_id', workspace.id)
|
|
451
|
-
.order('updated_at', { ascending: false })
|
|
452
|
-
.limit(args.limit || 20);
|
|
453
|
-
|
|
454
|
-
if (error) return `Error: ${error.message}`;
|
|
455
|
-
|
|
456
|
-
return JSON.stringify(data, null, 2);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
async function getCredits(): Promise<string> {
|
|
460
|
-
await ensureAuth();
|
|
461
|
-
|
|
462
|
-
const { data: workspace } = await supabase!
|
|
463
|
-
.from('workspaces')
|
|
464
|
-
.select('credits, plan, pro_expires_at')
|
|
465
|
-
.single();
|
|
466
|
-
|
|
467
|
-
if (!workspace) return 'Error: No workspace found.';
|
|
468
|
-
|
|
469
|
-
const now = new Date();
|
|
470
|
-
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
|
471
|
-
|
|
472
|
-
const { data: usage } = await supabase!
|
|
473
|
-
.from('usage')
|
|
474
|
-
.select('comparison_count')
|
|
475
|
-
.eq('month', monthKey)
|
|
476
|
-
.single();
|
|
477
|
-
|
|
133
|
+
// Use the internal session-less API — falls through to REST
|
|
134
|
+
// For now, this tool is simplified to just report the endpoint
|
|
478
135
|
return JSON.stringify(
|
|
479
136
|
{
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
free_comparisons_used: (usage as any)?.comparison_count ?? 0,
|
|
484
|
-
free_comparisons_limit: 3,
|
|
137
|
+
message:
|
|
138
|
+
'Use compare_documents to create comparisons, and get_comparison to check results.',
|
|
139
|
+
api_docs: `${BASE_URL}/mcp`,
|
|
485
140
|
},
|
|
486
141
|
null,
|
|
487
142
|
2,
|
|
488
143
|
);
|
|
489
144
|
}
|
|
490
145
|
|
|
491
|
-
// ---------------------------------------------------------------------------
|
|
492
|
-
// Utility
|
|
493
|
-
// ---------------------------------------------------------------------------
|
|
494
|
-
|
|
495
|
-
function sleep(ms: number): Promise<void> {
|
|
496
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
497
|
-
}
|
|
498
|
-
|
|
499
146
|
// ---------------------------------------------------------------------------
|
|
500
147
|
// MCP Server
|
|
501
148
|
// ---------------------------------------------------------------------------
|
|
502
149
|
|
|
503
150
|
const server = new Server(
|
|
504
|
-
{ name: 'differino', version: '0.
|
|
151
|
+
{ name: 'differino', version: '0.2.0' },
|
|
505
152
|
{ capabilities: { tools: {} } },
|
|
506
153
|
);
|
|
507
154
|
|
|
@@ -511,7 +158,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
511
158
|
name: 'compare_documents',
|
|
512
159
|
description:
|
|
513
160
|
'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
|
|
514
|
-
'Reads files from local disk, uploads them to Differino, and returns a diff summary.',
|
|
161
|
+
'Reads files from local disk, uploads them to Differino, and returns a diff summary with a URL to view the full comparison.',
|
|
515
162
|
inputSchema: {
|
|
516
163
|
type: 'object' as const,
|
|
517
164
|
properties: {
|
|
@@ -523,18 +170,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
523
170
|
type: 'string',
|
|
524
171
|
description: 'Absolute path to the modified document',
|
|
525
172
|
},
|
|
526
|
-
accuracy_mode: {
|
|
527
|
-
type: 'string',
|
|
528
|
-
enum: ['fast', 'balanced', 'thorough'],
|
|
529
|
-
description: 'Comparison accuracy mode (default: balanced)',
|
|
530
|
-
},
|
|
531
173
|
},
|
|
532
174
|
required: ['file_a_path', 'file_b_path'],
|
|
533
175
|
},
|
|
534
176
|
},
|
|
535
177
|
{
|
|
536
178
|
name: 'get_comparison',
|
|
537
|
-
description:
|
|
179
|
+
description:
|
|
180
|
+
'Get the status and results of a specific comparison by its ID.',
|
|
538
181
|
inputSchema: {
|
|
539
182
|
type: 'object' as const,
|
|
540
183
|
properties: {
|
|
@@ -546,41 +189,6 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
546
189
|
required: ['comparison_id'],
|
|
547
190
|
},
|
|
548
191
|
},
|
|
549
|
-
{
|
|
550
|
-
name: 'list_comparisons',
|
|
551
|
-
description: 'List recent document comparisons in the user\'s workspace.',
|
|
552
|
-
inputSchema: {
|
|
553
|
-
type: 'object' as const,
|
|
554
|
-
properties: {
|
|
555
|
-
limit: {
|
|
556
|
-
type: 'number',
|
|
557
|
-
description: 'Maximum number of comparisons to return (default: 10)',
|
|
558
|
-
},
|
|
559
|
-
},
|
|
560
|
-
},
|
|
561
|
-
},
|
|
562
|
-
{
|
|
563
|
-
name: 'list_documents',
|
|
564
|
-
description: 'List documents in the user\'s Differino library.',
|
|
565
|
-
inputSchema: {
|
|
566
|
-
type: 'object' as const,
|
|
567
|
-
properties: {
|
|
568
|
-
limit: {
|
|
569
|
-
type: 'number',
|
|
570
|
-
description: 'Maximum number of documents to return (default: 20)',
|
|
571
|
-
},
|
|
572
|
-
},
|
|
573
|
-
},
|
|
574
|
-
},
|
|
575
|
-
{
|
|
576
|
-
name: 'get_credits',
|
|
577
|
-
description:
|
|
578
|
-
'Check the user\'s credit balance, plan status, and free comparison usage for the current month.',
|
|
579
|
-
inputSchema: {
|
|
580
|
-
type: 'object' as const,
|
|
581
|
-
properties: {},
|
|
582
|
-
},
|
|
583
|
-
},
|
|
584
192
|
],
|
|
585
193
|
}));
|
|
586
194
|
|
|
@@ -597,15 +205,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
597
205
|
case 'get_comparison':
|
|
598
206
|
result = await getComparison(args as any);
|
|
599
207
|
break;
|
|
600
|
-
case 'list_comparisons':
|
|
601
|
-
result = await listComparisons(args as any);
|
|
602
|
-
break;
|
|
603
|
-
case 'list_documents':
|
|
604
|
-
result = await listDocuments(args as any);
|
|
605
|
-
break;
|
|
606
|
-
case 'get_credits':
|
|
607
|
-
result = await getCredits();
|
|
608
|
-
break;
|
|
609
208
|
default:
|
|
610
209
|
result = `Unknown tool: ${name}`;
|
|
611
210
|
}
|
|
@@ -627,29 +226,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
627
226
|
// ---------------------------------------------------------------------------
|
|
628
227
|
|
|
629
228
|
async function main(): Promise<void> {
|
|
630
|
-
|
|
631
|
-
if (!EMAIL || !PASSWORD) {
|
|
632
|
-
console.error(
|
|
633
|
-
'Error: DIFFERINO_EMAIL and DIFFERINO_PASSWORD environment variables are required.',
|
|
634
|
-
);
|
|
635
|
-
process.exit(1);
|
|
636
|
-
}
|
|
637
|
-
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
|
229
|
+
if (!API_KEY) {
|
|
638
230
|
console.error(
|
|
639
|
-
'Error:
|
|
231
|
+
'Error: DIFFERINO_API_KEY environment variable is required.\n' +
|
|
232
|
+
'Generate one at https://www.differino.com/settings',
|
|
640
233
|
);
|
|
641
234
|
process.exit(1);
|
|
642
235
|
}
|
|
643
236
|
|
|
644
|
-
// Authenticate on startup so we fail fast if credentials are wrong
|
|
645
|
-
try {
|
|
646
|
-
await authenticate();
|
|
647
|
-
} catch (err: unknown) {
|
|
648
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
649
|
-
console.error(message);
|
|
650
|
-
process.exit(1);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
237
|
const transport = new StdioServerTransport();
|
|
654
238
|
await server.connect(transport);
|
|
655
239
|
}
|