@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,12 @@
|
|
|
1
|
+
export interface ParsedJsonc {
|
|
2
|
+
value: unknown;
|
|
3
|
+
hadComments: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function stripJsonComments(text: string): {
|
|
6
|
+
out: string;
|
|
7
|
+
hadComments: boolean;
|
|
8
|
+
};
|
|
9
|
+
/** Removes trailing commas before } or ] — legal in JSONC, fatal to JSON.parse. */
|
|
10
|
+
export declare function stripTrailingCommas(text: string): string;
|
|
11
|
+
/** Parses JSON or JSONC. Throws the underlying SyntaxError on real malformed input. */
|
|
12
|
+
export declare function parseJsonc(text: string): ParsedJsonc;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Minimal JSONC tolerance for editor config files.
|
|
2
|
+
//
|
|
3
|
+
// Zed's settings.json and VS Code's mcp.json ship WITH comments and often
|
|
4
|
+
// carry trailing commas. `JSON.parse` throws on both, and a naive regex
|
|
5
|
+
// stripper corrupts any string containing "//" — e.g. every URL in the file.
|
|
6
|
+
// So this walks the text character by character with a string-state machine.
|
|
7
|
+
//
|
|
8
|
+
// Round-tripping comments is out of scope: we detect them (`hadComments`) so
|
|
9
|
+
// the caller can warn the user and back the file up before rewriting.
|
|
10
|
+
export function stripJsonComments(text) {
|
|
11
|
+
let out = '';
|
|
12
|
+
let hadComments = false;
|
|
13
|
+
let i = 0;
|
|
14
|
+
let inString = false;
|
|
15
|
+
let inLineComment = false;
|
|
16
|
+
let inBlockComment = false;
|
|
17
|
+
while (i < text.length) {
|
|
18
|
+
const ch = text[i];
|
|
19
|
+
const next = text[i + 1];
|
|
20
|
+
if (inLineComment) {
|
|
21
|
+
if (ch === '\n') {
|
|
22
|
+
inLineComment = false;
|
|
23
|
+
out += ch;
|
|
24
|
+
}
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (inBlockComment) {
|
|
29
|
+
if (ch === '*' && next === '/') {
|
|
30
|
+
inBlockComment = false;
|
|
31
|
+
i += 2;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === '\n')
|
|
35
|
+
out += ch; // keep line numbers honest for error messages
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (inString) {
|
|
40
|
+
out += ch;
|
|
41
|
+
if (ch === '\\') {
|
|
42
|
+
out += next ?? '';
|
|
43
|
+
i += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '"')
|
|
47
|
+
inString = false;
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === '"') {
|
|
52
|
+
inString = true;
|
|
53
|
+
out += ch;
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === '/' && next === '/') {
|
|
58
|
+
inLineComment = true;
|
|
59
|
+
hadComments = true;
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === '/' && next === '*') {
|
|
64
|
+
inBlockComment = true;
|
|
65
|
+
hadComments = true;
|
|
66
|
+
i += 2;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
out += ch;
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
return { out, hadComments };
|
|
73
|
+
}
|
|
74
|
+
/** Removes trailing commas before } or ] — legal in JSONC, fatal to JSON.parse. */
|
|
75
|
+
export function stripTrailingCommas(text) {
|
|
76
|
+
let out = '';
|
|
77
|
+
let inString = false;
|
|
78
|
+
for (let i = 0; i < text.length; i++) {
|
|
79
|
+
const ch = text[i];
|
|
80
|
+
if (inString) {
|
|
81
|
+
out += ch;
|
|
82
|
+
if (ch === '\\') {
|
|
83
|
+
out += text[i + 1] ?? '';
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === '"')
|
|
88
|
+
inString = false;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === '"') {
|
|
92
|
+
inString = true;
|
|
93
|
+
out += ch;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === ',') {
|
|
97
|
+
// Look ahead past whitespace for a closer.
|
|
98
|
+
let j = i + 1;
|
|
99
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
100
|
+
j++;
|
|
101
|
+
if (text[j] === '}' || text[j] === ']')
|
|
102
|
+
continue; // drop this comma
|
|
103
|
+
}
|
|
104
|
+
out += ch;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** Parses JSON or JSONC. Throws the underlying SyntaxError on real malformed input. */
|
|
109
|
+
export function parseJsonc(text) {
|
|
110
|
+
const trimmed = text.trim();
|
|
111
|
+
if (trimmed === '')
|
|
112
|
+
return { value: {}, hadComments: false };
|
|
113
|
+
const { out, hadComments } = stripJsonComments(text);
|
|
114
|
+
return { value: JSON.parse(stripTrailingCommas(out)), hadComments };
|
|
115
|
+
}
|
package/dist/cli/ui.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const bold: (s: string) => string;
|
|
2
|
+
export declare const dim: (s: string) => string;
|
|
3
|
+
export declare const red: (s: string) => string;
|
|
4
|
+
export declare const green: (s: string) => string;
|
|
5
|
+
export declare const yellow: (s: string) => string;
|
|
6
|
+
export declare const OK: () => string;
|
|
7
|
+
export declare const FAIL: () => string;
|
|
8
|
+
export declare const WARN: () => string;
|
|
9
|
+
export declare const INFO: () => string;
|
|
10
|
+
export declare function say(line?: string): void;
|
|
11
|
+
export declare function heading(text: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* Opens a URL in the user's browser, best effort. Returns false when there is
|
|
14
|
+
* clearly no browser to open (headless Linux, CI) so the caller prints the URL
|
|
15
|
+
* instead of pretending something happened.
|
|
16
|
+
*/
|
|
17
|
+
export declare function openBrowser(url: string): boolean;
|
|
18
|
+
/** Big enough to read across a room, small enough to fit a narrow terminal. */
|
|
19
|
+
export declare function codeBox(code: string): string;
|
package/dist/cli/ui.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Terminal output helpers.
|
|
2
|
+
//
|
|
3
|
+
// Everything the CLI prints goes to STDOUT here — but note the MCP server
|
|
4
|
+
// itself prints only to stderr (stdout carries the protocol). The two never
|
|
5
|
+
// run at the same time: a subcommand exits before any transport is created.
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { platform } from 'node:os';
|
|
8
|
+
const useColor = process.stdout.isTTY === true &&
|
|
9
|
+
!process.env.NO_COLOR &&
|
|
10
|
+
process.env.TERM !== 'dumb';
|
|
11
|
+
const wrap = (code) => (s) => (useColor ? `[${code}m${s}[0m` : s);
|
|
12
|
+
export const bold = wrap('1');
|
|
13
|
+
export const dim = wrap('2');
|
|
14
|
+
export const red = wrap('31');
|
|
15
|
+
export const green = wrap('32');
|
|
16
|
+
export const yellow = wrap('33');
|
|
17
|
+
export const OK = () => green('✓');
|
|
18
|
+
export const FAIL = () => red('✗');
|
|
19
|
+
export const WARN = () => yellow('!');
|
|
20
|
+
export const INFO = () => dim('·');
|
|
21
|
+
export function say(line = '') {
|
|
22
|
+
process.stdout.write(`${line}\n`);
|
|
23
|
+
}
|
|
24
|
+
export function heading(text) {
|
|
25
|
+
say();
|
|
26
|
+
say(bold(text));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Opens a URL in the user's browser, best effort. Returns false when there is
|
|
30
|
+
* clearly no browser to open (headless Linux, CI) so the caller prints the URL
|
|
31
|
+
* instead of pretending something happened.
|
|
32
|
+
*/
|
|
33
|
+
export function openBrowser(url) {
|
|
34
|
+
const os = platform();
|
|
35
|
+
const headlessLinux = os === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY && !process.env.WSL_DISTRO_NAME;
|
|
36
|
+
if (process.env.CI || headlessLinux)
|
|
37
|
+
return false;
|
|
38
|
+
const [cmd, args] = os === 'darwin' ? ['open', [url]] :
|
|
39
|
+
os === 'win32' ? ['cmd', ['/c', 'start', '', url]] :
|
|
40
|
+
['xdg-open', [url]];
|
|
41
|
+
try {
|
|
42
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
43
|
+
child.on('error', () => { });
|
|
44
|
+
child.unref();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Big enough to read across a room, small enough to fit a narrow terminal. */
|
|
52
|
+
export function codeBox(code) {
|
|
53
|
+
const inner = ` ${code} `;
|
|
54
|
+
const rule = '─'.repeat(inner.length);
|
|
55
|
+
return [`┌${rule}┐`, `│${inner}│`, `└${rule}┘`].join('\n');
|
|
56
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -11,3 +11,13 @@ export interface ResolvedSettings {
|
|
|
11
11
|
export declare function configPath(env?: NodeJS.ProcessEnv): string;
|
|
12
12
|
export declare function loadFileConfig(env?: NodeJS.ProcessEnv, warn?: (msg: string) => void): FileConfig;
|
|
13
13
|
export declare function resolveSettings(defaultApiBase: string, env?: NodeJS.ProcessEnv, warn?: (msg: string) => void): ResolvedSettings;
|
|
14
|
+
/** Fields a write may touch. `undefined` leaves the existing value alone. */
|
|
15
|
+
export interface ConfigPatch {
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
apiBase?: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function writeFileConfig(patch: ConfigPatch, env?: NodeJS.ProcessEnv): string;
|
|
20
|
+
/** Removes the stored key, keeping any api_base override. True if a key was there. */
|
|
21
|
+
export declare function clearStoredKey(env?: NodeJS.ProcessEnv): boolean;
|
|
22
|
+
/** Keys are long secrets — never print more than their shape. */
|
|
23
|
+
export declare function maskKey(key: string): string;
|
package/dist/config.js
CHANGED
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
// Environment variables always win over the file. DEBUGAI_CONFIG_PATH
|
|
7
7
|
// overrides the file location (tests point it at a temp dir; users normally
|
|
8
8
|
// never set it).
|
|
9
|
-
|
|
9
|
+
//
|
|
10
|
+
// This file is the ONLY place the API key is ever written. `debugai-mcp
|
|
11
|
+
// install` deliberately does not put the key into any MCP client config:
|
|
12
|
+
// one secret, one file, 0600 — rotating or revoking is a single edit, and a
|
|
13
|
+
// shared or committed client config never carries a live key.
|
|
14
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
10
15
|
import { homedir } from 'node:os';
|
|
11
|
-
import { join } from 'node:path';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
12
17
|
export function configPath(env = process.env) {
|
|
13
18
|
const override = (env.DEBUGAI_CONFIG_PATH ?? '').trim();
|
|
14
19
|
return override || join(homedir(), '.debugai', 'config.json');
|
|
@@ -46,3 +51,50 @@ export function resolveSettings(defaultApiBase, env = process.env, warn = (msg)
|
|
|
46
51
|
const apiBase = (envBase || file.apiBase || defaultApiBase).replace(/\/+$/, '');
|
|
47
52
|
return { apiKey, apiBase, keySource };
|
|
48
53
|
}
|
|
54
|
+
export function writeFileConfig(patch, env = process.env) {
|
|
55
|
+
const path = configPath(env);
|
|
56
|
+
const existing = loadFileConfig(env, () => { }); // corrupt file → start clean
|
|
57
|
+
const next = {};
|
|
58
|
+
const apiKey = patch.apiKey !== undefined ? patch.apiKey : existing.apiKey;
|
|
59
|
+
const apiBase = patch.apiBase !== undefined ? patch.apiBase : existing.apiBase;
|
|
60
|
+
if (apiKey)
|
|
61
|
+
next.api_key = apiKey;
|
|
62
|
+
if (apiBase)
|
|
63
|
+
next.api_base = apiBase;
|
|
64
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
65
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
66
|
+
writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
67
|
+
try {
|
|
68
|
+
chmodSync(tmp, 0o600); // umask can defeat the mode passed to writeFileSync
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* non-POSIX filesystem — best effort */
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
renameSync(tmp, path);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(tmp);
|
|
79
|
+
}
|
|
80
|
+
catch { /* ignore */ }
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
/** Removes the stored key, keeping any api_base override. True if a key was there. */
|
|
86
|
+
export function clearStoredKey(env = process.env) {
|
|
87
|
+
const existing = loadFileConfig(env, () => { });
|
|
88
|
+
if (!existing.apiKey)
|
|
89
|
+
return false;
|
|
90
|
+
writeFileConfig({ apiKey: '' }, env);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/** Keys are long secrets — never print more than their shape. */
|
|
94
|
+
export function maskKey(key) {
|
|
95
|
+
if (!key)
|
|
96
|
+
return '(none)';
|
|
97
|
+
if (key.length <= 12)
|
|
98
|
+
return `${key.slice(0, 4)}…`;
|
|
99
|
+
return `${key.slice(0, 8)}…${key.slice(-4)}`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Shared endpoints. The API base is overridable per-user (DEBUGAI_API_BASE or
|
|
2
|
+
// api_base in the config file) for self-hosted and staging setups; these are
|
|
3
|
+
// only the defaults.
|
|
4
|
+
export const DEFAULT_API_BASE = 'https://debugai-mvp-production.up.railway.app/api';
|
|
5
|
+
export const WEB_BASE = 'https://debugai.io';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export interface DeviceLinkStart {
|
|
2
|
+
deviceCode: string;
|
|
3
|
+
userCode: string;
|
|
4
|
+
verificationUri: string;
|
|
5
|
+
/** URL with the code pre-filled — what we actually open / print first. */
|
|
6
|
+
verificationUriComplete: string;
|
|
7
|
+
expiresIn: number;
|
|
8
|
+
/** Minimum seconds between polls, per the server. */
|
|
9
|
+
interval: number;
|
|
10
|
+
}
|
|
11
|
+
export type DeviceLinkPoll = {
|
|
12
|
+
status: 'pending';
|
|
13
|
+
} | {
|
|
14
|
+
status: 'slow_down';
|
|
15
|
+
interval: number;
|
|
16
|
+
} | {
|
|
17
|
+
status: 'expired';
|
|
18
|
+
} | {
|
|
19
|
+
status: 'denied';
|
|
20
|
+
} | {
|
|
21
|
+
status: 'linked';
|
|
22
|
+
apiKey: string;
|
|
23
|
+
email?: string;
|
|
24
|
+
tier?: string;
|
|
25
|
+
};
|
|
26
|
+
export interface DeviceLinkOptions {
|
|
27
|
+
apiBase: string;
|
|
28
|
+
/** Shown on the approval page so the human knows what they're authorizing. */
|
|
29
|
+
clientLabel?: string;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
fetchImpl?: typeof fetch;
|
|
32
|
+
}
|
|
33
|
+
export declare class DeviceLinkError extends Error {
|
|
34
|
+
readonly status: number;
|
|
35
|
+
constructor(message: string, status?: number);
|
|
36
|
+
}
|
|
37
|
+
export declare function startDeviceLink(opts: DeviceLinkOptions): Promise<DeviceLinkStart>;
|
|
38
|
+
/** One poll. Never throws on a normal pending/expired answer — those are statuses. */
|
|
39
|
+
export declare function pollDeviceLink(deviceCode: string, opts: DeviceLinkOptions): Promise<DeviceLinkPoll>;
|
|
40
|
+
export interface WaitOptions extends DeviceLinkOptions {
|
|
41
|
+
/** Called once per state change so a CLI can show progress. */
|
|
42
|
+
onTick?: (secondsLeft: number) => void;
|
|
43
|
+
sleep?: (ms: number) => Promise<void>;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Blocks until the human approves, denies, or the code expires. Honors the
|
|
48
|
+
* server's interval and backs off when told to (`slow_down`) — a client that
|
|
49
|
+
* ignores that is how a device flow turns into a self-inflicted DoS.
|
|
50
|
+
*/
|
|
51
|
+
export declare function waitForDeviceLink(start: DeviceLinkStart, opts: WaitOptions): Promise<DeviceLinkPoll>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Device-link client — the API-key-paste killer.
|
|
2
|
+
//
|
|
3
|
+
// Modeled on the OAuth 2.0 Device Authorization Grant (RFC 8628), which
|
|
4
|
+
// exists for exactly this shape of problem: a program that can't own a
|
|
5
|
+
// browser redirect needs a credential a human holds. Same three moves:
|
|
6
|
+
//
|
|
7
|
+
// 1. start → server mints a long secret (device_code) + a short
|
|
8
|
+
// human-typeable code (user_code) and a verification URL
|
|
9
|
+
// 2. human → opens the URL in a real browser, signs in, confirms the code
|
|
10
|
+
// 3. poll → the program exchanges device_code for the credential
|
|
11
|
+
//
|
|
12
|
+
// Deviations from RFC 8628, on purpose: this returns a DebugAI API key
|
|
13
|
+
// rather than an OAuth access token (no token endpoint, no refresh cycle,
|
|
14
|
+
// and the key is the same one the extension and dashboard already use), and
|
|
15
|
+
// there is no client_id — the npm package is the only client.
|
|
16
|
+
//
|
|
17
|
+
// Poll statuses mirror the RFC's error codes so the state machine is
|
|
18
|
+
// familiar: authorization_pending, slow_down, expired_token, access_denied.
|
|
19
|
+
const START_TIMEOUT_MS = 15_000;
|
|
20
|
+
const POLL_TIMEOUT_MS = 15_000;
|
|
21
|
+
export class DeviceLinkError extends Error {
|
|
22
|
+
status;
|
|
23
|
+
constructor(message, status = 0) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.status = status;
|
|
26
|
+
this.name = 'DeviceLinkError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function postJson(url, body, timeoutMs, fetchImpl) {
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetchImpl(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
signal: controller.signal,
|
|
38
|
+
});
|
|
39
|
+
const text = await res.text().catch(() => '');
|
|
40
|
+
let json = {};
|
|
41
|
+
try {
|
|
42
|
+
json = text ? JSON.parse(text) : {};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
json = { raw: text };
|
|
46
|
+
}
|
|
47
|
+
return { status: res.status, json };
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
if (controller.signal.aborted) {
|
|
51
|
+
throw new DeviceLinkError(`DebugAI did not answer within ${Math.round(timeoutMs / 1000)}s`, 504);
|
|
52
|
+
}
|
|
53
|
+
throw new DeviceLinkError(`Could not reach DebugAI at ${url}: ${err?.message ?? String(err)}`, 0);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
clearTimeout(timer);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function startDeviceLink(opts) {
|
|
60
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
61
|
+
const { status, json } = await postJson(`${opts.apiBase}/device-link/start`, { client_label: opts.clientLabel ?? 'DebugAI MCP server' }, opts.timeoutMs ?? START_TIMEOUT_MS, fetchImpl);
|
|
62
|
+
if (status !== 200 || !json?.device_code || !json?.user_code) {
|
|
63
|
+
throw new DeviceLinkError(json?.error
|
|
64
|
+
? `DebugAI refused to start the link: ${json.error}`
|
|
65
|
+
: `DebugAI returned HTTP ${status} when starting the link`, status);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
deviceCode: String(json.device_code),
|
|
69
|
+
userCode: String(json.user_code),
|
|
70
|
+
verificationUri: String(json.verification_uri),
|
|
71
|
+
verificationUriComplete: String(json.verification_uri_complete ?? json.verification_uri),
|
|
72
|
+
expiresIn: Number(json.expires_in) || 600,
|
|
73
|
+
interval: Number(json.interval) || 5,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** One poll. Never throws on a normal pending/expired answer — those are statuses. */
|
|
77
|
+
export async function pollDeviceLink(deviceCode, opts) {
|
|
78
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
79
|
+
const { status, json } = await postJson(`${opts.apiBase}/device-link/poll`, { device_code: deviceCode }, opts.timeoutMs ?? POLL_TIMEOUT_MS, fetchImpl);
|
|
80
|
+
if (status === 200 && json?.api_key) {
|
|
81
|
+
return { status: 'linked', apiKey: String(json.api_key), email: json.email, tier: json.tier };
|
|
82
|
+
}
|
|
83
|
+
// A proxy or rate limiter answering 429 means "you are early", never "this
|
|
84
|
+
// code is dead" — treating it as expired would kill a perfectly good login.
|
|
85
|
+
if (status === 429) {
|
|
86
|
+
return { status: 'slow_down', interval: Number(json?.interval) || 15 };
|
|
87
|
+
}
|
|
88
|
+
switch (json?.error) {
|
|
89
|
+
case 'authorization_pending': return { status: 'pending' };
|
|
90
|
+
case 'slow_down': return { status: 'slow_down', interval: Number(json.interval) || 10 };
|
|
91
|
+
case 'expired_token': return { status: 'expired' };
|
|
92
|
+
case 'access_denied': return { status: 'denied' };
|
|
93
|
+
default:
|
|
94
|
+
// An unknown 4xx means this device_code will never succeed — treat it as
|
|
95
|
+
// expired rather than spinning forever against a dead code.
|
|
96
|
+
if (status >= 400 && status < 500)
|
|
97
|
+
return { status: 'expired' };
|
|
98
|
+
throw new DeviceLinkError(`Unexpected response while polling (HTTP ${status})`, status);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
102
|
+
/**
|
|
103
|
+
* Blocks until the human approves, denies, or the code expires. Honors the
|
|
104
|
+
* server's interval and backs off when told to (`slow_down`) — a client that
|
|
105
|
+
* ignores that is how a device flow turns into a self-inflicted DoS.
|
|
106
|
+
*/
|
|
107
|
+
export async function waitForDeviceLink(start, opts) {
|
|
108
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
109
|
+
const now = opts.now ?? Date.now;
|
|
110
|
+
const deadline = now() + start.expiresIn * 1000;
|
|
111
|
+
let interval = Math.max(1, start.interval);
|
|
112
|
+
for (;;) {
|
|
113
|
+
if (now() >= deadline)
|
|
114
|
+
return { status: 'expired' };
|
|
115
|
+
await sleep(interval * 1000);
|
|
116
|
+
const result = await pollDeviceLink(start.deviceCode, opts);
|
|
117
|
+
if (result.status === 'slow_down') {
|
|
118
|
+
interval = Math.max(interval + 5, result.interval);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (result.status !== 'pending')
|
|
122
|
+
return result;
|
|
123
|
+
opts.onTick?.(Math.max(0, Math.round((deadline - now()) / 1000)));
|
|
124
|
+
}
|
|
125
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -5,8 +5,10 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
6
|
import { createServer } from './server.js';
|
|
7
7
|
import { DEFAULT_TIMEOUT_MS } from './backend.js';
|
|
8
|
+
import { AuthProvider } from './auth.js';
|
|
9
|
+
import { DEFAULT_API_BASE } from './constants.js';
|
|
8
10
|
import { configPath, resolveSettings } from './config.js';
|
|
9
|
-
|
|
11
|
+
import { cmdDoctor, cmdInstall, cmdLogin, cmdLogout, cmdSetup, cmdStatus, cmdUninstall, } from './cli/commands.js';
|
|
10
12
|
function packageVersion() {
|
|
11
13
|
try {
|
|
12
14
|
const pkgPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
@@ -17,57 +19,102 @@ function packageVersion() {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
const VERSION = packageVersion();
|
|
20
|
-
const HELP = `debugai-mcp v${VERSION} — DebugAI MCP server
|
|
22
|
+
const HELP = `debugai-mcp v${VERSION} — DebugAI MCP server + setup CLI
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
Quick start (one command, no config files to edit):
|
|
25
|
+
npx -y @debugai/mcp setup
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
Commands:
|
|
28
|
+
setup sign in, then wire up every MCP client found on this machine
|
|
29
|
+
login sign in via browser and store the key (--key dbg_… to paste one)
|
|
30
|
+
logout remove the stored key
|
|
31
|
+
status show the active key and account
|
|
32
|
+
install add DebugAI to MCP client configs
|
|
33
|
+
--list show every supported client and where it lives
|
|
34
|
+
--client=cursor target one client (comma-separate for several)
|
|
35
|
+
--all every supported client, detected or not
|
|
36
|
+
--dry-run print what would change, write nothing
|
|
37
|
+
--remove take the entry back out
|
|
38
|
+
uninstall remove DebugAI from every client config
|
|
39
|
+
doctor diagnose setup: key, API reachability, client wiring
|
|
40
|
+
(no command) run the MCP server on stdio — this is what clients launch
|
|
41
|
+
|
|
42
|
+
Tools exposed to your agent:
|
|
43
|
+
debug_error hand it an error or stack trace, get root cause + ranked
|
|
44
|
+
fixes with machine-applicable edits (v2 contract)
|
|
45
|
+
report_outcome tell DebugAI whether an applied fix worked — failed-fix
|
|
46
|
+
follow-ups improve future answers for your codebase
|
|
29
47
|
|
|
30
48
|
Environment:
|
|
31
|
-
DEBUGAI_API_KEY your API key (dbg_
|
|
49
|
+
DEBUGAI_API_KEY your API key (dbg_…). Overrides the stored key.
|
|
32
50
|
DEBUGAI_API_BASE optional — API base URL (default: DebugAI production)
|
|
33
51
|
DEBUGAI_TIMEOUT_MS optional — per-request deadline in ms (default: ${DEFAULT_TIMEOUT_MS})
|
|
52
|
+
DEBUGAI_CONFIG_PATH optional — alternate config file location
|
|
34
53
|
|
|
35
|
-
Config file (
|
|
36
|
-
|
|
37
|
-
Env vars win over the file. api_base is also accepted.
|
|
54
|
+
Config file (written by login, read by every MCP client on this machine):
|
|
55
|
+
${configPath()} {"api_key": "dbg_…"}
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
(Claude Desktop, Claude Code, Cursor, Zed, ...), not run interactively.
|
|
41
|
-
Config snippets: https://www.npmjs.com/package/@debugai/mcp
|
|
57
|
+
Docs: https://debugai.io/start?src=mcp
|
|
42
58
|
`;
|
|
43
|
-
|
|
44
|
-
|
|
59
|
+
const SUBCOMMANDS = new Set([
|
|
60
|
+
'setup', 'login', 'logout', 'status', 'install', 'uninstall', 'doctor',
|
|
61
|
+
]);
|
|
62
|
+
async function runSubcommand(name, argv) {
|
|
63
|
+
switch (name) {
|
|
64
|
+
case 'setup': return cmdSetup(argv);
|
|
65
|
+
case 'login': return cmdLogin(argv);
|
|
66
|
+
case 'logout': return cmdLogout(argv);
|
|
67
|
+
case 'status': return cmdStatus(argv);
|
|
68
|
+
case 'install': return cmdInstall(argv);
|
|
69
|
+
case 'uninstall': return cmdUninstall(argv);
|
|
70
|
+
case 'doctor': return cmdDoctor(argv);
|
|
71
|
+
default: return 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// stdout carries the MCP protocol — in server mode every human-facing line goes
|
|
75
|
+
// to stderr. Subcommands print to stdout and exit before any transport exists,
|
|
76
|
+
// so the two can never interleave.
|
|
77
|
+
async function main() {
|
|
45
78
|
const args = process.argv.slice(2);
|
|
46
79
|
if (args.includes('--version') || args.includes('-v')) {
|
|
47
80
|
process.stdout.write(`${VERSION}\n`);
|
|
48
81
|
return;
|
|
49
82
|
}
|
|
50
|
-
if (args.includes('--help') || args.includes('-h')) {
|
|
83
|
+
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
|
51
84
|
process.stdout.write(HELP);
|
|
52
85
|
return;
|
|
53
86
|
}
|
|
87
|
+
const [first, ...rest] = args;
|
|
88
|
+
if (first && SUBCOMMANDS.has(first)) {
|
|
89
|
+
process.exitCode = await runSubcommand(first, rest);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (first && !first.startsWith('-')) {
|
|
93
|
+
console.error(`[debugai-mcp] unknown command: ${first} (see --help)`);
|
|
94
|
+
process.exitCode = 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
54
97
|
if (args.length > 0) {
|
|
55
98
|
console.error(`[debugai-mcp] unknown argument(s): ${args.join(' ')} (see --help)`);
|
|
56
99
|
process.exitCode = 1;
|
|
57
100
|
return;
|
|
58
101
|
}
|
|
102
|
+
// ── server mode ────────────────────────────────────────────────────────────
|
|
59
103
|
const { apiKey, apiBase, keySource } = resolveSettings(DEFAULT_API_BASE);
|
|
60
104
|
const rawTimeout = Number(process.env.DEBUGAI_TIMEOUT_MS);
|
|
61
105
|
const timeoutMs = Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : DEFAULT_TIMEOUT_MS;
|
|
106
|
+
// The auth provider re-reads the key on every tool call and can start a
|
|
107
|
+
// browser sign-in from inside a conversation, so a missing key is a
|
|
108
|
+
// 20-second detour instead of a dead end. See auth.ts.
|
|
109
|
+
const auth = new AuthProvider({ apiBase, clientLabel: 'DebugAI MCP server' });
|
|
62
110
|
if (!apiKey) {
|
|
63
|
-
console.error('[debugai-mcp] no API key
|
|
64
|
-
'
|
|
65
|
-
`MCP client config or write it once to ${configPath()} as {"api_key": "dbg_..."}.`);
|
|
111
|
+
console.error('[debugai-mcp] no API key yet — the first tool call will hand your agent a sign-in link. ' +
|
|
112
|
+
'To do it now instead, run: npx -y @debugai/mcp login');
|
|
66
113
|
}
|
|
67
114
|
else if (!apiKey.startsWith('dbg_')) {
|
|
68
115
|
console.error('[debugai-mcp] warning: DEBUGAI_API_KEY does not look like a DebugAI key (expected dbg_ prefix).');
|
|
69
116
|
}
|
|
70
|
-
const server = createServer({ apiKey, apiBase, version: VERSION, timeoutMs });
|
|
117
|
+
const server = createServer({ apiKey, apiBase, version: VERSION, timeoutMs, auth });
|
|
71
118
|
const shutdown = (signal) => {
|
|
72
119
|
console.error(`[debugai-mcp] received ${signal}, shutting down`);
|
|
73
120
|
void server.close().finally(() => process.exit(0));
|
|
@@ -80,4 +127,4 @@ function main() {
|
|
|
80
127
|
process.exit(1);
|
|
81
128
|
});
|
|
82
129
|
}
|
|
83
|
-
main();
|
|
130
|
+
void main();
|
package/dist/server.js
CHANGED
|
@@ -1,7 +1,37 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { registerDebugError } from './tools/debugError.js';
|
|
3
|
+
import { registerReportOutcome } from './tools/reportOutcome.js';
|
|
4
|
+
// `instructions` is handed to the agent's context at connect time — the one
|
|
5
|
+
// channel the protocol gives a server to say what it is FOR. Without it,
|
|
6
|
+
// agents skip debug_error, because "analyze this error" looks like something
|
|
7
|
+
// they already do themselves. The framing here is deliberate: lead with what
|
|
8
|
+
// the model cannot derive on its own (this project's error history, a fix a
|
|
9
|
+
// human confirmed, a mechanical check), not with "AI-powered analysis".
|
|
10
|
+
const INSTRUCTIONS = `DebugAI is this project's debugging memory and fix verifier.
|
|
11
|
+
|
|
12
|
+
When a runtime error, exception, stack trace, or failing test output appears,
|
|
13
|
+
call debug_error before reasoning your way to a fix alone. It returns things
|
|
14
|
+
you cannot derive from the code in front of you:
|
|
15
|
+
- whether this exact error has been seen before in this project, and how often
|
|
16
|
+
- the fix a human already confirmed worked for it, if there is one
|
|
17
|
+
- a verification state per fix: mechanically checked and passed, checked and
|
|
18
|
+
FAILED, or not checked at all (never treat "not checked" as verified)
|
|
19
|
+
- machine-applicable edits (exact old/new strings) you can apply directly
|
|
20
|
+
|
|
21
|
+
After you apply or abandon a fix, call report_outcome with the debug_log_id.
|
|
22
|
+
That is what turns a one-off answer into memory for the next person who hits
|
|
23
|
+
the same error. Skipping it costs the user the feature they are paying for.
|
|
24
|
+
|
|
25
|
+
When you present findings that came from DebugAI's memory or verification,
|
|
26
|
+
say so — "DebugAI has seen this 3 times; the confirmed fix is …" — so the user
|
|
27
|
+
can tell which claims were checked and which are your own reasoning.
|
|
28
|
+
|
|
29
|
+
Not signed in yet? The first tool call returns a short code and a URL. Read
|
|
30
|
+
both out to the user; once they confirm in the browser, retry the same call.
|
|
31
|
+
No config editing and no client restart needed.`;
|
|
3
32
|
export function createServer(config) {
|
|
4
|
-
const server = new McpServer({ name: 'debugai', version: config.version }, { capabilities: { tools: { listChanged: true } } });
|
|
33
|
+
const server = new McpServer({ name: 'debugai', version: config.version }, { capabilities: { tools: { listChanged: true } }, instructions: INSTRUCTIONS });
|
|
5
34
|
registerDebugError(server, config);
|
|
35
|
+
registerReportOutcome(server, config);
|
|
6
36
|
return server;
|
|
7
37
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import type { BackendConfig } from '../backend.js';
|
|
3
|
+
export type AuthGate = {
|
|
4
|
+
ok: true;
|
|
5
|
+
config: BackendConfig;
|
|
6
|
+
} | {
|
|
7
|
+
ok: false;
|
|
8
|
+
result: CallToolResult;
|
|
9
|
+
};
|
|
10
|
+
export declare function resolveAuth(config: BackendConfig): Promise<AuthGate>;
|