@viewert/mcp 0.1.1 → 0.1.4

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,43 @@ 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 @viewert/mcp setup
15
+ ```
16
+
17
+ The interactive wizard will:
18
+ - Install the package globally with the correct binary path
19
+ - Verify your API key against your account
20
+ - Auto-detect Claude Desktop, Cursor, and Windsurf
21
+ - Write the config for whichever clients you choose — without overwriting your other MCP servers
22
+ - Print the exact restart steps for each client
23
+
24
+ ---
25
+
26
+ ### Option B — Manual setup
27
+
28
+ #### 1. Get an API key
29
+
30
+ Go to **Settings → API Keys** on [viewert.com](https://www.viewert.com/settings) and create a key. Copy it — it's shown only once.
31
+
32
+ #### 2. Install the package globally
14
33
 
15
- ### 2. Add to your MCP client config
34
+ ```bash
35
+ npm install -g @viewert/mcp
36
+ ```
37
+
38
+ Then find the installed binary path — you'll need this for your config:
39
+
40
+ ```bash
41
+ which viewert-mcp
42
+ # e.g. /usr/local/bin/viewert-mcp
43
+ ```
44
+
45
+ > **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.
46
+
47
+ #### 3. Add to your MCP client config
16
48
 
17
49
  **Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
18
50
 
@@ -20,8 +52,8 @@ Go to **Settings → API Keys** on Viewert and create a key. Copy it — it's sh
20
52
  {
21
53
  "mcpServers": {
22
54
  "viewert": {
23
- "command": "npx",
24
- "args": ["-y", "@viewert/mcp"],
55
+ "command": "/usr/local/bin/viewert-mcp",
56
+ "args": [],
25
57
  "env": {
26
58
  "VIEWERT_API_KEY": "vwt_your_key_here"
27
59
  }
@@ -30,14 +62,16 @@ Go to **Settings → API Keys** on Viewert and create a key. Copy it — it's sh
30
62
  }
31
63
  ```
32
64
 
33
- **Cursor / Windsurf** (`.cursor/mcp.json` or `.windsurf/mcp.json` in your project, or global config):
65
+ Replace `/usr/local/bin/viewert-mcp` with the path from `which viewert-mcp`.
66
+
67
+ **Cursor / Windsurf** (`.cursor/mcp.json` or `.windsurf/mcp.json` in your project):
34
68
 
35
69
  ```json
36
70
  {
37
71
  "mcpServers": {
38
72
  "viewert": {
39
- "command": "npx",
40
- "args": ["-y", "@viewert/mcp"],
73
+ "command": "/usr/local/bin/viewert-mcp",
74
+ "args": [],
41
75
  "env": {
42
76
  "VIEWERT_API_KEY": "vwt_your_key_here"
43
77
  }
@@ -46,9 +80,11 @@ Go to **Settings → API Keys** on Viewert and create a key. Copy it — it's sh
46
80
  }
47
81
  ```
48
82
 
49
- ### 3. Restart your AI client
83
+ #### 4. Restart your AI client
84
+
85
+ **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
86
 
51
- The Viewert tools will appear automatically.
87
+ **Cursor / Windsurf:** Run **Reload Window** from the command palette (`Cmd+Shift+P`).
52
88
 
53
89
  ---
54
90
 
@@ -65,7 +101,7 @@ The Viewert tools will appear automatically.
65
101
  | Variable | Required | Default |
66
102
  |----------|----------|---------|
67
103
  | `VIEWERT_API_KEY` | ✅ Yes | — |
68
- | `VIEWERT_API_URL` | No | `https://viewert.com/api` |
104
+ | `VIEWERT_API_URL` | No | `https://www.viewert.com/api` |
69
105
 
70
106
  ---
71
107
 
package/dist/index.d.ts CHANGED
@@ -9,6 +9,6 @@
9
9
  * VIEWERT_API_KEY — your vwt_... API key from viewert.com/settings
10
10
  *
11
11
  * Optional env vars:
12
- * VIEWERT_API_URL — defaults to https://viewert.com/api
12
+ * VIEWERT_API_URL — defaults to https://www.viewert.com/api
13
13
  */
14
14
  export {};
package/dist/index.js CHANGED
@@ -9,13 +9,13 @@
9
9
  * VIEWERT_API_KEY — your vwt_... API key from viewert.com/settings
10
10
  *
11
11
  * Optional env vars:
12
- * VIEWERT_API_URL — defaults to https://viewert.com/api
12
+ * VIEWERT_API_URL — defaults to https://www.viewert.com/api
13
13
  */
14
14
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
15
15
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
16
  import { z } from 'zod';
17
17
  const API_KEY = process.env.VIEWERT_API_KEY;
18
- const API_BASE = (process.env.VIEWERT_API_URL ?? 'https://viewert.com/api').replace(/\/$/, '');
18
+ const API_BASE = (process.env.VIEWERT_API_URL ?? 'https://www.viewert.com/api').replace(/\/$/, '');
19
19
  if (!API_KEY) {
20
20
  process.stderr.write('[viewert-mcp] ERROR: VIEWERT_API_KEY is not set.\n' +
21
21
  'Generate a key at https://viewert.com/settings and add it to your MCP config.\n');
@@ -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.1",
3
+ "version": "0.1.4",
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/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * VIEWERT_API_KEY — your vwt_... API key from viewert.com/settings
10
10
  *
11
11
  * Optional env vars:
12
- * VIEWERT_API_URL — defaults to https://viewert.com/api
12
+ * VIEWERT_API_URL — defaults to https://www.viewert.com/api
13
13
  */
14
14
 
15
15
  import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js'
@@ -17,7 +17,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
17
17
  import { z } from 'zod'
18
18
 
19
19
  const API_KEY = process.env.VIEWERT_API_KEY
20
- const API_BASE = (process.env.VIEWERT_API_URL ?? 'https://viewert.com/api').replace(/\/$/, '')
20
+ const API_BASE = (process.env.VIEWERT_API_URL ?? 'https://www.viewert.com/api').replace(/\/$/, '')
21
21
 
22
22
  if (!API_KEY) {
23
23
  process.stderr.write(
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
+ })