differino-mcp 0.1.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 +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +568 -0
- package/package.json +21 -0
- package/src/index.ts +660 -0
- package/tsconfig.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# differino-mcp
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for [Differino](https://differino.com) -- compare PDF, DOCX, and TXT documents from any AI agent.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
### 1. Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd mcp
|
|
11
|
+
npm install
|
|
12
|
+
npm run build
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Or install globally:
|
|
16
|
+
|
|
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`) |
|
|
39
|
+
|
|
40
|
+
### 3. Add to Claude Desktop
|
|
41
|
+
|
|
42
|
+
Add to your `claude_desktop_config.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"differino": {
|
|
48
|
+
"command": "node",
|
|
49
|
+
"args": ["/path/to/comparadocs/mcp/dist/index.js"],
|
|
50
|
+
"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"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or if installed globally via npm:
|
|
62
|
+
|
|
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
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Tools
|
|
81
|
+
|
|
82
|
+
### compare_documents
|
|
83
|
+
|
|
84
|
+
Compare two local files and get a diff summary.
|
|
85
|
+
|
|
86
|
+
**Parameters:**
|
|
87
|
+
- `file_a_path` (required) -- Absolute path to the original document
|
|
88
|
+
- `file_b_path` (required) -- Absolute path to the modified document
|
|
89
|
+
- `accuracy_mode` (optional) -- `fast`, `balanced` (default), or `thorough`
|
|
90
|
+
|
|
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.
|
|
92
|
+
|
|
93
|
+
Files under 4 MB are uploaded directly. Larger files use a signed-URL flow that bypasses Vercel's body size limit.
|
|
94
|
+
|
|
95
|
+
### get_comparison
|
|
96
|
+
|
|
97
|
+
Check the status and results of a comparison by its UUID.
|
|
98
|
+
|
|
99
|
+
### list_comparisons
|
|
100
|
+
|
|
101
|
+
List recent comparisons in your workspace (default: 10).
|
|
102
|
+
|
|
103
|
+
### list_documents
|
|
104
|
+
|
|
105
|
+
List documents in your Differino library (default: 20).
|
|
106
|
+
|
|
107
|
+
### get_credits
|
|
108
|
+
|
|
109
|
+
Check your credit balance, current plan, and free comparison usage for the month.
|
|
110
|
+
|
|
111
|
+
## Supported Formats
|
|
112
|
+
|
|
113
|
+
- PDF (.pdf)
|
|
114
|
+
- Word (.docx)
|
|
115
|
+
- Plain Text (.txt)
|
|
116
|
+
|
|
117
|
+
## Credits
|
|
118
|
+
|
|
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).
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
38
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
39
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
40
|
+
const supabase_js_1 = require("@supabase/supabase-js");
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Configuration
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
const SUPABASE_URL = process.env.SUPABASE_URL ||
|
|
47
|
+
process.env.DIFFERINO_SUPABASE_URL ||
|
|
48
|
+
'';
|
|
49
|
+
const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY ||
|
|
50
|
+
process.env.DIFFERINO_SUPABASE_ANON_KEY ||
|
|
51
|
+
'';
|
|
52
|
+
const BASE_URL = process.env.DIFFERINO_URL || 'https://differino.com';
|
|
53
|
+
const EMAIL = process.env.DIFFERINO_EMAIL || '';
|
|
54
|
+
const PASSWORD = process.env.DIFFERINO_PASSWORD || '';
|
|
55
|
+
let accessToken = null;
|
|
56
|
+
let refreshToken = null;
|
|
57
|
+
let supabase = null;
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// Auth helpers
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
async function authenticate() {
|
|
62
|
+
supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
63
|
+
const { data, error } = await supabase.auth.signInWithPassword({
|
|
64
|
+
email: EMAIL,
|
|
65
|
+
password: PASSWORD,
|
|
66
|
+
});
|
|
67
|
+
if (error)
|
|
68
|
+
throw new Error(`Authentication failed: ${error.message}`);
|
|
69
|
+
accessToken = data.session.access_token;
|
|
70
|
+
refreshToken = data.session.refresh_token;
|
|
71
|
+
}
|
|
72
|
+
async function ensureAuth() {
|
|
73
|
+
if (!supabase || !accessToken) {
|
|
74
|
+
await authenticate();
|
|
75
|
+
}
|
|
76
|
+
return supabase;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the cookie header that the Next.js Supabase SSR client expects.
|
|
80
|
+
*
|
|
81
|
+
* @supabase/ssr stores the session in a cookie whose name follows the pattern
|
|
82
|
+
* `sb-<project-ref>-auth-token`. The value is a base64url-encoded JSON string
|
|
83
|
+
* containing `access_token`, `refresh_token`, etc.
|
|
84
|
+
*
|
|
85
|
+
* For large tokens the library may chunk the cookie across multiple numbered
|
|
86
|
+
* cookies (sb-<ref>-auth-token.0, .1, ...). We produce a single cookie here
|
|
87
|
+
* which works for typical token sizes.
|
|
88
|
+
*/
|
|
89
|
+
function buildAuthCookie() {
|
|
90
|
+
const projectRef = SUPABASE_URL.match(/https:\/\/([^.]+)\./)?.[1] || 'ref';
|
|
91
|
+
const cookieName = `sb-${projectRef}-auth-token`;
|
|
92
|
+
const cookieValue = JSON.stringify({
|
|
93
|
+
access_token: accessToken,
|
|
94
|
+
refresh_token: refreshToken,
|
|
95
|
+
token_type: 'bearer',
|
|
96
|
+
});
|
|
97
|
+
// The SSR helper may also check chunked variants (.0, .1, …).
|
|
98
|
+
// Providing a single non-chunked cookie is accepted when the value fits in
|
|
99
|
+
// one cookie (< 3180 bytes). For very long JWTs we split into chunks.
|
|
100
|
+
const encoded = cookieValue;
|
|
101
|
+
const CHUNK_SIZE = 3180;
|
|
102
|
+
if (encoded.length <= CHUNK_SIZE) {
|
|
103
|
+
return `${cookieName}=${encodeURIComponent(encoded)}`;
|
|
104
|
+
}
|
|
105
|
+
// Chunked cookies
|
|
106
|
+
const chunks = [];
|
|
107
|
+
for (let i = 0; i * CHUNK_SIZE < encoded.length; i++) {
|
|
108
|
+
const slice = encoded.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
109
|
+
chunks.push(`${cookieName}.${i}=${encodeURIComponent(slice)}`);
|
|
110
|
+
}
|
|
111
|
+
return chunks.join('; ');
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// HTTP helpers (calls to the Differino Next.js API)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
async function apiCall(endpoint, options = {}) {
|
|
117
|
+
const url = `${BASE_URL}${endpoint}`;
|
|
118
|
+
const headers = {
|
|
119
|
+
...(options.headers || {}),
|
|
120
|
+
};
|
|
121
|
+
if (accessToken) {
|
|
122
|
+
headers['Cookie'] = buildAuthCookie();
|
|
123
|
+
}
|
|
124
|
+
const res = await fetch(url, { ...options, headers, redirect: 'manual' });
|
|
125
|
+
// Handle redirects (auth middleware may redirect to /login)
|
|
126
|
+
if (res.status >= 300 && res.status < 400) {
|
|
127
|
+
throw new Error(`API redirected (${res.status}) — session may have expired. Location: ${res.headers.get('location')}`);
|
|
128
|
+
}
|
|
129
|
+
const text = await res.text();
|
|
130
|
+
let json;
|
|
131
|
+
try {
|
|
132
|
+
json = JSON.parse(text);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new Error(`Non-JSON response from ${endpoint}: ${text.slice(0, 200)}`);
|
|
136
|
+
}
|
|
137
|
+
if (!res.ok) {
|
|
138
|
+
throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
|
|
139
|
+
}
|
|
140
|
+
return json;
|
|
141
|
+
}
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// File utilities
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
const SUPPORTED_EXTENSIONS = new Set(['pdf', 'docx', 'txt']);
|
|
146
|
+
function getExtension(filename) {
|
|
147
|
+
return (filename.split('.').pop() || '').toLowerCase();
|
|
148
|
+
}
|
|
149
|
+
function getMimeType(ext) {
|
|
150
|
+
switch (ext) {
|
|
151
|
+
case 'pdf':
|
|
152
|
+
return 'application/pdf';
|
|
153
|
+
case 'docx':
|
|
154
|
+
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
155
|
+
case 'txt':
|
|
156
|
+
return 'text/plain';
|
|
157
|
+
default:
|
|
158
|
+
return 'application/octet-stream';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Tool implementations
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
async function compareDocuments(args) {
|
|
165
|
+
await ensureAuth();
|
|
166
|
+
const { file_a_path, file_b_path, accuracy_mode = 'balanced' } = args;
|
|
167
|
+
// --- Validate files ---
|
|
168
|
+
for (const fp of [file_a_path, file_b_path]) {
|
|
169
|
+
if (!fs.existsSync(fp)) {
|
|
170
|
+
return `Error: File not found: ${fp}`;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const nameA = path.basename(file_a_path);
|
|
174
|
+
const nameB = path.basename(file_b_path);
|
|
175
|
+
const extA = getExtension(nameA);
|
|
176
|
+
const extB = getExtension(nameB);
|
|
177
|
+
if (!SUPPORTED_EXTENSIONS.has(extA)) {
|
|
178
|
+
return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
|
|
179
|
+
}
|
|
180
|
+
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
181
|
+
return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
|
|
182
|
+
}
|
|
183
|
+
// --- Upload file A ---
|
|
184
|
+
const fileABuffer = fs.readFileSync(file_a_path);
|
|
185
|
+
const fileASize = fileABuffer.byteLength;
|
|
186
|
+
// Use init/complete flow for files > 4MB, direct upload for smaller ones
|
|
187
|
+
let uploadA;
|
|
188
|
+
let uploadB;
|
|
189
|
+
if (fileASize < 4 * 1024 * 1024) {
|
|
190
|
+
// Direct upload via /api/upload
|
|
191
|
+
const formA = new FormData();
|
|
192
|
+
formA.append('file', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
|
|
193
|
+
formA.append('name', nameA);
|
|
194
|
+
uploadA = await apiCall('/api/upload', {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
body: formA,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
uploadA = await uploadLargeFile(file_a_path, nameA, fileABuffer, extA);
|
|
201
|
+
}
|
|
202
|
+
// --- Upload file B ---
|
|
203
|
+
const fileBBuffer = fs.readFileSync(file_b_path);
|
|
204
|
+
const fileBSize = fileBBuffer.byteLength;
|
|
205
|
+
if (fileBSize < 4 * 1024 * 1024) {
|
|
206
|
+
const formB = new FormData();
|
|
207
|
+
formB.append('file', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
|
|
208
|
+
formB.append('name', nameB);
|
|
209
|
+
uploadB = await apiCall('/api/upload', {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
body: formB,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
uploadB = await uploadLargeFile(file_b_path, nameB, fileBBuffer, extB);
|
|
216
|
+
}
|
|
217
|
+
// --- Wait for text extraction ---
|
|
218
|
+
const MAX_WAIT_MS = 120_000;
|
|
219
|
+
const POLL_MS = 2_000;
|
|
220
|
+
for (const vId of [uploadA.versionId, uploadB.versionId]) {
|
|
221
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
222
|
+
while (Date.now() < deadline) {
|
|
223
|
+
const { data } = await supabase
|
|
224
|
+
.from('versions')
|
|
225
|
+
.select('status')
|
|
226
|
+
.eq('id', vId)
|
|
227
|
+
.single();
|
|
228
|
+
if (data?.status === 'ready')
|
|
229
|
+
break;
|
|
230
|
+
if (data?.status === 'failed') {
|
|
231
|
+
return `Error: Text extraction failed for version ${vId}.`;
|
|
232
|
+
}
|
|
233
|
+
await sleep(POLL_MS);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// --- Create comparison ---
|
|
237
|
+
const comparison = await apiCall('/api/comparisons', {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({
|
|
241
|
+
versionAId: uploadA.versionId,
|
|
242
|
+
versionBId: uploadB.versionId,
|
|
243
|
+
accuracyMode: accuracy_mode,
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
// --- Poll for comparison result ---
|
|
247
|
+
const compDeadline = Date.now() + MAX_WAIT_MS;
|
|
248
|
+
while (Date.now() < compDeadline) {
|
|
249
|
+
const { data } = await supabase
|
|
250
|
+
.from('comparisons')
|
|
251
|
+
.select('status, summary, error_message')
|
|
252
|
+
.eq('id', comparison.id)
|
|
253
|
+
.single();
|
|
254
|
+
if (data?.status === 'ready') {
|
|
255
|
+
const summary = data.summary;
|
|
256
|
+
return JSON.stringify({
|
|
257
|
+
status: 'ready',
|
|
258
|
+
comparison_id: comparison.id,
|
|
259
|
+
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
260
|
+
summary: {
|
|
261
|
+
added_blocks: summary?.added_blocks ?? 0,
|
|
262
|
+
removed_blocks: summary?.removed_blocks ?? 0,
|
|
263
|
+
modified_blocks: summary?.modified_blocks ?? 0,
|
|
264
|
+
unchanged_blocks: summary?.unchanged_blocks ?? 0,
|
|
265
|
+
},
|
|
266
|
+
}, null, 2);
|
|
267
|
+
}
|
|
268
|
+
if (data?.status === 'failed') {
|
|
269
|
+
return `Error: Comparison failed — ${data.error_message || 'unknown error'}`;
|
|
270
|
+
}
|
|
271
|
+
await sleep(POLL_MS);
|
|
272
|
+
}
|
|
273
|
+
return JSON.stringify({
|
|
274
|
+
status: 'processing',
|
|
275
|
+
comparison_id: comparison.id,
|
|
276
|
+
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
277
|
+
message: 'Comparison is still processing. Check the URL for results.',
|
|
278
|
+
}, null, 2);
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Upload a large file (>4 MB) via the init + signed-URL + complete flow,
|
|
282
|
+
* which bypasses Vercel's 4.5 MB request body limit.
|
|
283
|
+
*/
|
|
284
|
+
async function uploadLargeFile(filePath, filename, buffer, ext) {
|
|
285
|
+
const mime = getMimeType(ext);
|
|
286
|
+
// 1. Init — creates version record and returns a signed upload URL
|
|
287
|
+
const init = await apiCall('/api/upload/init', {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
headers: { 'Content-Type': 'application/json' },
|
|
290
|
+
body: JSON.stringify({
|
|
291
|
+
filename,
|
|
292
|
+
contentType: mime,
|
|
293
|
+
fileSize: buffer.byteLength,
|
|
294
|
+
documentName: filename,
|
|
295
|
+
}),
|
|
296
|
+
});
|
|
297
|
+
// 2. Upload directly to Supabase Storage via the signed URL
|
|
298
|
+
const uploadRes = await fetch(init.signedUrl, {
|
|
299
|
+
method: 'PUT',
|
|
300
|
+
headers: { 'Content-Type': mime },
|
|
301
|
+
body: new Uint8Array(buffer),
|
|
302
|
+
});
|
|
303
|
+
if (!uploadRes.ok) {
|
|
304
|
+
const text = await uploadRes.text();
|
|
305
|
+
throw new Error(`Storage upload failed (${uploadRes.status}): ${text.slice(0, 200)}`);
|
|
306
|
+
}
|
|
307
|
+
// 3. Complete — enqueues extraction job
|
|
308
|
+
const complete = await apiCall('/api/upload/complete', {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
headers: { 'Content-Type': 'application/json' },
|
|
311
|
+
body: JSON.stringify({ versionId: init.versionId }),
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
versionId: init.versionId,
|
|
315
|
+
documentId: init.documentId,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
async function getComparison(args) {
|
|
319
|
+
await ensureAuth();
|
|
320
|
+
const { data, error } = await supabase
|
|
321
|
+
.from('comparisons')
|
|
322
|
+
.select(`
|
|
323
|
+
id, status, summary, error_message, created_at, completed_at,
|
|
324
|
+
version_a:versions!version_a_id ( id, document_id, documents ( name ) ),
|
|
325
|
+
version_b:versions!version_b_id ( id, document_id, documents ( name ) )
|
|
326
|
+
`)
|
|
327
|
+
.eq('id', args.comparison_id)
|
|
328
|
+
.single();
|
|
329
|
+
if (error || !data)
|
|
330
|
+
return 'Error: Comparison not found.';
|
|
331
|
+
const rec = data;
|
|
332
|
+
return JSON.stringify({
|
|
333
|
+
comparison_id: rec.id,
|
|
334
|
+
status: rec.status,
|
|
335
|
+
url: `${BASE_URL}/compare?id=${rec.id}`,
|
|
336
|
+
file_a: rec.version_a?.documents?.name ?? 'Unknown',
|
|
337
|
+
file_b: rec.version_b?.documents?.name ?? 'Unknown',
|
|
338
|
+
summary: rec.summary ?? null,
|
|
339
|
+
error: rec.error_message ?? null,
|
|
340
|
+
created_at: rec.created_at,
|
|
341
|
+
completed_at: rec.completed_at,
|
|
342
|
+
}, null, 2);
|
|
343
|
+
}
|
|
344
|
+
async function listComparisons(args) {
|
|
345
|
+
await ensureAuth();
|
|
346
|
+
const limit = args.limit || 10;
|
|
347
|
+
const { data: workspace } = await supabase
|
|
348
|
+
.from('workspaces')
|
|
349
|
+
.select('id')
|
|
350
|
+
.single();
|
|
351
|
+
if (!workspace)
|
|
352
|
+
return 'Error: No workspace found.';
|
|
353
|
+
const { data, error } = await supabase
|
|
354
|
+
.from('comparisons')
|
|
355
|
+
.select(`
|
|
356
|
+
id, status, created_at,
|
|
357
|
+
version_a:versions!version_a_id ( documents ( name ) ),
|
|
358
|
+
version_b:versions!version_b_id ( documents ( name ) )
|
|
359
|
+
`)
|
|
360
|
+
.eq('workspace_id', workspace.id)
|
|
361
|
+
.order('created_at', { ascending: false })
|
|
362
|
+
.limit(limit);
|
|
363
|
+
if (error)
|
|
364
|
+
return `Error: ${error.message}`;
|
|
365
|
+
const comparisons = (data || []).map((c) => ({
|
|
366
|
+
id: c.id,
|
|
367
|
+
status: c.status,
|
|
368
|
+
file_a: c.version_a?.documents?.name ?? 'Unknown',
|
|
369
|
+
file_b: c.version_b?.documents?.name ?? 'Unknown',
|
|
370
|
+
created_at: c.created_at,
|
|
371
|
+
url: `${BASE_URL}/compare?id=${c.id}`,
|
|
372
|
+
}));
|
|
373
|
+
return JSON.stringify(comparisons, null, 2);
|
|
374
|
+
}
|
|
375
|
+
async function listDocuments(args) {
|
|
376
|
+
await ensureAuth();
|
|
377
|
+
const { data: workspace } = await supabase
|
|
378
|
+
.from('workspaces')
|
|
379
|
+
.select('id')
|
|
380
|
+
.single();
|
|
381
|
+
if (!workspace)
|
|
382
|
+
return 'Error: No workspace found.';
|
|
383
|
+
const { data, error } = await supabase
|
|
384
|
+
.from('documents')
|
|
385
|
+
.select(`
|
|
386
|
+
id, name, updated_at,
|
|
387
|
+
versions ( id, version_number, status, created_at )
|
|
388
|
+
`)
|
|
389
|
+
.eq('workspace_id', workspace.id)
|
|
390
|
+
.order('updated_at', { ascending: false })
|
|
391
|
+
.limit(args.limit || 20);
|
|
392
|
+
if (error)
|
|
393
|
+
return `Error: ${error.message}`;
|
|
394
|
+
return JSON.stringify(data, null, 2);
|
|
395
|
+
}
|
|
396
|
+
async function getCredits() {
|
|
397
|
+
await ensureAuth();
|
|
398
|
+
const { data: workspace } = await supabase
|
|
399
|
+
.from('workspaces')
|
|
400
|
+
.select('credits, plan, pro_expires_at')
|
|
401
|
+
.single();
|
|
402
|
+
if (!workspace)
|
|
403
|
+
return 'Error: No workspace found.';
|
|
404
|
+
const now = new Date();
|
|
405
|
+
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
|
406
|
+
const { data: usage } = await supabase
|
|
407
|
+
.from('usage')
|
|
408
|
+
.select('comparison_count')
|
|
409
|
+
.eq('month', monthKey)
|
|
410
|
+
.single();
|
|
411
|
+
return JSON.stringify({
|
|
412
|
+
credits: workspace.credits,
|
|
413
|
+
plan: workspace.plan,
|
|
414
|
+
pro_expires_at: workspace.pro_expires_at,
|
|
415
|
+
free_comparisons_used: usage?.comparison_count ?? 0,
|
|
416
|
+
free_comparisons_limit: 3,
|
|
417
|
+
}, null, 2);
|
|
418
|
+
}
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Utility
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
function sleep(ms) {
|
|
423
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
424
|
+
}
|
|
425
|
+
// ---------------------------------------------------------------------------
|
|
426
|
+
// MCP Server
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
const server = new index_js_1.Server({ name: 'differino', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
429
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async () => ({
|
|
430
|
+
tools: [
|
|
431
|
+
{
|
|
432
|
+
name: 'compare_documents',
|
|
433
|
+
description: 'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
|
|
434
|
+
'Reads files from local disk, uploads them to Differino, and returns a diff summary.',
|
|
435
|
+
inputSchema: {
|
|
436
|
+
type: 'object',
|
|
437
|
+
properties: {
|
|
438
|
+
file_a_path: {
|
|
439
|
+
type: 'string',
|
|
440
|
+
description: 'Absolute path to the original document',
|
|
441
|
+
},
|
|
442
|
+
file_b_path: {
|
|
443
|
+
type: 'string',
|
|
444
|
+
description: 'Absolute path to the modified document',
|
|
445
|
+
},
|
|
446
|
+
accuracy_mode: {
|
|
447
|
+
type: 'string',
|
|
448
|
+
enum: ['fast', 'balanced', 'thorough'],
|
|
449
|
+
description: 'Comparison accuracy mode (default: balanced)',
|
|
450
|
+
},
|
|
451
|
+
},
|
|
452
|
+
required: ['file_a_path', 'file_b_path'],
|
|
453
|
+
},
|
|
454
|
+
},
|
|
455
|
+
{
|
|
456
|
+
name: 'get_comparison',
|
|
457
|
+
description: 'Get the status and results of a specific comparison by its ID.',
|
|
458
|
+
inputSchema: {
|
|
459
|
+
type: 'object',
|
|
460
|
+
properties: {
|
|
461
|
+
comparison_id: {
|
|
462
|
+
type: 'string',
|
|
463
|
+
description: 'The comparison UUID',
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
required: ['comparison_id'],
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: 'list_comparisons',
|
|
471
|
+
description: 'List recent document comparisons in the user\'s workspace.',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
properties: {
|
|
475
|
+
limit: {
|
|
476
|
+
type: 'number',
|
|
477
|
+
description: 'Maximum number of comparisons to return (default: 10)',
|
|
478
|
+
},
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
name: 'list_documents',
|
|
484
|
+
description: 'List documents in the user\'s Differino library.',
|
|
485
|
+
inputSchema: {
|
|
486
|
+
type: 'object',
|
|
487
|
+
properties: {
|
|
488
|
+
limit: {
|
|
489
|
+
type: 'number',
|
|
490
|
+
description: 'Maximum number of documents to return (default: 20)',
|
|
491
|
+
},
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
{
|
|
496
|
+
name: 'get_credits',
|
|
497
|
+
description: 'Check the user\'s credit balance, plan status, and free comparison usage for the current month.',
|
|
498
|
+
inputSchema: {
|
|
499
|
+
type: 'object',
|
|
500
|
+
properties: {},
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
}));
|
|
505
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
|
|
506
|
+
const { name, arguments: args } = request.params;
|
|
507
|
+
try {
|
|
508
|
+
let result;
|
|
509
|
+
switch (name) {
|
|
510
|
+
case 'compare_documents':
|
|
511
|
+
result = await compareDocuments(args);
|
|
512
|
+
break;
|
|
513
|
+
case 'get_comparison':
|
|
514
|
+
result = await getComparison(args);
|
|
515
|
+
break;
|
|
516
|
+
case 'list_comparisons':
|
|
517
|
+
result = await listComparisons(args);
|
|
518
|
+
break;
|
|
519
|
+
case 'list_documents':
|
|
520
|
+
result = await listDocuments(args);
|
|
521
|
+
break;
|
|
522
|
+
case 'get_credits':
|
|
523
|
+
result = await getCredits();
|
|
524
|
+
break;
|
|
525
|
+
default:
|
|
526
|
+
result = `Unknown tool: ${name}`;
|
|
527
|
+
}
|
|
528
|
+
return {
|
|
529
|
+
content: [{ type: 'text', text: result }],
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
534
|
+
return {
|
|
535
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
536
|
+
isError: true,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
// ---------------------------------------------------------------------------
|
|
541
|
+
// Entry point
|
|
542
|
+
// ---------------------------------------------------------------------------
|
|
543
|
+
async function main() {
|
|
544
|
+
// Validate required env vars
|
|
545
|
+
if (!EMAIL || !PASSWORD) {
|
|
546
|
+
console.error('Error: DIFFERINO_EMAIL and DIFFERINO_PASSWORD environment variables are required.');
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
|
550
|
+
console.error('Error: SUPABASE_URL and SUPABASE_ANON_KEY environment variables are required.');
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
// Authenticate on startup so we fail fast if credentials are wrong
|
|
554
|
+
try {
|
|
555
|
+
await authenticate();
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
559
|
+
console.error(message);
|
|
560
|
+
process.exit(1);
|
|
561
|
+
}
|
|
562
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
563
|
+
await server.connect(transport);
|
|
564
|
+
}
|
|
565
|
+
main().catch((err) => {
|
|
566
|
+
console.error('Fatal error:', err);
|
|
567
|
+
process.exit(1);
|
|
568
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "differino-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Differino document comparison",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"differino-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
15
|
+
"@supabase/supabase-js": "^2.39.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.3.0",
|
|
19
|
+
"@types/node": "^20.0.0"
|
|
20
|
+
}
|
|
21
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
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 { createClient, SupabaseClient } from '@supabase/supabase-js';
|
|
10
|
+
import * as fs from 'fs';
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Configuration
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const SUPABASE_URL =
|
|
18
|
+
process.env.SUPABASE_URL ||
|
|
19
|
+
process.env.DIFFERINO_SUPABASE_URL ||
|
|
20
|
+
'';
|
|
21
|
+
const SUPABASE_ANON_KEY =
|
|
22
|
+
process.env.SUPABASE_ANON_KEY ||
|
|
23
|
+
process.env.DIFFERINO_SUPABASE_ANON_KEY ||
|
|
24
|
+
'';
|
|
25
|
+
const BASE_URL = process.env.DIFFERINO_URL || 'https://differino.com';
|
|
26
|
+
const EMAIL = process.env.DIFFERINO_EMAIL || '';
|
|
27
|
+
const PASSWORD = process.env.DIFFERINO_PASSWORD || '';
|
|
28
|
+
|
|
29
|
+
let accessToken: string | null = null;
|
|
30
|
+
let refreshToken: string | null = null;
|
|
31
|
+
let supabase: SupabaseClient | null = null;
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Auth helpers
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
async function authenticate(): Promise<void> {
|
|
38
|
+
supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
|
|
39
|
+
const { data, error } = await supabase.auth.signInWithPassword({
|
|
40
|
+
email: EMAIL,
|
|
41
|
+
password: PASSWORD,
|
|
42
|
+
});
|
|
43
|
+
if (error) throw new Error(`Authentication failed: ${error.message}`);
|
|
44
|
+
accessToken = data.session.access_token;
|
|
45
|
+
refreshToken = data.session.refresh_token;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function ensureAuth(): Promise<SupabaseClient> {
|
|
49
|
+
if (!supabase || !accessToken) {
|
|
50
|
+
await authenticate();
|
|
51
|
+
}
|
|
52
|
+
return supabase!;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the cookie header that the Next.js Supabase SSR client expects.
|
|
57
|
+
*
|
|
58
|
+
* @supabase/ssr stores the session in a cookie whose name follows the pattern
|
|
59
|
+
* `sb-<project-ref>-auth-token`. The value is a base64url-encoded JSON string
|
|
60
|
+
* containing `access_token`, `refresh_token`, etc.
|
|
61
|
+
*
|
|
62
|
+
* For large tokens the library may chunk the cookie across multiple numbered
|
|
63
|
+
* cookies (sb-<ref>-auth-token.0, .1, ...). We produce a single cookie here
|
|
64
|
+
* which works for typical token sizes.
|
|
65
|
+
*/
|
|
66
|
+
function buildAuthCookie(): string {
|
|
67
|
+
const projectRef = SUPABASE_URL.match(/https:\/\/([^.]+)\./)?.[1] || 'ref';
|
|
68
|
+
const cookieName = `sb-${projectRef}-auth-token`;
|
|
69
|
+
const cookieValue = JSON.stringify({
|
|
70
|
+
access_token: accessToken,
|
|
71
|
+
refresh_token: refreshToken,
|
|
72
|
+
token_type: 'bearer',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// The SSR helper may also check chunked variants (.0, .1, …).
|
|
76
|
+
// Providing a single non-chunked cookie is accepted when the value fits in
|
|
77
|
+
// one cookie (< 3180 bytes). For very long JWTs we split into chunks.
|
|
78
|
+
const encoded = cookieValue;
|
|
79
|
+
const CHUNK_SIZE = 3180;
|
|
80
|
+
|
|
81
|
+
if (encoded.length <= CHUNK_SIZE) {
|
|
82
|
+
return `${cookieName}=${encodeURIComponent(encoded)}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Chunked cookies
|
|
86
|
+
const chunks: string[] = [];
|
|
87
|
+
for (let i = 0; i * CHUNK_SIZE < encoded.length; i++) {
|
|
88
|
+
const slice = encoded.substring(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
|
|
89
|
+
chunks.push(`${cookieName}.${i}=${encodeURIComponent(slice)}`);
|
|
90
|
+
}
|
|
91
|
+
return chunks.join('; ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// HTTP helpers (calls to the Differino Next.js API)
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async function apiCall(
|
|
99
|
+
endpoint: string,
|
|
100
|
+
options: RequestInit = {},
|
|
101
|
+
): Promise<any> {
|
|
102
|
+
const url = `${BASE_URL}${endpoint}`;
|
|
103
|
+
const headers: Record<string, string> = {
|
|
104
|
+
...(options.headers as Record<string, string> || {}),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if (accessToken) {
|
|
108
|
+
headers['Cookie'] = buildAuthCookie();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const res = await fetch(url, { ...options, headers, redirect: 'manual' });
|
|
112
|
+
|
|
113
|
+
// Handle redirects (auth middleware may redirect to /login)
|
|
114
|
+
if (res.status >= 300 && res.status < 400) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`API redirected (${res.status}) — session may have expired. Location: ${res.headers.get('location')}`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const text = await res.text();
|
|
121
|
+
let json: any;
|
|
122
|
+
try {
|
|
123
|
+
json = JSON.parse(text);
|
|
124
|
+
} catch {
|
|
125
|
+
throw new Error(`Non-JSON response from ${endpoint}: ${text.slice(0, 200)}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
throw new Error(json.error || `API error ${res.status}: ${text.slice(0, 300)}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return json;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// File utilities
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const SUPPORTED_EXTENSIONS = new Set(['pdf', 'docx', 'txt']);
|
|
140
|
+
|
|
141
|
+
function getExtension(filename: string): string {
|
|
142
|
+
return (filename.split('.').pop() || '').toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getMimeType(ext: string): string {
|
|
146
|
+
switch (ext) {
|
|
147
|
+
case 'pdf':
|
|
148
|
+
return 'application/pdf';
|
|
149
|
+
case 'docx':
|
|
150
|
+
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
151
|
+
case 'txt':
|
|
152
|
+
return 'text/plain';
|
|
153
|
+
default:
|
|
154
|
+
return 'application/octet-stream';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Tool implementations
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
async function compareDocuments(args: {
|
|
163
|
+
file_a_path: string;
|
|
164
|
+
file_b_path: string;
|
|
165
|
+
accuracy_mode?: string;
|
|
166
|
+
}): Promise<string> {
|
|
167
|
+
await ensureAuth();
|
|
168
|
+
|
|
169
|
+
const { file_a_path, file_b_path, accuracy_mode = 'balanced' } = args;
|
|
170
|
+
|
|
171
|
+
// --- Validate files ---
|
|
172
|
+
for (const fp of [file_a_path, file_b_path]) {
|
|
173
|
+
if (!fs.existsSync(fp)) {
|
|
174
|
+
return `Error: File not found: ${fp}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const nameA = path.basename(file_a_path);
|
|
179
|
+
const nameB = path.basename(file_b_path);
|
|
180
|
+
const extA = getExtension(nameA);
|
|
181
|
+
const extB = getExtension(nameB);
|
|
182
|
+
|
|
183
|
+
if (!SUPPORTED_EXTENSIONS.has(extA)) {
|
|
184
|
+
return `Error: Unsupported file type for ${nameA}. Supported: PDF, DOCX, TXT.`;
|
|
185
|
+
}
|
|
186
|
+
if (!SUPPORTED_EXTENSIONS.has(extB)) {
|
|
187
|
+
return `Error: Unsupported file type for ${nameB}. Supported: PDF, DOCX, TXT.`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// --- Upload file A ---
|
|
191
|
+
const fileABuffer = fs.readFileSync(file_a_path);
|
|
192
|
+
const fileASize = fileABuffer.byteLength;
|
|
193
|
+
|
|
194
|
+
// Use init/complete flow for files > 4MB, direct upload for smaller ones
|
|
195
|
+
let uploadA: { versionId: string; documentId: string };
|
|
196
|
+
let uploadB: { versionId: string; documentId: string };
|
|
197
|
+
|
|
198
|
+
if (fileASize < 4 * 1024 * 1024) {
|
|
199
|
+
// Direct upload via /api/upload
|
|
200
|
+
const formA = new FormData();
|
|
201
|
+
formA.append('file', new Blob([fileABuffer], { type: getMimeType(extA) }), nameA);
|
|
202
|
+
formA.append('name', nameA);
|
|
203
|
+
|
|
204
|
+
uploadA = await apiCall('/api/upload', {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
body: formA,
|
|
207
|
+
});
|
|
208
|
+
} else {
|
|
209
|
+
uploadA = await uploadLargeFile(file_a_path, nameA, fileABuffer, extA);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Upload file B ---
|
|
213
|
+
const fileBBuffer = fs.readFileSync(file_b_path);
|
|
214
|
+
const fileBSize = fileBBuffer.byteLength;
|
|
215
|
+
|
|
216
|
+
if (fileBSize < 4 * 1024 * 1024) {
|
|
217
|
+
const formB = new FormData();
|
|
218
|
+
formB.append('file', new Blob([fileBBuffer], { type: getMimeType(extB) }), nameB);
|
|
219
|
+
formB.append('name', nameB);
|
|
220
|
+
|
|
221
|
+
uploadB = await apiCall('/api/upload', {
|
|
222
|
+
method: 'POST',
|
|
223
|
+
body: formB,
|
|
224
|
+
});
|
|
225
|
+
} else {
|
|
226
|
+
uploadB = await uploadLargeFile(file_b_path, nameB, fileBBuffer, extB);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// --- Wait for text extraction ---
|
|
230
|
+
const MAX_WAIT_MS = 120_000;
|
|
231
|
+
const POLL_MS = 2_000;
|
|
232
|
+
|
|
233
|
+
for (const vId of [uploadA.versionId, uploadB.versionId]) {
|
|
234
|
+
const deadline = Date.now() + MAX_WAIT_MS;
|
|
235
|
+
while (Date.now() < deadline) {
|
|
236
|
+
const { data } = await supabase!
|
|
237
|
+
.from('versions')
|
|
238
|
+
.select('status')
|
|
239
|
+
.eq('id', vId)
|
|
240
|
+
.single();
|
|
241
|
+
|
|
242
|
+
if (data?.status === 'ready') break;
|
|
243
|
+
if (data?.status === 'failed') {
|
|
244
|
+
return `Error: Text extraction failed for version ${vId}.`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await sleep(POLL_MS);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Create comparison ---
|
|
252
|
+
const comparison = await apiCall('/api/comparisons', {
|
|
253
|
+
method: 'POST',
|
|
254
|
+
headers: { 'Content-Type': 'application/json' },
|
|
255
|
+
body: JSON.stringify({
|
|
256
|
+
versionAId: uploadA.versionId,
|
|
257
|
+
versionBId: uploadB.versionId,
|
|
258
|
+
accuracyMode: accuracy_mode,
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// --- Poll for comparison result ---
|
|
263
|
+
const compDeadline = Date.now() + MAX_WAIT_MS;
|
|
264
|
+
while (Date.now() < compDeadline) {
|
|
265
|
+
const { data } = await supabase!
|
|
266
|
+
.from('comparisons')
|
|
267
|
+
.select('status, summary, error_message')
|
|
268
|
+
.eq('id', comparison.id)
|
|
269
|
+
.single();
|
|
270
|
+
|
|
271
|
+
if (data?.status === 'ready') {
|
|
272
|
+
const summary = data.summary as Record<string, number> | null;
|
|
273
|
+
return JSON.stringify(
|
|
274
|
+
{
|
|
275
|
+
status: 'ready',
|
|
276
|
+
comparison_id: comparison.id,
|
|
277
|
+
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
278
|
+
summary: {
|
|
279
|
+
added_blocks: summary?.added_blocks ?? 0,
|
|
280
|
+
removed_blocks: summary?.removed_blocks ?? 0,
|
|
281
|
+
modified_blocks: summary?.modified_blocks ?? 0,
|
|
282
|
+
unchanged_blocks: summary?.unchanged_blocks ?? 0,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
null,
|
|
286
|
+
2,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (data?.status === 'failed') {
|
|
291
|
+
return `Error: Comparison failed — ${data.error_message || 'unknown error'}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
await sleep(POLL_MS);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return JSON.stringify(
|
|
298
|
+
{
|
|
299
|
+
status: 'processing',
|
|
300
|
+
comparison_id: comparison.id,
|
|
301
|
+
url: `${BASE_URL}/compare?id=${comparison.id}`,
|
|
302
|
+
message: 'Comparison is still processing. Check the URL for results.',
|
|
303
|
+
},
|
|
304
|
+
null,
|
|
305
|
+
2,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Upload a large file (>4 MB) via the init + signed-URL + complete flow,
|
|
311
|
+
* which bypasses Vercel's 4.5 MB request body limit.
|
|
312
|
+
*/
|
|
313
|
+
async function uploadLargeFile(
|
|
314
|
+
filePath: string,
|
|
315
|
+
filename: string,
|
|
316
|
+
buffer: Buffer,
|
|
317
|
+
ext: string,
|
|
318
|
+
): Promise<{ versionId: string; documentId: string }> {
|
|
319
|
+
const mime = getMimeType(ext);
|
|
320
|
+
|
|
321
|
+
// 1. Init — creates version record and returns a signed upload URL
|
|
322
|
+
const init = await apiCall('/api/upload/init', {
|
|
323
|
+
method: 'POST',
|
|
324
|
+
headers: { 'Content-Type': 'application/json' },
|
|
325
|
+
body: JSON.stringify({
|
|
326
|
+
filename,
|
|
327
|
+
contentType: mime,
|
|
328
|
+
fileSize: buffer.byteLength,
|
|
329
|
+
documentName: filename,
|
|
330
|
+
}),
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// 2. Upload directly to Supabase Storage via the signed URL
|
|
334
|
+
const uploadRes = await fetch(init.signedUrl, {
|
|
335
|
+
method: 'PUT',
|
|
336
|
+
headers: { 'Content-Type': mime },
|
|
337
|
+
body: new Uint8Array(buffer),
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
if (!uploadRes.ok) {
|
|
341
|
+
const text = await uploadRes.text();
|
|
342
|
+
throw new Error(`Storage upload failed (${uploadRes.status}): ${text.slice(0, 200)}`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// 3. Complete — enqueues extraction job
|
|
346
|
+
const complete = await apiCall('/api/upload/complete', {
|
|
347
|
+
method: 'POST',
|
|
348
|
+
headers: { 'Content-Type': 'application/json' },
|
|
349
|
+
body: JSON.stringify({ versionId: init.versionId }),
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
versionId: init.versionId,
|
|
354
|
+
documentId: init.documentId,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function getComparison(args: { comparison_id: string }): Promise<string> {
|
|
359
|
+
await ensureAuth();
|
|
360
|
+
|
|
361
|
+
const { data, error } = await supabase!
|
|
362
|
+
.from('comparisons')
|
|
363
|
+
.select(
|
|
364
|
+
`
|
|
365
|
+
id, status, summary, error_message, created_at, completed_at,
|
|
366
|
+
version_a:versions!version_a_id ( id, document_id, documents ( name ) ),
|
|
367
|
+
version_b:versions!version_b_id ( id, document_id, documents ( name ) )
|
|
368
|
+
`,
|
|
369
|
+
)
|
|
370
|
+
.eq('id', args.comparison_id)
|
|
371
|
+
.single();
|
|
372
|
+
|
|
373
|
+
if (error || !data) return 'Error: Comparison not found.';
|
|
374
|
+
|
|
375
|
+
const rec = data as any;
|
|
376
|
+
return JSON.stringify(
|
|
377
|
+
{
|
|
378
|
+
comparison_id: rec.id,
|
|
379
|
+
status: rec.status,
|
|
380
|
+
url: `${BASE_URL}/compare?id=${rec.id}`,
|
|
381
|
+
file_a: rec.version_a?.documents?.name ?? 'Unknown',
|
|
382
|
+
file_b: rec.version_b?.documents?.name ?? 'Unknown',
|
|
383
|
+
summary: rec.summary ?? null,
|
|
384
|
+
error: rec.error_message ?? null,
|
|
385
|
+
created_at: rec.created_at,
|
|
386
|
+
completed_at: rec.completed_at,
|
|
387
|
+
},
|
|
388
|
+
null,
|
|
389
|
+
2,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function listComparisons(args: { limit?: number }): Promise<string> {
|
|
394
|
+
await ensureAuth();
|
|
395
|
+
|
|
396
|
+
const limit = args.limit || 10;
|
|
397
|
+
|
|
398
|
+
const { data: workspace } = await supabase!
|
|
399
|
+
.from('workspaces')
|
|
400
|
+
.select('id')
|
|
401
|
+
.single();
|
|
402
|
+
|
|
403
|
+
if (!workspace) return 'Error: No workspace found.';
|
|
404
|
+
|
|
405
|
+
const { data, error } = await supabase!
|
|
406
|
+
.from('comparisons')
|
|
407
|
+
.select(
|
|
408
|
+
`
|
|
409
|
+
id, status, created_at,
|
|
410
|
+
version_a:versions!version_a_id ( documents ( name ) ),
|
|
411
|
+
version_b:versions!version_b_id ( documents ( name ) )
|
|
412
|
+
`,
|
|
413
|
+
)
|
|
414
|
+
.eq('workspace_id', workspace.id)
|
|
415
|
+
.order('created_at', { ascending: false })
|
|
416
|
+
.limit(limit);
|
|
417
|
+
|
|
418
|
+
if (error) return `Error: ${error.message}`;
|
|
419
|
+
|
|
420
|
+
const comparisons = (data || []).map((c: any) => ({
|
|
421
|
+
id: c.id,
|
|
422
|
+
status: c.status,
|
|
423
|
+
file_a: c.version_a?.documents?.name ?? 'Unknown',
|
|
424
|
+
file_b: c.version_b?.documents?.name ?? 'Unknown',
|
|
425
|
+
created_at: c.created_at,
|
|
426
|
+
url: `${BASE_URL}/compare?id=${c.id}`,
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
return JSON.stringify(comparisons, null, 2);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function listDocuments(args: { limit?: number }): Promise<string> {
|
|
433
|
+
await ensureAuth();
|
|
434
|
+
|
|
435
|
+
const { data: workspace } = await supabase!
|
|
436
|
+
.from('workspaces')
|
|
437
|
+
.select('id')
|
|
438
|
+
.single();
|
|
439
|
+
|
|
440
|
+
if (!workspace) return 'Error: No workspace found.';
|
|
441
|
+
|
|
442
|
+
const { data, error } = await supabase!
|
|
443
|
+
.from('documents')
|
|
444
|
+
.select(
|
|
445
|
+
`
|
|
446
|
+
id, name, updated_at,
|
|
447
|
+
versions ( id, version_number, status, created_at )
|
|
448
|
+
`,
|
|
449
|
+
)
|
|
450
|
+
.eq('workspace_id', workspace.id)
|
|
451
|
+
.order('updated_at', { ascending: false })
|
|
452
|
+
.limit(args.limit || 20);
|
|
453
|
+
|
|
454
|
+
if (error) return `Error: ${error.message}`;
|
|
455
|
+
|
|
456
|
+
return JSON.stringify(data, null, 2);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function getCredits(): Promise<string> {
|
|
460
|
+
await ensureAuth();
|
|
461
|
+
|
|
462
|
+
const { data: workspace } = await supabase!
|
|
463
|
+
.from('workspaces')
|
|
464
|
+
.select('credits, plan, pro_expires_at')
|
|
465
|
+
.single();
|
|
466
|
+
|
|
467
|
+
if (!workspace) return 'Error: No workspace found.';
|
|
468
|
+
|
|
469
|
+
const now = new Date();
|
|
470
|
+
const monthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
|
471
|
+
|
|
472
|
+
const { data: usage } = await supabase!
|
|
473
|
+
.from('usage')
|
|
474
|
+
.select('comparison_count')
|
|
475
|
+
.eq('month', monthKey)
|
|
476
|
+
.single();
|
|
477
|
+
|
|
478
|
+
return JSON.stringify(
|
|
479
|
+
{
|
|
480
|
+
credits: (workspace as any).credits,
|
|
481
|
+
plan: (workspace as any).plan,
|
|
482
|
+
pro_expires_at: (workspace as any).pro_expires_at,
|
|
483
|
+
free_comparisons_used: (usage as any)?.comparison_count ?? 0,
|
|
484
|
+
free_comparisons_limit: 3,
|
|
485
|
+
},
|
|
486
|
+
null,
|
|
487
|
+
2,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
// Utility
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
function sleep(ms: number): Promise<void> {
|
|
496
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// MCP Server
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
const server = new Server(
|
|
504
|
+
{ name: 'differino', version: '0.1.0' },
|
|
505
|
+
{ capabilities: { tools: {} } },
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
509
|
+
tools: [
|
|
510
|
+
{
|
|
511
|
+
name: 'compare_documents',
|
|
512
|
+
description:
|
|
513
|
+
'Compare two documents (PDF, DOCX, or TXT) and see the differences. ' +
|
|
514
|
+
'Reads files from local disk, uploads them to Differino, and returns a diff summary.',
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: 'object' as const,
|
|
517
|
+
properties: {
|
|
518
|
+
file_a_path: {
|
|
519
|
+
type: 'string',
|
|
520
|
+
description: 'Absolute path to the original document',
|
|
521
|
+
},
|
|
522
|
+
file_b_path: {
|
|
523
|
+
type: 'string',
|
|
524
|
+
description: 'Absolute path to the modified document',
|
|
525
|
+
},
|
|
526
|
+
accuracy_mode: {
|
|
527
|
+
type: 'string',
|
|
528
|
+
enum: ['fast', 'balanced', 'thorough'],
|
|
529
|
+
description: 'Comparison accuracy mode (default: balanced)',
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
required: ['file_a_path', 'file_b_path'],
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
name: 'get_comparison',
|
|
537
|
+
description: 'Get the status and results of a specific comparison by its ID.',
|
|
538
|
+
inputSchema: {
|
|
539
|
+
type: 'object' as const,
|
|
540
|
+
properties: {
|
|
541
|
+
comparison_id: {
|
|
542
|
+
type: 'string',
|
|
543
|
+
description: 'The comparison UUID',
|
|
544
|
+
},
|
|
545
|
+
},
|
|
546
|
+
required: ['comparison_id'],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: 'list_comparisons',
|
|
551
|
+
description: 'List recent document comparisons in the user\'s workspace.',
|
|
552
|
+
inputSchema: {
|
|
553
|
+
type: 'object' as const,
|
|
554
|
+
properties: {
|
|
555
|
+
limit: {
|
|
556
|
+
type: 'number',
|
|
557
|
+
description: 'Maximum number of comparisons to return (default: 10)',
|
|
558
|
+
},
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
name: 'list_documents',
|
|
564
|
+
description: 'List documents in the user\'s Differino library.',
|
|
565
|
+
inputSchema: {
|
|
566
|
+
type: 'object' as const,
|
|
567
|
+
properties: {
|
|
568
|
+
limit: {
|
|
569
|
+
type: 'number',
|
|
570
|
+
description: 'Maximum number of documents to return (default: 20)',
|
|
571
|
+
},
|
|
572
|
+
},
|
|
573
|
+
},
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
name: 'get_credits',
|
|
577
|
+
description:
|
|
578
|
+
'Check the user\'s credit balance, plan status, and free comparison usage for the current month.',
|
|
579
|
+
inputSchema: {
|
|
580
|
+
type: 'object' as const,
|
|
581
|
+
properties: {},
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
],
|
|
585
|
+
}));
|
|
586
|
+
|
|
587
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
588
|
+
const { name, arguments: args } = request.params;
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
let result: string;
|
|
592
|
+
|
|
593
|
+
switch (name) {
|
|
594
|
+
case 'compare_documents':
|
|
595
|
+
result = await compareDocuments(args as any);
|
|
596
|
+
break;
|
|
597
|
+
case 'get_comparison':
|
|
598
|
+
result = await getComparison(args as any);
|
|
599
|
+
break;
|
|
600
|
+
case 'list_comparisons':
|
|
601
|
+
result = await listComparisons(args as any);
|
|
602
|
+
break;
|
|
603
|
+
case 'list_documents':
|
|
604
|
+
result = await listDocuments(args as any);
|
|
605
|
+
break;
|
|
606
|
+
case 'get_credits':
|
|
607
|
+
result = await getCredits();
|
|
608
|
+
break;
|
|
609
|
+
default:
|
|
610
|
+
result = `Unknown tool: ${name}`;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return {
|
|
614
|
+
content: [{ type: 'text', text: result }],
|
|
615
|
+
};
|
|
616
|
+
} catch (err: unknown) {
|
|
617
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
618
|
+
return {
|
|
619
|
+
content: [{ type: 'text', text: `Error: ${message}` }],
|
|
620
|
+
isError: true,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
// Entry point
|
|
627
|
+
// ---------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
async function main(): Promise<void> {
|
|
630
|
+
// Validate required env vars
|
|
631
|
+
if (!EMAIL || !PASSWORD) {
|
|
632
|
+
console.error(
|
|
633
|
+
'Error: DIFFERINO_EMAIL and DIFFERINO_PASSWORD environment variables are required.',
|
|
634
|
+
);
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
|
638
|
+
console.error(
|
|
639
|
+
'Error: SUPABASE_URL and SUPABASE_ANON_KEY environment variables are required.',
|
|
640
|
+
);
|
|
641
|
+
process.exit(1);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Authenticate on startup so we fail fast if credentials are wrong
|
|
645
|
+
try {
|
|
646
|
+
await authenticate();
|
|
647
|
+
} catch (err: unknown) {
|
|
648
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
649
|
+
console.error(message);
|
|
650
|
+
process.exit(1);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const transport = new StdioServerTransport();
|
|
654
|
+
await server.connect(transport);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
main().catch((err) => {
|
|
658
|
+
console.error('Fatal error:', err);
|
|
659
|
+
process.exit(1);
|
|
660
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
}
|