@viewert/mcp 0.1.2 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @viewert/mcp
2
2
 
3
- MCP server for [Viewert](https://viewert.com) — expose your Librams (AI context collections) to any MCP-compatible AI client.
3
+ MCP server for [Viewert](https://www.viewert.com) — expose your Librams (AI context collections) to any MCP-compatible AI client.
4
4
 
5
5
  ## What it does
6
6
 
@@ -8,11 +8,49 @@ Connects Claude Desktop, Cursor, Windsurf, or any MCP client to your Viewert Lib
8
8
 
9
9
  ## Setup
10
10
 
11
- ### 1. Get an API key
11
+ ### Option A One-command setup (recommended)
12
12
 
13
- Go to **Settings → API Keys** on Viewert and create a key. Copy it — it's shown only once.
13
+ ```bash
14
+ npx --package=@viewert/mcp viewert-mcp-setup
15
+ ```
16
+
17
+ Or if you already have the package installed globally:
18
+
19
+ ```bash
20
+ viewert-mcp-setup
21
+ ```
22
+
23
+ The interactive wizard will:
24
+ - Install the package globally with the correct binary path
25
+ - Verify your API key against your account
26
+ - Auto-detect Claude Desktop, Cursor, and Windsurf
27
+ - Write the config for whichever clients you choose — without overwriting your other MCP servers
28
+ - Print the exact restart steps for each client
29
+
30
+ ---
31
+
32
+ ### Option B — Manual setup
33
+
34
+ #### 1. Get an API key
35
+
36
+ Go to **Settings → API Keys** on [viewert.com](https://www.viewert.com/settings) and create a key. Copy it — it's shown only once.
14
37
 
15
- ### 2. Add to your MCP client config
38
+ #### 2. Install the package globally
39
+
40
+ ```bash
41
+ npm install -g @viewert/mcp
42
+ ```
43
+
44
+ Then find the installed binary path — you'll need this for your config:
45
+
46
+ ```bash
47
+ which viewert-mcp
48
+ # e.g. /usr/local/bin/viewert-mcp
49
+ ```
50
+
51
+ > **Why global install?** MCP clients like Claude Desktop launch the server as a subprocess and may not propagate environment variables correctly when using `npx`. A global install with an absolute binary path is the most reliable approach.
52
+
53
+ #### 3. Add to your MCP client config
16
54
 
17
55
  **Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
18
56
 
@@ -20,8 +58,8 @@ Go to **Settings → API Keys** on Viewert and create a key. Copy it — it's sh
20
58
  {
21
59
  "mcpServers": {
22
60
  "viewert": {
23
- "command": "npx",
24
- "args": ["-y", "@viewert/mcp"],
61
+ "command": "/usr/local/bin/viewert-mcp",
62
+ "args": [],
25
63
  "env": {
26
64
  "VIEWERT_API_KEY": "vwt_your_key_here"
27
65
  }
@@ -30,14 +68,16 @@ Go to **Settings → API Keys** on Viewert and create a key. Copy it — it's sh
30
68
  }
31
69
  ```
32
70
 
33
- **Cursor / Windsurf** (`.cursor/mcp.json` or `.windsurf/mcp.json` in your project, or global config):
71
+ Replace `/usr/local/bin/viewert-mcp` with the path from `which viewert-mcp`.
72
+
73
+ **Cursor / Windsurf** (`.cursor/mcp.json` or `.windsurf/mcp.json` in your project):
34
74
 
35
75
  ```json
36
76
  {
37
77
  "mcpServers": {
38
78
  "viewert": {
39
- "command": "npx",
40
- "args": ["-y", "@viewert/mcp"],
79
+ "command": "/usr/local/bin/viewert-mcp",
80
+ "args": [],
41
81
  "env": {
42
82
  "VIEWERT_API_KEY": "vwt_your_key_here"
43
83
  }
@@ -46,9 +86,11 @@ Go to **Settings → API Keys** on Viewert and create a key. Copy it — it's sh
46
86
  }
47
87
  ```
48
88
 
49
- ### 3. Restart your AI client
89
+ #### 4. Restart your AI client
90
+
91
+ **Claude Desktop:** Right-click the Claude icon in the menu bar → **Quit** (closing the window is not enough). Reopen Claude. A hammer icon in the toolbar confirms MCP tools are active.
50
92
 
51
- The Viewert tools will appear automatically.
93
+ **Cursor / Windsurf:** Run **Reload Window** from the command palette (`Cmd+Shift+P`).
52
94
 
53
95
  ---
54
96
 
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Viewert MCP Setup CLI
4
+ *
5
+ * Interactive setup wizard that installs the Viewert MCP server into
6
+ * Claude Desktop, Cursor, and/or Windsurf with zero manual config editing.
7
+ *
8
+ * Usage: npx @viewert/mcp setup
9
+ * or: viewert-mcp-setup (after global install)
10
+ */
11
+ export {};
package/dist/setup.js ADDED
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Viewert MCP Setup CLI
4
+ *
5
+ * Interactive setup wizard that installs the Viewert MCP server into
6
+ * Claude Desktop, Cursor, and/or Windsurf with zero manual config editing.
7
+ *
8
+ * Usage: npx @viewert/mcp setup
9
+ * or: viewert-mcp-setup (after global install)
10
+ */
11
+ import { execSync, spawnSync } from 'child_process';
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
13
+ import { createInterface } from 'readline';
14
+ import { homedir, platform } from 'os';
15
+ import { dirname, join } from 'path';
16
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
17
+ const c = {
18
+ reset: '\x1b[0m',
19
+ bold: '\x1b[1m',
20
+ dim: '\x1b[2m',
21
+ cyan: '\x1b[36m',
22
+ green: '\x1b[32m',
23
+ yellow: '\x1b[33m',
24
+ red: '\x1b[31m',
25
+ white: '\x1b[37m',
26
+ bgCyan: '\x1b[46m',
27
+ bgGreen: '\x1b[42m',
28
+ blue: '\x1b[34m',
29
+ magenta: '\x1b[35m',
30
+ };
31
+ const bold = (s) => `${c.bold}${s}${c.reset}`;
32
+ const dim = (s) => `${c.dim}${s}${c.reset}`;
33
+ const cyan = (s) => `${c.cyan}${s}${c.reset}`;
34
+ const green = (s) => `${c.green}${s}${c.reset}`;
35
+ const yellow = (s) => `${c.yellow}${s}${c.reset}`;
36
+ const red = (s) => `${c.red}${s}${c.reset}`;
37
+ const blue = (s) => `${c.blue}${s}${c.reset}`;
38
+ const CHECK = green('✓');
39
+ const CROSS = red('✗');
40
+ const ARROW = cyan('›');
41
+ const BULLET = dim('•');
42
+ const WARN = yellow('⚠');
43
+ function box(lines, color = cyan) {
44
+ const stripped = lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, ''));
45
+ const width = Math.max(...stripped.map(l => l.length), 0) + 4;
46
+ const top = color('╭' + '─'.repeat(width) + '╮');
47
+ const bottom = color('╰' + '─'.repeat(width) + '╯');
48
+ console.log(top);
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const pad = width - stripped[i].length - 2;
51
+ console.log(color('│') + ' ' + lines[i] + ' '.repeat(pad) + color('│'));
52
+ }
53
+ console.log(bottom);
54
+ }
55
+ function hr(char = '─', width = 56) {
56
+ console.log(dim(char.repeat(width)));
57
+ }
58
+ function nl() { console.log(''); }
59
+ // ── Readline helpers ──────────────────────────────────────────────────────────
60
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
61
+ function ask(question) {
62
+ return new Promise(resolve => {
63
+ rl.question(question, answer => resolve(answer.trim()));
64
+ });
65
+ }
66
+ function askSecret(question) {
67
+ return new Promise(resolve => {
68
+ // Pause readline so it doesn't compete for stdin events
69
+ rl.pause();
70
+ process.stdout.write(question);
71
+ process.stdin.setRawMode?.(true);
72
+ process.stdin.resume();
73
+ process.stdin.setEncoding('utf8');
74
+ let input = '';
75
+ let done = false;
76
+ const finish = () => {
77
+ if (done)
78
+ return;
79
+ done = true;
80
+ process.stdin.setRawMode?.(false);
81
+ process.stdin.pause();
82
+ process.stdin.removeListener('data', onData);
83
+ process.stdout.write('\n');
84
+ rl.resume();
85
+ resolve(input);
86
+ };
87
+ const onData = (chunk) => {
88
+ // Handle multi-char chunks (e.g. paste) character by character
89
+ for (const ch of chunk) {
90
+ if (ch === '\r' || ch === '\n') {
91
+ finish();
92
+ return;
93
+ }
94
+ else if (ch === '\u0003') {
95
+ process.stdout.write('\n');
96
+ process.exit(1);
97
+ }
98
+ else if (ch === '\u007f' || ch === '\u0008') {
99
+ if (input.length > 0) {
100
+ input = input.slice(0, -1);
101
+ process.stdout.write('\b \b');
102
+ }
103
+ }
104
+ else if (ch >= ' ') {
105
+ input += ch;
106
+ process.stdout.write('*');
107
+ }
108
+ }
109
+ };
110
+ process.stdin.on('data', onData);
111
+ });
112
+ }
113
+ async function confirm(question, defaultYes = true) {
114
+ const hint = defaultYes ? dim('Y/n') : dim('y/N');
115
+ const answer = await ask(`${question} ${hint} `);
116
+ if (answer === '')
117
+ return defaultYes;
118
+ return answer.toLowerCase().startsWith('y');
119
+ }
120
+ // ── Platform helpers ──────────────────────────────────────────────────────────
121
+ const IS_WIN = platform() === 'win32';
122
+ const IS_MAC = platform() === 'darwin';
123
+ const HOME = homedir();
124
+ function expandHome(p) {
125
+ return p.startsWith('~') ? join(HOME, p.slice(1)) : p;
126
+ }
127
+ function getBinaryPath() {
128
+ try {
129
+ const result = spawnSync(IS_WIN ? 'where' : 'which', ['viewert-mcp'], { encoding: 'utf8' });
130
+ const path = result.stdout?.trim().split('\n')[0];
131
+ if (path && existsSync(path))
132
+ return path;
133
+ return null;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ function getClients() {
140
+ const clients = [];
141
+ // Claude Desktop
142
+ const claudePath = IS_WIN
143
+ ? expandHome(join('~', 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'))
144
+ : expandHome('~/Library/Application Support/Claude/claude_desktop_config.json');
145
+ clients.push({
146
+ name: 'Claude Desktop',
147
+ emoji: '🤖',
148
+ configPath: claudePath,
149
+ reloadHint: IS_MAC
150
+ ? 'Right-click Claude in the menu bar → Quit, then reopen'
151
+ : 'Fully quit Claude Desktop from the system tray, then reopen',
152
+ detected: existsSync(dirname(claudePath)) || existsSync(claudePath),
153
+ });
154
+ // Cursor (global)
155
+ const cursorPath = IS_WIN
156
+ ? expandHome(join('~', '.cursor', 'mcp.json'))
157
+ : expandHome('~/.cursor/mcp.json');
158
+ clients.push({
159
+ name: 'Cursor',
160
+ emoji: '⚡',
161
+ configPath: cursorPath,
162
+ reloadHint: 'Run "Reload Window" in the command palette (Cmd+Shift+P)',
163
+ detected: existsSync(dirname(cursorPath)) || existsSync(cursorPath),
164
+ });
165
+ // Windsurf
166
+ const windsurfPath = IS_WIN
167
+ ? expandHome(join('~', '.codeium', 'windsurf', 'mcp_settings.json'))
168
+ : expandHome('~/.codeium/windsurf/mcp_settings.json');
169
+ clients.push({
170
+ name: 'Windsurf',
171
+ emoji: '🏄',
172
+ configPath: windsurfPath,
173
+ reloadHint: 'Run "Reload Window" in the command palette (Cmd+Shift+P)',
174
+ detected: existsSync(dirname(windsurfPath)) || existsSync(windsurfPath),
175
+ });
176
+ return clients;
177
+ }
178
+ // ── Config read/write ─────────────────────────────────────────────────────────
179
+ function readConfig(path) {
180
+ if (!existsSync(path))
181
+ return {};
182
+ try {
183
+ return JSON.parse(readFileSync(path, 'utf8'));
184
+ }
185
+ catch {
186
+ return null; // signals parse failure — caller should warn and skip
187
+ }
188
+ }
189
+ function writeConfig(path, data) {
190
+ const dir = dirname(path);
191
+ if (!existsSync(dir))
192
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
193
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
194
+ }
195
+ function injectViewertServer(config, binaryPath, apiKey) {
196
+ // Deep-clone mcpServers so we don't mutate the original object
197
+ const existingServers = (config.mcpServers && typeof config.mcpServers === 'object')
198
+ ? JSON.parse(JSON.stringify(config.mcpServers))
199
+ : {};
200
+ return {
201
+ ...config,
202
+ mcpServers: {
203
+ ...existingServers,
204
+ viewert: {
205
+ command: binaryPath,
206
+ args: [],
207
+ env: { VIEWERT_API_KEY: apiKey },
208
+ },
209
+ },
210
+ };
211
+ }
212
+ function alreadyConfigured(config) {
213
+ const servers = config.mcpServers;
214
+ return !!(servers?.['viewert']);
215
+ }
216
+ async function validateKey(apiKey) {
217
+ const controller = new AbortController();
218
+ const timeout = setTimeout(() => controller.abort(), 8000);
219
+ try {
220
+ const res = await fetch('https://www.viewert.com/api/librams/mine', {
221
+ headers: { Authorization: `Bearer ${apiKey}` },
222
+ signal: controller.signal,
223
+ });
224
+ clearTimeout(timeout);
225
+ if (res.ok)
226
+ return 'valid';
227
+ // 401/403 = bad key; 429/5xx = server issue, treat as offline
228
+ if (res.status === 401 || res.status === 403)
229
+ return 'invalid';
230
+ return 'offline';
231
+ }
232
+ catch (err) {
233
+ clearTimeout(timeout);
234
+ if (err instanceof Error && err.name === 'AbortError')
235
+ return 'offline';
236
+ return 'offline';
237
+ }
238
+ }
239
+ // ── Main ──────────────────────────────────────────────────────────────────────
240
+ async function main() {
241
+ // ── Banner ──
242
+ nl();
243
+ box([
244
+ bold(cyan(' Viewert MCP ') + dim('setup wizard')),
245
+ '',
246
+ dim(' Connect your Librams to Claude, Cursor, Windsurf'),
247
+ dim(' and any MCP-compatible AI in under 2 minutes.'),
248
+ ]);
249
+ nl();
250
+ // ── Step 1: Check binary ──
251
+ console.log(`${bold('Step 1')} ${cyan('Check installation')}`);
252
+ nl();
253
+ let binaryPath = getBinaryPath();
254
+ if (binaryPath) {
255
+ console.log(` ${CHECK} viewert-mcp found at ${dim(binaryPath)}`);
256
+ }
257
+ else {
258
+ console.log(` ${WARN} viewert-mcp not found — installing globally…`);
259
+ nl();
260
+ try {
261
+ execSync('npm install -g @viewert/mcp', { stdio: 'inherit' });
262
+ binaryPath = getBinaryPath();
263
+ if (!binaryPath)
264
+ throw new Error('Binary not found after install');
265
+ nl();
266
+ console.log(` ${CHECK} Installed at ${dim(binaryPath)}`);
267
+ }
268
+ catch {
269
+ nl();
270
+ console.log(` ${CROSS} ${red('Installation failed.')}`);
271
+ console.log(` Try manually: ${cyan('npm install -g @viewert/mcp')}`);
272
+ rl.close();
273
+ process.exit(1);
274
+ }
275
+ }
276
+ nl();
277
+ hr();
278
+ nl();
279
+ // ── Step 2: API Key ──
280
+ console.log(`${bold('Step 2')} ${cyan('API Key')}`);
281
+ nl();
282
+ console.log(` Your API key starts with ${cyan('vwt_')} and can be generated at:`);
283
+ console.log(` ${blue('https://www.viewert.com/settings')} ${dim('→ API Keys → Create Key')}`);
284
+ nl();
285
+ console.log(` ${BULLET} The key is shown ${bold('only once')} — copy it before closing that page.`);
286
+ console.log(` ${BULLET} Create one key per AI client so you can revoke individually.`);
287
+ nl();
288
+ let apiKey = '';
289
+ let keyValid = false;
290
+ while (!keyValid) {
291
+ apiKey = await askSecret(` ${ARROW} Paste your API key: `);
292
+ if (!apiKey.startsWith('vwt_')) {
293
+ console.log(`\n ${CROSS} ${red('Key must start with')} ${cyan('vwt_')}${red('.')} ${dim('Check you copied the full key.')}`);
294
+ nl();
295
+ continue;
296
+ }
297
+ process.stdout.write(`\n ${dim('Verifying key…')}`);
298
+ const valid = await validateKey(apiKey);
299
+ process.stdout.write('\r' + ' '.repeat(40) + '\r');
300
+ if (valid === 'valid') {
301
+ console.log(` ${CHECK} ${green('Key verified successfully!')}`);
302
+ keyValid = true;
303
+ }
304
+ else if (valid === 'offline') {
305
+ console.log(` ${WARN} ${yellow('Could not reach viewert.com — check your internet connection.')}`);
306
+ nl();
307
+ const proceed = await confirm(` ${ARROW} Continue anyway without verifying?`, false);
308
+ if (proceed) {
309
+ console.log(`\n ${WARN} ${yellow('Proceeding unverified — the integration may not work if the key is invalid.')}`);
310
+ keyValid = true;
311
+ }
312
+ }
313
+ else {
314
+ console.log(` ${CROSS} ${red('Key not recognised.')} ${dim('Make sure you copied it from viewert.com/settings and it hasn\'t been revoked.')}`);
315
+ nl();
316
+ const retry = await confirm(` ${ARROW} Try a different key?`, true);
317
+ if (!retry) {
318
+ console.log(`\n ${WARN} ${yellow('Skipping key verification — setup will continue but the integration may not work.')}`);
319
+ keyValid = true;
320
+ }
321
+ }
322
+ }
323
+ nl();
324
+ hr();
325
+ nl();
326
+ // ── Step 3: Choose clients ──
327
+ console.log(`${bold('Step 3')} ${cyan('Choose AI clients to configure')}`);
328
+ nl();
329
+ const clients = getClients();
330
+ for (const client of clients) {
331
+ const status = client.detected ? green('detected') : dim('not detected');
332
+ console.log(` ${BULLET} ${client.emoji} ${bold(client.name)} ${status}`);
333
+ }
334
+ nl();
335
+ const selected = [];
336
+ for (const client of clients) {
337
+ const defaultVal = client.detected;
338
+ const yn = await confirm(` ${ARROW} Configure ${bold(client.name)}?`, defaultVal);
339
+ if (yn)
340
+ selected.push(client);
341
+ }
342
+ if (selected.length === 0) {
343
+ nl();
344
+ console.log(` ${WARN} ${yellow('No clients selected. Nothing to configure.')}`);
345
+ rl.close();
346
+ process.exit(0);
347
+ }
348
+ nl();
349
+ hr();
350
+ nl();
351
+ // ── Step 4: Write configs ──
352
+ console.log(`${bold('Step 4')} ${cyan('Writing configuration')}`);
353
+ nl();
354
+ const results = [];
355
+ for (const client of selected) {
356
+ try {
357
+ const existing = readConfig(client.configPath);
358
+ if (existing === null) {
359
+ // Malformed JSON — don't clobber the existing file
360
+ throw new Error(`Config file exists but contains invalid JSON.\n Fix it manually at: ${client.configPath}`);
361
+ }
362
+ const wasConfigured = alreadyConfigured(existing);
363
+ const updated = injectViewertServer(existing, binaryPath, apiKey);
364
+ writeConfig(client.configPath, updated);
365
+ const status = wasConfigured ? 'updated' : 'added';
366
+ results.push({ client, status, hint: client.reloadHint });
367
+ const action = wasConfigured ? yellow('updated') : green('added');
368
+ console.log(` ${CHECK} ${client.emoji} ${bold(client.name)} ${action}`);
369
+ console.log(` ${dim(client.configPath)}`);
370
+ }
371
+ catch (err) {
372
+ results.push({ client, status: 'error', hint: '' });
373
+ console.log(` ${CROSS} ${client.emoji} ${bold(client.name)} ${red('failed')}`);
374
+ console.log(` ${dim(String(err))}`);
375
+ }
376
+ nl();
377
+ }
378
+ hr();
379
+ nl();
380
+ // ── Done ──
381
+ const succeeded = results.filter(r => r.status !== 'error');
382
+ const failed = results.filter(r => r.status === 'error');
383
+ if (succeeded.length > 0) {
384
+ box([
385
+ `${green('✓')} ${bold('Setup complete!')}`,
386
+ '',
387
+ ...succeeded.map(r => ` ${r.client.emoji} ${bold(r.client.name)} ${dim('→')} ${r.status === 'updated' ? yellow('config updated') : green('configured')}`),
388
+ ], green);
389
+ nl();
390
+ console.log(`${bold('Next steps')}`);
391
+ nl();
392
+ for (const r of succeeded) {
393
+ console.log(` ${r.client.emoji} ${bold(r.client.name)}`);
394
+ console.log(` ${ARROW} ${r.hint}`);
395
+ nl();
396
+ }
397
+ console.log(` ${bold('Then try:')} ${dim('"Load my [Libram name] and summarise the key points."')}`);
398
+ nl();
399
+ console.log(` ${BULLET} Manage Librams: ${blue('https://www.viewert.com/librams')}`);
400
+ console.log(` ${BULLET} Revoke keys: ${blue('https://www.viewert.com/settings')}`);
401
+ console.log(` ${BULLET} Docs: ${blue('https://www.viewert.com/docs/api-keys-mcp')}`);
402
+ }
403
+ if (failed.length > 0) {
404
+ nl();
405
+ console.log(` ${WARN} ${yellow('Some clients could not be configured:')}`);
406
+ for (const r of failed) {
407
+ console.log(` ${CROSS} ${r.client.name}`);
408
+ }
409
+ console.log(`\n ${dim('You can configure them manually — see docs at')} ${blue('https://www.viewert.com/docs/api-keys-mcp')}`);
410
+ }
411
+ nl();
412
+ rl.close();
413
+ }
414
+ main().catch(err => {
415
+ console.error(`\n${red('Unexpected error:')} ${String(err)}`);
416
+ process.exit(1);
417
+ });
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "name": "@viewert/mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "MCP server for Viewert Librams — expose AI-enabled Vellums as context to any MCP-compatible AI client",
5
5
  "keywords": ["mcp", "viewert", "libram", "ai-context", "model-context-protocol"],
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "main": "dist/index.js",
9
9
  "bin": {
10
- "viewert-mcp": "dist/index.js"
10
+ "viewert-mcp": "dist/index.js",
11
+ "viewert-mcp-setup": "dist/setup.js"
11
12
  },
12
13
  "packageManager": "pnpm@9.0.0",
13
14
  "scripts": {
14
15
  "build": "tsc",
15
16
  "dev": "tsx src/index.ts",
17
+ "setup": "tsx src/setup.ts",
16
18
  "start": "node dist/index.js",
17
19
  "prepublishOnly": "pnpm run build"
18
20
  },
package/src/setup.ts ADDED
@@ -0,0 +1,476 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Viewert MCP Setup CLI
4
+ *
5
+ * Interactive setup wizard that installs the Viewert MCP server into
6
+ * Claude Desktop, Cursor, and/or Windsurf with zero manual config editing.
7
+ *
8
+ * Usage: npx @viewert/mcp setup
9
+ * or: viewert-mcp-setup (after global install)
10
+ */
11
+
12
+ import { execSync, spawnSync } from 'child_process'
13
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
14
+ import { createInterface } from 'readline'
15
+ import { homedir, platform } from 'os'
16
+ import { dirname, join } from 'path'
17
+
18
+ // ── ANSI helpers ──────────────────────────────────────────────────────────────
19
+
20
+ const c = {
21
+ reset: '\x1b[0m',
22
+ bold: '\x1b[1m',
23
+ dim: '\x1b[2m',
24
+ cyan: '\x1b[36m',
25
+ green: '\x1b[32m',
26
+ yellow: '\x1b[33m',
27
+ red: '\x1b[31m',
28
+ white: '\x1b[37m',
29
+ bgCyan: '\x1b[46m',
30
+ bgGreen: '\x1b[42m',
31
+ blue: '\x1b[34m',
32
+ magenta: '\x1b[35m',
33
+ }
34
+
35
+ const bold = (s: string) => `${c.bold}${s}${c.reset}`
36
+ const dim = (s: string) => `${c.dim}${s}${c.reset}`
37
+ const cyan = (s: string) => `${c.cyan}${s}${c.reset}`
38
+ const green = (s: string) => `${c.green}${s}${c.reset}`
39
+ const yellow = (s: string) => `${c.yellow}${s}${c.reset}`
40
+ const red = (s: string) => `${c.red}${s}${c.reset}`
41
+ const blue = (s: string) => `${c.blue}${s}${c.reset}`
42
+
43
+ const CHECK = green('✓')
44
+ const CROSS = red('✗')
45
+ const ARROW = cyan('›')
46
+ const BULLET = dim('•')
47
+ const WARN = yellow('⚠')
48
+
49
+ function box(lines: string[], color: (s: string) => string = cyan): void {
50
+ const stripped = lines.map(l => l.replace(/\x1b\[[0-9;]*m/g, ''))
51
+ const width = Math.max(...stripped.map(l => l.length), 0) + 4
52
+ const top = color('╭' + '─'.repeat(width) + '╮')
53
+ const bottom = color('╰' + '─'.repeat(width) + '╯')
54
+ console.log(top)
55
+ for (let i = 0; i < lines.length; i++) {
56
+ const pad = width - stripped[i].length - 2
57
+ console.log(color('│') + ' ' + lines[i] + ' '.repeat(pad) + color('│'))
58
+ }
59
+ console.log(bottom)
60
+ }
61
+
62
+ function hr(char = '─', width = 56): void {
63
+ console.log(dim(char.repeat(width)))
64
+ }
65
+
66
+ function nl(): void { console.log('') }
67
+
68
+ // ── Readline helpers ──────────────────────────────────────────────────────────
69
+
70
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
71
+
72
+ function ask(question: string): Promise<string> {
73
+ return new Promise(resolve => {
74
+ rl.question(question, answer => resolve(answer.trim()))
75
+ })
76
+ }
77
+
78
+ function askSecret(question: string): Promise<string> {
79
+ return new Promise(resolve => {
80
+ // Pause readline so it doesn't compete for stdin events
81
+ rl.pause()
82
+ process.stdout.write(question)
83
+ process.stdin.setRawMode?.(true)
84
+ process.stdin.resume()
85
+ process.stdin.setEncoding('utf8')
86
+ let input = ''
87
+ let done = false
88
+ const finish = () => {
89
+ if (done) return
90
+ done = true
91
+ process.stdin.setRawMode?.(false)
92
+ process.stdin.pause()
93
+ process.stdin.removeListener('data', onData)
94
+ process.stdout.write('\n')
95
+ rl.resume()
96
+ resolve(input)
97
+ }
98
+ const onData = (chunk: string) => {
99
+ // Handle multi-char chunks (e.g. paste) character by character
100
+ for (const ch of chunk) {
101
+ if (ch === '\r' || ch === '\n') {
102
+ finish()
103
+ return
104
+ } else if (ch === '\u0003') {
105
+ process.stdout.write('\n')
106
+ process.exit(1)
107
+ } else if (ch === '\u007f' || ch === '\u0008') {
108
+ if (input.length > 0) {
109
+ input = input.slice(0, -1)
110
+ process.stdout.write('\b \b')
111
+ }
112
+ } else if (ch >= ' ') {
113
+ input += ch
114
+ process.stdout.write('*')
115
+ }
116
+ }
117
+ }
118
+ process.stdin.on('data', onData)
119
+ })
120
+ }
121
+
122
+ async function confirm(question: string, defaultYes = true): Promise<boolean> {
123
+ const hint = defaultYes ? dim('Y/n') : dim('y/N')
124
+ const answer = await ask(`${question} ${hint} `)
125
+ if (answer === '') return defaultYes
126
+ return answer.toLowerCase().startsWith('y')
127
+ }
128
+
129
+ // ── Platform helpers ──────────────────────────────────────────────────────────
130
+
131
+ const IS_WIN = platform() === 'win32'
132
+ const IS_MAC = platform() === 'darwin'
133
+ const HOME = homedir()
134
+
135
+ function expandHome(p: string): string {
136
+ return p.startsWith('~') ? join(HOME, p.slice(1)) : p
137
+ }
138
+
139
+ function getBinaryPath(): string | null {
140
+ try {
141
+ const result = spawnSync(IS_WIN ? 'where' : 'which', ['viewert-mcp'], { encoding: 'utf8' })
142
+ const path = result.stdout?.trim().split('\n')[0]
143
+ if (path && existsSync(path)) return path
144
+ return null
145
+ } catch {
146
+ return null
147
+ }
148
+ }
149
+
150
+ // ── Client config paths ───────────────────────────────────────────────────────
151
+
152
+ interface ClientConfig {
153
+ name: string
154
+ emoji: string
155
+ configPath: string
156
+ reloadHint: string
157
+ detected: boolean
158
+ }
159
+
160
+ function getClients(): ClientConfig[] {
161
+ const clients: ClientConfig[] = []
162
+
163
+ // Claude Desktop
164
+ const claudePath = IS_WIN
165
+ ? expandHome(join('~', 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'))
166
+ : expandHome('~/Library/Application Support/Claude/claude_desktop_config.json')
167
+ clients.push({
168
+ name: 'Claude Desktop',
169
+ emoji: '🤖',
170
+ configPath: claudePath,
171
+ reloadHint: IS_MAC
172
+ ? 'Right-click Claude in the menu bar → Quit, then reopen'
173
+ : 'Fully quit Claude Desktop from the system tray, then reopen',
174
+ detected: existsSync(dirname(claudePath)) || existsSync(claudePath),
175
+ })
176
+
177
+ // Cursor (global)
178
+ const cursorPath = IS_WIN
179
+ ? expandHome(join('~', '.cursor', 'mcp.json'))
180
+ : expandHome('~/.cursor/mcp.json')
181
+ clients.push({
182
+ name: 'Cursor',
183
+ emoji: '⚡',
184
+ configPath: cursorPath,
185
+ reloadHint: 'Run "Reload Window" in the command palette (Cmd+Shift+P)',
186
+ detected: existsSync(dirname(cursorPath)) || existsSync(cursorPath),
187
+ })
188
+
189
+ // Windsurf
190
+ const windsurfPath = IS_WIN
191
+ ? expandHome(join('~', '.codeium', 'windsurf', 'mcp_settings.json'))
192
+ : expandHome('~/.codeium/windsurf/mcp_settings.json')
193
+ clients.push({
194
+ name: 'Windsurf',
195
+ emoji: '🏄',
196
+ configPath: windsurfPath,
197
+ reloadHint: 'Run "Reload Window" in the command palette (Cmd+Shift+P)',
198
+ detected: existsSync(dirname(windsurfPath)) || existsSync(windsurfPath),
199
+ })
200
+
201
+ return clients
202
+ }
203
+
204
+ // ── Config read/write ─────────────────────────────────────────────────────────
205
+
206
+ function readConfig(path: string): Record<string, unknown> | null {
207
+ if (!existsSync(path)) return {}
208
+ try {
209
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
210
+ } catch {
211
+ return null // signals parse failure — caller should warn and skip
212
+ }
213
+ }
214
+
215
+ function writeConfig(path: string, data: Record<string, unknown>): void {
216
+ const dir = dirname(path)
217
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 })
218
+ writeFileSync(path, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
219
+ }
220
+
221
+ function injectViewertServer(
222
+ config: Record<string, unknown>,
223
+ binaryPath: string,
224
+ apiKey: string,
225
+ ): Record<string, unknown> {
226
+ // Deep-clone mcpServers so we don't mutate the original object
227
+ const existingServers = (config.mcpServers && typeof config.mcpServers === 'object')
228
+ ? JSON.parse(JSON.stringify(config.mcpServers)) as Record<string, unknown>
229
+ : {}
230
+ return {
231
+ ...config,
232
+ mcpServers: {
233
+ ...existingServers,
234
+ viewert: {
235
+ command: binaryPath,
236
+ args: [],
237
+ env: { VIEWERT_API_KEY: apiKey },
238
+ },
239
+ },
240
+ }
241
+ }
242
+
243
+ function alreadyConfigured(config: Record<string, unknown>): boolean {
244
+ const servers = config.mcpServers as Record<string, unknown> | undefined
245
+ return !!(servers?.['viewert'])
246
+ }
247
+
248
+ // ── Validate API key against production ──────────────────────────────────────
249
+
250
+ type KeyValidationResult = 'valid' | 'invalid' | 'offline'
251
+
252
+ async function validateKey(apiKey: string): Promise<KeyValidationResult> {
253
+ const controller = new AbortController()
254
+ const timeout = setTimeout(() => controller.abort(), 8000)
255
+ try {
256
+ const res = await fetch('https://www.viewert.com/api/librams/mine', {
257
+ headers: { Authorization: `Bearer ${apiKey}` },
258
+ signal: controller.signal,
259
+ })
260
+ clearTimeout(timeout)
261
+ if (res.ok) return 'valid'
262
+ // 401/403 = bad key; 429/5xx = server issue, treat as offline
263
+ if (res.status === 401 || res.status === 403) return 'invalid'
264
+ return 'offline'
265
+ } catch (err) {
266
+ clearTimeout(timeout)
267
+ if (err instanceof Error && err.name === 'AbortError') return 'offline'
268
+ return 'offline'
269
+ }
270
+ }
271
+
272
+ // ── Main ──────────────────────────────────────────────────────────────────────
273
+
274
+ async function main(): Promise<void> {
275
+ // ── Banner ──
276
+ nl()
277
+ box([
278
+ bold(cyan(' Viewert MCP ') + dim('setup wizard')),
279
+ '',
280
+ dim(' Connect your Librams to Claude, Cursor, Windsurf'),
281
+ dim(' and any MCP-compatible AI in under 2 minutes.'),
282
+ ])
283
+ nl()
284
+
285
+ // ── Step 1: Check binary ──
286
+ console.log(`${bold('Step 1')} ${cyan('Check installation')}`)
287
+ nl()
288
+
289
+ let binaryPath = getBinaryPath()
290
+
291
+ if (binaryPath) {
292
+ console.log(` ${CHECK} viewert-mcp found at ${dim(binaryPath)}`)
293
+ } else {
294
+ console.log(` ${WARN} viewert-mcp not found — installing globally…`)
295
+ nl()
296
+ try {
297
+ execSync('npm install -g @viewert/mcp', { stdio: 'inherit' })
298
+ binaryPath = getBinaryPath()
299
+ if (!binaryPath) throw new Error('Binary not found after install')
300
+ nl()
301
+ console.log(` ${CHECK} Installed at ${dim(binaryPath)}`)
302
+ } catch {
303
+ nl()
304
+ console.log(` ${CROSS} ${red('Installation failed.')}`)
305
+ console.log(` Try manually: ${cyan('npm install -g @viewert/mcp')}`)
306
+ rl.close()
307
+ process.exit(1)
308
+ }
309
+ }
310
+
311
+ nl()
312
+ hr()
313
+ nl()
314
+
315
+ // ── Step 2: API Key ──
316
+ console.log(`${bold('Step 2')} ${cyan('API Key')}`)
317
+ nl()
318
+ console.log(` Your API key starts with ${cyan('vwt_')} and can be generated at:`)
319
+ console.log(` ${blue('https://www.viewert.com/settings')} ${dim('→ API Keys → Create Key')}`)
320
+ nl()
321
+ console.log(` ${BULLET} The key is shown ${bold('only once')} — copy it before closing that page.`)
322
+ console.log(` ${BULLET} Create one key per AI client so you can revoke individually.`)
323
+ nl()
324
+
325
+ let apiKey = ''
326
+ let keyValid = false
327
+
328
+ while (!keyValid) {
329
+ apiKey = await askSecret(` ${ARROW} Paste your API key: `)
330
+
331
+ if (!apiKey.startsWith('vwt_')) {
332
+ console.log(`\n ${CROSS} ${red('Key must start with')} ${cyan('vwt_')}${red('.')} ${dim('Check you copied the full key.')}`)
333
+ nl()
334
+ continue
335
+ }
336
+
337
+ process.stdout.write(`\n ${dim('Verifying key…')}`)
338
+ const valid = await validateKey(apiKey)
339
+ process.stdout.write('\r' + ' '.repeat(40) + '\r')
340
+
341
+ if (valid === 'valid') {
342
+ console.log(` ${CHECK} ${green('Key verified successfully!')}`)
343
+ keyValid = true
344
+ } else if (valid === 'offline') {
345
+ console.log(` ${WARN} ${yellow('Could not reach viewert.com — check your internet connection.')}`)
346
+ nl()
347
+ const proceed = await confirm(` ${ARROW} Continue anyway without verifying?`, false)
348
+ if (proceed) {
349
+ console.log(`\n ${WARN} ${yellow('Proceeding unverified — the integration may not work if the key is invalid.')}`)
350
+ keyValid = true
351
+ }
352
+ } else {
353
+ console.log(` ${CROSS} ${red('Key not recognised.')} ${dim('Make sure you copied it from viewert.com/settings and it hasn\'t been revoked.')}`)
354
+ nl()
355
+ const retry = await confirm(` ${ARROW} Try a different key?`, true)
356
+ if (!retry) {
357
+ console.log(`\n ${WARN} ${yellow('Skipping key verification — setup will continue but the integration may not work.')}`)
358
+ keyValid = true
359
+ }
360
+ }
361
+ }
362
+
363
+ nl()
364
+ hr()
365
+ nl()
366
+
367
+ // ── Step 3: Choose clients ──
368
+ console.log(`${bold('Step 3')} ${cyan('Choose AI clients to configure')}`)
369
+ nl()
370
+
371
+ const clients = getClients()
372
+
373
+ for (const client of clients) {
374
+ const status = client.detected ? green('detected') : dim('not detected')
375
+ console.log(` ${BULLET} ${client.emoji} ${bold(client.name)} ${status}`)
376
+ }
377
+
378
+ nl()
379
+
380
+ const selected: ClientConfig[] = []
381
+
382
+ for (const client of clients) {
383
+ const defaultVal = client.detected
384
+ const yn = await confirm(` ${ARROW} Configure ${bold(client.name)}?`, defaultVal)
385
+ if (yn) selected.push(client)
386
+ }
387
+
388
+ if (selected.length === 0) {
389
+ nl()
390
+ console.log(` ${WARN} ${yellow('No clients selected. Nothing to configure.')}`)
391
+ rl.close()
392
+ process.exit(0)
393
+ }
394
+
395
+ nl()
396
+ hr()
397
+ nl()
398
+
399
+ // ── Step 4: Write configs ──
400
+ console.log(`${bold('Step 4')} ${cyan('Writing configuration')}`)
401
+ nl()
402
+
403
+ const results: { client: ClientConfig; status: 'added' | 'updated' | 'error'; hint: string }[] = []
404
+
405
+ for (const client of selected) {
406
+ try {
407
+ const existing = readConfig(client.configPath)
408
+ if (existing === null) {
409
+ // Malformed JSON — don't clobber the existing file
410
+ throw new Error(`Config file exists but contains invalid JSON.\n Fix it manually at: ${client.configPath}`)
411
+ }
412
+ const wasConfigured = alreadyConfigured(existing)
413
+ const updated = injectViewertServer(existing, binaryPath!, apiKey)
414
+ writeConfig(client.configPath, updated)
415
+
416
+ const status = wasConfigured ? 'updated' : 'added'
417
+ results.push({ client, status, hint: client.reloadHint })
418
+
419
+ const action = wasConfigured ? yellow('updated') : green('added')
420
+ console.log(` ${CHECK} ${client.emoji} ${bold(client.name)} ${action}`)
421
+ console.log(` ${dim(client.configPath)}`)
422
+ } catch (err) {
423
+ results.push({ client, status: 'error', hint: '' })
424
+ console.log(` ${CROSS} ${client.emoji} ${bold(client.name)} ${red('failed')}`)
425
+ console.log(` ${dim(String(err))}`)
426
+ }
427
+ nl()
428
+ }
429
+
430
+ hr()
431
+ nl()
432
+
433
+ // ── Done ──
434
+ const succeeded = results.filter(r => r.status !== 'error')
435
+ const failed = results.filter(r => r.status === 'error')
436
+
437
+ if (succeeded.length > 0) {
438
+ box([
439
+ `${green('✓')} ${bold('Setup complete!')}`,
440
+ '',
441
+ ...succeeded.map(r => ` ${r.client.emoji} ${bold(r.client.name)} ${dim('→')} ${r.status === 'updated' ? yellow('config updated') : green('configured')}`),
442
+ ], green)
443
+ nl()
444
+
445
+ console.log(`${bold('Next steps')}`)
446
+ nl()
447
+ for (const r of succeeded) {
448
+ console.log(` ${r.client.emoji} ${bold(r.client.name)}`)
449
+ console.log(` ${ARROW} ${r.hint}`)
450
+ nl()
451
+ }
452
+
453
+ console.log(` ${bold('Then try:')} ${dim('"Load my [Libram name] and summarise the key points."')}`)
454
+ nl()
455
+ console.log(` ${BULLET} Manage Librams: ${blue('https://www.viewert.com/librams')}`)
456
+ console.log(` ${BULLET} Revoke keys: ${blue('https://www.viewert.com/settings')}`)
457
+ console.log(` ${BULLET} Docs: ${blue('https://www.viewert.com/docs/api-keys-mcp')}`)
458
+ }
459
+
460
+ if (failed.length > 0) {
461
+ nl()
462
+ console.log(` ${WARN} ${yellow('Some clients could not be configured:')}`)
463
+ for (const r of failed) {
464
+ console.log(` ${CROSS} ${r.client.name}`)
465
+ }
466
+ console.log(`\n ${dim('You can configure them manually — see docs at')} ${blue('https://www.viewert.com/docs/api-keys-mcp')}`)
467
+ }
468
+
469
+ nl()
470
+ rl.close()
471
+ }
472
+
473
+ main().catch(err => {
474
+ console.error(`\n${red('Unexpected error:')} ${String(err)}`)
475
+ process.exit(1)
476
+ })