@debugai/mcp 1.1.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -101
- package/dist/auth.d.ts +40 -0
- package/dist/auth.js +115 -0
- package/dist/backend.d.ts +37 -0
- package/dist/backend.js +12 -4
- package/dist/cli/clients.d.ts +36 -0
- package/dist/cli/clients.js +143 -0
- package/dist/cli/commands.d.ts +7 -0
- package/dist/cli/commands.js +360 -0
- package/dist/cli/install.d.ts +21 -0
- package/dist/cli/install.js +150 -0
- package/dist/cli/jsonc.d.ts +12 -0
- package/dist/cli/jsonc.js +115 -0
- package/dist/cli/ui.d.ts +19 -0
- package/dist/cli/ui.js +56 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +54 -2
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +5 -0
- package/dist/deviceLink.d.ts +51 -0
- package/dist/deviceLink.js +125 -0
- package/dist/index.js +70 -23
- package/dist/server.js +31 -1
- package/dist/tools/authGate.d.ts +10 -0
- package/dist/tools/authGate.js +29 -0
- package/dist/tools/debugError.d.ts +1 -0
- package/dist/tools/debugError.js +36 -2
- package/dist/tools/reportOutcome.d.ts +3 -0
- package/dist/tools/reportOutcome.js +63 -0
- package/package.json +2 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Where every MCP client keeps its config, and what shape it wants.
|
|
2
|
+
//
|
|
3
|
+
// This registry is the thing that replaces "copy this JSON blob into the
|
|
4
|
+
// right file" in the README. Adding a client = one entry here; the install
|
|
5
|
+
// command, doctor, and `--print` output all read from it.
|
|
6
|
+
//
|
|
7
|
+
// Two config shapes exist in the wild:
|
|
8
|
+
// mcpServers { command, args, env? } (most clients)
|
|
9
|
+
// context_servers { source, command: { path, args, env? } } (Zed)
|
|
10
|
+
// servers { type: "stdio", command, args } (VS Code native)
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { homedir, platform } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
function appDataDir(env) {
|
|
15
|
+
const home = homedir();
|
|
16
|
+
if (platform() === 'win32')
|
|
17
|
+
return env.APPDATA ?? join(home, 'AppData', 'Roaming');
|
|
18
|
+
if (platform() === 'darwin')
|
|
19
|
+
return join(home, 'Library', 'Application Support');
|
|
20
|
+
return env.XDG_CONFIG_HOME ?? join(home, '.config');
|
|
21
|
+
}
|
|
22
|
+
/** VS Code's per-user directory — Cline stores its MCP config under it too. */
|
|
23
|
+
function vscodeUserDir(env) {
|
|
24
|
+
return join(appDataDir(env), 'Code', 'User');
|
|
25
|
+
}
|
|
26
|
+
export function knownClients(env = process.env) {
|
|
27
|
+
const home = homedir();
|
|
28
|
+
const appData = appDataDir(env);
|
|
29
|
+
return [
|
|
30
|
+
{
|
|
31
|
+
id: 'claude-code',
|
|
32
|
+
label: 'Claude Code',
|
|
33
|
+
shape: 'mcpServers',
|
|
34
|
+
configPath: join(home, '.claude.json'),
|
|
35
|
+
probes: [join(home, '.claude'), join(home, '.claude.json')],
|
|
36
|
+
afterInstall: 'Start a new Claude Code session (or run /mcp to reconnect).',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'claude-desktop',
|
|
40
|
+
label: 'Claude Desktop',
|
|
41
|
+
shape: 'mcpServers',
|
|
42
|
+
configPath: join(appData, 'Claude', 'claude_desktop_config.json'),
|
|
43
|
+
probes: [join(appData, 'Claude')],
|
|
44
|
+
afterInstall: 'Quit Claude Desktop completely and reopen it (closing the window is not enough).',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'cursor',
|
|
48
|
+
label: 'Cursor',
|
|
49
|
+
shape: 'mcpServers',
|
|
50
|
+
configPath: join(home, '.cursor', 'mcp.json'),
|
|
51
|
+
probes: [join(home, '.cursor')],
|
|
52
|
+
afterInstall: 'Cursor picks this up on its own — check Settings, MCP for a green dot.',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'windsurf',
|
|
56
|
+
label: 'Windsurf',
|
|
57
|
+
shape: 'mcpServers',
|
|
58
|
+
configPath: join(home, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
59
|
+
probes: [join(home, '.codeium', 'windsurf'), join(home, '.codeium')],
|
|
60
|
+
afterInstall: 'Open Windsurf, Settings, MCP and hit refresh.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'zed',
|
|
64
|
+
label: 'Zed',
|
|
65
|
+
shape: 'context_servers',
|
|
66
|
+
configPath: platform() === 'win32'
|
|
67
|
+
? join(appData, 'Zed', 'settings.json')
|
|
68
|
+
: join(env.XDG_CONFIG_HOME ?? join(home, '.config'), 'zed', 'settings.json'),
|
|
69
|
+
probes: [
|
|
70
|
+
join(env.XDG_CONFIG_HOME ?? join(home, '.config'), 'zed'),
|
|
71
|
+
join(appData, 'Zed'),
|
|
72
|
+
],
|
|
73
|
+
afterInstall: 'Zed reloads settings on save — the server appears in the agent panel.',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'gemini-cli',
|
|
77
|
+
label: 'Gemini CLI',
|
|
78
|
+
shape: 'mcpServers',
|
|
79
|
+
configPath: join(home, '.gemini', 'settings.json'),
|
|
80
|
+
probes: [join(home, '.gemini')],
|
|
81
|
+
afterInstall: 'Restart the Gemini CLI session.',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'cline',
|
|
85
|
+
label: 'Cline (VS Code)',
|
|
86
|
+
shape: 'mcpServers',
|
|
87
|
+
configPath: join(vscodeUserDir(env), 'globalStorage', 'saoudrizwan.claude-dev', 'settings', 'cline_mcp_settings.json'),
|
|
88
|
+
probes: [join(vscodeUserDir(env), 'globalStorage', 'saoudrizwan.claude-dev')],
|
|
89
|
+
afterInstall: 'Open the Cline panel, MCP Servers — it reconnects without a VS Code restart.',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
id: 'vscode',
|
|
93
|
+
label: 'VS Code (native MCP)',
|
|
94
|
+
shape: 'vscode_servers',
|
|
95
|
+
configPath: join(vscodeUserDir(env), 'mcp.json'),
|
|
96
|
+
probes: [vscodeUserDir(env)],
|
|
97
|
+
optIn: true,
|
|
98
|
+
note: 'The DebugAI VS Code extension already registers this server (VS Code 1.101+), '
|
|
99
|
+
+ 'plus one-click fix apply and proactive scan. Only install here if you do not want the extension.',
|
|
100
|
+
afterInstall: 'Run "MCP: List Servers" from the command palette to confirm.',
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
export function findClient(id, env = process.env) {
|
|
105
|
+
return knownClients(env).find((c) => c.id === id.toLowerCase());
|
|
106
|
+
}
|
|
107
|
+
/** A client counts as present when its config file OR its app directory exists. */
|
|
108
|
+
export function isDetected(client) {
|
|
109
|
+
if (client.configPath && existsSync(client.configPath))
|
|
110
|
+
return true;
|
|
111
|
+
return client.probes.some((p) => existsSync(p));
|
|
112
|
+
}
|
|
113
|
+
export function detectedClients(env = process.env) {
|
|
114
|
+
return knownClients(env).filter(isDetected);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The server entry itself. `npx -y` is deliberate: it self-updates on each
|
|
118
|
+
* launch and needs no global install, which is the only variant that works
|
|
119
|
+
* identically on a laptop, a devcontainer, and CI.
|
|
120
|
+
*
|
|
121
|
+
* No `env` block, ever — the key lives in ~/.debugai/config.json (see
|
|
122
|
+
* config.ts). Client configs get committed to repos; keys should not.
|
|
123
|
+
*/
|
|
124
|
+
export function serverEntry(client) {
|
|
125
|
+
const command = 'npx';
|
|
126
|
+
const args = ['-y', '@debugai/mcp'];
|
|
127
|
+
if (client.shape === 'context_servers') {
|
|
128
|
+
return { source: 'custom', command: { path: command, args } };
|
|
129
|
+
}
|
|
130
|
+
if (client.shape === 'vscode_servers') {
|
|
131
|
+
return { type: 'stdio', command, args };
|
|
132
|
+
}
|
|
133
|
+
return { command, args };
|
|
134
|
+
}
|
|
135
|
+
/** Top-level key in that client's config file where servers are listed. */
|
|
136
|
+
export function serversKey(shape) {
|
|
137
|
+
if (shape === 'context_servers')
|
|
138
|
+
return 'context_servers';
|
|
139
|
+
if (shape === 'vscode_servers')
|
|
140
|
+
return 'servers';
|
|
141
|
+
return 'mcpServers';
|
|
142
|
+
}
|
|
143
|
+
export const SERVER_NAME = 'debugai';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function cmdLogin(argv: string[], env?: NodeJS.ProcessEnv): Promise<number>;
|
|
2
|
+
export declare function cmdLogout(_argv: string[], env?: NodeJS.ProcessEnv): number;
|
|
3
|
+
export declare function cmdStatus(_argv: string[], env?: NodeJS.ProcessEnv): Promise<number>;
|
|
4
|
+
export declare function cmdInstall(argv: string[], env?: NodeJS.ProcessEnv): number;
|
|
5
|
+
export declare function cmdUninstall(argv: string[], env?: NodeJS.ProcessEnv): number;
|
|
6
|
+
export declare function cmdDoctor(_argv: string[], env?: NodeJS.ProcessEnv): Promise<number>;
|
|
7
|
+
export declare function cmdSetup(argv: string[], env?: NodeJS.ProcessEnv): Promise<number>;
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The subcommands. Every one of these exists to delete a step a human used
|
|
2
|
+
// to do by hand:
|
|
3
|
+
//
|
|
4
|
+
// setup login + install, the only command the README leads with
|
|
5
|
+
// login device link — replaces "copy your key out of the dashboard"
|
|
6
|
+
// install writes client configs — replaces "paste this JSON blob"
|
|
7
|
+
// doctor one command that answers "why isn't it working"
|
|
8
|
+
// status what account/key is active right now
|
|
9
|
+
// logout removes the stored key
|
|
10
|
+
// uninstall removes the server entry from client configs
|
|
11
|
+
//
|
|
12
|
+
// Each returns a process exit code. Nothing here ever writes to stdout while
|
|
13
|
+
// the MCP transport is live — subcommands exit before a server is created.
|
|
14
|
+
import { statSync } from 'node:fs';
|
|
15
|
+
import { hostname, platform } from 'node:os';
|
|
16
|
+
import { clearStoredKey, configPath, loadFileConfig, maskKey, resolveSettings, writeFileConfig, } from '../config.js';
|
|
17
|
+
import { DeviceLinkError, startDeviceLink, waitForDeviceLink } from '../deviceLink.js';
|
|
18
|
+
import { DEFAULT_API_BASE } from '../constants.js';
|
|
19
|
+
import { detectedClients, findClient, isDetected, knownClients, } from './clients.js';
|
|
20
|
+
import { applyToClient, isInstalled } from './install.js';
|
|
21
|
+
import { FAIL, INFO, OK, WARN, bold, codeBox, dim, heading, openBrowser, say, yellow } from './ui.js';
|
|
22
|
+
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
23
|
+
function flag(argv, name) {
|
|
24
|
+
return argv.includes(`--${name}`);
|
|
25
|
+
}
|
|
26
|
+
function flagValue(argv, name) {
|
|
27
|
+
const eq = argv.find((a) => a.startsWith(`--${name}=`));
|
|
28
|
+
if (eq)
|
|
29
|
+
return eq.slice(name.length + 3);
|
|
30
|
+
const idx = argv.indexOf(`--${name}`);
|
|
31
|
+
if (idx >= 0 && argv[idx + 1] && !argv[idx + 1].startsWith('-'))
|
|
32
|
+
return argv[idx + 1];
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Confirms a key actually works against the live API. Null = could not verify. */
|
|
36
|
+
async function verifyKey(apiBase, apiKey) {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), 12_000);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${apiBase}/user/me`, {
|
|
41
|
+
headers: { 'x-api-key': apiKey },
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok)
|
|
45
|
+
return null;
|
|
46
|
+
const body = await res.json();
|
|
47
|
+
const user = body?.user ?? body ?? {};
|
|
48
|
+
return {
|
|
49
|
+
email: user.email,
|
|
50
|
+
tier: user.tier,
|
|
51
|
+
usedToday: user.usage_today ?? user.used_today ?? user.daily_usage,
|
|
52
|
+
dailyLimit: user.daily_limit ?? user.limit,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function clientLabel() {
|
|
63
|
+
return `${hostname()} (${platform()})`;
|
|
64
|
+
}
|
|
65
|
+
function describeResult(r) {
|
|
66
|
+
const name = bold(r.client.label);
|
|
67
|
+
switch (r.action) {
|
|
68
|
+
case 'wrote':
|
|
69
|
+
case 'updated':
|
|
70
|
+
say(` ${OK()} ${name} — ${r.action === 'wrote' ? 'added to' : 'updated in'} ${dim(r.path ?? '')}`);
|
|
71
|
+
if (r.backupPath)
|
|
72
|
+
say(` ${dim(`backup: ${r.backupPath}`)}`);
|
|
73
|
+
if (r.warning)
|
|
74
|
+
say(` ${WARN()} ${yellow(r.warning)}`);
|
|
75
|
+
say(` ${dim(`next: ${r.client.afterInstall}`)}`);
|
|
76
|
+
break;
|
|
77
|
+
case 'unchanged':
|
|
78
|
+
say(` ${OK()} ${name} — already configured, nothing to change`);
|
|
79
|
+
break;
|
|
80
|
+
case 'removed':
|
|
81
|
+
say(` ${OK()} ${name} — entry removed from ${dim(r.path ?? '')}`);
|
|
82
|
+
if (r.backupPath)
|
|
83
|
+
say(` ${dim(`backup: ${r.backupPath}`)}`);
|
|
84
|
+
break;
|
|
85
|
+
case 'skipped':
|
|
86
|
+
say(` ${INFO()} ${name} — skipped${r.warning ? `: ${r.warning}` : ''}`);
|
|
87
|
+
break;
|
|
88
|
+
case 'failed':
|
|
89
|
+
say(` ${FAIL()} ${name} — ${r.error ?? 'failed'}`);
|
|
90
|
+
say(` ${dim(`file: ${r.path ?? '(unknown)'}`)}`);
|
|
91
|
+
if (r.preview) {
|
|
92
|
+
say(` ${dim('merge this in by hand:')}`);
|
|
93
|
+
r.preview.split('\n').forEach((l) => say(` ${dim(l)}`));
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ── login ────────────────────────────────────────────────────────────────────
|
|
99
|
+
export async function cmdLogin(argv, env = process.env) {
|
|
100
|
+
const { apiBase } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
101
|
+
const manualKey = flagValue(argv, 'key');
|
|
102
|
+
// Escape hatch for CI, air-gapped machines, and anyone who would rather
|
|
103
|
+
// paste. Same storage path, so everything downstream behaves identically.
|
|
104
|
+
if (manualKey) {
|
|
105
|
+
if (!manualKey.startsWith('dbg_')) {
|
|
106
|
+
say(`${FAIL()} That does not look like a DebugAI key. They start with ${bold('dbg_')}.`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
const path = writeFileConfig({ apiKey: manualKey }, env);
|
|
110
|
+
const info = await verifyKey(apiBase, manualKey);
|
|
111
|
+
say(`${OK()} Key stored in ${path}${info?.email ? ` for ${bold(info.email)}` : ''}.`);
|
|
112
|
+
if (!info)
|
|
113
|
+
say(`${WARN()} ${yellow('Could not verify it against the API just now. Run "debugai-mcp doctor" later.')}`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
const existing = loadFileConfig(env, () => { }).apiKey;
|
|
117
|
+
if (existing && !flag(argv, 'force')) {
|
|
118
|
+
const info = await verifyKey(apiBase, existing);
|
|
119
|
+
if (info) {
|
|
120
|
+
say(`${OK()} Already signed in as ${bold(info.email ?? 'this account')} (${info.tier ?? 'free'} tier).`);
|
|
121
|
+
say(` ${dim(`Key ${maskKey(existing)} in ${configPath(env)}. Re-link with "debugai-mcp login --force".`)}`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
say(`${WARN()} A stored key exists but the API rejected it. Re-linking.`);
|
|
125
|
+
}
|
|
126
|
+
let start;
|
|
127
|
+
try {
|
|
128
|
+
start = await startDeviceLink({ apiBase, clientLabel: clientLabel() });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const detail = err instanceof DeviceLinkError ? err.message : String(err);
|
|
132
|
+
say(`${FAIL()} Could not start the sign-in link: ${detail}`);
|
|
133
|
+
say(` ${dim('Fallback: grab a key at https://debugai.io/dashboard and run')}`);
|
|
134
|
+
say(` ${dim('debugai-mcp login --key dbg_your_key')}`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
say();
|
|
138
|
+
say(bold('Sign in to DebugAI'));
|
|
139
|
+
say(codeBox(start.userCode));
|
|
140
|
+
say(` Confirm that code at ${bold(start.verificationUriComplete)}`);
|
|
141
|
+
const opened = openBrowser(start.verificationUriComplete);
|
|
142
|
+
say(opened
|
|
143
|
+
? dim(' (opening your browser. free account, 10 debugs/day, no card)')
|
|
144
|
+
: dim(' (open that link on any device. free account, 10 debugs/day, no card)'));
|
|
145
|
+
say();
|
|
146
|
+
say(dim(` Waiting… the code expires in ${Math.round(start.expiresIn / 60)} minutes. Ctrl-C to cancel.`));
|
|
147
|
+
const result = await waitForDeviceLink(start, { apiBase });
|
|
148
|
+
if (result.status === 'linked') {
|
|
149
|
+
const path = writeFileConfig({ apiKey: result.apiKey }, env);
|
|
150
|
+
say();
|
|
151
|
+
say(`${OK()} Signed in${result.email ? ` as ${bold(result.email)}` : ''}${result.tier ? ` (${result.tier} tier)` : ''}.`);
|
|
152
|
+
say(` ${dim(`Key saved to ${path}. Every MCP client on this machine reads it.`)}`);
|
|
153
|
+
say();
|
|
154
|
+
say(` Next: ${bold('npx -y @debugai/mcp install')} ${dim('(wires up the MCP clients you have)')}`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
say();
|
|
158
|
+
if (result.status === 'denied')
|
|
159
|
+
say(`${FAIL()} Sign-in was declined in the browser.`);
|
|
160
|
+
else if (result.status === 'expired')
|
|
161
|
+
say(`${FAIL()} The code expired. Run "debugai-mcp login" again.`);
|
|
162
|
+
else
|
|
163
|
+
say(`${FAIL()} Sign-in did not complete.`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
// ── logout / status ──────────────────────────────────────────────────────────
|
|
167
|
+
export function cmdLogout(_argv, env = process.env) {
|
|
168
|
+
const removed = clearStoredKey(env);
|
|
169
|
+
say(removed
|
|
170
|
+
? `${OK()} Stored key removed from ${configPath(env)}.`
|
|
171
|
+
: `${INFO()} No stored key to remove (${configPath(env)}).`);
|
|
172
|
+
if ((env.DEBUGAI_API_KEY ?? '').trim()) {
|
|
173
|
+
say(`${WARN()} ${yellow('DEBUGAI_API_KEY is still set in this environment and takes priority over the file.')}`);
|
|
174
|
+
}
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
export async function cmdStatus(_argv, env = process.env) {
|
|
178
|
+
const { apiKey, apiBase, keySource } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
179
|
+
say(`${bold('DebugAI MCP status')}`);
|
|
180
|
+
say(` API base ${apiBase}`);
|
|
181
|
+
say(` Key ${apiKey ? `${maskKey(apiKey)} (from ${keySource === 'env' ? 'DEBUGAI_API_KEY' : configPath(env)})` : dim('none')}`);
|
|
182
|
+
if (!apiKey) {
|
|
183
|
+
say();
|
|
184
|
+
say(` Run ${bold('npx -y @debugai/mcp login')} to sign in.`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
const info = await verifyKey(apiBase, apiKey);
|
|
188
|
+
if (!info) {
|
|
189
|
+
say(` Account ${FAIL()} key rejected or API unreachable`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
say(` Account ${info.email ?? '(unknown)'} · ${info.tier ?? 'free'} tier`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
// ── install / uninstall ──────────────────────────────────────────────────────
|
|
196
|
+
function selectClients(argv, env) {
|
|
197
|
+
const raw = flagValue(argv, 'client');
|
|
198
|
+
if (raw) {
|
|
199
|
+
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
200
|
+
const clients = [];
|
|
201
|
+
for (const id of ids) {
|
|
202
|
+
const found = findClient(id, env);
|
|
203
|
+
if (found)
|
|
204
|
+
clients.push(found);
|
|
205
|
+
else
|
|
206
|
+
say(`${WARN()} Unknown client "${id}". Run "debugai-mcp install --list" to see the names.`);
|
|
207
|
+
}
|
|
208
|
+
return { clients, explicit: true };
|
|
209
|
+
}
|
|
210
|
+
if (flag(argv, 'all'))
|
|
211
|
+
return { clients: knownClients(env).filter((c) => !c.optIn), explicit: false };
|
|
212
|
+
return { clients: detectedClients(env).filter((c) => !c.optIn), explicit: false };
|
|
213
|
+
}
|
|
214
|
+
function listClients(env) {
|
|
215
|
+
heading('Known MCP clients');
|
|
216
|
+
for (const c of knownClients(env)) {
|
|
217
|
+
const mark = isDetected(c) ? OK() : INFO();
|
|
218
|
+
const state = isDetected(c) ? (isInstalled(c) ? 'detected · debugai configured' : 'detected') : 'not found';
|
|
219
|
+
say(` ${mark} ${bold(c.id.padEnd(15))} ${c.label.padEnd(22)} ${dim(state)}`);
|
|
220
|
+
say(` ${dim(c.configPath ?? 'no config path on this OS')}`);
|
|
221
|
+
if (c.note)
|
|
222
|
+
say(` ${dim(c.note)}`);
|
|
223
|
+
}
|
|
224
|
+
say();
|
|
225
|
+
say(dim(' Install one explicitly: debugai-mcp install --client=cursor'));
|
|
226
|
+
}
|
|
227
|
+
export function cmdInstall(argv, env = process.env) {
|
|
228
|
+
if (flag(argv, 'list')) {
|
|
229
|
+
listClients(env);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
const dryRun = flag(argv, 'dry-run') || flag(argv, 'print');
|
|
233
|
+
const remove = flag(argv, 'remove');
|
|
234
|
+
const { clients, explicit } = selectClients(argv, env);
|
|
235
|
+
if (!clients.length) {
|
|
236
|
+
say(`${WARN()} No MCP clients detected on this machine.`);
|
|
237
|
+
say();
|
|
238
|
+
listClients(env);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
heading(remove ? 'Removing DebugAI from MCP clients' : 'Installing DebugAI into MCP clients');
|
|
242
|
+
const results = clients.map((c) => applyToClient(c, { dryRun, remove }));
|
|
243
|
+
results.forEach(describeResult);
|
|
244
|
+
if (dryRun) {
|
|
245
|
+
for (const r of results.filter((x) => x.preview)) {
|
|
246
|
+
heading(`${r.client.label} — ${r.path}`);
|
|
247
|
+
say(r.preview.trimEnd());
|
|
248
|
+
}
|
|
249
|
+
say();
|
|
250
|
+
say(dim(' Dry run — nothing was written.'));
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
const failed = results.filter((r) => r.action === 'failed').length;
|
|
254
|
+
const changed = results.filter((r) => r.action === 'wrote' || r.action === 'updated' || r.action === 'removed').length;
|
|
255
|
+
say();
|
|
256
|
+
if (!remove) {
|
|
257
|
+
const { apiKey } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
258
|
+
if (!apiKey) {
|
|
259
|
+
say(`${WARN()} ${yellow('No API key stored yet — run')} ${bold('npx -y @debugai/mcp login')} ${yellow('to finish.')}`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
say(`${OK()} Key already stored — ${changed ? 'restart the clients above and you are done.' : 'nothing left to do.'}`);
|
|
263
|
+
}
|
|
264
|
+
if (!explicit)
|
|
265
|
+
say(dim(' Missing a client? "debugai-mcp install --list" shows every name.'));
|
|
266
|
+
}
|
|
267
|
+
return failed ? 1 : 0;
|
|
268
|
+
}
|
|
269
|
+
export function cmdUninstall(argv, env = process.env) {
|
|
270
|
+
return cmdInstall([...argv, '--remove', '--all'], env);
|
|
271
|
+
}
|
|
272
|
+
// ── doctor ───────────────────────────────────────────────────────────────────
|
|
273
|
+
export async function cmdDoctor(_argv, env = process.env) {
|
|
274
|
+
let hardFailures = 0;
|
|
275
|
+
const fail = (msg, hint) => {
|
|
276
|
+
hardFailures++;
|
|
277
|
+
say(` ${FAIL()} ${msg}`);
|
|
278
|
+
if (hint)
|
|
279
|
+
say(` ${dim(hint)}`);
|
|
280
|
+
};
|
|
281
|
+
const pass = (msg, detail) => {
|
|
282
|
+
say(` ${OK()} ${msg}`);
|
|
283
|
+
if (detail)
|
|
284
|
+
say(` ${dim(detail)}`);
|
|
285
|
+
};
|
|
286
|
+
heading('Runtime');
|
|
287
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
288
|
+
if (major >= 18)
|
|
289
|
+
pass(`Node ${process.versions.node}`);
|
|
290
|
+
else
|
|
291
|
+
fail(`Node ${process.versions.node} is too old`, 'DebugAI MCP needs Node 18 or newer (it uses global fetch).');
|
|
292
|
+
heading('Account');
|
|
293
|
+
const { apiKey, apiBase, keySource } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
294
|
+
if (!apiKey) {
|
|
295
|
+
fail('No API key found', 'Run: npx -y @debugai/mcp login');
|
|
296
|
+
}
|
|
297
|
+
else if (!apiKey.startsWith('dbg_')) {
|
|
298
|
+
fail(`Key does not look like a DebugAI key (${maskKey(apiKey)})`, 'DebugAI keys start with dbg_.');
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
pass(`Key ${maskKey(apiKey)}`, `source: ${keySource === 'env' ? 'DEBUGAI_API_KEY env var' : configPath(env)}`);
|
|
302
|
+
}
|
|
303
|
+
if (keySource === 'file') {
|
|
304
|
+
try {
|
|
305
|
+
const mode = statSync(configPath(env)).mode & 0o777;
|
|
306
|
+
if (platform() !== 'win32' && (mode & 0o077) !== 0) {
|
|
307
|
+
say(` ${WARN()} ${yellow(`Config file is readable by other users (mode ${mode.toString(8)})`)}`);
|
|
308
|
+
say(` ${dim(`fix: chmod 600 ${configPath(env)}`)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch { /* file vanished between calls — the key check above already covered it */ }
|
|
312
|
+
}
|
|
313
|
+
heading('API');
|
|
314
|
+
if (!apiKey) {
|
|
315
|
+
say(` ${INFO()} Skipped — no key to test with.`);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const info = await verifyKey(apiBase, apiKey);
|
|
319
|
+
if (info) {
|
|
320
|
+
pass(`${apiBase} reachable`, `${info.email ?? 'account'} · ${info.tier ?? 'free'} tier`);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
fail(`Could not authenticate against ${apiBase}`, 'Either the key was rotated (run: debugai-mcp login --force) or the API is unreachable from here.');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
heading('MCP clients');
|
|
327
|
+
const detected = detectedClients(env);
|
|
328
|
+
if (!detected.length) {
|
|
329
|
+
say(` ${INFO()} None detected. "debugai-mcp install --list" shows every supported client.`);
|
|
330
|
+
}
|
|
331
|
+
for (const c of detected) {
|
|
332
|
+
if (isInstalled(c))
|
|
333
|
+
pass(`${c.label} — debugai configured`, c.configPath ?? undefined);
|
|
334
|
+
else
|
|
335
|
+
say(` ${WARN()} ${yellow(`${c.label} — installed but DebugAI is not in its config`)}\n ${dim(`fix: debugai-mcp install --client=${c.id}`)}`);
|
|
336
|
+
}
|
|
337
|
+
say();
|
|
338
|
+
if (hardFailures) {
|
|
339
|
+
say(`${FAIL()} ${hardFailures} problem${hardFailures === 1 ? '' : 's'} to fix.`);
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
say(`${OK()} Everything checks out.`);
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
// ── setup (the headline command) ─────────────────────────────────────────────
|
|
346
|
+
export async function cmdSetup(argv, env = process.env) {
|
|
347
|
+
const { apiKey } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
348
|
+
if (!apiKey || flag(argv, 'force')) {
|
|
349
|
+
const code = await cmdLogin(argv, env);
|
|
350
|
+
if (code !== 0)
|
|
351
|
+
return code;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
say(`${OK()} Already signed in — skipping login (use --force to re-link).`);
|
|
355
|
+
}
|
|
356
|
+
const installCode = cmdInstall(argv.filter((a) => a !== '--force'), env);
|
|
357
|
+
if (installCode !== 0)
|
|
358
|
+
return installCode;
|
|
359
|
+
return cmdDoctor([], env);
|
|
360
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type McpClient } from './clients.js';
|
|
2
|
+
export type InstallAction = 'wrote' | 'updated' | 'unchanged' | 'removed' | 'skipped' | 'failed';
|
|
3
|
+
export interface InstallResult {
|
|
4
|
+
client: McpClient;
|
|
5
|
+
action: InstallAction;
|
|
6
|
+
path: string | null;
|
|
7
|
+
backupPath?: string;
|
|
8
|
+
/** Non-fatal thing the user must know (dropped comments, unsupported OS). */
|
|
9
|
+
warning?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
/** The exact JSON we would write — used by --dry-run and by failure output. */
|
|
12
|
+
preview?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface InstallOptions {
|
|
15
|
+
dryRun?: boolean;
|
|
16
|
+
/** Remove the debugai entry instead of adding it. */
|
|
17
|
+
remove?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function applyToClient(client: McpClient, opts?: InstallOptions): InstallResult;
|
|
20
|
+
/** True when the client's config already points at this server. */
|
|
21
|
+
export declare function isInstalled(client: McpClient): boolean;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Writes the DebugAI server into MCP client config files.
|
|
2
|
+
//
|
|
3
|
+
// Rules this code refuses to break, because it is editing files a user's
|
|
4
|
+
// whole editor setup depends on:
|
|
5
|
+
// - never overwrite the file wholesale — parse, touch only our own key,
|
|
6
|
+
// write everything else back untouched
|
|
7
|
+
// - back up before the first modification, always, with the path printed
|
|
8
|
+
// - write to a temp file in the same directory and rename over the target,
|
|
9
|
+
// so a crash mid-write cannot leave a truncated config behind
|
|
10
|
+
// - a file we cannot parse is left ALONE and reported, never "fixed"
|
|
11
|
+
// - running twice is a no-op ("unchanged"), never a duplicate entry
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
import { parseJsonc } from './jsonc.js';
|
|
15
|
+
import { SERVER_NAME, serverEntry, serversKey } from './clients.js';
|
|
16
|
+
function backupPath(path) {
|
|
17
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
18
|
+
return `${path}.debugai-backup-${stamp}`;
|
|
19
|
+
}
|
|
20
|
+
function writeAtomic(path, contents) {
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
23
|
+
writeFileSync(tmp, contents, 'utf8');
|
|
24
|
+
try {
|
|
25
|
+
renameSync(tmp, path);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
try {
|
|
29
|
+
unlinkSync(tmp);
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore */ }
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function sameEntry(a, b) {
|
|
36
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
37
|
+
}
|
|
38
|
+
export function applyToClient(client, opts = {}) {
|
|
39
|
+
const path = client.configPath;
|
|
40
|
+
if (!path) {
|
|
41
|
+
return {
|
|
42
|
+
client,
|
|
43
|
+
action: 'skipped',
|
|
44
|
+
path: null,
|
|
45
|
+
warning: `${client.label} has no known config location on this operating system.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const key = serversKey(client.shape);
|
|
49
|
+
const desired = serverEntry(client);
|
|
50
|
+
let root = {};
|
|
51
|
+
let hadComments = false;
|
|
52
|
+
const existed = existsSync(path);
|
|
53
|
+
if (existed) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync(path, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { client, action: 'failed', path, error: `could not read: ${err.message}` };
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const parsed = parseJsonc(raw);
|
|
63
|
+
hadComments = parsed.hadComments;
|
|
64
|
+
if (typeof parsed.value !== 'object' || parsed.value === null || Array.isArray(parsed.value)) {
|
|
65
|
+
throw new Error('top level is not a JSON object');
|
|
66
|
+
}
|
|
67
|
+
root = parsed.value;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
// Do NOT rewrite a file we failed to understand — that is how people
|
|
71
|
+
// lose their editor settings. Report it and hand back the snippet.
|
|
72
|
+
return {
|
|
73
|
+
client,
|
|
74
|
+
action: 'failed',
|
|
75
|
+
path,
|
|
76
|
+
error: `could not parse (${err.message}) — left untouched`,
|
|
77
|
+
preview: JSON.stringify({ [key]: { [SERVER_NAME]: desired } }, null, 2),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (opts.remove) {
|
|
82
|
+
return { client, action: 'unchanged', path };
|
|
83
|
+
}
|
|
84
|
+
const existingServers = root[key];
|
|
85
|
+
const servers = typeof existingServers === 'object' && existingServers !== null && !Array.isArray(existingServers)
|
|
86
|
+
? { ...existingServers }
|
|
87
|
+
: {};
|
|
88
|
+
if (opts.remove) {
|
|
89
|
+
if (!(SERVER_NAME in servers))
|
|
90
|
+
return { client, action: 'unchanged', path };
|
|
91
|
+
delete servers[SERVER_NAME];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
if (sameEntry(servers[SERVER_NAME], desired))
|
|
95
|
+
return { client, action: 'unchanged', path };
|
|
96
|
+
servers[SERVER_NAME] = desired;
|
|
97
|
+
}
|
|
98
|
+
const nextRoot = { ...root, [key]: servers };
|
|
99
|
+
const contents = `${JSON.stringify(nextRoot, null, 2)}\n`;
|
|
100
|
+
const warning = hadComments && !opts.dryRun
|
|
101
|
+
? 'this file had comments; JSON does not keep them, so they were dropped (the backup above still has them)'
|
|
102
|
+
: undefined;
|
|
103
|
+
if (opts.dryRun) {
|
|
104
|
+
return {
|
|
105
|
+
client,
|
|
106
|
+
action: opts.remove ? 'removed' : existed ? 'updated' : 'wrote',
|
|
107
|
+
path,
|
|
108
|
+
preview: contents,
|
|
109
|
+
warning: hadComments ? 'this file has comments; installing would drop them (a backup is written first)' : undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
let backup;
|
|
113
|
+
try {
|
|
114
|
+
if (existed) {
|
|
115
|
+
backup = backupPath(path);
|
|
116
|
+
copyFileSync(path, backup);
|
|
117
|
+
}
|
|
118
|
+
writeAtomic(path, contents);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return {
|
|
122
|
+
client,
|
|
123
|
+
action: 'failed',
|
|
124
|
+
path,
|
|
125
|
+
backupPath: backup,
|
|
126
|
+
error: err.message,
|
|
127
|
+
preview: JSON.stringify({ [key]: { [SERVER_NAME]: desired } }, null, 2),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
client,
|
|
132
|
+
action: opts.remove ? 'removed' : existed ? 'updated' : 'wrote',
|
|
133
|
+
path,
|
|
134
|
+
backupPath: backup,
|
|
135
|
+
warning,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** True when the client's config already points at this server. */
|
|
139
|
+
export function isInstalled(client) {
|
|
140
|
+
if (!client.configPath || !existsSync(client.configPath))
|
|
141
|
+
return false;
|
|
142
|
+
try {
|
|
143
|
+
const parsed = parseJsonc(readFileSync(client.configPath, 'utf8'));
|
|
144
|
+
const servers = parsed.value?.[serversKey(client.shape)];
|
|
145
|
+
return Boolean(servers && typeof servers === 'object' && SERVER_NAME in servers);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|