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.
- package/README.md +179 -61
- package/dist/index.js +355 -16
- package/package.json +2 -1
- 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
|
-
|
|
11
|
-
npm install
|
|
12
|
-
npm run build
|
|
10
|
+
pnpm add -g differino-mcp
|
|
13
11
|
```
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
### 2. Get an API Key
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
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": "
|
|
49
|
-
"args": ["
|
|
27
|
+
"command": "pnpm",
|
|
28
|
+
"args": ["dlx", "differino-mcp"],
|
|
50
29
|
"env": {
|
|
51
|
-
"
|
|
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
|
-
|
|
37
|
+
### Local development
|
|
62
38
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
218
|
+
### Error codes
|
|
106
219
|
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
134
|
+
throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
|
|
106
135
|
}
|
|
107
136
|
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
132
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
527
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
189
528
|
}
|
|
190
529
|
return {
|
|
191
530
|
content: [{ type: 'text', text: result }],
|
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
132
|
+
throw new Error(`Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`);
|
|
97
133
|
}
|
|
98
134
|
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
99
|
-
|
|
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
|
|
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: {
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
640
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
210
641
|
}
|
|
211
642
|
|
|
212
643
|
return {
|