@deepsql/mcp 0.2.0 → 0.3.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/package.json +1 -1
- package/src/api/client.js +26 -2
- package/src/auth/password-flow.js +149 -0
- package/src/auth/prompt.js +96 -0
- package/src/cli.js +10 -1
- package/src/commands/login.js +22 -9
package/package.json
CHANGED
package/src/api/client.js
CHANGED
|
@@ -32,7 +32,7 @@ function resolveUrl(baseUrl, path) {
|
|
|
32
32
|
return new URL(withApi, normalizeBaseUrl(baseUrl)).toString();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, token, timeoutMs = 120000, query } = {}) {
|
|
35
|
+
async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, token, timeoutMs = 120000, query, returnHeaders = false } = {}) {
|
|
36
36
|
let url;
|
|
37
37
|
if (typeof pathOrUrl === "string" && /^https?:\/\//i.test(pathOrUrl)) {
|
|
38
38
|
url = pathOrUrl;
|
|
@@ -87,7 +87,31 @@ async function request(baseUrl, pathOrUrl, { method = "GET", json, headers, toke
|
|
|
87
87
|
const message = (body && typeof body === "object" && (body.message || body.error)) || `HTTP ${response.status}`;
|
|
88
88
|
throw new ApiError(message, { status: response.status, body });
|
|
89
89
|
}
|
|
90
|
+
if (returnHeaders) {
|
|
91
|
+
// Node's fetch exposes Set-Cookie via getSetCookie() (Node 20+); the
|
|
92
|
+
// password-login flow needs the auth_token cookie value to mint a long-
|
|
93
|
+
// lived MCP token afterwards.
|
|
94
|
+
const setCookies = typeof response.headers.getSetCookie === "function"
|
|
95
|
+
? response.headers.getSetCookie()
|
|
96
|
+
: [];
|
|
97
|
+
return { body, status: response.status, setCookies };
|
|
98
|
+
}
|
|
90
99
|
return body;
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Extract a single cookie value from an array of Set-Cookie header values.
|
|
104
|
+
*
|
|
105
|
+
* parseCookieValue(["auth_token=abc.def; Path=/; HttpOnly", "refresh_token=…"], "auth_token")
|
|
106
|
+
* => "abc.def"
|
|
107
|
+
*/
|
|
108
|
+
function parseCookieValue(setCookies, name) {
|
|
109
|
+
if (!Array.isArray(setCookies)) return null;
|
|
110
|
+
for (const raw of setCookies) {
|
|
111
|
+
const match = raw && raw.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
|
|
112
|
+
if (match) return decodeURIComponent(match[1]);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = { ApiError, request, resolveUrl, normalizeBaseUrl, parseCookieValue };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Direct username/password login — for headless boxes where neither a browser
|
|
5
|
+
* nor copy-paste of a device code is convenient (e.g. provisioning a fresh
|
|
6
|
+
* self-host VM via Ansible/cloud-init, or a CI runner that needs to mint a
|
|
7
|
+
* service-account token from a stored secret).
|
|
8
|
+
*
|
|
9
|
+
* Flow:
|
|
10
|
+
* 1. Prompt for email + password (or accept --email and --password-stdin).
|
|
11
|
+
* 2. POST /auth/login. The backend either:
|
|
12
|
+
* a) succeeds and sets the auth_token cookie containing a session JWT, or
|
|
13
|
+
* b) returns { challengeId, message } indicating an email-OTP step.
|
|
14
|
+
* 3. If a challenge is required, kick off /auth/email/start, prompt for the
|
|
15
|
+
* OTP, then POST /auth/email/verify. Same auth_token cookie is set on
|
|
16
|
+
* success.
|
|
17
|
+
* 4. With the JWT in hand, POST /auth/mcp-tokens to mint a long-lived MCP
|
|
18
|
+
* token. The session cookie is short-lived (minutes); the MCP token lives
|
|
19
|
+
* until revoked, which is what we want to persist to ~/.config/deepsql.
|
|
20
|
+
*
|
|
21
|
+
* Security notes:
|
|
22
|
+
* - We never persist the raw password. It's read from a TTY (echo off) or
|
|
23
|
+
* from stdin and then dropped after the login POST.
|
|
24
|
+
* - The session JWT is held only in memory between login and the
|
|
25
|
+
* mcp-tokens POST.
|
|
26
|
+
* - For CI-style use, callers should pipe the password via stdin
|
|
27
|
+
* (`--password-stdin`) rather than passing it as an argv flag, since argv
|
|
28
|
+
* shows up in `ps`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const os = require("node:os");
|
|
32
|
+
|
|
33
|
+
const { ApiError, parseCookieValue, request } = require("../api/client");
|
|
34
|
+
const { prompt, promptPassword, readSingleLineFromStdin } = require("./prompt");
|
|
35
|
+
|
|
36
|
+
async function runPasswordFlow({ baseUrl, email, passwordStdin, hostname, clientLabel, log = () => {} }) {
|
|
37
|
+
const resolvedEmail = email && email.trim() ? email.trim() : await prompt("Email: ");
|
|
38
|
+
if (!resolvedEmail) throw new Error("Email is required.");
|
|
39
|
+
|
|
40
|
+
const password = passwordStdin
|
|
41
|
+
? await readSingleLineFromStdin()
|
|
42
|
+
: await promptPassword("Password: ");
|
|
43
|
+
if (!password) throw new Error("Password is required.");
|
|
44
|
+
|
|
45
|
+
log(`Authenticating ${resolvedEmail} against ${baseUrl}…`);
|
|
46
|
+
let jwt = await postLoginAndExtractJwt(baseUrl, resolvedEmail, password);
|
|
47
|
+
|
|
48
|
+
if (!jwt) {
|
|
49
|
+
// Server returned a challenge — currently the only kind we handle is
|
|
50
|
+
// email OTP. Other challenges (e.g. authenticator-app MFA) should fall
|
|
51
|
+
// back to the browser flow with a clear error.
|
|
52
|
+
log("Server requires email verification. Sending OTP…");
|
|
53
|
+
jwt = await runEmailOtpChallenge(baseUrl, resolvedEmail);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
log("Minting long-lived CLI token…");
|
|
57
|
+
const tokenName = buildTokenName(clientLabel, hostname);
|
|
58
|
+
const created = await request(baseUrl, "/auth/mcp-tokens", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
token: jwt,
|
|
61
|
+
json: { name: tokenName },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
token: created.token,
|
|
66
|
+
token_id: created.id,
|
|
67
|
+
username: extractUsername(created, resolvedEmail),
|
|
68
|
+
expires_at: created.expiresAt || null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function postLoginAndExtractJwt(baseUrl, email, password) {
|
|
73
|
+
let result;
|
|
74
|
+
try {
|
|
75
|
+
result = await request(baseUrl, "/auth/login", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
json: { email, password },
|
|
78
|
+
returnHeaders: true,
|
|
79
|
+
});
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
|
|
82
|
+
const detail = err.body && typeof err.body === "object" ? err.body.message : null;
|
|
83
|
+
throw new Error(detail || "Invalid email or password.");
|
|
84
|
+
}
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const jwt = parseCookieValue(result.setCookies, "auth_token");
|
|
89
|
+
if (jwt) return jwt;
|
|
90
|
+
|
|
91
|
+
// No cookie on the response — must be a challenge handoff. Body shape is
|
|
92
|
+
// { challengeId, message }.
|
|
93
|
+
if (result.body && result.body.challengeId) return null;
|
|
94
|
+
|
|
95
|
+
throw new Error(
|
|
96
|
+
"Login succeeded but no session token was issued — your DeepSQL instance may use SSO that the CLI can't drive. Use `deepsql login` (browser flow) instead.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function runEmailOtpChallenge(baseUrl, email) {
|
|
101
|
+
// Fresh challenge so /email/start has something to act on. The login call
|
|
102
|
+
// above already created a challenge but didn't expose its id; calling
|
|
103
|
+
// /email/start without a prior challenge id is supported and starts a new
|
|
104
|
+
// one for the same user.
|
|
105
|
+
let started;
|
|
106
|
+
try {
|
|
107
|
+
started = await request(baseUrl, "/auth/email/start", {
|
|
108
|
+
method: "POST",
|
|
109
|
+
json: { email },
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Could not start email verification: ${err.message}. Use \`deepsql login\` (browser flow) if your account requires SSO or authenticator MFA.`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const otp = await prompt(`Email OTP (sent to ${email}): `);
|
|
118
|
+
if (!otp) throw new Error("OTP is required.");
|
|
119
|
+
|
|
120
|
+
const result = await request(baseUrl, "/auth/email/verify", {
|
|
121
|
+
method: "POST",
|
|
122
|
+
json: { challengeId: started.challengeId, otp },
|
|
123
|
+
returnHeaders: true,
|
|
124
|
+
});
|
|
125
|
+
const jwt = parseCookieValue(result.setCookies, "auth_token");
|
|
126
|
+
if (!jwt) {
|
|
127
|
+
throw new Error("Email verification did not return a session token. Aborting.");
|
|
128
|
+
}
|
|
129
|
+
return jwt;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function extractUsername(createdTokenResponse, fallbackEmail) {
|
|
133
|
+
if (createdTokenResponse && typeof createdTokenResponse === "object") {
|
|
134
|
+
if (createdTokenResponse.username) return createdTokenResponse.username;
|
|
135
|
+
if (createdTokenResponse.userId) return String(createdTokenResponse.userId);
|
|
136
|
+
}
|
|
137
|
+
return fallbackEmail;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildTokenName(clientLabel, hostname) {
|
|
141
|
+
const parts = ["CLI"];
|
|
142
|
+
parts.push(clientLabel && clientLabel.trim() ? clientLabel.trim() : "deepsql");
|
|
143
|
+
if (hostname && hostname.trim()) parts.push(`@ ${hostname.trim()}`);
|
|
144
|
+
parts.push("(password)");
|
|
145
|
+
const name = parts.join(" ");
|
|
146
|
+
return name.length > 120 ? name.slice(0, 120) : name;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { runPasswordFlow };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Tiny interactive prompt helpers — no dependency on a TTY library.
|
|
5
|
+
*
|
|
6
|
+
* For passwords we mute the terminal echo so the value does not show on screen
|
|
7
|
+
* or get captured by ttyrec / screen-recording. If stdin is not a TTY (piped
|
|
8
|
+
* input), we read a single line as-is.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const readline = require("node:readline");
|
|
12
|
+
|
|
13
|
+
function prompt(question) {
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
16
|
+
rl.question(question, (answer) => {
|
|
17
|
+
rl.close();
|
|
18
|
+
resolve(answer.trim());
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function promptPassword(question) {
|
|
24
|
+
if (!process.stdin.isTTY) {
|
|
25
|
+
return readSingleLineFromStdin();
|
|
26
|
+
}
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
process.stderr.write(question);
|
|
29
|
+
const stdin = process.stdin;
|
|
30
|
+
const previousRaw = stdin.isRaw;
|
|
31
|
+
stdin.setRawMode(true);
|
|
32
|
+
stdin.resume();
|
|
33
|
+
stdin.setEncoding("utf8");
|
|
34
|
+
|
|
35
|
+
const ETX = "\u0003"; // Ctrl-C
|
|
36
|
+
const EOT = "\u0004"; // Ctrl-D
|
|
37
|
+
const BS = "\u0008"; // ASCII backspace
|
|
38
|
+
const DEL = "\u007f"; // most modern terminals send this on backspace
|
|
39
|
+
|
|
40
|
+
let buffer = "";
|
|
41
|
+
const onData = (char) => {
|
|
42
|
+
switch (char) {
|
|
43
|
+
case "\r":
|
|
44
|
+
case "\n":
|
|
45
|
+
case EOT:
|
|
46
|
+
finish();
|
|
47
|
+
return;
|
|
48
|
+
case ETX:
|
|
49
|
+
cleanup();
|
|
50
|
+
process.stderr.write("\n");
|
|
51
|
+
reject(new Error("Cancelled"));
|
|
52
|
+
return;
|
|
53
|
+
case BS:
|
|
54
|
+
case DEL:
|
|
55
|
+
buffer = buffer.slice(0, -1);
|
|
56
|
+
return;
|
|
57
|
+
default:
|
|
58
|
+
// Accept printable chars only; ignore other ANSI control sequences.
|
|
59
|
+
if (char.charCodeAt(0) >= 32) buffer += char;
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const cleanup = () => {
|
|
64
|
+
stdin.removeListener("data", onData);
|
|
65
|
+
try {
|
|
66
|
+
stdin.setRawMode(previousRaw);
|
|
67
|
+
} catch {}
|
|
68
|
+
stdin.pause();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const finish = () => {
|
|
72
|
+
cleanup();
|
|
73
|
+
process.stderr.write("\n");
|
|
74
|
+
resolve(buffer);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
stdin.on("data", onData);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readSingleLineFromStdin() {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
let buffer = "";
|
|
84
|
+
process.stdin.setEncoding("utf8");
|
|
85
|
+
process.stdin.on("data", (chunk) => {
|
|
86
|
+
buffer += chunk;
|
|
87
|
+
});
|
|
88
|
+
process.stdin.on("end", () => {
|
|
89
|
+
// Strip a single trailing newline if the caller did echo \$PWD | …
|
|
90
|
+
resolve(buffer.replace(/\r?\n$/, ""));
|
|
91
|
+
});
|
|
92
|
+
process.stdin.on("error", reject);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { prompt, promptPassword, readSingleLineFromStdin };
|
package/src/cli.js
CHANGED
|
@@ -31,8 +31,14 @@ Usage:
|
|
|
31
31
|
deepsql <command> [options]
|
|
32
32
|
|
|
33
33
|
Commands:
|
|
34
|
-
login [--url <url>] [--device|--browser] [--no-browser] [--label <name>]
|
|
34
|
+
login [--url <url>] [--device|--browser|--password] [--no-browser] [--label <name>]
|
|
35
|
+
[--email <email>] [--password-stdin]
|
|
35
36
|
Authorize this CLI with a DeepSQL instance.
|
|
37
|
+
Default: browser callback (PKCE), or device-code
|
|
38
|
+
on headless boxes. Use --password for direct
|
|
39
|
+
email+password login on a fresh self-host VM
|
|
40
|
+
with no browser; pipe the password via stdin
|
|
41
|
+
with --password-stdin for non-interactive use.
|
|
36
42
|
logout [--url <url>] Revoke and forget the saved token.
|
|
37
43
|
whoami Show the user behind the saved token.
|
|
38
44
|
config show List saved profiles.
|
|
@@ -107,6 +113,9 @@ function buildOpts(parsed) {
|
|
|
107
113
|
device: !!f.device,
|
|
108
114
|
browser: !!f.browser,
|
|
109
115
|
noBrowser: !!f.noBrowser,
|
|
116
|
+
password: !!f.password,
|
|
117
|
+
passwordStdin: !!f.passwordStdin,
|
|
118
|
+
email: f.email || null,
|
|
110
119
|
label: f.label || null,
|
|
111
120
|
connection: f.connection || f.c || null,
|
|
112
121
|
chat: f.chat || null,
|
package/src/commands/login.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const os = require("node:os");
|
|
4
4
|
const { runBrowserFlow } = require("../auth/browser-flow");
|
|
5
5
|
const { runDeviceFlow } = require("../auth/device-flow");
|
|
6
|
+
const { runPasswordFlow } = require("../auth/password-flow");
|
|
6
7
|
const store = require("../auth/store");
|
|
7
8
|
const { ApiError } = require("../api/client");
|
|
8
9
|
|
|
@@ -10,13 +11,15 @@ function defaultClientLabel() {
|
|
|
10
11
|
return `deepsql-cli@${process.versions.node}`;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
if (opts.
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
return
|
|
14
|
+
function pickFlow(opts) {
|
|
15
|
+
// Explicit user choice wins.
|
|
16
|
+
if (opts.password) return "password";
|
|
17
|
+
if (opts.device) return "device";
|
|
18
|
+
if (opts.browser) return "browser";
|
|
19
|
+
// Heuristic: prefer browser; fall back to device-code in headless envs.
|
|
20
|
+
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT) return "device";
|
|
21
|
+
if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return "device";
|
|
22
|
+
return "browser";
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
async function run(opts, { stderr = process.stderr, stdout = process.stdout } = {}) {
|
|
@@ -28,14 +31,24 @@ async function run(opts, { stderr = process.stderr, stdout = process.stdout } =
|
|
|
28
31
|
}
|
|
29
32
|
const hostname = os.hostname();
|
|
30
33
|
const label = opts.label || defaultClientLabel();
|
|
31
|
-
const
|
|
34
|
+
const flow = pickFlow(opts);
|
|
32
35
|
const log = (msg) => stderr.write(`[deepsql] ${msg}\n`);
|
|
33
36
|
|
|
34
37
|
let issued;
|
|
35
38
|
try {
|
|
36
|
-
if (
|
|
39
|
+
if (flow === "device") {
|
|
37
40
|
log(`Starting device-code login against ${baseUrl}…`);
|
|
38
41
|
issued = await runDeviceFlow({ baseUrl, hostname, clientLabel: label, log });
|
|
42
|
+
} else if (flow === "password") {
|
|
43
|
+
log(`Starting password login against ${baseUrl}…`);
|
|
44
|
+
issued = await runPasswordFlow({
|
|
45
|
+
baseUrl,
|
|
46
|
+
email: opts.email,
|
|
47
|
+
passwordStdin: !!opts.passwordStdin,
|
|
48
|
+
hostname,
|
|
49
|
+
clientLabel: label,
|
|
50
|
+
log,
|
|
51
|
+
});
|
|
39
52
|
} else {
|
|
40
53
|
log(`Starting browser login against ${baseUrl}…`);
|
|
41
54
|
issued = await runBrowserFlow({
|