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/.next/trace +1 -0
- package/README.md +179 -61
- package/dist/index.js +324 -336
- package/package.json +3 -3
- package/src/index.ts +399 -384
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
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
|
-
//
|
|
24
|
+
// HTTP helpers
|
|
35
25
|
// ---------------------------------------------------------------------------
|
|
36
26
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
+
comparison_mode?: 'visual' | 'text';
|
|
166
116
|
}): Promise<string> {
|
|
167
|
-
|
|
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
|
-
//
|
|
119
|
+
// Validate files exist
|
|
172
120
|
for (const fp of [file_a_path, file_b_path]) {
|
|
173
121
|
if (!fs.existsSync(fp)) {
|
|
174
|
-
|
|
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
|
-
|
|
132
|
+
throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
|
|
185
133
|
}
|
|
186
134
|
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
187
|
-
|
|
135
|
+
throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
|
|
188
136
|
}
|
|
189
137
|
|
|
190
|
-
//
|
|
138
|
+
// Build multipart form
|
|
139
|
+
const form = new FormData();
|
|
191
140
|
const fileABuffer = fs.readFileSync(file_a_path);
|
|
192
|
-
const
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
//
|
|
213
|
-
const
|
|
214
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
178
|
+
const result = await apiCall(`/api/v1/comparisons${qs ? `?${qs}` : ''}`);
|
|
179
|
+
return JSON.stringify(result, null, 2);
|
|
180
|
+
}
|
|
250
181
|
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
199
|
+
format: 'pdf',
|
|
200
|
+
includeUnchanged: include_unchanged,
|
|
201
|
+
locale,
|
|
259
202
|
}),
|
|
260
203
|
});
|
|
261
204
|
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
316
|
+
if (changes.length >= max_changes_per_page) {
|
|
317
|
+
omittedChanges += 1;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
344
320
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
379
|
-
status:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const {
|
|
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
|
-
|
|
460
|
-
await ensureAuth();
|
|
374
|
+
const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
|
|
461
375
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
|
470
|
-
|
|
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
|
|
473
|
-
|
|
474
|
-
.
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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.
|
|
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
|
-
|
|
473
|
+
comparison_mode: {
|
|
527
474
|
type: 'string',
|
|
528
|
-
enum: ['
|
|
529
|
-
description: '
|
|
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:
|
|
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:
|
|
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 (
|
|
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: '
|
|
564
|
-
description:
|
|
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
|
-
|
|
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
|
|
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: '
|
|
586
|
+
name: 'get_text_diff',
|
|
577
587
|
description:
|
|
578
|
-
'
|
|
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 '
|
|
604
|
-
result = await
|
|
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 '
|
|
607
|
-
result = await
|
|
636
|
+
case 'get_text_diff':
|
|
637
|
+
result = await getTextDiff(args as any);
|
|
608
638
|
break;
|
|
609
639
|
default:
|
|
610
|
-
|
|
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
|
-
|
|
631
|
-
if (!EMAIL || !PASSWORD) {
|
|
660
|
+
if (!API_KEY) {
|
|
632
661
|
console.error(
|
|
633
|
-
'Error:
|
|
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);
|