differino-mcp 0.2.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.
Files changed (4) hide show
  1. package/README.md +179 -61
  2. package/dist/index.js +355 -16
  3. package/package.json +2 -1
  4. package/src/index.ts +448 -17
package/README.md CHANGED
@@ -1,41 +1,20 @@
1
1
  # differino-mcp
2
2
 
3
- MCP (Model Context Protocol) server for [Differino](https://differino.com) -- compare PDF, DOCX, and TXT documents from any AI agent.
3
+ MCP (Model Context Protocol) server for [Differino](https://www.differino.com) -- compare PDF, DOCX, and TXT documents from any AI agent.
4
4
 
5
5
  ## Setup
6
6
 
7
7
  ### 1. Install
8
8
 
9
9
  ```bash
10
- cd mcp
11
- npm install
12
- npm run build
10
+ pnpm add -g differino-mcp
13
11
  ```
14
12
 
15
- Or install globally:
13
+ ### 2. Get an API Key
16
14
 
17
- ```bash
18
- npm install -g differino-mcp
19
- ```
20
-
21
- ### 2. Configure
22
-
23
- Set environment variables:
24
-
25
- ```bash
26
- export DIFFERINO_EMAIL="your@email.com"
27
- export DIFFERINO_PASSWORD="your-password"
28
- export SUPABASE_URL="https://your-project.supabase.co"
29
- export SUPABASE_ANON_KEY="your-anon-key"
30
- ```
31
-
32
- | Variable | Required | Description |
33
- |----------|----------|-------------|
34
- | `DIFFERINO_EMAIL` | Yes | Your Differino account email |
35
- | `DIFFERINO_PASSWORD` | Yes | Your Differino account password |
36
- | `SUPABASE_URL` | Yes | Supabase project URL (also accepts `DIFFERINO_SUPABASE_URL`) |
37
- | `SUPABASE_ANON_KEY` | Yes | Supabase anon/public key (also accepts `DIFFERINO_SUPABASE_ANON_KEY`) |
38
- | `DIFFERINO_URL` | No | Base URL for the web app (default: `https://differino.com`) |
15
+ 1. Sign up at [differino.com](https://www.differino.com/signup)
16
+ 2. Go to **Settings → API Key**
17
+ 3. Click **Generate Key** and copy it
39
18
 
40
19
  ### 3. Add to Claude Desktop
41
20
 
@@ -45,68 +24,207 @@ Add to your `claude_desktop_config.json`:
45
24
  {
46
25
  "mcpServers": {
47
26
  "differino": {
48
- "command": "node",
49
- "args": ["/path/to/comparadocs/mcp/dist/index.js"],
27
+ "command": "pnpm",
28
+ "args": ["dlx", "differino-mcp"],
50
29
  "env": {
51
- "DIFFERINO_EMAIL": "your@email.com",
52
- "DIFFERINO_PASSWORD": "your-password",
53
- "SUPABASE_URL": "https://your-project.supabase.co",
54
- "SUPABASE_ANON_KEY": "your-anon-key"
30
+ "DIFFERINO_API_KEY": "dfn_your_api_key_here"
55
31
  }
56
32
  }
57
33
  }
58
34
  }
59
35
  ```
60
36
 
61
- Or if installed globally via npm:
37
+ ### Local development
62
38
 
63
- ```json
64
- {
65
- "mcpServers": {
66
- "differino": {
67
- "command": "npx",
68
- "args": ["differino-mcp"],
69
- "env": {
70
- "DIFFERINO_EMAIL": "your@email.com",
71
- "DIFFERINO_PASSWORD": "your-password",
72
- "SUPABASE_URL": "https://your-project.supabase.co",
73
- "SUPABASE_ANON_KEY": "your-anon-key"
74
- }
75
- }
76
- }
77
- }
39
+ From the repository root:
40
+
41
+ ```bash
42
+ pnpm install
43
+ pnpm --dir mcp build
78
44
  ```
79
45
 
46
+ | Variable | Required | Description |
47
+ |----------|----------|-------------|
48
+ | `DIFFERINO_API_KEY` | Yes | Your API key -- generate at Settings → API Key |
49
+ | `DIFFERINO_URL` | No | Base URL (default: `https://www.differino.com`) |
50
+
80
51
  ## Tools
81
52
 
82
53
  ### compare_documents
83
54
 
84
- Compare two local files and get a diff summary.
55
+ Compare two local files and get a diff summary plus the visual document-review manifest when available. This is the only tool that consumes a comparison (your free comparison or 1 credit).
85
56
 
86
57
  **Parameters:**
87
58
  - `file_a_path` (required) -- Absolute path to the original document
88
59
  - `file_b_path` (required) -- Absolute path to the modified document
89
- - `accuracy_mode` (optional) -- `fast`, `balanced` (default), or `thorough`
60
+ - `comparison_mode` (optional) -- `visual` for full document review, or `text` for fast text-only comparison (default: `visual`)
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`.
63
+
64
+ Responses include visual artifact metadata when available:
90
65
 
91
- The tool uploads both files, waits for text extraction and diff computation, and returns a summary with added/removed/modified block counts plus a link to the full visual comparison.
66
+ - `comparisonMode`: `visual` or `text`
67
+ - `visual.status`: `ready`, `pending`, `missing`, or `disabled`
68
+ - `visual.manifest`: the document-review contract when `visual.status` is `ready`
69
+ - `pages[]`: side-by-side page pairs with image URLs and dimensions
70
+ - `pages[].regions[]`: red/green-ready change regions with `leftBox`, `rightBox`, snippets, change type, salience metadata, and a stable `groupId`
71
+ - `regions[].groupId`: shared identity for paired change fragments, including changes that cross page boundaries
72
+ - `regions[].salience`: `subtle`, `visible`, `structural`, or `technical`, plus `salienceReason` when available
73
+ - `totalChanges` and `changedPageCount`: summary fields for compact controls and optional review navigation; paired fragments with the same `groupId` count as one change
74
+ - `visual.diffUrl`: signed URL for the visual diff JSON when available
92
75
 
93
- Files under 4 MB are uploaded directly. Larger files use a signed-URL flow that bypasses Vercel's body size limit.
76
+ Agents should treat `visual.manifest` as the primary surface for professional document review: render the two documents continuously, draw exact inline highlights when coordinates are present, use `groupId` for selection and synchronized scrolling, and derive center-gutter/minimap markers from the region coordinates. If `visual.status` is not `ready`, agents should use the text diff summary and comparison URL instead of assuming there are no visual changes.
77
+
78
+ Example:
79
+
80
+ ```json
81
+ {
82
+ "name": "compare_documents",
83
+ "arguments": {
84
+ "file_a_path": "/docs/contract_v1.pdf",
85
+ "file_b_path": "/docs/contract_v2.pdf",
86
+ "comparison_mode": "visual"
87
+ }
88
+ }
89
+ ```
94
90
 
95
91
  ### get_comparison
96
92
 
97
- Check the status and results of a comparison by its UUID.
93
+ Check the status and results of a comparison by its UUID. Returns the same visual artifact contract as `compare_documents`, with fresh signed URLs. Use it to poll comparisons that returned `status: "processing"`. Free (does not consume credits).
94
+
95
+ **Parameters:**
96
+ - `comparison_id` (required) -- The comparison UUID
98
97
 
99
98
  ### list_comparisons
100
99
 
101
- List recent comparisons in your workspace (default: 10).
100
+ List recent comparisons in your workspace, newest first. Returns `id`, `status`, `comparisonMode`, `file_a`/`file_b` name and type, `summary` counters, `created_at`, and `completed_at` for each comparison. Useful to recover a comparison id or check recent activity. Free.
101
+
102
+ **Parameters:**
103
+ - `limit` (optional) -- Maximum results, 1 to 50 (default: 10)
104
+ - `status` (optional) -- Filter by `pending`, `processing`, `ready`, or `failed`
105
+
106
+ Example:
107
+
108
+ ```json
109
+ {
110
+ "name": "list_comparisons",
111
+ "arguments": { "limit": 5, "status": "ready" }
112
+ }
113
+ ```
114
+
115
+ ### export_comparison_pdf
116
+
117
+ Export a finished comparison as a PDF report and get a temporary download URL (valid for about 5 minutes). With `wait: true` (default) the tool polls the export job every 2 seconds for up to 3 minutes and returns the `downloadUrl`. Free workspaces get a watermarked PDF; workspaces with credits export without watermark. Free (exports do not consume credits).
118
+
119
+ **Parameters:**
120
+ - `comparison_id` (required) -- The comparison UUID (must have `status: "ready"`)
121
+ - `include_unchanged` (optional) -- Include unchanged content in the report (default: `false`)
122
+ - `locale` (optional) -- Report language: `en`, `es`, `zh`, `fr`, `de`, or `hi` (default: `en`)
123
+ - `wait` (optional) -- Poll until the export completes (default: `true`)
124
+
125
+ Example:
126
+
127
+ ```json
128
+ {
129
+ "name": "export_comparison_pdf",
130
+ "arguments": {
131
+ "comparison_id": "8f14e45f-...",
132
+ "locale": "es",
133
+ "include_unchanged": false
134
+ }
135
+ }
136
+ ```
137
+
138
+ ### summarize_visual_diff
139
+
140
+ Compact, page-by-page summary of the visual diff: one entry per change group (fragments that share a `groupId` are deduplicated), with change type, title, salience, and optional text snippets. No image URLs and no pixel coordinates, so it is the cheapest way for an agent to understand what changed and where. Requires a visual comparison with `visual.status: "ready"`. Free.
141
+
142
+ **Parameters:**
143
+ - `comparison_id` (required) -- The comparison UUID
144
+ - `min_salience` (optional) -- Only include changes at or above this prominence level; order is `technical` < `subtle` < `visible` < `structural`
145
+ - `include_snippets` (optional) -- Include `snippetA`/`snippetB` excerpts (default: `true`)
146
+ - `max_changes_per_page` (optional) -- Cap per page, extra changes are counted as omitted (default: 20)
147
+
148
+ Example:
149
+
150
+ ```json
151
+ {
152
+ "name": "summarize_visual_diff",
153
+ "arguments": {
154
+ "comparison_id": "8f14e45f-...",
155
+ "min_salience": "visible",
156
+ "max_changes_per_page": 10
157
+ }
158
+ }
159
+ ```
160
+
161
+ ### get_text_diff
162
+
163
+ Fetch the block-level text diff of a finished comparison. Returns the diff summary counters plus the diff blocks: `added` and `removed` blocks carry the affected text, `modified` blocks also carry word-level `spans` marking exactly which words changed. By default only changed blocks are returned. Best when you need to quote exact text changes. Free.
164
+
165
+ **Parameters:**
166
+ - `comparison_id` (required) -- The comparison UUID (must have `status: "ready"`)
167
+ - `only_changed` (optional) -- Skip `equal` blocks (default: `true`)
168
+ - `max_blocks` (optional) -- Maximum blocks returned (default: 100)
169
+
170
+ Example:
171
+
172
+ ```json
173
+ {
174
+ "name": "get_text_diff",
175
+ "arguments": { "comparison_id": "8f14e45f-...", "max_blocks": 50 }
176
+ }
177
+ ```
178
+
179
+ ## REST API
180
+
181
+ The MCP server uses the Differino REST API under the hood. You can also call it directly:
182
+
183
+ ```bash
184
+ # Compare two files
185
+ curl -X POST https://www.differino.com/api/v1/compare \
186
+ -H "Authorization: Bearer dfn_your_api_key" \
187
+ -F "file_a=@original.pdf" \
188
+ -F "file_b=@modified.pdf" \
189
+ -F "comparison_mode=visual" \
190
+ -F "accuracy_mode=balanced"
191
+
192
+ # Check comparison status
193
+ curl https://www.differino.com/api/v1/comparisons/{id} \
194
+ -H "Authorization: Bearer dfn_your_api_key"
195
+
196
+ # List recent comparisons
197
+ curl "https://www.differino.com/api/v1/comparisons?limit=10&status=ready" \
198
+ -H "Authorization: Bearer dfn_your_api_key"
199
+
200
+ # Export a comparison as PDF, then poll for the download URL
201
+ curl -X POST https://www.differino.com/api/v1/comparisons/{id}/export \
202
+ -H "Authorization: Bearer dfn_your_api_key" \
203
+ -H "Content-Type: application/json" \
204
+ -d '{"format": "pdf", "includeUnchanged": false, "locale": "en"}'
205
+
206
+ curl "https://www.differino.com/api/v1/comparisons/{id}/export?jobId={jobId}" \
207
+ -H "Authorization: Bearer dfn_your_api_key"
208
+ ```
209
+
210
+ ### accuracy_mode
211
+
212
+ `POST /api/v1/compare` accepts an optional `accuracy_mode` form field:
102
213
 
103
- ### list_documents
214
+ - `fast` -- quickest results, best for plain text and simple layouts (default for `comparison_mode=text`)
215
+ - `balanced` -- good accuracy/speed trade-off (default for `comparison_mode=visual`)
216
+ - `thorough` -- maximum accuracy for complex layouts, slower
104
217
 
105
- List documents in your Differino library (default: 20).
218
+ ### Error codes
106
219
 
107
- ### get_credits
220
+ | HTTP | `code` | Meaning |
221
+ |------|--------|---------|
222
+ | 400 | | Invalid request: missing files, unsupported file type, file too large, or invalid parameters |
223
+ | 401 | | Missing or invalid API key |
224
+ | 402 | `NO_CREDITS` | No free comparison left and no credits remaining; buy a pack at [differino.com/pricing](https://www.differino.com/pricing) |
225
+ | 404 | | Comparison or export job not found in your workspace |
108
226
 
109
- Check your credit balance, current plan, and free comparison usage for the month.
227
+ When present, the `code` field is machine-readable; the MCP server prefixes it to error messages (for example `[NO_CREDITS] No credits remaining...`).
110
228
 
111
229
  ## Supported Formats
112
230
 
@@ -116,5 +234,5 @@ Check your credit balance, current plan, and free comparison usage for the month
116
234
 
117
235
  ## Credits
118
236
 
119
- 3 free comparisons per month. After that, each comparison costs 1 credit.
120
- Buy credit packs at [differino.com/pricing](https://differino.com/pricing).
237
+ Every account gets 1 free comparison (lifetime, not monthly). After that, each comparison costs 1 credit. Checking results, listing comparisons, exporting PDFs, and reading diffs are always free.
238
+ Buy credit packs at [differino.com/pricing](https://www.differino.com/pricing).
package/dist/index.js CHANGED
@@ -44,29 +44,58 @@ const path = __importStar(require("path"));
44
44
  // ---------------------------------------------------------------------------
45
45
  const API_KEY = process.env.DIFFERINO_API_KEY || '';
46
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
47
50
  // ---------------------------------------------------------------------------
48
51
  // HTTP helpers
49
52
  // ---------------------------------------------------------------------------
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'))));
59
+ }
50
60
  async function apiCall(endpoint, options = {}) {
51
61
  const url = `${BASE_URL}${endpoint}`;
52
62
  const headers = {
53
63
  Authorization: `Bearer ${API_KEY}`,
54
64
  ...(options.headers || {}),
55
65
  };
56
- const res = await fetch(url, { ...options, headers });
66
+ let res;
67
+ try {
68
+ res = await fetch(url, {
69
+ ...options,
70
+ headers,
71
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
72
+ });
73
+ }
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;
80
+ }
57
81
  const text = await res.text();
58
82
  let json;
59
83
  try {
60
84
  json = JSON.parse(text);
61
85
  }
62
86
  catch {
63
- 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)}`);
64
88
  }
65
89
  if (!res.ok) {
66
- throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
90
+ const message = json.error || `API error ${res.status}: ${text.slice(0, 300)}`;
91
+ // Surface machine-readable error codes (e.g. NO_CREDITS) to the agent.
92
+ throw new Error(json.code ? `[${json.code}] ${message}` : message);
67
93
  }
68
94
  return json;
69
95
  }
96
+ function sleep(ms) {
97
+ return new Promise((resolve) => setTimeout(resolve, ms));
98
+ }
70
99
  // ---------------------------------------------------------------------------
71
100
  // File utilities
72
101
  // ---------------------------------------------------------------------------
@@ -90,11 +119,11 @@ function getMimeType(ext) {
90
119
  // Tool implementations
91
120
  // ---------------------------------------------------------------------------
92
121
  async function compareDocuments(args) {
93
- const { file_a_path, file_b_path } = args;
122
+ const { file_a_path, file_b_path, comparison_mode = 'visual' } = args;
94
123
  // Validate files exist
95
124
  for (const fp of [file_a_path, file_b_path]) {
96
125
  if (!fs.existsSync(fp)) {
97
- return `Error: File not found: ${fp}`;
126
+ throw new Error(`File not found: ${fp}`);
98
127
  }
99
128
  }
100
129
  const nameA = path.basename(file_a_path);
@@ -102,10 +131,10 @@ async function compareDocuments(args) {
102
131
  const extA = getExtension(nameA);
103
132
  const extB = getExtension(nameB);
104
133
  if (!SUPPORTED_EXTENSIONS.has(extA)) {
105
- return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
134
+ throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
106
135
  }
107
136
  if (!SUPPORTED_EXTENSIONS.has(extB)) {
108
- return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
137
+ throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
109
138
  }
110
139
  // Build multipart form
111
140
  const form = new FormData();
@@ -113,7 +142,8 @@ async function compareDocuments(args) {
113
142
  const fileBBuffer = fs.readFileSync(file_b_path);
114
143
  form.append('file_a', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
115
144
  form.append('file_b', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
116
- // Call the REST API — it handles upload, extraction, comparison, and polling
145
+ form.append('comparison_mode', comparison_mode);
146
+ // Call the REST API. It handles upload, extraction, comparison, and polling.
117
147
  const result = await apiCall('/api/v1/compare', {
118
148
  method: 'POST',
119
149
  body: form,
@@ -125,23 +155,205 @@ async function getComparison(args) {
125
155
  return JSON.stringify(result, null, 2);
126
156
  }
127
157
  async function listComparisons(args) {
128
- // Use the internal session-less API — falls through to REST
129
- // For now, this tool is simplified to just report the endpoint
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`, {
170
+ method: 'POST',
171
+ headers: { 'Content-Type': 'application/json' },
172
+ body: JSON.stringify({
173
+ format: 'pdf',
174
+ includeUnchanged: include_unchanged,
175
+ locale,
176
+ }),
177
+ });
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') {
193
+ return JSON.stringify({
194
+ jobId,
195
+ status: 'completed',
196
+ downloadUrl: statusRes.downloadUrl,
197
+ note: 'The download URL is signed and expires in about 5 minutes.',
198
+ }, null, 2);
199
+ }
200
+ if (statusRes.status === 'failed') {
201
+ throw new Error(`Export failed: ${statusRes.error || 'unknown error'}`);
202
+ }
203
+ }
204
+ return JSON.stringify({
205
+ jobId,
206
+ status: 'processing',
207
+ message: `Export still processing after ${EXPORT_POLL_TIMEOUT_MS / 1000}s. ` +
208
+ `Poll GET ${statusEndpoint} for the downloadUrl.`,
209
+ }, null, 2);
210
+ }
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
+ }
282
+ }
283
+ return JSON.stringify({
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,
291
+ }, null, 2);
292
+ }
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
+ : {}),
332
+ }));
130
333
  return JSON.stringify({
131
- message: 'Use compare_documents to create comparisons, and get_comparison to check results.',
132
- api_docs: `${BASE_URL}/mcp`,
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,
133
344
  }, null, 2);
134
345
  }
135
346
  // ---------------------------------------------------------------------------
136
347
  // MCP Server
137
348
  // ---------------------------------------------------------------------------
138
- const server = new index_js_1.Server({ name: 'differino', version: '0.2.0' }, { capabilities: { tools: {} } });
349
+ const server = new index_js_1.Server({ name: 'differino', version: '0.4.0' }, { capabilities: { tools: {} } });
139
350
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
140
351
  tools: [
141
352
  {
142
353
  name: 'compare_documents',
143
354
  description: 'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
144
- 'Reads files from local disk, uploads them to Differino, and returns a diff summary with a URL to view the full comparison.',
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.',
145
357
  inputSchema: {
146
358
  type: 'object',
147
359
  properties: {
@@ -153,13 +365,118 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
153
365
  type: 'string',
154
366
  description: 'Absolute path to the modified document',
155
367
  },
368
+ comparison_mode: {
369
+ type: 'string',
370
+ enum: ['visual', 'text'],
371
+ description: 'visual for full document review, or text for fast text-only comparison',
372
+ default: 'visual',
373
+ },
156
374
  },
157
375
  required: ['file_a_path', 'file_b_path'],
158
376
  },
159
377
  },
160
378
  {
161
379
  name: 'get_comparison',
162
- 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.',
382
+ inputSchema: {
383
+ type: 'object',
384
+ properties: {
385
+ comparison_id: {
386
+ type: 'string',
387
+ description: 'The comparison UUID',
388
+ },
389
+ },
390
+ required: ['comparison_id'],
391
+ },
392
+ },
393
+ {
394
+ name: 'list_comparisons',
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.',
397
+ inputSchema: {
398
+ type: 'object',
399
+ properties: {
400
+ limit: {
401
+ type: 'number',
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',
409
+ },
410
+ },
411
+ required: [],
412
+ },
413
+ },
414
+ {
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.',
419
+ inputSchema: {
420
+ type: 'object',
421
+ properties: {
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: {
468
+ type: 'number',
469
+ description: 'Maximum changes listed per page; extra changes are counted as omitted',
470
+ default: 20,
471
+ },
472
+ },
473
+ required: ['comparison_id'],
474
+ },
475
+ },
476
+ {
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.',
163
480
  inputSchema: {
164
481
  type: 'object',
165
482
  properties: {
@@ -167,6 +484,16 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
167
484
  type: 'string',
168
485
  description: 'The comparison UUID',
169
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
+ },
170
497
  },
171
498
  required: ['comparison_id'],
172
499
  },
@@ -184,8 +511,20 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
184
511
  case 'get_comparison':
185
512
  result = await getComparison(args);
186
513
  break;
514
+ case 'list_comparisons':
515
+ result = await listComparisons(args);
516
+ break;
517
+ case 'export_comparison_pdf':
518
+ result = await exportComparisonPdf(args);
519
+ break;
520
+ case 'summarize_visual_diff':
521
+ result = await summarizeVisualDiff(args);
522
+ break;
523
+ case 'get_text_diff':
524
+ result = await getTextDiff(args);
525
+ break;
187
526
  default:
188
- result = `Unknown tool: ${name}`;
527
+ throw new Error(`Unknown tool: ${name}`);
189
528
  }
190
529
  return {
191
530
  content: [{ type: 'text', text: result }],
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "differino-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "MCP server for Differino document comparison",
5
+ "packageManager": "pnpm@9.0.0",
5
6
  "main": "dist/index.js",
6
7
  "bin": {
7
8
  "differino-mcp": "dist/index.js"
package/src/index.ts CHANGED
@@ -16,10 +16,24 @@ import * as path from 'path';
16
16
  const API_KEY = process.env.DIFFERINO_API_KEY || '';
17
17
  const BASE_URL = process.env.DIFFERINO_URL || 'https://www.differino.com';
18
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
+
19
23
  // ---------------------------------------------------------------------------
20
24
  // HTTP helpers
21
25
  // ---------------------------------------------------------------------------
22
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
+
23
37
  async function apiCall(
24
38
  endpoint: string,
25
39
  options: RequestInit = {},
@@ -30,23 +44,44 @@ async function apiCall(
30
44
  ...(options.headers as Record<string, string> || {}),
31
45
  };
32
46
 
33
- const res = await fetch(url, { ...options, headers });
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
+ }
34
63
 
35
64
  const text = await res.text();
36
65
  let json: any;
37
66
  try {
38
67
  json = JSON.parse(text);
39
68
  } catch {
40
- 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)}`);
41
70
  }
42
71
 
43
72
  if (!res.ok) {
44
- throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
73
+ const message = json.error || `API error ${res.status}: ${text.slice(0, 300)}`;
74
+ // Surface machine-readable error codes (e.g. NO_CREDITS) to the agent.
75
+ throw new Error(json.code ? `[${json.code}] ${message}` : message);
45
76
  }
46
77
 
47
78
  return json;
48
79
  }
49
80
 
81
+ function sleep(ms: number): Promise<void> {
82
+ return new Promise((resolve) => setTimeout(resolve, ms));
83
+ }
84
+
50
85
  // ---------------------------------------------------------------------------
51
86
  // File utilities
52
87
  // ---------------------------------------------------------------------------
@@ -77,13 +112,14 @@ function getMimeType(ext: string): string {
77
112
  async function compareDocuments(args: {
78
113
  file_a_path: string;
79
114
  file_b_path: string;
115
+ comparison_mode?: 'visual' | 'text';
80
116
  }): Promise<string> {
81
- const { file_a_path, file_b_path } = args;
117
+ const { file_a_path, file_b_path, comparison_mode = 'visual' } = args;
82
118
 
83
119
  // Validate files exist
84
120
  for (const fp of [file_a_path, file_b_path]) {
85
121
  if (!fs.existsSync(fp)) {
86
- return `Error: File not found: ${fp}`;
122
+ throw new Error(`File not found: ${fp}`);
87
123
  }
88
124
  }
89
125
 
@@ -93,10 +129,10 @@ async function compareDocuments(args: {
93
129
  const extB = getExtension(nameB);
94
130
 
95
131
  if (!SUPPORTED_EXTENSIONS.has(extA)) {
96
- return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
132
+ throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
97
133
  }
98
134
  if (!SUPPORTED_EXTENSIONS.has(extB)) {
99
- return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
135
+ throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
100
136
  }
101
137
 
102
138
  // Build multipart form
@@ -114,8 +150,9 @@ async function compareDocuments(args: {
114
150
  new Blob([fileBBuffer], { type: getMimeType(extB) }),
115
151
  nameB,
116
152
  );
153
+ form.append('comparison_mode', comparison_mode);
117
154
 
118
- // Call the REST API it handles upload, extraction, comparison, and polling
155
+ // Call the REST API. It handles upload, extraction, comparison, and polling.
119
156
  const result = await apiCall('/api/v1/compare', {
120
157
  method: 'POST',
121
158
  body: form,
@@ -129,14 +166,276 @@ async function getComparison(args: { comparison_id: string }): Promise<string> {
129
166
  return JSON.stringify(result, null, 2);
130
167
  }
131
168
 
132
- async function listComparisons(args: { limit?: number }): Promise<string> {
133
- // Use the internal session-less API — falls through to REST
134
- // For now, this tool is simplified to just report the endpoint
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
+
135
244
  return JSON.stringify(
136
245
  {
246
+ jobId,
247
+ status: 'processing',
137
248
  message:
138
- 'Use compare_documents to create comparisons, and get_comparison to check results.',
139
- api_docs: `${BASE_URL}/mcp`,
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,
140
439
  },
141
440
  null,
142
441
  2,
@@ -148,7 +447,7 @@ async function listComparisons(args: { limit?: number }): Promise<string> {
148
447
  // ---------------------------------------------------------------------------
149
448
 
150
449
  const server = new Server(
151
- { name: 'differino', version: '0.2.0' },
450
+ { name: 'differino', version: '0.4.0' },
152
451
  { capabilities: { tools: {} } },
153
452
  );
154
453
 
@@ -158,7 +457,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
158
457
  name: 'compare_documents',
159
458
  description:
160
459
  'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
161
- 'Reads files from local disk, uploads them to Differino, and returns a diff summary with a URL to view the full comparison.',
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.',
162
462
  inputSchema: {
163
463
  type: 'object' as const,
164
464
  properties: {
@@ -170,6 +470,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
170
470
  type: 'string',
171
471
  description: 'Absolute path to the modified document',
172
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
+ },
173
479
  },
174
480
  required: ['file_a_path', 'file_b_path'],
175
481
  },
@@ -177,7 +483,47 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
177
483
  {
178
484
  name: 'get_comparison',
179
485
  description:
180
- 'Get the status and results of a specific comparison by its ID.',
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.',
181
527
  inputSchema: {
182
528
  type: 'object' as const,
183
529
  properties: {
@@ -185,6 +531,79 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
185
531
  type: 'string',
186
532
  description: 'The comparison UUID',
187
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
+ },
188
607
  },
189
608
  required: ['comparison_id'],
190
609
  },
@@ -205,8 +624,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
205
624
  case 'get_comparison':
206
625
  result = await getComparison(args as any);
207
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;
208
639
  default:
209
- result = `Unknown tool: ${name}`;
640
+ throw new Error(`Unknown tool: ${name}`);
210
641
  }
211
642
 
212
643
  return {