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 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 SUPABASE_URL = process.env.SUPABASE_URL ||
47
- process.env.DIFFERINO_SUPABASE_URL ||
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
- // Auth helpers
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
- if (accessToken) {
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
- await ensureAuth();
166
- const { file_a_path, file_b_path, accuracy_mode = 'balanced' } = args;
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
- // --- Upload file A ---
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
- const fileBSize = fileBBuffer.byteLength;
205
- if (fileBSize < 4 * 1024 * 1024) {
206
- const formB = new FormData();
207
- formB.append('file', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
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
- headers: { 'Content-Type': 'application/json' },
240
- body: JSON.stringify({
241
- versionAId: uploadA.versionId,
242
- versionBId: uploadB.versionId,
243
- accuracyMode: accuracy_mode,
244
- }),
119
+ body: form,
245
120
  });
246
- // --- Poll for comparison result ---
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 ensureAuth();
320
- const { data, error } = await supabase
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
- await ensureAuth();
346
- const limit = args.limit || 10;
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
- credits: workspace.credits,
413
- plan: workspace.plan,
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.1.0' }, { capabilities: { tools: {} } });
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
- // Validate required env vars
545
- if (!EMAIL || !PASSWORD) {
546
- console.error('Error: DIFFERINO_EMAIL and DIFFERINO_PASSWORD environment variables are required.');
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.1.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 SUPABASE_URL =
18
- process.env.SUPABASE_URL ||
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
- // Auth helpers
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
- if (accessToken) {
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
- await ensureAuth();
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
- // --- Validate files ---
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
- // --- Upload file A ---
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
- // --- Wait for text extraction ---
230
- const MAX_WAIT_MS = 120_000;
231
- const POLL_MS = 2_000;
232
-
233
- for (const vId of [uploadA.versionId, uploadB.versionId]) {
234
- const deadline = Date.now() + MAX_WAIT_MS;
235
- while (Date.now() < deadline) {
236
- const { data } = await supabase!
237
- .from('versions')
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
- // 3. Completeenqueues extraction job
346
- const complete = await apiCall('/api/upload/complete', {
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
- headers: { 'Content-Type': 'application/json' },
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 ensureAuth();
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
- await ensureAuth();
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
- credits: (workspace as any).credits,
481
- plan: (workspace as any).plan,
482
- pro_expires_at: (workspace as any).pro_expires_at,
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.1.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: 'Get the status and results of a specific comparison by its ID.',
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
- // Validate required env vars
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: SUPABASE_URL and SUPABASE_ANON_KEY environment variables are required.',
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
  }