@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 +48 -12
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/setup.d.ts +11 -0
- package/dist/setup.js +417 -0
- package/package.json +4 -2
- package/src/index.ts +2 -2
- package/src/setup.ts +476 -0
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
|
-
###
|
|
11
|
+
### Option A — One-command setup (recommended)
|
|
12
12
|
|
|
13
|
-
|
|
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
|
-
|
|
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": "
|
|
24
|
-
"args": [
|
|
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
|
-
|
|
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": "
|
|
40
|
-
"args": [
|
|
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
|
-
|
|
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
|
-
|
|
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
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');
|
package/dist/setup.d.ts
ADDED
|
@@ -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.
|
|
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
|
+
})
|