differino-mcp 0.4.0 → 0.5.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/README.md +86 -5
- package/dist/index.js +112 -7
- package/package.json +6 -2
- package/.next/trace +0 -1
- package/src/index.ts +0 -675
- package/tsconfig.json +0 -14
package/README.md
CHANGED
|
@@ -59,7 +59,9 @@ Compare two local files and get a diff summary plus the visual document-review m
|
|
|
59
59
|
- `file_b_path` (required) -- Absolute path to the modified document
|
|
60
60
|
- `comparison_mode` (optional) -- `visual` for full document review, or `text` for fast text-only comparison (default: `visual`)
|
|
61
61
|
|
|
62
|
-
The tool uploads both files, waits for extraction and comparison, and returns a summary with added/removed/modified block counts plus a link to the full comparison. If the server is still working when the internal wait expires, the response has `status: "processing"`; poll `get_comparison` with the returned `id` until `status` is `ready`.
|
|
62
|
+
The tool uploads both files, waits for extraction and comparison, and returns a summary with added/removed/modified block counts plus a link to the full comparison. Successful responses include `creditsRemaining` and `freeComparisonRemaining`. If the server is still working when the internal wait expires, the response has `status: "processing"`; poll `get_comparison` with the returned `id` until `status` is `ready`.
|
|
63
|
+
|
|
64
|
+
Files up to 45 MB each are supported. Small pairs (up to ~4 MB combined) go through the multipart endpoint; larger files are uploaded automatically via signed upload URLs (`POST /api/v1/uploads` -> direct PUT to storage -> `POST /api/v1/uploads/complete` -> compare by version ids). This is transparent: you always just call `compare_documents`.
|
|
63
65
|
|
|
64
66
|
Responses include visual artifact metadata when available:
|
|
65
67
|
|
|
@@ -176,14 +178,33 @@ Example:
|
|
|
176
178
|
}
|
|
177
179
|
```
|
|
178
180
|
|
|
181
|
+
### get_balance
|
|
182
|
+
|
|
183
|
+
Get the account status of the workspace behind the API key: credit balance, whether the lifetime free comparison is still available, effective plan, and workspace id. Useful before `compare_documents` or after a `[NO_CREDITS]` error. Free.
|
|
184
|
+
|
|
185
|
+
**Parameters:** none
|
|
186
|
+
|
|
187
|
+
Example response:
|
|
188
|
+
|
|
189
|
+
```json
|
|
190
|
+
{
|
|
191
|
+
"credits": 42,
|
|
192
|
+
"freeComparisonRemaining": 0,
|
|
193
|
+
"plan": "pro",
|
|
194
|
+
"workspaceId": "3b2f...",
|
|
195
|
+
"buyUrl": "https://www.differino.com/billing?pack=comparino"
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
179
199
|
## REST API
|
|
180
200
|
|
|
181
201
|
The MCP server uses the Differino REST API under the hood. You can also call it directly:
|
|
182
202
|
|
|
183
203
|
```bash
|
|
184
|
-
# Compare two files
|
|
204
|
+
# Compare two SMALL files (multipart body limited to ~4.5 MB total on Vercel)
|
|
185
205
|
curl -X POST https://www.differino.com/api/v1/compare \
|
|
186
206
|
-H "Authorization: Bearer dfn_your_api_key" \
|
|
207
|
+
-H "Idempotency-Key: my-unique-key-001" \
|
|
187
208
|
-F "file_a=@original.pdf" \
|
|
188
209
|
-F "file_b=@modified.pdf" \
|
|
189
210
|
-F "comparison_mode=visual" \
|
|
@@ -197,6 +218,10 @@ curl https://www.differino.com/api/v1/comparisons/{id} \
|
|
|
197
218
|
curl "https://www.differino.com/api/v1/comparisons?limit=10&status=ready" \
|
|
198
219
|
-H "Authorization: Bearer dfn_your_api_key"
|
|
199
220
|
|
|
221
|
+
# Account status: credits, free comparison, plan
|
|
222
|
+
curl https://www.differino.com/api/v1/me \
|
|
223
|
+
-H "Authorization: Bearer dfn_your_api_key"
|
|
224
|
+
|
|
200
225
|
# Export a comparison as PDF, then poll for the download URL
|
|
201
226
|
curl -X POST https://www.differino.com/api/v1/comparisons/{id}/export \
|
|
202
227
|
-H "Authorization: Bearer dfn_your_api_key" \
|
|
@@ -207,6 +232,56 @@ curl "https://www.differino.com/api/v1/comparisons/{id}/export?jobId={jobId}" \
|
|
|
207
232
|
-H "Authorization: Bearer dfn_your_api_key"
|
|
208
233
|
```
|
|
209
234
|
|
|
235
|
+
### Large files: the signed-upload flow
|
|
236
|
+
|
|
237
|
+
Multipart `POST /api/v1/compare` is limited to about 4.5 MB of TOTAL request body (Vercel limit); above that it returns `413 PAYLOAD_TOO_LARGE`. For files up to 45 MB each, upload directly to storage:
|
|
238
|
+
|
|
239
|
+
```bash
|
|
240
|
+
# 1. Init: declare both files, get signed upload URLs + version ids
|
|
241
|
+
curl -X POST https://www.differino.com/api/v1/uploads \
|
|
242
|
+
-H "Authorization: Bearer dfn_your_api_key" \
|
|
243
|
+
-H "Content-Type: application/json" \
|
|
244
|
+
-d '{"fileA": {"name": "original.pdf", "type": "application/pdf", "size": 10485760},
|
|
245
|
+
"fileB": {"name": "modified.pdf", "type": "application/pdf", "size": 11534336}}'
|
|
246
|
+
|
|
247
|
+
# 2. PUT each file body to its uploadUrl (URLs are valid for 2 hours)
|
|
248
|
+
curl -X PUT "{fileA.uploadUrl}" \
|
|
249
|
+
-H "Content-Type: application/pdf" \
|
|
250
|
+
--data-binary @original.pdf
|
|
251
|
+
curl -X PUT "{fileB.uploadUrl}" \
|
|
252
|
+
-H "Content-Type: application/pdf" \
|
|
253
|
+
--data-binary @modified.pdf
|
|
254
|
+
|
|
255
|
+
# 3. Complete: verify the stored objects and queue extraction
|
|
256
|
+
curl -X POST https://www.differino.com/api/v1/uploads/complete \
|
|
257
|
+
-H "Authorization: Bearer dfn_your_api_key" \
|
|
258
|
+
-H "Content-Type: application/json" \
|
|
259
|
+
-d '{"versionIds": ["{fileA.versionId}", "{fileB.versionId}"]}'
|
|
260
|
+
|
|
261
|
+
# 4. Compare the uploaded versions (JSON body instead of multipart)
|
|
262
|
+
curl -X POST https://www.differino.com/api/v1/compare \
|
|
263
|
+
-H "Authorization: Bearer dfn_your_api_key" \
|
|
264
|
+
-H "Content-Type: application/json" \
|
|
265
|
+
-d '{"versionAId": "{fileA.versionId}", "versionBId": "{fileB.versionId}", "comparisonMode": "visual"}'
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
Steps 1-3 are free; only step 4 consumes the free comparison or a credit.
|
|
269
|
+
|
|
270
|
+
### Idempotency
|
|
271
|
+
|
|
272
|
+
`POST /api/v1/compare` accepts an optional `Idempotency-Key` header (up to 200 characters). Repeating a key within your workspace returns the existing comparison (`idempotentReplay: true`) without charging again. Use it to make retries safe.
|
|
273
|
+
|
|
274
|
+
### Rate limits
|
|
275
|
+
|
|
276
|
+
All limits are per workspace. Exceeding one returns `429` with `code: "RATE_LIMITED"`, a `retryAfter` field (seconds), and a `Retry-After` header.
|
|
277
|
+
|
|
278
|
+
| Endpoint | Limit |
|
|
279
|
+
|----------|-------|
|
|
280
|
+
| `POST /api/v1/compare` | 10 / minute |
|
|
281
|
+
| `POST /api/v1/comparisons/{id}/export` | 20 / hour, plus at most 5 pending/processing export jobs (`429 EXPORT_QUEUE_FULL`) |
|
|
282
|
+
| `GET` endpoints (list, get, export status, me) | 120 / minute (shared) |
|
|
283
|
+
| `POST /api/v1/uploads` + `/uploads/complete` | 30 / minute (shared) |
|
|
284
|
+
|
|
210
285
|
### accuracy_mode
|
|
211
286
|
|
|
212
287
|
`POST /api/v1/compare` accepts an optional `accuracy_mode` form field:
|
|
@@ -220,11 +295,17 @@ curl "https://www.differino.com/api/v1/comparisons/{id}/export?jobId={jobId}" \
|
|
|
220
295
|
| HTTP | `code` | Meaning |
|
|
221
296
|
|------|--------|---------|
|
|
222
297
|
| 400 | | Invalid request: missing files, unsupported file type, file too large, or invalid parameters |
|
|
298
|
+
| 400 | `INVALID_FILE` | File content does not match its extension (magic-byte check), or upload verification failed |
|
|
299
|
+
| 400 | `FILE_TOO_LARGE` | Declared file size exceeds the 45 MB per-file limit |
|
|
300
|
+
| 400 | `VERSION_FAILED` / `VERSION_NOT_PENDING` | Referenced version cannot be used (processing failed, or already completed) |
|
|
223
301
|
| 401 | | Missing or invalid API key |
|
|
224
|
-
| 402 | `NO_CREDITS` | No free comparison left and no credits remaining;
|
|
225
|
-
| 404 | | Comparison or export job not found in your workspace |
|
|
302
|
+
| 402 | `NO_CREDITS` | No free comparison left and no credits remaining; the response includes `buyUrl` (https://www.differino.com/billing?pack=comparino) |
|
|
303
|
+
| 404 | `VERSION_NOT_FOUND` | Comparison, version, or export job not found in your workspace |
|
|
304
|
+
| 413 | `PAYLOAD_TOO_LARGE` | Multipart body exceeds ~4.5 MB total; use the signed-upload flow |
|
|
305
|
+
| 429 | `RATE_LIMITED` | Rate limit exceeded; retry after `retryAfter` seconds |
|
|
306
|
+
| 429 | `EXPORT_QUEUE_FULL` | More than 5 export jobs pending/processing in your workspace |
|
|
226
307
|
|
|
227
|
-
When present, the `code` field is machine-readable; the MCP server prefixes it to error messages (for example `[NO_CREDITS] No credits remaining...`).
|
|
308
|
+
When present, the `code` field is machine-readable; the MCP server prefixes it to error messages (for example `[NO_CREDITS] No credits remaining...`) and appends `retryAfter` / `buyUrl` details when the API provides them.
|
|
228
309
|
|
|
229
310
|
## Supported Formats
|
|
230
311
|
|
package/dist/index.js
CHANGED
|
@@ -47,6 +47,12 @@ const BASE_URL = process.env.DIFFERINO_URL || 'https://www.differino.com';
|
|
|
47
47
|
const REQUEST_TIMEOUT_MS = 300_000; // 5 minutes: compare can wait on extraction + diff
|
|
48
48
|
const EXPORT_POLL_INTERVAL_MS = 2_000;
|
|
49
49
|
const EXPORT_POLL_TIMEOUT_MS = 180_000; // 3 minutes
|
|
50
|
+
// Vercel rejects request bodies above ~4.5 MB, so multipart /api/v1/compare
|
|
51
|
+
// only works for small pairs. Above this COMBINED size we transparently use
|
|
52
|
+
// the signed-upload flow (uploads -> PUT -> complete -> compare by version
|
|
53
|
+
// ids), which supports files up to 45 MB each.
|
|
54
|
+
const MULTIPART_TOTAL_LIMIT_BYTES = 4 * 1024 * 1024;
|
|
55
|
+
const UPLOAD_TIMEOUT_MS = 600_000; // 10 minutes for direct-to-storage PUTs
|
|
50
56
|
// ---------------------------------------------------------------------------
|
|
51
57
|
// HTTP helpers
|
|
52
58
|
// ---------------------------------------------------------------------------
|
|
@@ -87,8 +93,15 @@ async function apiCall(endpoint, options = {}) {
|
|
|
87
93
|
throw new Error(`Non-JSON response from ${endpoint} (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
|
88
94
|
}
|
|
89
95
|
if (!res.ok) {
|
|
90
|
-
|
|
91
|
-
// Surface
|
|
96
|
+
let message = json.error || `API error ${res.status}: ${text.slice(0, 300)}`;
|
|
97
|
+
// Surface actionable metadata to the agent.
|
|
98
|
+
if (json.code === 'RATE_LIMITED' && json.retryAfter != null) {
|
|
99
|
+
message += ` Wait ${json.retryAfter} seconds before retrying.`;
|
|
100
|
+
}
|
|
101
|
+
if (json.buyUrl) {
|
|
102
|
+
message += ` Buy credits at ${json.buyUrl}`;
|
|
103
|
+
}
|
|
104
|
+
// Surface machine-readable error codes (e.g. NO_CREDITS, RATE_LIMITED) to the agent.
|
|
92
105
|
throw new Error(json.code ? `[${json.code}] ${message}` : message);
|
|
93
106
|
}
|
|
94
107
|
return json;
|
|
@@ -118,6 +131,30 @@ function getMimeType(ext) {
|
|
|
118
131
|
// ---------------------------------------------------------------------------
|
|
119
132
|
// Tool implementations
|
|
120
133
|
// ---------------------------------------------------------------------------
|
|
134
|
+
async function putToSignedUrl(uploadUrl, buffer, mimeType, label) {
|
|
135
|
+
let res;
|
|
136
|
+
try {
|
|
137
|
+
res = await fetch(uploadUrl, {
|
|
138
|
+
method: 'PUT',
|
|
139
|
+
headers: {
|
|
140
|
+
'Content-Type': mimeType,
|
|
141
|
+
'x-upsert': 'false',
|
|
142
|
+
},
|
|
143
|
+
body: new Uint8Array(buffer),
|
|
144
|
+
signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (isTimeoutError(err)) {
|
|
149
|
+
throw new Error(`Uploading ${label} timed out after ${UPLOAD_TIMEOUT_MS / 1000}s. Try again.`);
|
|
150
|
+
}
|
|
151
|
+
throw err;
|
|
152
|
+
}
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const body = await res.text().catch(() => '');
|
|
155
|
+
throw new Error(`Uploading ${label} to storage failed (HTTP ${res.status}): ${body.slice(0, 200)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
121
158
|
async function compareDocuments(args) {
|
|
122
159
|
const { file_a_path, file_b_path, comparison_mode = 'visual' } = args;
|
|
123
160
|
// Validate files exist
|
|
@@ -136,10 +173,23 @@ async function compareDocuments(args) {
|
|
|
136
173
|
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
137
174
|
throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
|
|
138
175
|
}
|
|
139
|
-
// Build multipart form
|
|
140
|
-
const form = new FormData();
|
|
141
176
|
const fileABuffer = fs.readFileSync(file_a_path);
|
|
142
177
|
const fileBBuffer = fs.readFileSync(file_b_path);
|
|
178
|
+
// Large pairs cannot go through the multipart endpoint (Vercel caps request
|
|
179
|
+
// bodies at ~4.5 MB total). Use the signed-upload flow transparently.
|
|
180
|
+
if (fileABuffer.length + fileBBuffer.length > MULTIPART_TOTAL_LIMIT_BYTES) {
|
|
181
|
+
return compareViaSignedUploads({
|
|
182
|
+
nameA,
|
|
183
|
+
nameB,
|
|
184
|
+
extA,
|
|
185
|
+
extB,
|
|
186
|
+
fileABuffer,
|
|
187
|
+
fileBBuffer,
|
|
188
|
+
comparison_mode,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// Build multipart form
|
|
192
|
+
const form = new FormData();
|
|
143
193
|
form.append('file_a', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
|
|
144
194
|
form.append('file_b', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
|
|
145
195
|
form.append('comparison_mode', comparison_mode);
|
|
@@ -150,6 +200,48 @@ async function compareDocuments(args) {
|
|
|
150
200
|
});
|
|
151
201
|
return JSON.stringify(result, null, 2);
|
|
152
202
|
}
|
|
203
|
+
async function compareViaSignedUploads(args) {
|
|
204
|
+
const { nameA, nameB, extA, extB, fileABuffer, fileBBuffer, comparison_mode } = args;
|
|
205
|
+
// 1. Init: create documents/versions and get signed upload URLs.
|
|
206
|
+
const init = await apiCall('/api/v1/uploads', {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
headers: { 'Content-Type': 'application/json' },
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
fileA: { name: nameA, type: getMimeType(extA), size: fileABuffer.length },
|
|
211
|
+
fileB: { name: nameB, type: getMimeType(extB), size: fileBBuffer.length },
|
|
212
|
+
}),
|
|
213
|
+
});
|
|
214
|
+
if (!init?.fileA?.uploadUrl || !init?.fileB?.uploadUrl) {
|
|
215
|
+
throw new Error('Signed upload init did not return upload URLs.');
|
|
216
|
+
}
|
|
217
|
+
// 2. PUT both files directly to storage.
|
|
218
|
+
await putToSignedUrl(init.fileA.uploadUrl, fileABuffer, getMimeType(extA), nameA);
|
|
219
|
+
await putToSignedUrl(init.fileB.uploadUrl, fileBBuffer, getMimeType(extB), nameB);
|
|
220
|
+
// 3. Complete: verify the stored objects and queue extraction.
|
|
221
|
+
await apiCall('/api/v1/uploads/complete', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
headers: { 'Content-Type': 'application/json' },
|
|
224
|
+
body: JSON.stringify({
|
|
225
|
+
versionIds: [init.fileA.versionId, init.fileB.versionId],
|
|
226
|
+
visualArtifacts: comparison_mode === 'visual',
|
|
227
|
+
}),
|
|
228
|
+
});
|
|
229
|
+
// 4. Compare the uploaded versions.
|
|
230
|
+
const result = await apiCall('/api/v1/compare', {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({
|
|
234
|
+
versionAId: init.fileA.versionId,
|
|
235
|
+
versionBId: init.fileB.versionId,
|
|
236
|
+
comparisonMode: comparison_mode,
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
return JSON.stringify(result, null, 2);
|
|
240
|
+
}
|
|
241
|
+
async function getBalance() {
|
|
242
|
+
const result = await apiCall('/api/v1/me');
|
|
243
|
+
return JSON.stringify(result, null, 2);
|
|
244
|
+
}
|
|
153
245
|
async function getComparison(args) {
|
|
154
246
|
const result = await apiCall(`/api/v1/comparisons/${args.comparison_id}`);
|
|
155
247
|
return JSON.stringify(result, null, 2);
|
|
@@ -346,14 +438,14 @@ async function getTextDiff(args) {
|
|
|
346
438
|
// ---------------------------------------------------------------------------
|
|
347
439
|
// MCP Server
|
|
348
440
|
// ---------------------------------------------------------------------------
|
|
349
|
-
const server = new index_js_1.Server({ name: 'differino', version: '0.
|
|
441
|
+
const server = new index_js_1.Server({ name: 'differino', version: '0.5.0' }, { capabilities: { tools: {} } });
|
|
350
442
|
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
351
443
|
tools: [
|
|
352
444
|
{
|
|
353
445
|
name: 'compare_documents',
|
|
354
446
|
description: 'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
|
|
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.',
|
|
447
|
+
'Reads files from local disk, uploads them to Differino, and returns a text diff summary. Files up to 45 MB each are supported: large files are uploaded automatically via signed upload URLs. 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. ' +
|
|
448
|
+
'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; the response includes creditsRemaining and freeComparisonRemaining.',
|
|
357
449
|
inputSchema: {
|
|
358
450
|
type: 'object',
|
|
359
451
|
properties: {
|
|
@@ -498,6 +590,16 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
|
498
590
|
required: ['comparison_id'],
|
|
499
591
|
},
|
|
500
592
|
},
|
|
593
|
+
{
|
|
594
|
+
name: 'get_balance',
|
|
595
|
+
description: 'Get the account status of the workspace behind the API key: credit balance, whether the lifetime free comparison is still available, effective plan, and workspace id. ' +
|
|
596
|
+
'Use it before compare_documents to know whether a comparison can be paid for, or after a [NO_CREDITS] error to confirm the balance and get the purchase URL. Does not consume credits.',
|
|
597
|
+
inputSchema: {
|
|
598
|
+
type: 'object',
|
|
599
|
+
properties: {},
|
|
600
|
+
required: [],
|
|
601
|
+
},
|
|
602
|
+
},
|
|
501
603
|
],
|
|
502
604
|
}));
|
|
503
605
|
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
@@ -523,6 +625,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
|
523
625
|
case 'get_text_diff':
|
|
524
626
|
result = await getTextDiff(args);
|
|
525
627
|
break;
|
|
628
|
+
case 'get_balance':
|
|
629
|
+
result = await getBalance();
|
|
630
|
+
break;
|
|
526
631
|
default:
|
|
527
632
|
throw new Error(`Unknown tool: ${name}`);
|
|
528
633
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "differino-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "MCP server for Differino document comparison",
|
|
5
5
|
"packageManager": "pnpm@9.0.0",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,5 +17,9 @@
|
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"typescript": "^5.3.0",
|
|
19
19
|
"@types/node": "^20.0.0"
|
|
20
|
-
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md"
|
|
24
|
+
]
|
|
21
25
|
}
|
package/.next/trace
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
[{"name":"generate-buildid","duration":115,"timestamp":148958595929,"id":4,"parentId":1,"tags":{},"startTime":1774429523967,"traceId":"be39ecf3904fcb99"},{"name":"load-custom-routes","duration":119,"timestamp":148958596120,"id":5,"parentId":1,"tags":{},"startTime":1774429523967,"traceId":"be39ecf3904fcb99"},{"name":"next-build","duration":82517,"timestamp":148958517288,"id":1,"tags":{"buildMode":"default","isTurboBuild":"false","version":"14.1.0","isTurbopack":false},"startTime":1774429523888,"traceId":"be39ecf3904fcb99"}]
|
package/src/index.ts
DELETED
|
@@ -1,675 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
-
import {
|
|
6
|
-
CallToolRequestSchema,
|
|
7
|
-
ListToolsRequestSchema,
|
|
8
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
|
-
import * as fs from 'fs';
|
|
10
|
-
import * as path from 'path';
|
|
11
|
-
|
|
12
|
-
// ---------------------------------------------------------------------------
|
|
13
|
-
// Configuration
|
|
14
|
-
// ---------------------------------------------------------------------------
|
|
15
|
-
|
|
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
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// HTTP helpers
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
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
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async function apiCall(
|
|
38
|
-
endpoint: string,
|
|
39
|
-
options: RequestInit = {},
|
|
40
|
-
): Promise<any> {
|
|
41
|
-
const url = `${BASE_URL}${endpoint}`;
|
|
42
|
-
const headers: Record<string, string> = {
|
|
43
|
-
Authorization: `Bearer ${API_KEY}`,
|
|
44
|
-
...(options.headers as Record<string, string> || {}),
|
|
45
|
-
};
|
|
46
|
-
|
|
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;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const text = await res.text();
|
|
65
|
-
let json: any;
|
|
66
|
-
try {
|
|
67
|
-
json = JSON.parse(text);
|
|
68
|
-
} catch {
|
|
69
|
-
throw new Error(`Non-JSON response from ${endpoint} (HTTP ${res.status}): ${text.slice(0, 200)}`);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (!res.ok) {
|
|
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);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return json;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function sleep(ms: number): Promise<void> {
|
|
82
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
// File utilities
|
|
87
|
-
// ---------------------------------------------------------------------------
|
|
88
|
-
|
|
89
|
-
const SUPPORTED_EXTENSIONS = new Set(['pdf', 'docx', 'txt']);
|
|
90
|
-
|
|
91
|
-
function getExtension(filename: string): string {
|
|
92
|
-
return (filename.split('.').pop() || '').toLowerCase();
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function getMimeType(ext: string): string {
|
|
96
|
-
switch (ext) {
|
|
97
|
-
case 'pdf':
|
|
98
|
-
return 'application/pdf';
|
|
99
|
-
case 'docx':
|
|
100
|
-
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
101
|
-
case 'txt':
|
|
102
|
-
return 'text/plain';
|
|
103
|
-
default:
|
|
104
|
-
return 'application/octet-stream';
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
// Tool implementations
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
async function compareDocuments(args: {
|
|
113
|
-
file_a_path: string;
|
|
114
|
-
file_b_path: string;
|
|
115
|
-
comparison_mode?: 'visual' | 'text';
|
|
116
|
-
}): Promise<string> {
|
|
117
|
-
const { file_a_path, file_b_path, comparison_mode = 'visual' } = args;
|
|
118
|
-
|
|
119
|
-
// Validate files exist
|
|
120
|
-
for (const fp of [file_a_path, file_b_path]) {
|
|
121
|
-
if (!fs.existsSync(fp)) {
|
|
122
|
-
throw new Error(`File not found: ${fp}`);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const nameA = path.basename(file_a_path);
|
|
127
|
-
const nameB = path.basename(file_b_path);
|
|
128
|
-
const extA = getExtension(nameA);
|
|
129
|
-
const extB = getExtension(nameB);
|
|
130
|
-
|
|
131
|
-
if (!SUPPORTED_EXTENSIONS.has(extA)) {
|
|
132
|
-
throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
|
|
133
|
-
}
|
|
134
|
-
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
135
|
-
throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Build multipart form
|
|
139
|
-
const form = new FormData();
|
|
140
|
-
const fileABuffer = fs.readFileSync(file_a_path);
|
|
141
|
-
const fileBBuffer = fs.readFileSync(file_b_path);
|
|
142
|
-
|
|
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);
|
|
154
|
-
|
|
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
|
-
});
|
|
160
|
-
|
|
161
|
-
return JSON.stringify(result, null, 2);
|
|
162
|
-
}
|
|
163
|
-
|
|
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
|
-
}
|
|
168
|
-
|
|
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();
|
|
177
|
-
|
|
178
|
-
const result = await apiCall(`/api/v1/comparisons${qs ? `?${qs}` : ''}`);
|
|
179
|
-
return JSON.stringify(result, null, 2);
|
|
180
|
-
}
|
|
181
|
-
|
|
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`, {
|
|
196
|
-
method: 'POST',
|
|
197
|
-
headers: { 'Content-Type': 'application/json' },
|
|
198
|
-
body: JSON.stringify({
|
|
199
|
-
format: 'pdf',
|
|
200
|
-
includeUnchanged: include_unchanged,
|
|
201
|
-
locale,
|
|
202
|
-
}),
|
|
203
|
-
});
|
|
204
|
-
|
|
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') {
|
|
228
|
-
return JSON.stringify(
|
|
229
|
-
{
|
|
230
|
-
jobId,
|
|
231
|
-
status: 'completed',
|
|
232
|
-
downloadUrl: statusRes.downloadUrl,
|
|
233
|
-
note: 'The download URL is signed and expires in about 5 minutes.',
|
|
234
|
-
},
|
|
235
|
-
null,
|
|
236
|
-
2,
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
if (statusRes.status === 'failed') {
|
|
240
|
-
throw new Error(`Export failed: ${statusRes.error || 'unknown error'}`);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return JSON.stringify(
|
|
245
|
-
{
|
|
246
|
-
jobId,
|
|
247
|
-
status: 'processing',
|
|
248
|
-
message:
|
|
249
|
-
`Export still processing after ${EXPORT_POLL_TIMEOUT_MS / 1000}s. ` +
|
|
250
|
-
`Poll GET ${statusEndpoint} for the downloadUrl.`,
|
|
251
|
-
},
|
|
252
|
-
null,
|
|
253
|
-
2,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
|
|
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
|
-
}
|
|
291
|
-
|
|
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
|
-
}
|
|
315
|
-
|
|
316
|
-
if (changes.length >= max_changes_per_page) {
|
|
317
|
-
omittedChanges += 1;
|
|
318
|
-
continue;
|
|
319
|
-
}
|
|
320
|
-
|
|
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
|
-
}
|
|
337
|
-
|
|
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
|
-
}
|
|
351
|
-
|
|
352
|
-
return JSON.stringify(
|
|
353
|
-
{
|
|
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,
|
|
361
|
-
},
|
|
362
|
-
null,
|
|
363
|
-
2,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
|
|
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;
|
|
373
|
-
|
|
374
|
-
const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
|
|
375
|
-
|
|
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
|
-
}
|
|
384
|
-
|
|
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
|
-
}
|
|
400
|
-
|
|
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
|
-
};
|
|
410
|
-
|
|
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
|
-
}));
|
|
426
|
-
|
|
427
|
-
return JSON.stringify(
|
|
428
|
-
{
|
|
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,
|
|
439
|
-
},
|
|
440
|
-
null,
|
|
441
|
-
2,
|
|
442
|
-
);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// ---------------------------------------------------------------------------
|
|
446
|
-
// MCP Server
|
|
447
|
-
// ---------------------------------------------------------------------------
|
|
448
|
-
|
|
449
|
-
const server = new Server(
|
|
450
|
-
{ name: 'differino', version: '0.4.0' },
|
|
451
|
-
{ capabilities: { tools: {} } },
|
|
452
|
-
);
|
|
453
|
-
|
|
454
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
455
|
-
tools: [
|
|
456
|
-
{
|
|
457
|
-
name: 'compare_documents',
|
|
458
|
-
description:
|
|
459
|
-
'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
|
|
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.',
|
|
462
|
-
inputSchema: {
|
|
463
|
-
type: 'object' as const,
|
|
464
|
-
properties: {
|
|
465
|
-
file_a_path: {
|
|
466
|
-
type: 'string',
|
|
467
|
-
description: 'Absolute path to the original document',
|
|
468
|
-
},
|
|
469
|
-
file_b_path: {
|
|
470
|
-
type: 'string',
|
|
471
|
-
description: 'Absolute path to the modified document',
|
|
472
|
-
},
|
|
473
|
-
comparison_mode: {
|
|
474
|
-
type: 'string',
|
|
475
|
-
enum: ['visual', 'text'],
|
|
476
|
-
description: 'visual for full document review, or text for fast text-only comparison',
|
|
477
|
-
default: 'visual',
|
|
478
|
-
},
|
|
479
|
-
},
|
|
480
|
-
required: ['file_a_path', 'file_b_path'],
|
|
481
|
-
},
|
|
482
|
-
},
|
|
483
|
-
{
|
|
484
|
-
name: 'get_comparison',
|
|
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.',
|
|
488
|
-
inputSchema: {
|
|
489
|
-
type: 'object' as const,
|
|
490
|
-
properties: {
|
|
491
|
-
comparison_id: {
|
|
492
|
-
type: 'string',
|
|
493
|
-
description: 'The comparison UUID',
|
|
494
|
-
},
|
|
495
|
-
},
|
|
496
|
-
required: ['comparison_id'],
|
|
497
|
-
},
|
|
498
|
-
},
|
|
499
|
-
{
|
|
500
|
-
name: 'list_comparisons',
|
|
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.',
|
|
504
|
-
inputSchema: {
|
|
505
|
-
type: 'object' as const,
|
|
506
|
-
properties: {
|
|
507
|
-
limit: {
|
|
508
|
-
type: 'number',
|
|
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',
|
|
516
|
-
},
|
|
517
|
-
},
|
|
518
|
-
required: [],
|
|
519
|
-
},
|
|
520
|
-
},
|
|
521
|
-
{
|
|
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.',
|
|
527
|
-
inputSchema: {
|
|
528
|
-
type: 'object' as const,
|
|
529
|
-
properties: {
|
|
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: {
|
|
577
|
-
type: 'number',
|
|
578
|
-
description: 'Maximum changes listed per page; extra changes are counted as omitted',
|
|
579
|
-
default: 20,
|
|
580
|
-
},
|
|
581
|
-
},
|
|
582
|
-
required: ['comparison_id'],
|
|
583
|
-
},
|
|
584
|
-
},
|
|
585
|
-
{
|
|
586
|
-
name: 'get_text_diff',
|
|
587
|
-
description:
|
|
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.',
|
|
590
|
-
inputSchema: {
|
|
591
|
-
type: 'object' as const,
|
|
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'],
|
|
609
|
-
},
|
|
610
|
-
},
|
|
611
|
-
],
|
|
612
|
-
}));
|
|
613
|
-
|
|
614
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
615
|
-
const { name, arguments: args } = request.params;
|
|
616
|
-
|
|
617
|
-
try {
|
|
618
|
-
let result: string;
|
|
619
|
-
|
|
620
|
-
switch (name) {
|
|
621
|
-
case 'compare_documents':
|
|
622
|
-
result = await compareDocuments(args as any);
|
|
623
|
-
break;
|
|
624
|
-
case 'get_comparison':
|
|
625
|
-
result = await getComparison(args as any);
|
|
626
|
-
break;
|
|
627
|
-
case 'list_comparisons':
|
|
628
|
-
result = await listComparisons(args as any);
|
|
629
|
-
break;
|
|
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);
|
|
635
|
-
break;
|
|
636
|
-
case 'get_text_diff':
|
|
637
|
-
result = await getTextDiff(args as any);
|
|
638
|
-
break;
|
|
639
|
-
default:
|
|
640
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
return {
|
|
644
|
-
content: [{ type: 'text', text: result }],
|
|
645
|
-
};
|
|
646
|
-
} catch (err: unknown) {
|
|
647
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
648
|
-
return {
|
|
649
|
-
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
650
|
-
isError: true,
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
|
|
655
|
-
// ---------------------------------------------------------------------------
|
|
656
|
-
// Entry point
|
|
657
|
-
// ---------------------------------------------------------------------------
|
|
658
|
-
|
|
659
|
-
async function main(): Promise<void> {
|
|
660
|
-
if (!API_KEY) {
|
|
661
|
-
console.error(
|
|
662
|
-
'Error: DIFFERINO_API_KEY environment variable is required.\n' +
|
|
663
|
-
'Generate one at https://www.differino.com/settings',
|
|
664
|
-
);
|
|
665
|
-
process.exit(1);
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const transport = new StdioServerTransport();
|
|
669
|
-
await server.connect(transport);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
main().catch((err) => {
|
|
673
|
-
console.error('Fatal error:', err);
|
|
674
|
-
process.exit(1);
|
|
675
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "Node16",
|
|
5
|
-
"moduleResolution": "Node16",
|
|
6
|
-
"outDir": "dist",
|
|
7
|
-
"rootDir": "src",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"declaration": true,
|
|
11
|
-
"skipLibCheck": true
|
|
12
|
-
},
|
|
13
|
-
"include": ["src/**/*"]
|
|
14
|
-
}
|