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 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).
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }