@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepsql/mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "DeepSQL CLI and stdio MCP server for self-hosted deployments",
5
5
  "bin": {
6
6
  "deepsql": "./bin/deepsql.js",
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
- module.exports = { ApiError, request, resolveUrl, normalizeBaseUrl };
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,
@@ -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 shouldUseDevice(opts) {
14
- if (opts.device) return true;
15
- if (opts.browser) return false;
16
- // Heuristic: assume browser unless we detect a headless env.
17
- if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT) return true;
18
- if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) return true;
19
- return false;
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 useDevice = shouldUseDevice(opts);
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 (useDevice) {
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({