differino-mcp 0.2.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 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,288 @@ 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. 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`.
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
+ Responses include visual artifact metadata when available:
92
67
 
93
- Files under 4 MB are uploaded directly. Larger files use a signed-URL flow that bypasses Vercel's body size limit.
68
+ - `comparisonMode`: `visual` or `text`
69
+ - `visual.status`: `ready`, `pending`, `missing`, or `disabled`
70
+ - `visual.manifest`: the document-review contract when `visual.status` is `ready`
71
+ - `pages[]`: side-by-side page pairs with image URLs and dimensions
72
+ - `pages[].regions[]`: red/green-ready change regions with `leftBox`, `rightBox`, snippets, change type, salience metadata, and a stable `groupId`
73
+ - `regions[].groupId`: shared identity for paired change fragments, including changes that cross page boundaries
74
+ - `regions[].salience`: `subtle`, `visible`, `structural`, or `technical`, plus `salienceReason` when available
75
+ - `totalChanges` and `changedPageCount`: summary fields for compact controls and optional review navigation; paired fragments with the same `groupId` count as one change
76
+ - `visual.diffUrl`: signed URL for the visual diff JSON when available
77
+
78
+ 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.
79
+
80
+ Example:
81
+
82
+ ```json
83
+ {
84
+ "name": "compare_documents",
85
+ "arguments": {
86
+ "file_a_path": "/docs/contract_v1.pdf",
87
+ "file_b_path": "/docs/contract_v2.pdf",
88
+ "comparison_mode": "visual"
89
+ }
90
+ }
91
+ ```
94
92
 
95
93
  ### get_comparison
96
94
 
97
- Check the status and results of a comparison by its UUID.
95
+ 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).
96
+
97
+ **Parameters:**
98
+ - `comparison_id` (required) -- The comparison UUID
98
99
 
99
100
  ### list_comparisons
100
101
 
101
- List recent comparisons in your workspace (default: 10).
102
+ 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.
103
+
104
+ **Parameters:**
105
+ - `limit` (optional) -- Maximum results, 1 to 50 (default: 10)
106
+ - `status` (optional) -- Filter by `pending`, `processing`, `ready`, or `failed`
107
+
108
+ Example:
109
+
110
+ ```json
111
+ {
112
+ "name": "list_comparisons",
113
+ "arguments": { "limit": 5, "status": "ready" }
114
+ }
115
+ ```
116
+
117
+ ### export_comparison_pdf
118
+
119
+ 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).
120
+
121
+ **Parameters:**
122
+ - `comparison_id` (required) -- The comparison UUID (must have `status: "ready"`)
123
+ - `include_unchanged` (optional) -- Include unchanged content in the report (default: `false`)
124
+ - `locale` (optional) -- Report language: `en`, `es`, `zh`, `fr`, `de`, or `hi` (default: `en`)
125
+ - `wait` (optional) -- Poll until the export completes (default: `true`)
126
+
127
+ Example:
128
+
129
+ ```json
130
+ {
131
+ "name": "export_comparison_pdf",
132
+ "arguments": {
133
+ "comparison_id": "8f14e45f-...",
134
+ "locale": "es",
135
+ "include_unchanged": false
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### summarize_visual_diff
141
+
142
+ 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.
143
+
144
+ **Parameters:**
145
+ - `comparison_id` (required) -- The comparison UUID
146
+ - `min_salience` (optional) -- Only include changes at or above this prominence level; order is `technical` < `subtle` < `visible` < `structural`
147
+ - `include_snippets` (optional) -- Include `snippetA`/`snippetB` excerpts (default: `true`)
148
+ - `max_changes_per_page` (optional) -- Cap per page, extra changes are counted as omitted (default: 20)
149
+
150
+ Example:
151
+
152
+ ```json
153
+ {
154
+ "name": "summarize_visual_diff",
155
+ "arguments": {
156
+ "comparison_id": "8f14e45f-...",
157
+ "min_salience": "visible",
158
+ "max_changes_per_page": 10
159
+ }
160
+ }
161
+ ```
162
+
163
+ ### get_text_diff
164
+
165
+ 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.
166
+
167
+ **Parameters:**
168
+ - `comparison_id` (required) -- The comparison UUID (must have `status: "ready"`)
169
+ - `only_changed` (optional) -- Skip `equal` blocks (default: `true`)
170
+ - `max_blocks` (optional) -- Maximum blocks returned (default: 100)
171
+
172
+ Example:
173
+
174
+ ```json
175
+ {
176
+ "name": "get_text_diff",
177
+ "arguments": { "comparison_id": "8f14e45f-...", "max_blocks": 50 }
178
+ }
179
+ ```
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
+
199
+ ## REST API
200
+
201
+ The MCP server uses the Differino REST API under the hood. You can also call it directly:
202
+
203
+ ```bash
204
+ # Compare two SMALL files (multipart body limited to ~4.5 MB total on Vercel)
205
+ curl -X POST https://www.differino.com/api/v1/compare \
206
+ -H "Authorization: Bearer dfn_your_api_key" \
207
+ -H "Idempotency-Key: my-unique-key-001" \
208
+ -F "file_a=@original.pdf" \
209
+ -F "file_b=@modified.pdf" \
210
+ -F "comparison_mode=visual" \
211
+ -F "accuracy_mode=balanced"
212
+
213
+ # Check comparison status
214
+ curl https://www.differino.com/api/v1/comparisons/{id} \
215
+ -H "Authorization: Bearer dfn_your_api_key"
216
+
217
+ # List recent comparisons
218
+ curl "https://www.differino.com/api/v1/comparisons?limit=10&status=ready" \
219
+ -H "Authorization: Bearer dfn_your_api_key"
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
+
225
+ # Export a comparison as PDF, then poll for the download URL
226
+ curl -X POST https://www.differino.com/api/v1/comparisons/{id}/export \
227
+ -H "Authorization: Bearer dfn_your_api_key" \
228
+ -H "Content-Type: application/json" \
229
+ -d '{"format": "pdf", "includeUnchanged": false, "locale": "en"}'
230
+
231
+ curl "https://www.differino.com/api/v1/comparisons/{id}/export?jobId={jobId}" \
232
+ -H "Authorization: Bearer dfn_your_api_key"
233
+ ```
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
+
285
+ ### accuracy_mode
286
+
287
+ `POST /api/v1/compare` accepts an optional `accuracy_mode` form field:
102
288
 
103
- ### list_documents
289
+ - `fast` -- quickest results, best for plain text and simple layouts (default for `comparison_mode=text`)
290
+ - `balanced` -- good accuracy/speed trade-off (default for `comparison_mode=visual`)
291
+ - `thorough` -- maximum accuracy for complex layouts, slower
104
292
 
105
- List documents in your Differino library (default: 20).
293
+ ### Error codes
106
294
 
107
- ### get_credits
295
+ | HTTP | `code` | Meaning |
296
+ |------|--------|---------|
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) |
301
+ | 401 | | Missing or invalid API key |
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 |
108
307
 
109
- Check your credit balance, current plan, and free comparison usage for the month.
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.
110
309
 
111
310
  ## Supported Formats
112
311
 
@@ -116,5 +315,5 @@ Check your credit balance, current plan, and free comparison usage for the month
116
315
 
117
316
  ## Credits
118
317
 
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).
318
+ 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.
319
+ Buy credit packs at [differino.com/pricing](https://www.differino.com/pricing).
package/dist/index.js CHANGED
@@ -44,29 +44,71 @@ 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
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
47
56
  // ---------------------------------------------------------------------------
48
57
  // HTTP helpers
49
58
  // ---------------------------------------------------------------------------
59
+ function isTimeoutError(err) {
60
+ return (err instanceof Error &&
61
+ (err.name === 'TimeoutError' ||
62
+ err.name === 'AbortError' ||
63
+ (err.cause instanceof Error &&
64
+ (err.cause.name === 'TimeoutError' || err.cause.name === 'AbortError'))));
65
+ }
50
66
  async function apiCall(endpoint, options = {}) {
51
67
  const url = `${BASE_URL}${endpoint}`;
52
68
  const headers = {
53
69
  Authorization: `Bearer ${API_KEY}`,
54
70
  ...(options.headers || {}),
55
71
  };
56
- const res = await fetch(url, { ...options, headers });
72
+ let res;
73
+ try {
74
+ res = await fetch(url, {
75
+ ...options,
76
+ headers,
77
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
78
+ });
79
+ }
80
+ catch (err) {
81
+ if (isTimeoutError(err)) {
82
+ throw new Error(`Request to ${endpoint} timed out after ${REQUEST_TIMEOUT_MS / 1000}s. ` +
83
+ 'The operation may still be running on the server; use get_comparison or list_comparisons to check its status.');
84
+ }
85
+ throw err;
86
+ }
57
87
  const text = await res.text();
58
88
  let json;
59
89
  try {
60
90
  json = JSON.parse(text);
61
91
  }
62
92
  catch {
63
- throw new Error(`Non-JSON response from ${endpoint}: ${text.slice(0, 200)}`);
93
+ throw new Error(`Non-JSON response from ${endpoint} (HTTP ${res.status}): ${text.slice(0, 200)}`);
64
94
  }
65
95
  if (!res.ok) {
66
- throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
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.
105
+ throw new Error(json.code ? `[${json.code}] ${message}` : message);
67
106
  }
68
107
  return json;
69
108
  }
109
+ function sleep(ms) {
110
+ return new Promise((resolve) => setTimeout(resolve, ms));
111
+ }
70
112
  // ---------------------------------------------------------------------------
71
113
  // File utilities
72
114
  // ---------------------------------------------------------------------------
@@ -89,12 +131,36 @@ function getMimeType(ext) {
89
131
  // ---------------------------------------------------------------------------
90
132
  // Tool implementations
91
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
+ }
92
158
  async function compareDocuments(args) {
93
- const { file_a_path, file_b_path } = args;
159
+ const { file_a_path, file_b_path, comparison_mode = 'visual' } = args;
94
160
  // Validate files exist
95
161
  for (const fp of [file_a_path, file_b_path]) {
96
162
  if (!fs.existsSync(fp)) {
97
- return `Error: File not found: ${fp}`;
163
+ throw new Error(`File not found: ${fp}`);
98
164
  }
99
165
  }
100
166
  const nameA = path.basename(file_a_path);
@@ -102,46 +168,284 @@ async function compareDocuments(args) {
102
168
  const extA = getExtension(nameA);
103
169
  const extB = getExtension(nameB);
104
170
  if (!SUPPORTED_EXTENSIONS.has(extA)) {
105
- return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
171
+ throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
106
172
  }
107
173
  if (!SUPPORTED_EXTENSIONS.has(extB)) {
108
- return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
174
+ throw new Error(`Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`);
109
175
  }
110
- // Build multipart form
111
- const form = new FormData();
112
176
  const fileABuffer = fs.readFileSync(file_a_path);
113
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();
114
193
  form.append('file_a', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
115
194
  form.append('file_b', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
116
- // Call the REST API — it handles upload, extraction, comparison, and polling
195
+ form.append('comparison_mode', comparison_mode);
196
+ // Call the REST API. It handles upload, extraction, comparison, and polling.
117
197
  const result = await apiCall('/api/v1/compare', {
118
198
  method: 'POST',
119
199
  body: form,
120
200
  });
121
201
  return JSON.stringify(result, null, 2);
122
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
+ }
123
245
  async function getComparison(args) {
124
246
  const result = await apiCall(`/api/v1/comparisons/${args.comparison_id}`);
125
247
  return JSON.stringify(result, null, 2);
126
248
  }
127
249
  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
250
+ const params = new URLSearchParams();
251
+ if (args.limit !== undefined)
252
+ params.set('limit', String(args.limit));
253
+ if (args.status)
254
+ params.set('status', args.status);
255
+ const qs = params.toString();
256
+ const result = await apiCall(`/api/v1/comparisons${qs ? `?${qs}` : ''}`);
257
+ return JSON.stringify(result, null, 2);
258
+ }
259
+ async function exportComparisonPdf(args) {
260
+ const { comparison_id, include_unchanged = false, locale = 'en', wait = true, } = args;
261
+ const created = await apiCall(`/api/v1/comparisons/${comparison_id}/export`, {
262
+ method: 'POST',
263
+ headers: { 'Content-Type': 'application/json' },
264
+ body: JSON.stringify({
265
+ format: 'pdf',
266
+ includeUnchanged: include_unchanged,
267
+ locale,
268
+ }),
269
+ });
270
+ const jobId = created.jobId;
271
+ const statusEndpoint = `/api/v1/comparisons/${comparison_id}/export?jobId=${encodeURIComponent(jobId)}`;
272
+ if (!wait) {
273
+ return JSON.stringify({
274
+ jobId,
275
+ status: created.status ?? 'processing',
276
+ message: 'Export queued. Call export_comparison_pdf again with wait=true, or poll ' +
277
+ `GET ${statusEndpoint} to get the downloadUrl.`,
278
+ }, null, 2);
279
+ }
280
+ const deadline = Date.now() + EXPORT_POLL_TIMEOUT_MS;
281
+ while (Date.now() < deadline) {
282
+ await sleep(EXPORT_POLL_INTERVAL_MS);
283
+ const statusRes = await apiCall(statusEndpoint);
284
+ if (statusRes.status === 'completed') {
285
+ return JSON.stringify({
286
+ jobId,
287
+ status: 'completed',
288
+ downloadUrl: statusRes.downloadUrl,
289
+ note: 'The download URL is signed and expires in about 5 minutes.',
290
+ }, null, 2);
291
+ }
292
+ if (statusRes.status === 'failed') {
293
+ throw new Error(`Export failed: ${statusRes.error || 'unknown error'}`);
294
+ }
295
+ }
296
+ return JSON.stringify({
297
+ jobId,
298
+ status: 'processing',
299
+ message: `Export still processing after ${EXPORT_POLL_TIMEOUT_MS / 1000}s. ` +
300
+ `Poll GET ${statusEndpoint} for the downloadUrl.`,
301
+ }, null, 2);
302
+ }
303
+ // Prominence order used by min_salience filtering, lowest to highest.
304
+ const SALIENCE_RANK = {
305
+ technical: 0,
306
+ subtle: 1,
307
+ visible: 2,
308
+ structural: 3,
309
+ };
310
+ async function summarizeVisualDiff(args) {
311
+ const { comparison_id, min_salience, include_snippets = true, max_changes_per_page = 20, } = args;
312
+ const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
313
+ const visual = comparison.visual ?? {};
314
+ const manifest = visual.manifest;
315
+ if (visual.status !== 'ready' || !manifest) {
316
+ const hint = comparison.status !== 'ready'
317
+ ? 'The comparison is still processing; retry once get_comparison reports status=ready.'
318
+ : 'This comparison has no visual manifest (it may be a text-mode comparison). Use get_text_diff instead.';
319
+ throw new Error(`Visual diff not available (comparison status=${comparison.status}, visual.status=${visual.status ?? 'unknown'}). ${hint}`);
320
+ }
321
+ const minRank = min_salience ? SALIENCE_RANK[min_salience] ?? 0 : 0;
322
+ const seenGroups = new Set();
323
+ const pages = [];
324
+ let listedChanges = 0;
325
+ let filteredBySalience = 0;
326
+ for (const page of manifest.pages ?? []) {
327
+ const regions = page.regions ?? [];
328
+ const changes = [];
329
+ let omittedChanges = 0;
330
+ for (const region of regions) {
331
+ const groupKey = region.groupId ?? region.id;
332
+ // Fragments of the same change (e.g. across page boundaries) share a
333
+ // groupId; report each change once.
334
+ if (seenGroups.has(groupKey))
335
+ continue;
336
+ seenGroups.add(groupKey);
337
+ const salience = region.salience ?? 'visible';
338
+ if ((SALIENCE_RANK[salience] ?? SALIENCE_RANK.visible) < minRank) {
339
+ filteredBySalience += 1;
340
+ continue;
341
+ }
342
+ if (changes.length >= max_changes_per_page) {
343
+ omittedChanges += 1;
344
+ continue;
345
+ }
346
+ changes.push({
347
+ groupId: groupKey,
348
+ type: region.type ?? 'changed',
349
+ title: region.title ?? null,
350
+ ...(region.description ? { description: region.description } : {}),
351
+ salience: region.salience ?? null,
352
+ ...(region.salienceReason ? { salienceReason: region.salienceReason } : {}),
353
+ ...(include_snippets
354
+ ? {
355
+ snippetA: region.snippetA ?? null,
356
+ snippetB: region.snippetB ?? null,
357
+ }
358
+ : {}),
359
+ });
360
+ listedChanges += 1;
361
+ }
362
+ if (changes.length > 0 || omittedChanges > 0) {
363
+ pages.push({
364
+ pageNumber: page.pageNumber,
365
+ changes,
366
+ ...(omittedChanges > 0
367
+ ? {
368
+ omittedChanges,
369
+ note: `Increase max_changes_per_page to see the ${omittedChanges} omitted change(s) on this page.`,
370
+ }
371
+ : {}),
372
+ });
373
+ }
374
+ }
375
+ return JSON.stringify({
376
+ comparisonId: comparison.id ?? comparison_id,
377
+ status: comparison.status,
378
+ totalChanges: manifest.totalChanges ?? null,
379
+ changedPageCount: manifest.changedPageCount ?? null,
380
+ listedChanges,
381
+ ...(min_salience ? { minSalience: min_salience, filteredBySalience } : {}),
382
+ pages,
383
+ }, null, 2);
384
+ }
385
+ async function getTextDiff(args) {
386
+ const { comparison_id, only_changed = true, max_blocks = 100 } = args;
387
+ const comparison = await apiCall(`/api/v1/comparisons/${comparison_id}`);
388
+ if (comparison.status !== 'ready') {
389
+ throw new Error(`Comparison is not ready yet (status=${comparison.status}). Retry once get_comparison reports status=ready.`);
390
+ }
391
+ if (!comparison.diffUrl) {
392
+ throw new Error('No text diff is available for this comparison.');
393
+ }
394
+ // diffUrl is a signed URL; no auth header needed.
395
+ let res;
396
+ try {
397
+ res = await fetch(comparison.diffUrl, {
398
+ signal: AbortSignal.timeout(60_000),
399
+ });
400
+ }
401
+ catch (err) {
402
+ if (isTimeoutError(err)) {
403
+ throw new Error('Timed out downloading the diff data after 60s. Try again.');
404
+ }
405
+ throw err;
406
+ }
407
+ if (!res.ok) {
408
+ throw new Error(`Failed to download diff data (HTTP ${res.status}).`);
409
+ }
410
+ const diff = (await res.json());
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
+ const outputBlocks = matching.slice(0, max_blocks).map((block) => ({
417
+ type: block.type,
418
+ ...(block.content_a !== undefined ? { content_a: block.content_a } : {}),
419
+ ...(block.content_b !== undefined ? { content_b: block.content_b } : {}),
420
+ // Word-level spans are only informative for modified blocks.
421
+ ...(block.type === 'modified' && Array.isArray(block.spans) && block.spans.length > 0
422
+ ? { spans: block.spans }
423
+ : {}),
424
+ }));
130
425
  return JSON.stringify({
131
- message: 'Use compare_documents to create comparisons, and get_comparison to check results.',
132
- api_docs: `${BASE_URL}/mcp`,
426
+ comparisonId: comparison.id ?? comparison_id,
427
+ summary: diff.summary ?? null,
428
+ only_changed,
429
+ returned_blocks: outputBlocks.length,
430
+ total_matching_blocks: matching.length,
431
+ truncated,
432
+ ...(truncated
433
+ ? { note: `Output truncated to max_blocks=${max_blocks}. Increase max_blocks to see more.` }
434
+ : {}),
435
+ blocks: outputBlocks,
133
436
  }, null, 2);
134
437
  }
135
438
  // ---------------------------------------------------------------------------
136
439
  // MCP Server
137
440
  // ---------------------------------------------------------------------------
138
- const server = new index_js_1.Server({ name: 'differino', version: '0.2.0' }, { capabilities: { tools: {} } });
441
+ const server = new index_js_1.Server({ name: 'differino', version: '0.5.0' }, { capabilities: { tools: {} } });
139
442
  server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
140
443
  tools: [
141
444
  {
142
445
  name: 'compare_documents',
143
446
  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.',
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.',
145
449
  inputSchema: {
146
450
  type: 'object',
147
451
  properties: {
@@ -153,13 +457,57 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
153
457
  type: 'string',
154
458
  description: 'Absolute path to the modified document',
155
459
  },
460
+ comparison_mode: {
461
+ type: 'string',
462
+ enum: ['visual', 'text'],
463
+ description: 'visual for full document review, or text for fast text-only comparison',
464
+ default: 'visual',
465
+ },
156
466
  },
157
467
  required: ['file_a_path', 'file_b_path'],
158
468
  },
159
469
  },
160
470
  {
161
471
  name: 'get_comparison',
162
- description: 'Get the status and results of a specific comparison by its ID.',
472
+ 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. ' +
473
+ '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.',
474
+ inputSchema: {
475
+ type: 'object',
476
+ properties: {
477
+ comparison_id: {
478
+ type: 'string',
479
+ description: 'The comparison UUID',
480
+ },
481
+ },
482
+ required: ['comparison_id'],
483
+ },
484
+ },
485
+ {
486
+ name: 'list_comparisons',
487
+ 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. ' +
488
+ '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.',
489
+ inputSchema: {
490
+ type: 'object',
491
+ properties: {
492
+ limit: {
493
+ type: 'number',
494
+ description: 'Maximum number of comparisons to return (1-50)',
495
+ default: 10,
496
+ },
497
+ status: {
498
+ type: 'string',
499
+ enum: ['pending', 'processing', 'ready', 'failed'],
500
+ description: 'Only return comparisons with this status',
501
+ },
502
+ },
503
+ required: [],
504
+ },
505
+ },
506
+ {
507
+ name: 'export_comparison_pdf',
508
+ description: 'Export a finished comparison as a PDF report and return a temporary download URL (valid for about 5 minutes). ' +
509
+ '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. ' +
510
+ 'The comparison must have status="ready". Free workspaces get a watermarked PDF; workspaces with credits export without watermark. Does not consume credits.',
163
511
  inputSchema: {
164
512
  type: 'object',
165
513
  properties: {
@@ -167,10 +515,91 @@ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
167
515
  type: 'string',
168
516
  description: 'The comparison UUID',
169
517
  },
518
+ include_unchanged: {
519
+ type: 'boolean',
520
+ description: 'Include unchanged content in the PDF report, not only the changes',
521
+ default: false,
522
+ },
523
+ locale: {
524
+ type: 'string',
525
+ enum: ['en', 'es', 'zh', 'fr', 'de', 'hi'],
526
+ description: 'Language for the report labels',
527
+ default: 'en',
528
+ },
529
+ wait: {
530
+ type: 'boolean',
531
+ description: 'Poll until the export completes (up to 3 minutes) and return the downloadUrl',
532
+ default: true,
533
+ },
170
534
  },
171
535
  required: ['comparison_id'],
172
536
  },
173
537
  },
538
+ {
539
+ name: 'summarize_visual_diff',
540
+ 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. ' +
541
+ '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.',
542
+ inputSchema: {
543
+ type: 'object',
544
+ properties: {
545
+ comparison_id: {
546
+ type: 'string',
547
+ description: 'The comparison UUID',
548
+ },
549
+ min_salience: {
550
+ type: 'string',
551
+ enum: ['technical', 'subtle', 'visible', 'structural'],
552
+ description: 'Only include changes at or above this prominence level',
553
+ },
554
+ include_snippets: {
555
+ type: 'boolean',
556
+ description: 'Include snippetA/snippetB text excerpts for each change',
557
+ default: true,
558
+ },
559
+ max_changes_per_page: {
560
+ type: 'number',
561
+ description: 'Maximum changes listed per page; extra changes are counted as omitted',
562
+ default: 20,
563
+ },
564
+ },
565
+ required: ['comparison_id'],
566
+ },
567
+ },
568
+ {
569
+ name: 'get_text_diff',
570
+ 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. ' +
571
+ '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.',
572
+ inputSchema: {
573
+ type: 'object',
574
+ properties: {
575
+ comparison_id: {
576
+ type: 'string',
577
+ description: 'The comparison UUID',
578
+ },
579
+ only_changed: {
580
+ type: 'boolean',
581
+ description: 'Return only added/removed/modified blocks, skipping equal ones',
582
+ default: true,
583
+ },
584
+ max_blocks: {
585
+ type: 'number',
586
+ description: 'Maximum number of blocks to return',
587
+ default: 100,
588
+ },
589
+ },
590
+ required: ['comparison_id'],
591
+ },
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
+ },
174
603
  ],
175
604
  }));
176
605
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
@@ -184,8 +613,23 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
184
613
  case 'get_comparison':
185
614
  result = await getComparison(args);
186
615
  break;
616
+ case 'list_comparisons':
617
+ result = await listComparisons(args);
618
+ break;
619
+ case 'export_comparison_pdf':
620
+ result = await exportComparisonPdf(args);
621
+ break;
622
+ case 'summarize_visual_diff':
623
+ result = await summarizeVisualDiff(args);
624
+ break;
625
+ case 'get_text_diff':
626
+ result = await getTextDiff(args);
627
+ break;
628
+ case 'get_balance':
629
+ result = await getBalance();
630
+ break;
187
631
  default:
188
- result = `Unknown tool: ${name}`;
632
+ throw new Error(`Unknown tool: ${name}`);
189
633
  }
190
634
  return {
191
635
  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.5.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"
@@ -16,5 +17,9 @@
16
17
  "devDependencies": {
17
18
  "typescript": "^5.3.0",
18
19
  "@types/node": "^20.0.0"
19
- }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "README.md"
24
+ ]
20
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,244 +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
- // ---------------------------------------------------------------------------
20
- // HTTP helpers
21
- // ---------------------------------------------------------------------------
22
-
23
- async function apiCall(
24
- endpoint: string,
25
- options: RequestInit = {},
26
- ): Promise<any> {
27
- const url = `${BASE_URL}${endpoint}`;
28
- const headers: Record<string, string> = {
29
- Authorization: `Bearer ${API_KEY}`,
30
- ...(options.headers as Record<string, string> || {}),
31
- };
32
-
33
- const res = await fetch(url, { ...options, headers });
34
-
35
- const text = await res.text();
36
- let json: any;
37
- try {
38
- json = JSON.parse(text);
39
- } catch {
40
- throw new Error(`Non-JSON response from ${endpoint}: ${text.slice(0, 200)}`);
41
- }
42
-
43
- if (!res.ok) {
44
- throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
45
- }
46
-
47
- return json;
48
- }
49
-
50
- // ---------------------------------------------------------------------------
51
- // File utilities
52
- // ---------------------------------------------------------------------------
53
-
54
- const SUPPORTED_EXTENSIONS = new Set(['pdf', 'docx', 'txt']);
55
-
56
- function getExtension(filename: string): string {
57
- return (filename.split('.').pop() || '').toLowerCase();
58
- }
59
-
60
- function getMimeType(ext: string): string {
61
- switch (ext) {
62
- case 'pdf':
63
- return 'application/pdf';
64
- case 'docx':
65
- return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
66
- case 'txt':
67
- return 'text/plain';
68
- default:
69
- return 'application/octet-stream';
70
- }
71
- }
72
-
73
- // ---------------------------------------------------------------------------
74
- // Tool implementations
75
- // ---------------------------------------------------------------------------
76
-
77
- async function compareDocuments(args: {
78
- file_a_path: string;
79
- file_b_path: string;
80
- }): Promise<string> {
81
- const { file_a_path, file_b_path } = args;
82
-
83
- // Validate files exist
84
- for (const fp of [file_a_path, file_b_path]) {
85
- if (!fs.existsSync(fp)) {
86
- return `Error: File not found: ${fp}`;
87
- }
88
- }
89
-
90
- const nameA = path.basename(file_a_path);
91
- const nameB = path.basename(file_b_path);
92
- const extA = getExtension(nameA);
93
- const extB = getExtension(nameB);
94
-
95
- if (!SUPPORTED_EXTENSIONS.has(extA)) {
96
- return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
97
- }
98
- if (!SUPPORTED_EXTENSIONS.has(extB)) {
99
- return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
100
- }
101
-
102
- // Build multipart form
103
- const form = new FormData();
104
- const fileABuffer = fs.readFileSync(file_a_path);
105
- const fileBBuffer = fs.readFileSync(file_b_path);
106
-
107
- form.append(
108
- 'file_a',
109
- new Blob([fileABuffer], { type: getMimeType(extA) }),
110
- nameA,
111
- );
112
- form.append(
113
- 'file_b',
114
- new Blob([fileBBuffer], { type: getMimeType(extB) }),
115
- nameB,
116
- );
117
-
118
- // Call the REST API — it handles upload, extraction, comparison, and polling
119
- const result = await apiCall('/api/v1/compare', {
120
- method: 'POST',
121
- body: form,
122
- });
123
-
124
- return JSON.stringify(result, null, 2);
125
- }
126
-
127
- async function getComparison(args: { comparison_id: string }): Promise<string> {
128
- const result = await apiCall(`/api/v1/comparisons/${args.comparison_id}`);
129
- return JSON.stringify(result, null, 2);
130
- }
131
-
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
135
- return JSON.stringify(
136
- {
137
- message:
138
- 'Use compare_documents to create comparisons, and get_comparison to check results.',
139
- api_docs: `${BASE_URL}/mcp`,
140
- },
141
- null,
142
- 2,
143
- );
144
- }
145
-
146
- // ---------------------------------------------------------------------------
147
- // MCP Server
148
- // ---------------------------------------------------------------------------
149
-
150
- const server = new Server(
151
- { name: 'differino', version: '0.2.0' },
152
- { capabilities: { tools: {} } },
153
- );
154
-
155
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
156
- tools: [
157
- {
158
- name: 'compare_documents',
159
- description:
160
- '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.',
162
- inputSchema: {
163
- type: 'object' as const,
164
- properties: {
165
- file_a_path: {
166
- type: 'string',
167
- description: 'Absolute path to the original document',
168
- },
169
- file_b_path: {
170
- type: 'string',
171
- description: 'Absolute path to the modified document',
172
- },
173
- },
174
- required: ['file_a_path', 'file_b_path'],
175
- },
176
- },
177
- {
178
- name: 'get_comparison',
179
- description:
180
- 'Get the status and results of a specific comparison by its ID.',
181
- inputSchema: {
182
- type: 'object' as const,
183
- properties: {
184
- comparison_id: {
185
- type: 'string',
186
- description: 'The comparison UUID',
187
- },
188
- },
189
- required: ['comparison_id'],
190
- },
191
- },
192
- ],
193
- }));
194
-
195
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
196
- const { name, arguments: args } = request.params;
197
-
198
- try {
199
- let result: string;
200
-
201
- switch (name) {
202
- case 'compare_documents':
203
- result = await compareDocuments(args as any);
204
- break;
205
- case 'get_comparison':
206
- result = await getComparison(args as any);
207
- break;
208
- default:
209
- result = `Unknown tool: ${name}`;
210
- }
211
-
212
- return {
213
- content: [{ type: 'text', text: result }],
214
- };
215
- } catch (err: unknown) {
216
- const message = err instanceof Error ? err.message : String(err);
217
- return {
218
- content: [{ type: 'text', text: `Error: ${message}` }],
219
- isError: true,
220
- };
221
- }
222
- });
223
-
224
- // ---------------------------------------------------------------------------
225
- // Entry point
226
- // ---------------------------------------------------------------------------
227
-
228
- async function main(): Promise<void> {
229
- if (!API_KEY) {
230
- console.error(
231
- 'Error: DIFFERINO_API_KEY environment variable is required.\n' +
232
- 'Generate one at https://www.differino.com/settings',
233
- );
234
- process.exit(1);
235
- }
236
-
237
- const transport = new StdioServerTransport();
238
- await server.connect(transport);
239
- }
240
-
241
- main().catch((err) => {
242
- console.error('Fatal error:', err);
243
- process.exit(1);
244
- });
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
- }