differino-mcp 0.1.0 → 0.4.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/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,107 +13,52 @@ 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';
18
+
19
+ const REQUEST_TIMEOUT_MS = 300_000; // 5 minutes: compare can wait on extraction + diff
20
+ const EXPORT_POLL_INTERVAL_MS = 2_000;
21
+ const EXPORT_POLL_TIMEOUT_MS = 180_000; // 3 minutes
32
22
 
33
23
  // ---------------------------------------------------------------------------
34
- // Auth helpers
24
+ // HTTP helpers
35
25
  // ---------------------------------------------------------------------------
36
26
 
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('; ');
27
+ function isTimeoutError(err: unknown): boolean {
28
+ return (
29
+ err instanceof Error &&
30
+ (err.name === 'TimeoutError' ||
31
+ err.name === 'AbortError' ||
32
+ (err.cause instanceof Error &&
33
+ (err.cause.name === 'TimeoutError' || err.cause.name === 'AbortError')))
34
+ );
92
35
  }
93
36
 
94
- // ---------------------------------------------------------------------------
95
- // HTTP helpers (calls to the Differino Next.js API)
96
- // ---------------------------------------------------------------------------
97
-
98
37
  async function apiCall(
99
38
  endpoint: string,
100
39
  options: RequestInit = {},
101
40
  ): Promise<any> {
102
41
  const url = `${BASE_URL}${endpoint}`;
103
42
  const headers: Record<string, string> = {
43
+ Authorization: `Bearer ${API_KEY}`,
104
44
  ...(options.headers as Record<string, string> || {}),
105
45
  };
106
46
 
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
- );
47
+ let res: Response;
48
+ try {
49
+ res = await fetch(url, {
50
+ ...options,
51
+ headers,
52
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
53
+ });
54
+ } catch (err) {
55
+ if (isTimeoutError(err)) {
56
+ throw new Error(
57
+ `Request to ${endpoint} timed out after ${REQUEST_TIMEOUT_MS / 1000}s. ` +
58
+ 'The operation may still be running on the server; use get_comparison or list_comparisons to check its status.',
59
+ );
60
+ }
61
+ throw err;
118
62
  }
119
63
 
120
64
  const text = await res.text();
@@ -122,16 +66,22 @@ async function apiCall(
122
66
  try {
123
67
  json = JSON.parse(text);
124
68
  } catch {
125
- throw new Error(`Non-JSON response from ${endpoint}: ${text.slice(0, 200)}`);
69
+ throw new Error(`Non-JSON response from ${endpoint} (HTTP ${res.status}): ${text.slice(0, 200)}`);
126
70
  }
127
71
 
128
72
  if (!res.ok) {
129
- throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
73
+ const message = json.error || `API error ${res.status}: ${text.slice(0, 300)}`;
74
+ // Surface machine-readable error codes (e.g. NO_CREDITS) to the agent.
75
+ throw new Error(json.code ? `[${json.code}] ${message}` : message);
130
76
  }
131
77
 
132
78
  return json;
133
79
  }
134
80
 
81
+ function sleep(ms: number): Promise<void> {
82
+ return new Promise((resolve) => setTimeout(resolve, ms));
83
+ }
84
+
135
85
  // ---------------------------------------------------------------------------
136
86
  // File utilities
137
87
  // ---------------------------------------------------------------------------
@@ -162,16 +112,14 @@ function getMimeType(ext: string): string {
162
112
  async function compareDocuments(args: {
163
113
  file_a_path: string;
164
114
  file_b_path: string;
165
- accuracy_mode?: string;
115
+ comparison_mode?: 'visual' | 'text';
166
116
  }): Promise<string> {
167
- await ensureAuth();
168
-
169
- const { file_a_path, file_b_path, accuracy_mode = 'balanced' } = args;
117
+ const { file_a_path, file_b_path, comparison_mode = 'visual' } = args;
170
118
 
171
- // --- Validate files ---
119
+ // Validate files exist
172
120
  for (const fp of [file_a_path, file_b_path]) {
173
121
  if (!fs.existsSync(fp)) {
174
- return `Error: File not found: ${fp}`;
122
+ throw new Error(`File not found: ${fp}`);
175
123
  }
176
124
  }
177
125
 
@@ -181,327 +129,325 @@ async function compareDocuments(args: {
181
129
  const extB = getExtension(nameB);
182
130
 
183
131
  if (!SUPPORTED_EXTENSIONS.has(extA)) {
184
- return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
132
+ throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
185
133
  }
186
134
  if (!SUPPORTED_EXTENSIONS.has(extB)) {
187
- return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
135
+ throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
188
136
  }
189
137
 
190
- // --- Upload file A ---
138
+ // Build multipart form
139
+ const form = new FormData();
191
140
  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);
141
+ const fileBBuffer = fs.readFileSync(file_b_path);
203
142
 
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
- }
143
+ form.append(
144
+ 'file_a',
145
+ new Blob([fileABuffer], { type: getMimeType(extA) }),
146
+ nameA,
147
+ );
148
+ form.append(
149
+ 'file_b',
150
+ new Blob([fileBBuffer], { type: getMimeType(extB) }),
151
+ nameB,
152
+ );
153
+ form.append('comparison_mode', comparison_mode);
211
154
 
212
- // --- Upload file B ---
213
- const fileBBuffer = fs.readFileSync(file_b_path);
214
- const fileBSize = fileBBuffer.byteLength;
155
+ // Call the REST API. It handles upload, extraction, comparison, and polling.
156
+ const result = await apiCall('/api/v1/compare', {
157
+ method: 'POST',
158
+ body: form,
159
+ });
215
160
 
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);
161
+ return JSON.stringify(result, null, 2);
162
+ }
220
163
 
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
- }
164
+ async function getComparison(args: { comparison_id: string }): Promise<string> {
165
+ const result = await apiCall(`/api/v1/comparisons/${args.comparison_id}`);
166
+ return JSON.stringify(result, null, 2);
167
+ }
228
168
 
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
- }
169
+ async function listComparisons(args: {
170
+ limit?: number;
171
+ status?: string;
172
+ }): Promise<string> {
173
+ const params = new URLSearchParams();
174
+ if (args.limit !== undefined) params.set('limit', String(args.limit));
175
+ if (args.status) params.set('status', args.status);
176
+ const qs = params.toString();
246
177
 
247
- await sleep(POLL_MS);
248
- }
249
- }
178
+ const result = await apiCall(`/api/v1/comparisons${qs ? `?${qs}` : ''}`);
179
+ return JSON.stringify(result, null, 2);
180
+ }
250
181
 
251
- // --- Create comparison ---
252
- const comparison = await apiCall('/api/comparisons', {
182
+ async function exportComparisonPdf(args: {
183
+ comparison_id: string;
184
+ include_unchanged?: boolean;
185
+ locale?: string;
186
+ wait?: boolean;
187
+ }): Promise<string> {
188
+ const {
189
+ comparison_id,
190
+ include_unchanged = false,
191
+ locale = 'en',
192
+ wait = true,
193
+ } = args;
194
+
195
+ const created = await apiCall(`/api/v1/comparisons/${comparison_id}/export`, {
253
196
  method: 'POST',
254
197
  headers: { 'Content-Type': 'application/json' },
255
198
  body: JSON.stringify({
256
- versionAId: uploadA.versionId,
257
- versionBId: uploadB.versionId,
258
- accuracyMode: accuracy_mode,
199
+ format: 'pdf',
200
+ includeUnchanged: include_unchanged,
201
+ locale,
259
202
  }),
260
203
  });
261
204
 
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;
205
+ const jobId: string = created.jobId;
206
+ const statusEndpoint = `/api/v1/comparisons/${comparison_id}/export?jobId=${encodeURIComponent(jobId)}`;
207
+
208
+ if (!wait) {
209
+ return JSON.stringify(
210
+ {
211
+ jobId,
212
+ status: created.status ?? 'processing',
213
+ message:
214
+ 'Export queued. Call export_comparison_pdf again with wait=true, or poll ' +
215
+ `GET ${statusEndpoint} to get the downloadUrl.`,
216
+ },
217
+ null,
218
+ 2,
219
+ );
220
+ }
221
+
222
+ const deadline = Date.now() + EXPORT_POLL_TIMEOUT_MS;
223
+ while (Date.now() < deadline) {
224
+ await sleep(EXPORT_POLL_INTERVAL_MS);
225
+ const statusRes = await apiCall(statusEndpoint);
226
+
227
+ if (statusRes.status === 'completed') {
273
228
  return JSON.stringify(
274
229
  {
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
- },
230
+ jobId,
231
+ status: 'completed',
232
+ downloadUrl: statusRes.downloadUrl,
233
+ note: 'The download URL is signed and expires in about 5 minutes.',
284
234
  },
285
235
  null,
286
236
  2,
287
237
  );
288
238
  }
289
-
290
- if (data?.status === 'failed') {
291
- return `Error: Comparison failed — ${data.error_message || 'unknown error'}`;
239
+ if (statusRes.status === 'failed') {
240
+ throw new Error(`Export failed: ${statusRes.error || 'unknown error'}`);
292
241
  }
293
-
294
- await sleep(POLL_MS);
295
242
  }
296
243
 
297
244
  return JSON.stringify(
298
245
  {
246
+ jobId,
299
247
  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.',
248
+ message:
249
+ `Export still processing after ${EXPORT_POLL_TIMEOUT_MS / 1000}s. ` +
250
+ `Poll GET ${statusEndpoint} for the downloadUrl.`,
303
251
  },
304
252
  null,
305
253
  2,
306
254
  );
307
255
  }
308
256
 
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
- });
257
+ // Prominence order used by min_salience filtering, lowest to highest.
258
+ const SALIENCE_RANK: Record<string, number> = {
259
+ technical: 0,
260
+ subtle: 1,
261
+ visible: 2,
262
+ structural: 3,
263
+ };
264
+
265
+ async function summarizeVisualDiff(args: {
266
+ comparison_id: string;
267
+ min_salience?: string;
268
+ include_snippets?: boolean;
269
+ max_changes_per_page?: number;
270
+ }): Promise<string> {
271
+ const {
272
+ comparison_id,
273
+ min_salience,
274
+ include_snippets = true,
275
+ max_changes_per_page = 20,
276
+ } = args;
277
+
278
+ const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
279
+ const visual = comparison.visual ?? {};
280
+ const manifest = visual.manifest;
281
+
282
+ if (visual.status !== 'ready' || !manifest) {
283
+ const hint =
284
+ comparison.status !== 'ready'
285
+ ? 'The comparison is still processing; retry once get_comparison reports status=ready.'
286
+ : 'This comparison has no visual manifest (it may be a text-mode comparison). Use get_text_diff instead.';
287
+ throw new Error(
288
+ `Visual diff not available (comparison status=${comparison.status}, visual.status=${visual.status ?? 'unknown'}). ${hint}`,
289
+ );
290
+ }
332
291
 
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
- });
292
+ const minRank = min_salience ? SALIENCE_RANK[min_salience] ?? 0 : 0;
293
+ const seenGroups = new Set<string>();
294
+ const pages: Array<Record<string, unknown>> = [];
295
+ let listedChanges = 0;
296
+ let filteredBySalience = 0;
297
+
298
+ for (const page of manifest.pages ?? []) {
299
+ const regions: any[] = page.regions ?? [];
300
+ const changes: Array<Record<string, unknown>> = [];
301
+ let omittedChanges = 0;
302
+
303
+ for (const region of regions) {
304
+ const groupKey: string = region.groupId ?? region.id;
305
+ // Fragments of the same change (e.g. across page boundaries) share a
306
+ // groupId; report each change once.
307
+ if (seenGroups.has(groupKey)) continue;
308
+ seenGroups.add(groupKey);
309
+
310
+ const salience: string = region.salience ?? 'visible';
311
+ if ((SALIENCE_RANK[salience] ?? SALIENCE_RANK.visible) < minRank) {
312
+ filteredBySalience += 1;
313
+ continue;
314
+ }
339
315
 
340
- if (!uploadRes.ok) {
341
- const text = await uploadRes.text();
342
- throw new Error(`Storage upload failed (${uploadRes.status}): ${text.slice(0, 200)}`);
343
- }
316
+ if (changes.length >= max_changes_per_page) {
317
+ omittedChanges += 1;
318
+ continue;
319
+ }
344
320
 
345
- // 3. Complete — enqueues extraction job
346
- const complete = await apiCall('/api/upload/complete', {
347
- method: 'POST',
348
- headers: { 'Content-Type': 'application/json' },
349
- body: JSON.stringify({ versionId: init.versionId }),
350
- });
321
+ changes.push({
322
+ groupId: groupKey,
323
+ type: region.type ?? 'changed',
324
+ title: region.title ?? null,
325
+ ...(region.description ? { description: region.description } : {}),
326
+ salience: region.salience ?? null,
327
+ ...(region.salienceReason ? { salienceReason: region.salienceReason } : {}),
328
+ ...(include_snippets
329
+ ? {
330
+ snippetA: region.snippetA ?? null,
331
+ snippetB: region.snippetB ?? null,
332
+ }
333
+ : {}),
334
+ });
335
+ listedChanges += 1;
336
+ }
351
337
 
352
- return {
353
- versionId: init.versionId,
354
- documentId: init.documentId,
355
- };
356
- }
338
+ if (changes.length > 0 || omittedChanges > 0) {
339
+ pages.push({
340
+ pageNumber: page.pageNumber,
341
+ changes,
342
+ ...(omittedChanges > 0
343
+ ? {
344
+ omittedChanges,
345
+ note: `Increase max_changes_per_page to see the ${omittedChanges} omitted change(s) on this page.`,
346
+ }
347
+ : {}),
348
+ });
349
+ }
350
+ }
357
351
 
358
- 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
352
  return JSON.stringify(
377
353
  {
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,
354
+ comparisonId: comparison.id ?? comparison_id,
355
+ status: comparison.status,
356
+ totalChanges: manifest.totalChanges ?? null,
357
+ changedPageCount: manifest.changedPageCount ?? null,
358
+ listedChanges,
359
+ ...(min_salience ? { minSalience: min_salience, filteredBySalience } : {}),
360
+ pages,
387
361
  },
388
362
  null,
389
363
  2,
390
364
  );
391
365
  }
392
366
 
393
- 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
- }
367
+ async function getTextDiff(args: {
368
+ comparison_id: string;
369
+ only_changed?: boolean;
370
+ max_blocks?: number;
371
+ }): Promise<string> {
372
+ const { comparison_id, only_changed = true, max_blocks = 100 } = args;
458
373
 
459
- async function getCredits(): Promise<string> {
460
- await ensureAuth();
374
+ const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
461
375
 
462
- const { data: workspace } = await supabase!
463
- .from('workspaces')
464
- .select('credits, plan, pro_expires_at')
465
- .single();
376
+ if (comparison.status !== 'ready') {
377
+ throw new Error(
378
+ `Comparison is not ready yet (status=${comparison.status}). Retry once get_comparison reports status=ready.`,
379
+ );
380
+ }
381
+ if (!comparison.diffUrl) {
382
+ throw new Error('No text diff is available for this comparison.');
383
+ }
466
384
 
467
- if (!workspace) return 'Error: No workspace found.';
385
+ // diffUrl is a signed URL; no auth header needed.
386
+ let res: Response;
387
+ try {
388
+ res = await fetch(comparison.diffUrl, {
389
+ signal: AbortSignal.timeout(60_000),
390
+ });
391
+ } catch (err) {
392
+ if (isTimeoutError(err)) {
393
+ throw new Error('Timed out downloading the diff data after 60s. Try again.');
394
+ }
395
+ throw err;
396
+ }
397
+ if (!res.ok) {
398
+ throw new Error(`Failed to download diff data (HTTP ${res.status}).`);
399
+ }
468
400
 
469
- const now = new Date();
470
- const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
401
+ const diff = (await res.json()) as {
402
+ summary?: Record<string, unknown>;
403
+ blocks?: Array<{
404
+ type: string;
405
+ content_a?: string;
406
+ content_b?: string;
407
+ spans?: Array<{ type: string; text: string }>;
408
+ }>;
409
+ };
471
410
 
472
- const { data: usage } = await supabase!
473
- .from('usage')
474
- .select('comparison_count')
475
- .eq('month', monthKey)
476
- .single();
411
+ const blocks = Array.isArray(diff.blocks) ? diff.blocks : [];
412
+ const matching = only_changed
413
+ ? blocks.filter((block) => block.type !== 'equal')
414
+ : blocks;
415
+ const truncated = matching.length > max_blocks;
416
+
417
+ const outputBlocks = matching.slice(0, max_blocks).map((block) => ({
418
+ type: block.type,
419
+ ...(block.content_a !== undefined ? { content_a: block.content_a } : {}),
420
+ ...(block.content_b !== undefined ? { content_b: block.content_b } : {}),
421
+ // Word-level spans are only informative for modified blocks.
422
+ ...(block.type === 'modified' && Array.isArray(block.spans) && block.spans.length > 0
423
+ ? { spans: block.spans }
424
+ : {}),
425
+ }));
477
426
 
478
427
  return JSON.stringify(
479
428
  {
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,
429
+ comparisonId: comparison.id ?? comparison_id,
430
+ summary: diff.summary ?? null,
431
+ only_changed,
432
+ returned_blocks: outputBlocks.length,
433
+ total_matching_blocks: matching.length,
434
+ truncated,
435
+ ...(truncated
436
+ ? { note: `Output truncated to max_blocks=${max_blocks}. Increase max_blocks to see more.` }
437
+ : {}),
438
+ blocks: outputBlocks,
485
439
  },
486
440
  null,
487
441
  2,
488
442
  );
489
443
  }
490
444
 
491
- // ---------------------------------------------------------------------------
492
- // Utility
493
- // ---------------------------------------------------------------------------
494
-
495
- function sleep(ms: number): Promise<void> {
496
- return new Promise((resolve) => setTimeout(resolve, ms));
497
- }
498
-
499
445
  // ---------------------------------------------------------------------------
500
446
  // MCP Server
501
447
  // ---------------------------------------------------------------------------
502
448
 
503
449
  const server = new Server(
504
- { name: 'differino', version: '0.1.0' },
450
+ { name: 'differino', version: '0.4.0' },
505
451
  { capabilities: { tools: {} } },
506
452
  );
507
453
 
@@ -511,7 +457,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
511
457
  name: 'compare_documents',
512
458
  description:
513
459
  '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.',
460
+ 'Reads files from local disk, uploads them to Differino, and returns a text diff summary. Use comparison_mode="visual" for the full document-review manifest with page images, red/green change regions, stable groupId values, and salience metadata; use comparison_mode="text" for faster text-only comparison. ' +
461
+ 'If the response has status="processing", the comparison is still running: poll get_comparison with the returned id until status="ready". Consumes the free comparison or 1 credit.',
515
462
  inputSchema: {
516
463
  type: 'object' as const,
517
464
  properties: {
@@ -523,10 +470,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
523
470
  type: 'string',
524
471
  description: 'Absolute path to the modified document',
525
472
  },
526
- accuracy_mode: {
473
+ comparison_mode: {
527
474
  type: 'string',
528
- enum: ['fast', 'balanced', 'thorough'],
529
- description: 'Comparison accuracy mode (default: balanced)',
475
+ enum: ['visual', 'text'],
476
+ description: 'visual for full document review, or text for fast text-only comparison',
477
+ default: 'visual',
530
478
  },
531
479
  },
532
480
  required: ['file_a_path', 'file_b_path'],
@@ -534,7 +482,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
534
482
  },
535
483
  {
536
484
  name: 'get_comparison',
537
- description: 'Get the status and results of a specific comparison by its ID.',
485
+ description:
486
+ 'Get the status and results of a specific comparison by its ID, including visual.status and the visual.manifest document-review contract with grouped change identities and salience metadata when visual artifacts are available. ' +
487
+ 'Use it to poll a comparison that is still processing, or to re-fetch results (including fresh signed URLs) for an existing comparison. Does not consume credits.',
538
488
  inputSchema: {
539
489
  type: 'object' as const,
540
490
  properties: {
@@ -548,37 +498,114 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
548
498
  },
549
499
  {
550
500
  name: 'list_comparisons',
551
- description: 'List recent document comparisons in the user\'s workspace.',
501
+ description:
502
+ 'List recent comparisons in the workspace, newest first. Returns for each comparison: id, status, comparisonMode, file_a/file_b name and type, summary counters, created_at, and completed_at. ' +
503
+ 'Use it to find a comparison id when you do not have one, to check recent activity, or to locate unfinished comparisons (status filter). Does not consume credits.',
552
504
  inputSchema: {
553
505
  type: 'object' as const,
554
506
  properties: {
555
507
  limit: {
556
508
  type: 'number',
557
- description: 'Maximum number of comparisons to return (default: 10)',
509
+ description: 'Maximum number of comparisons to return (1-50)',
510
+ default: 10,
511
+ },
512
+ status: {
513
+ type: 'string',
514
+ enum: ['pending', 'processing', 'ready', 'failed'],
515
+ description: 'Only return comparisons with this status',
558
516
  },
559
517
  },
518
+ required: [],
560
519
  },
561
520
  },
562
521
  {
563
- name: 'list_documents',
564
- description: 'List documents in the user\'s Differino library.',
522
+ name: 'export_comparison_pdf',
523
+ description:
524
+ 'Export a finished comparison as a PDF report and return a temporary download URL (valid for about 5 minutes). ' +
525
+ 'With wait=true (default) it polls the export job every 2 seconds for up to 3 minutes and returns { jobId, status: "completed", downloadUrl }. With wait=false it returns { jobId, status } immediately; call again later or poll the REST endpoint to fetch the URL. ' +
526
+ 'The comparison must have status="ready". Free workspaces get a watermarked PDF; workspaces with credits export without watermark. Does not consume credits.',
565
527
  inputSchema: {
566
528
  type: 'object' as const,
567
529
  properties: {
568
- limit: {
530
+ comparison_id: {
531
+ type: 'string',
532
+ description: 'The comparison UUID',
533
+ },
534
+ include_unchanged: {
535
+ type: 'boolean',
536
+ description: 'Include unchanged content in the PDF report, not only the changes',
537
+ default: false,
538
+ },
539
+ locale: {
540
+ type: 'string',
541
+ enum: ['en', 'es', 'zh', 'fr', 'de', 'hi'],
542
+ description: 'Language for the report labels',
543
+ default: 'en',
544
+ },
545
+ wait: {
546
+ type: 'boolean',
547
+ description: 'Poll until the export completes (up to 3 minutes) and return the downloadUrl',
548
+ default: true,
549
+ },
550
+ },
551
+ required: ['comparison_id'],
552
+ },
553
+ },
554
+ {
555
+ name: 'summarize_visual_diff',
556
+ description:
557
+ 'Compact page-by-page summary of the visual diff of a finished visual comparison: one entry per change group (fragments sharing a groupId are deduplicated), with type, title, salience, and optional text snippets. No image URLs and no pixel coordinates, so it is the cheapest way for an agent to reason about what changed and where. ' +
558
+ 'Use min_salience to hide low-prominence changes; prominence order is technical < subtle < visible < structural. Requires visual.status="ready"; for text-mode comparisons use get_text_diff instead. Does not consume credits.',
559
+ inputSchema: {
560
+ type: 'object' as const,
561
+ properties: {
562
+ comparison_id: {
563
+ type: 'string',
564
+ description: 'The comparison UUID',
565
+ },
566
+ min_salience: {
567
+ type: 'string',
568
+ enum: ['technical', 'subtle', 'visible', 'structural'],
569
+ description: 'Only include changes at or above this prominence level',
570
+ },
571
+ include_snippets: {
572
+ type: 'boolean',
573
+ description: 'Include snippetA/snippetB text excerpts for each change',
574
+ default: true,
575
+ },
576
+ max_changes_per_page: {
569
577
  type: 'number',
570
- description: 'Maximum number of documents to return (default: 20)',
578
+ description: 'Maximum changes listed per page; extra changes are counted as omitted',
579
+ default: 20,
571
580
  },
572
581
  },
582
+ required: ['comparison_id'],
573
583
  },
574
584
  },
575
585
  {
576
- name: 'get_credits',
586
+ name: 'get_text_diff',
577
587
  description:
578
- 'Check the user\'s credit balance, plan status, and free comparison usage for the current month.',
588
+ 'Fetch the block-level text diff of a finished comparison. Returns summary counters plus diff blocks with their content: added and removed blocks carry the affected text, modified blocks also carry word-level spans marking exactly which words changed. ' +
589
+ 'By default only changed blocks are returned (only_changed=true); set only_changed=false to include equal blocks for full context. Best when you need to quote exact text changes. Works for both text and visual comparisons. Does not consume credits.',
579
590
  inputSchema: {
580
591
  type: 'object' as const,
581
- properties: {},
592
+ properties: {
593
+ comparison_id: {
594
+ type: 'string',
595
+ description: 'The comparison UUID',
596
+ },
597
+ only_changed: {
598
+ type: 'boolean',
599
+ description: 'Return only added/removed/modified blocks, skipping equal ones',
600
+ default: true,
601
+ },
602
+ max_blocks: {
603
+ type: 'number',
604
+ description: 'Maximum number of blocks to return',
605
+ default: 100,
606
+ },
607
+ },
608
+ required: ['comparison_id'],
582
609
  },
583
610
  },
584
611
  ],
@@ -600,14 +627,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
600
627
  case 'list_comparisons':
601
628
  result = await listComparisons(args as any);
602
629
  break;
603
- case 'list_documents':
604
- result = await listDocuments(args as any);
630
+ case 'export_comparison_pdf':
631
+ result = await exportComparisonPdf(args as any);
632
+ break;
633
+ case 'summarize_visual_diff':
634
+ result = await summarizeVisualDiff(args as any);
605
635
  break;
606
- case 'get_credits':
607
- result = await getCredits();
636
+ case 'get_text_diff':
637
+ result = await getTextDiff(args as any);
608
638
  break;
609
639
  default:
610
- result = `Unknown tool: ${name}`;
640
+ throw new Error(`Unknown tool: ${name}`);
611
641
  }
612
642
 
613
643
  return {
@@ -627,28 +657,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
627
657
  // ---------------------------------------------------------------------------
628
658
 
629
659
  async function main(): Promise<void> {
630
- // Validate required env vars
631
- if (!EMAIL || !PASSWORD) {
660
+ if (!API_KEY) {
632
661
  console.error(
633
- 'Error: DIFFERINO_EMAIL and DIFFERINO_PASSWORD environment variables are required.',
662
+ 'Error: DIFFERINO_API_KEY environment variable is required.\n' +
663
+ 'Generate one at https://www.differino.com/settings',
634
664
  );
635
665
  process.exit(1);
636
666
  }
637
- if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
638
- console.error(
639
- 'Error: SUPABASE_URL and SUPABASE_ANON_KEY environment variables are required.',
640
- );
641
- process.exit(1);
642
- }
643
-
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
667
 
653
668
  const transport = new StdioServerTransport();
654
669
  await server.connect(transport);