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/dist/index.js CHANGED
@@ -37,94 +37,46 @@ 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';
47
+ const REQUEST_TIMEOUT_MS = 300_000; // 5 minutes: compare can wait on extraction + diff
48
+ const EXPORT_POLL_INTERVAL_MS = 2_000;
49
+ const EXPORT_POLL_TIMEOUT_MS = 180_000; // 3 minutes
58
50
  // ---------------------------------------------------------------------------
59
- // Auth helpers
51
+ // HTTP helpers
60
52
  // ---------------------------------------------------------------------------
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('; ');
53
+ function isTimeoutError(err) {
54
+ return (err instanceof Error &&
55
+ (err.name === 'TimeoutError' ||
56
+ err.name === 'AbortError' ||
57
+ (err.cause instanceof Error &&
58
+ (err.cause.name === 'TimeoutError' || err.cause.name === 'AbortError'))));
112
59
  }
113
- // ---------------------------------------------------------------------------
114
- // HTTP helpers (calls to the Differino Next.js API)
115
- // ---------------------------------------------------------------------------
116
60
  async function apiCall(endpoint, options = {}) {
117
61
  const url = `${BASE_URL}${endpoint}`;
118
62
  const headers = {
63
+ Authorization: `Bearer ${API_KEY}`,
119
64
  ...(options.headers || {}),
120
65
  };
121
- if (accessToken) {
122
- headers['Cookie'] = buildAuthCookie();
66
+ let res;
67
+ try {
68
+ res = await fetch(url, {
69
+ ...options,
70
+ headers,
71
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
72
+ });
123
73
  }
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')}`);
74
+ catch (err) {
75
+ if (isTimeoutError(err)) {
76
+ throw new Error(`Request to ${endpoint} timed out after ${REQUEST_TIMEOUT_MS / 1000}s. ` +
77
+ 'The operation may still be running on the server; use get_comparison or list_comparisons to check its status.');
78
+ }
79
+ throw err;
128
80
  }
129
81
  const text = await res.text();
130
82
  let json;
@@ -132,13 +84,18 @@ async function apiCall(endpoint, options = {}) {
132
84
  json = JSON.parse(text);
133
85
  }
134
86
  catch {
135
- throw new Error(`Non-JSON response from ${endpoint}: ${text.slice(0, 200)}`);
87
+ throw new Error(`Non-JSON response from ${endpoint} (HTTP ${res.status}): ${text.slice(0, 200)}`);
136
88
  }
137
89
  if (!res.ok) {
138
- throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
90
+ const message = json.error || `API error ${res.status}: ${text.slice(0, 300)}`;
91
+ // Surface machine-readable error codes (e.g. NO_CREDITS) to the agent.
92
+ throw new Error(json.code ? `[${json.code}] ${message}` : message);
139
93
  }
140
94
  return json;
141
95
  }
96
+ function sleep(ms) {
97
+ return new Promise((resolve) => setTimeout(resolve, ms));
98
+ }
142
99
  // ---------------------------------------------------------------------------
143
100
  // File utilities
144
101
  // ---------------------------------------------------------------------------
@@ -162,12 +119,11 @@ function getMimeType(ext) {
162
119
  // Tool implementations
163
120
  // ---------------------------------------------------------------------------
164
121
  async function compareDocuments(args) {
165
- await ensureAuth();
166
- const { file_a_path, file_b_path, accuracy_mode = 'balanced' } = args;
167
- // --- Validate files ---
122
+ const { file_a_path, file_b_path, comparison_mode = 'visual' } = args;
123
+ // Validate files exist
168
124
  for (const fp of [file_a_path, file_b_path]) {
169
125
  if (!fs.existsSync(fp)) {
170
- return `Error: File not found: ${fp}`;
126
+ throw new Error(`File not found: ${fp}`);
171
127
  }
172
128
  }
173
129
  const nameA = path.basename(file_a_path);
@@ -175,263 +131,229 @@ async function compareDocuments(args) {
175
131
  const extA = getExtension(nameA);
176
132
  const extB = getExtension(nameB);
177
133
  if (!SUPPORTED_EXTENSIONS.has(extA)) {
178
- return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
134
+ throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
179
135
  }
180
136
  if (!SUPPORTED_EXTENSIONS.has(extB)) {
181
- return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
137
+ throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
182
138
  }
183
- // --- Upload file A ---
139
+ // Build multipart form
140
+ const form = new FormData();
184
141
  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
142
  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', {
143
+ form.append('file_a', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
144
+ form.append('file_b', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
145
+ form.append('comparison_mode', comparison_mode);
146
+ // Call the REST API. It handles upload, extraction, comparison, and polling.
147
+ const result = await apiCall('/api/v1/compare', {
148
+ method: 'POST',
149
+ body: form,
150
+ });
151
+ return JSON.stringify(result, null, 2);
152
+ }
153
+ async function getComparison(args) {
154
+ const result = await apiCall(`/api/v1/comparisons/${args.comparison_id}`);
155
+ return JSON.stringify(result, null, 2);
156
+ }
157
+ async function listComparisons(args) {
158
+ const params = new URLSearchParams();
159
+ if (args.limit !== undefined)
160
+ params.set('limit', String(args.limit));
161
+ if (args.status)
162
+ params.set('status', args.status);
163
+ const qs = params.toString();
164
+ const result = await apiCall(`/api/v1/comparisons${qs ? `?${qs}` : ''}`);
165
+ return JSON.stringify(result, null, 2);
166
+ }
167
+ async function exportComparisonPdf(args) {
168
+ const { comparison_id, include_unchanged = false, locale = 'en', wait = true, } = args;
169
+ const created = await apiCall(`/api/v1/comparisons/${comparison_id}/export`, {
238
170
  method: 'POST',
239
171
  headers: { 'Content-Type': 'application/json' },
240
172
  body: JSON.stringify({
241
- versionAId: uploadA.versionId,
242
- versionBId: uploadB.versionId,
243
- accuracyMode: accuracy_mode,
173
+ format: 'pdf',
174
+ includeUnchanged: include_unchanged,
175
+ locale,
244
176
  }),
245
177
  });
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;
178
+ const jobId = created.jobId;
179
+ const statusEndpoint = `/api/v1/comparisons/${comparison_id}/export?jobId=${encodeURIComponent(jobId)}`;
180
+ if (!wait) {
181
+ return JSON.stringify({
182
+ jobId,
183
+ status: created.status ?? 'processing',
184
+ message: 'Export queued. Call export_comparison_pdf again with wait=true, or poll ' +
185
+ `GET ${statusEndpoint} to get the downloadUrl.`,
186
+ }, null, 2);
187
+ }
188
+ const deadline = Date.now() + EXPORT_POLL_TIMEOUT_MS;
189
+ while (Date.now() < deadline) {
190
+ await sleep(EXPORT_POLL_INTERVAL_MS);
191
+ const statusRes = await apiCall(statusEndpoint);
192
+ if (statusRes.status === 'completed') {
256
193
  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
- },
194
+ jobId,
195
+ status: 'completed',
196
+ downloadUrl: statusRes.downloadUrl,
197
+ note: 'The download URL is signed and expires in about 5 minutes.',
266
198
  }, null, 2);
267
199
  }
268
- if (data?.status === 'failed') {
269
- return `Error: Comparison failed ${data.error_message || 'unknown error'}`;
200
+ if (statusRes.status === 'failed') {
201
+ throw new Error(`Export failed: ${statusRes.error || 'unknown error'}`);
270
202
  }
271
- await sleep(POLL_MS);
272
203
  }
273
204
  return JSON.stringify({
205
+ jobId,
274
206
  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.',
207
+ message: `Export still processing after ${EXPORT_POLL_TIMEOUT_MS / 1000}s. ` +
208
+ `Poll GET ${statusEndpoint} for the downloadUrl.`,
278
209
  }, null, 2);
279
210
  }
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)}`);
211
+ // Prominence order used by min_salience filtering, lowest to highest.
212
+ const SALIENCE_RANK = {
213
+ technical: 0,
214
+ subtle: 1,
215
+ visible: 2,
216
+ structural: 3,
217
+ };
218
+ async function summarizeVisualDiff(args) {
219
+ const { comparison_id, min_salience, include_snippets = true, max_changes_per_page = 20, } = args;
220
+ const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
221
+ const visual = comparison.visual ?? {};
222
+ const manifest = visual.manifest;
223
+ if (visual.status !== 'ready' || !manifest) {
224
+ const hint = comparison.status !== 'ready'
225
+ ? 'The comparison is still processing; retry once get_comparison reports status=ready.'
226
+ : 'This comparison has no visual manifest (it may be a text-mode comparison). Use get_text_diff instead.';
227
+ throw new Error(`Visual diff not available (comparison status=${comparison.status}, visual.status=${visual.status ?? 'unknown'}). ${hint}`);
228
+ }
229
+ const minRank = min_salience ? SALIENCE_RANK[min_salience] ?? 0 : 0;
230
+ const seenGroups = new Set();
231
+ const pages = [];
232
+ let listedChanges = 0;
233
+ let filteredBySalience = 0;
234
+ for (const page of manifest.pages ?? []) {
235
+ const regions = page.regions ?? [];
236
+ const changes = [];
237
+ let omittedChanges = 0;
238
+ for (const region of regions) {
239
+ const groupKey = region.groupId ?? region.id;
240
+ // Fragments of the same change (e.g. across page boundaries) share a
241
+ // groupId; report each change once.
242
+ if (seenGroups.has(groupKey))
243
+ continue;
244
+ seenGroups.add(groupKey);
245
+ const salience = region.salience ?? 'visible';
246
+ if ((SALIENCE_RANK[salience] ?? SALIENCE_RANK.visible) < minRank) {
247
+ filteredBySalience += 1;
248
+ continue;
249
+ }
250
+ if (changes.length >= max_changes_per_page) {
251
+ omittedChanges += 1;
252
+ continue;
253
+ }
254
+ changes.push({
255
+ groupId: groupKey,
256
+ type: region.type ?? 'changed',
257
+ title: region.title ?? null,
258
+ ...(region.description ? { description: region.description } : {}),
259
+ salience: region.salience ?? null,
260
+ ...(region.salienceReason ? { salienceReason: region.salienceReason } : {}),
261
+ ...(include_snippets
262
+ ? {
263
+ snippetA: region.snippetA ?? null,
264
+ snippetB: region.snippetB ?? null,
265
+ }
266
+ : {}),
267
+ });
268
+ listedChanges += 1;
269
+ }
270
+ if (changes.length > 0 || omittedChanges > 0) {
271
+ pages.push({
272
+ pageNumber: page.pageNumber,
273
+ changes,
274
+ ...(omittedChanges > 0
275
+ ? {
276
+ omittedChanges,
277
+ note: `Increase max_changes_per_page to see the ${omittedChanges} omitted change(s) on this page.`,
278
+ }
279
+ : {}),
280
+ });
281
+ }
306
282
  }
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
- };
317
- }
318
- 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
283
  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,
284
+ comparisonId: comparison.id ?? comparison_id,
285
+ status: comparison.status,
286
+ totalChanges: manifest.totalChanges ?? null,
287
+ changedPageCount: manifest.changedPageCount ?? null,
288
+ listedChanges,
289
+ ...(min_salience ? { minSalience: min_salience, filteredBySalience } : {}),
290
+ pages,
342
291
  }, null, 2);
343
292
  }
344
- 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}`,
293
+ async function getTextDiff(args) {
294
+ const { comparison_id, only_changed = true, max_blocks = 100 } = args;
295
+ const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
296
+ if (comparison.status !== 'ready') {
297
+ throw new Error(`Comparison is not ready yet (status=${comparison.status}). Retry once get_comparison reports status=ready.`);
298
+ }
299
+ if (!comparison.diffUrl) {
300
+ throw new Error('No text diff is available for this comparison.');
301
+ }
302
+ // diffUrl is a signed URL; no auth header needed.
303
+ let res;
304
+ try {
305
+ res = await fetch(comparison.diffUrl, {
306
+ signal: AbortSignal.timeout(60_000),
307
+ });
308
+ }
309
+ catch (err) {
310
+ if (isTimeoutError(err)) {
311
+ throw new Error('Timed out downloading the diff data after 60s. Try again.');
312
+ }
313
+ throw err;
314
+ }
315
+ if (!res.ok) {
316
+ throw new Error(`Failed to download diff data (HTTP ${res.status}).`);
317
+ }
318
+ const diff = (await res.json());
319
+ const blocks = Array.isArray(diff.blocks) ? diff.blocks : [];
320
+ const matching = only_changed
321
+ ? blocks.filter((block) => block.type !== 'equal')
322
+ : blocks;
323
+ const truncated = matching.length > max_blocks;
324
+ const outputBlocks = matching.slice(0, max_blocks).map((block) => ({
325
+ type: block.type,
326
+ ...(block.content_a !== undefined ? { content_a: block.content_a } : {}),
327
+ ...(block.content_b !== undefined ? { content_b: block.content_b } : {}),
328
+ // Word-level spans are only informative for modified blocks.
329
+ ...(block.type === 'modified' && Array.isArray(block.spans) && block.spans.length > 0
330
+ ? { spans: block.spans }
331
+ : {}),
372
332
  }));
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();
411
333
  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,
334
+ comparisonId: comparison.id ?? comparison_id,
335
+ summary: diff.summary ?? null,
336
+ only_changed,
337
+ returned_blocks: outputBlocks.length,
338
+ total_matching_blocks: matching.length,
339
+ truncated,
340
+ ...(truncated
341
+ ? { note: `Output truncated to max_blocks=${max_blocks}. Increase max_blocks to see more.` }
342
+ : {}),
343
+ blocks: outputBlocks,
417
344
  }, null, 2);
418
345
  }
419
346
  // ---------------------------------------------------------------------------
420
- // Utility
421
- // ---------------------------------------------------------------------------
422
- function sleep(ms) {
423
- return new Promise((resolve) => setTimeout(resolve, ms));
424
- }
425
- // ---------------------------------------------------------------------------
426
347
  // MCP Server
427
348
  // ---------------------------------------------------------------------------
428
- const server = new index_js_1.Server({ name: 'differino', version: '0.1.0' }, { capabilities: { tools: {} } });
349
+ const server = new index_js_1.Server({ name: 'differino', version: '0.4.0' }, { capabilities: { tools: {} } });
429
350
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
430
351
  tools: [
431
352
  {
432
353
  name: 'compare_documents',
433
354
  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.',
355
+ '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. ' +
356
+ '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.',
435
357
  inputSchema: {
436
358
  type: 'object',
437
359
  properties: {
@@ -443,10 +365,11 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
443
365
  type: 'string',
444
366
  description: 'Absolute path to the modified document',
445
367
  },
446
- accuracy_mode: {
368
+ comparison_mode: {
447
369
  type: 'string',
448
- enum: ['fast', 'balanced', 'thorough'],
449
- description: 'Comparison accuracy mode (default: balanced)',
370
+ enum: ['visual', 'text'],
371
+ description: 'visual for full document review, or text for fast text-only comparison',
372
+ default: 'visual',
450
373
  },
451
374
  },
452
375
  required: ['file_a_path', 'file_b_path'],
@@ -454,7 +377,8 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
454
377
  },
455
378
  {
456
379
  name: 'get_comparison',
457
- description: 'Get the status and results of a specific comparison by its ID.',
380
+ description: '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. ' +
381
+ '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.',
458
382
  inputSchema: {
459
383
  type: 'object',
460
384
  properties: {
@@ -468,36 +392,110 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
468
392
  },
469
393
  {
470
394
  name: 'list_comparisons',
471
- description: 'List recent document comparisons in the user\'s workspace.',
395
+ description: '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. ' +
396
+ '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.',
472
397
  inputSchema: {
473
398
  type: 'object',
474
399
  properties: {
475
400
  limit: {
476
401
  type: 'number',
477
- description: 'Maximum number of comparisons to return (default: 10)',
402
+ description: 'Maximum number of comparisons to return (1-50)',
403
+ default: 10,
404
+ },
405
+ status: {
406
+ type: 'string',
407
+ enum: ['pending', 'processing', 'ready', 'failed'],
408
+ description: 'Only return comparisons with this status',
478
409
  },
479
410
  },
411
+ required: [],
480
412
  },
481
413
  },
482
414
  {
483
- name: 'list_documents',
484
- description: 'List documents in the user\'s Differino library.',
415
+ name: 'export_comparison_pdf',
416
+ description: 'Export a finished comparison as a PDF report and return a temporary download URL (valid for about 5 minutes). ' +
417
+ '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. ' +
418
+ 'The comparison must have status="ready". Free workspaces get a watermarked PDF; workspaces with credits export without watermark. Does not consume credits.',
485
419
  inputSchema: {
486
420
  type: 'object',
487
421
  properties: {
488
- limit: {
422
+ comparison_id: {
423
+ type: 'string',
424
+ description: 'The comparison UUID',
425
+ },
426
+ include_unchanged: {
427
+ type: 'boolean',
428
+ description: 'Include unchanged content in the PDF report, not only the changes',
429
+ default: false,
430
+ },
431
+ locale: {
432
+ type: 'string',
433
+ enum: ['en', 'es', 'zh', 'fr', 'de', 'hi'],
434
+ description: 'Language for the report labels',
435
+ default: 'en',
436
+ },
437
+ wait: {
438
+ type: 'boolean',
439
+ description: 'Poll until the export completes (up to 3 minutes) and return the downloadUrl',
440
+ default: true,
441
+ },
442
+ },
443
+ required: ['comparison_id'],
444
+ },
445
+ },
446
+ {
447
+ name: 'summarize_visual_diff',
448
+ description: '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. ' +
449
+ '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.',
450
+ inputSchema: {
451
+ type: 'object',
452
+ properties: {
453
+ comparison_id: {
454
+ type: 'string',
455
+ description: 'The comparison UUID',
456
+ },
457
+ min_salience: {
458
+ type: 'string',
459
+ enum: ['technical', 'subtle', 'visible', 'structural'],
460
+ description: 'Only include changes at or above this prominence level',
461
+ },
462
+ include_snippets: {
463
+ type: 'boolean',
464
+ description: 'Include snippetA/snippetB text excerpts for each change',
465
+ default: true,
466
+ },
467
+ max_changes_per_page: {
489
468
  type: 'number',
490
- description: 'Maximum number of documents to return (default: 20)',
469
+ description: 'Maximum changes listed per page; extra changes are counted as omitted',
470
+ default: 20,
491
471
  },
492
472
  },
473
+ required: ['comparison_id'],
493
474
  },
494
475
  },
495
476
  {
496
- name: 'get_credits',
497
- description: 'Check the user\'s credit balance, plan status, and free comparison usage for the current month.',
477
+ name: 'get_text_diff',
478
+ description: '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. ' +
479
+ '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.',
498
480
  inputSchema: {
499
481
  type: 'object',
500
- properties: {},
482
+ properties: {
483
+ comparison_id: {
484
+ type: 'string',
485
+ description: 'The comparison UUID',
486
+ },
487
+ only_changed: {
488
+ type: 'boolean',
489
+ description: 'Return only added/removed/modified blocks, skipping equal ones',
490
+ default: true,
491
+ },
492
+ max_blocks: {
493
+ type: 'number',
494
+ description: 'Maximum number of blocks to return',
495
+ default: 100,
496
+ },
497
+ },
498
+ required: ['comparison_id'],
501
499
  },
502
500
  },
503
501
  ],
@@ -516,14 +514,17 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
516
514
  case 'list_comparisons':
517
515
  result = await listComparisons(args);
518
516
  break;
519
- case 'list_documents':
520
- result = await listDocuments(args);
517
+ case 'export_comparison_pdf':
518
+ result = await exportComparisonPdf(args);
519
+ break;
520
+ case 'summarize_visual_diff':
521
+ result = await summarizeVisualDiff(args);
521
522
  break;
522
- case 'get_credits':
523
- result = await getCredits();
523
+ case 'get_text_diff':
524
+ result = await getTextDiff(args);
524
525
  break;
525
526
  default:
526
- result = `Unknown tool: ${name}`;
527
+ throw new Error(`Unknown tool: ${name}`);
527
528
  }
528
529
  return {
529
530
  content: [{ type: 'text', text: result }],
@@ -541,22 +542,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
541
542
  // Entry point
542
543
  // ---------------------------------------------------------------------------
543
544
  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);
545
+ if (!API_KEY) {
546
+ console.error('Error: DIFFERINO_API_KEY environment variable is required.\n' +
547
+ 'Generate one at https://www.differino.com/settings');
560
548
  process.exit(1);
561
549
  }
562
550
  const transport = new stdio_js_1.StdioServerTransport();