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/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
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
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
|
-
//
|
|
51
|
+
// HTTP helpers
|
|
60
52
|
// ---------------------------------------------------------------------------
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
|
|
179
135
|
}
|
|
180
136
|
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
181
|
-
|
|
137
|
+
throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
|
|
182
138
|
}
|
|
183
|
-
//
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
173
|
+
format: 'pdf',
|
|
174
|
+
includeUnchanged: include_unchanged,
|
|
175
|
+
locale,
|
|
244
176
|
}),
|
|
245
177
|
});
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
.
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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 (
|
|
269
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
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
|
-
|
|
334
|
-
status:
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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.
|
|
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
|
-
|
|
368
|
+
comparison_mode: {
|
|
447
369
|
type: 'string',
|
|
448
|
-
enum: ['
|
|
449
|
-
description: '
|
|
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
|
|
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 (
|
|
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: '
|
|
484
|
-
description: '
|
|
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
|
-
|
|
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
|
|
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: '
|
|
497
|
-
description: '
|
|
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 '
|
|
520
|
-
result = await
|
|
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 '
|
|
523
|
-
result = await
|
|
523
|
+
case 'get_text_diff':
|
|
524
|
+
result = await getTextDiff(args);
|
|
524
525
|
break;
|
|
525
526
|
default:
|
|
526
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
process.exit(1);
|
|
548
|
-
}
|
|
549
|
-
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
|
550
|
-
console.error('Error: SUPABASE_URL and SUPABASE_ANON_KEY environment variables are required.');
|
|
551
|
-
process.exit(1);
|
|
552
|
-
}
|
|
553
|
-
// Authenticate on startup so we fail fast if credentials are wrong
|
|
554
|
-
try {
|
|
555
|
-
await authenticate();
|
|
556
|
-
}
|
|
557
|
-
catch (err) {
|
|
558
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
559
|
-
console.error(message);
|
|
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();
|