@mokoconsulting/mokomysql-mcp 2.5.4 → 2.6.1

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.
@@ -0,0 +1,263 @@
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ *
3
+ * This file is part of a Moko Consulting project.
4
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ *
7
+ * FILE INFORMATION
8
+ * DEFGROUP: mokomysql-mcp.Migrate
9
+ * INGROUP: mokomysql-mcp
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
11
+ * PATH: /source/migrate.ts
12
+ * BRIEF: Self-heal inline secrets in the MySQL MCP config into file-backed refs.
13
+ *
14
+ * No-inline-secrets policy (MokoCLI #1043). Any secret found INLINE in the
15
+ * on-disk `~/.mcp_mokomysql.json` is migrated to `~/.ssh/tokens/<slug>.token`
16
+ * and the config is rewritten so the field becomes `file:<path>`.
17
+ *
18
+ * mysql-specific: secret files are written as RAW values (NOT JSON). A MySQL
19
+ * password is an opaque string; `resolveRef()` reads the file verbatim, so a
20
+ * JSON wrapper would be read back literally and corrupt the credential. This
21
+ * intentionally diverges from the GitHub/OAuth JSON token-file shape used by
22
+ * token-based MCPs.
23
+ */
24
+
25
+ import { constants as FS } from 'node:fs';
26
+ import { access, chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
27
+ import { homedir } from 'node:os';
28
+ import { dirname, resolve } from 'node:path';
29
+
30
+ /** Directory that holds file-backed secrets. */
31
+ function tokensDir(): string {
32
+ return resolve(homedir(), '.ssh', 'tokens');
33
+ }
34
+
35
+ /** True when the value is already a `file:<path>` reference (nothing to migrate). */
36
+ function isFileRef(value: unknown): boolean {
37
+ return typeof value === 'string' && /^file:/.test(value.trim());
38
+ }
39
+
40
+ /** True when the value is a non-empty inline literal that must be migrated. */
41
+ function isInlineSecret(value: unknown): value is string {
42
+ return typeof value === 'string' && value.trim() !== '' && !isFileRef(value);
43
+ }
44
+
45
+ /** Sanitize a slug component to the `[A-Za-z0-9._-]` alphabet. */
46
+ function sanitize(part: string): string {
47
+ return part.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '');
48
+ }
49
+
50
+ /** stderr only — stdout is the MCP stdio channel and must never carry logs. */
51
+ function warn(message: string): void {
52
+ process.stderr.write(`[mokomysql] WARNING: ${message}\n`);
53
+ }
54
+
55
+ /**
56
+ * One sensitive field discovered inline on a connection, with the slug its
57
+ * secret file should take. `field` is the config key to rewrite; `get`/`set`
58
+ * read and replace the value in the parsed object.
59
+ */
60
+ interface InlineField {
61
+ connectionKey: string;
62
+ /** Human label for warnings, e.g. "prod.password" or "prod.ssl.key". */
63
+ label: string;
64
+ slug: string;
65
+ value: string;
66
+ /** Replace the inline value with a `file:` ref in the parsed object. */
67
+ apply(ref: string): void;
68
+ }
69
+
70
+ /**
71
+ * Enumerate every inline secret across all connections. The ONLY top-level
72
+ * connection secret in this config shape is `password`; an inline PEM private
73
+ * key under `ssl.key` is also a secret value (a key *value*, not a path) and is
74
+ * migrated too. A `file:` ref or empty value is skipped.
75
+ */
76
+ function findInlineSecrets(connections: Record<string, unknown>): InlineField[] {
77
+ const out: InlineField[] = [];
78
+ for (const [key, raw] of Object.entries(connections)) {
79
+ if (!raw || typeof raw !== 'object') continue;
80
+ const conn = raw as Record<string, unknown>;
81
+ const connSlug = sanitize(key) || 'connection';
82
+
83
+ if (isInlineSecret(conn.password)) {
84
+ out.push({
85
+ connectionKey: key,
86
+ label: `${key}.password`,
87
+ slug: `mysql-${connSlug}-password`,
88
+ value: conn.password,
89
+ apply: (ref) => {
90
+ conn.password = ref;
91
+ },
92
+ });
93
+ }
94
+
95
+ const ssl = conn.ssl;
96
+ if (ssl && typeof ssl === 'object') {
97
+ const sslRec = ssl as Record<string, unknown>;
98
+ // An inline PEM private-key *value* (begins with a PEM header) is a
99
+ // secret; a short reference/path is left alone.
100
+ if (isInlineSecret(sslRec.key) && /BEGIN[^\n]*PRIVATE KEY/.test(sslRec.key)) {
101
+ out.push({
102
+ connectionKey: key,
103
+ label: `${key}.ssl.key`,
104
+ slug: `mysql-${connSlug}-ssl-key`,
105
+ value: sslRec.key,
106
+ apply: (ref) => {
107
+ sslRec.key = ref;
108
+ },
109
+ });
110
+ }
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
116
+ /** True if the path exists. */
117
+ async function exists(path: string): Promise<boolean> {
118
+ try {
119
+ await access(path, FS.F_OK);
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Write the raw secret to `~/.ssh/tokens/<slug>.token` (mode 0600). If the file
128
+ * already exists it is NOT overwritten — the existing file is reused. Returns
129
+ * the absolute token path. mysql reads this file RAW, so the content is the
130
+ * verbatim secret with no trailing newline and no JSON wrapper.
131
+ */
132
+ async function writeTokenFile(slug: string, secret: string): Promise<string> {
133
+ const dir = tokensDir();
134
+ await mkdir(dir, { recursive: true, mode: 0o700 });
135
+ // Best-effort tighten perms if the dir pre-existed with looser bits.
136
+ await chmod(dir, 0o700).catch(() => {});
137
+
138
+ const tokenPath = resolve(dir, `${slug}.token`);
139
+ if (await exists(tokenPath)) {
140
+ warn(`secret file already exists, reusing without overwrite: ${tokenPath}`);
141
+ return tokenPath;
142
+ }
143
+
144
+ // Atomic: write temp in the same dir, chmod, rename into place.
145
+ const tmp = `${tokenPath}.tmp-${process.pid}`;
146
+ await writeFile(tmp, secret, { encoding: 'utf-8', mode: 0o600 });
147
+ await chmod(tmp, 0o600).catch(() => {});
148
+ await rename(tmp, tokenPath);
149
+ return tokenPath;
150
+ }
151
+
152
+ /** Collapse an absolute path under $HOME back to a portable `~/...` form. */
153
+ function tildeify(absPath: string): string {
154
+ const home = homedir();
155
+ if (absPath === home) return '~';
156
+ const prefix = home.endsWith('/') || home.endsWith('\\') ? home : home + '/';
157
+ const prefixBack = home.endsWith('/') || home.endsWith('\\') ? home : home + '\\';
158
+ if (absPath.startsWith(prefix)) return '~/' + absPath.slice(prefix.length).replace(/\\/g, '/');
159
+ if (absPath.startsWith(prefixBack)) return '~/' + absPath.slice(prefixBack.length).replace(/\\/g, '/');
160
+ return absPath;
161
+ }
162
+
163
+ /**
164
+ * Atomically rewrite the config file with the migrated `file:` refs. Backs the
165
+ * original up to `<config>.bak` once (only if no `.bak` exists yet). The config
166
+ * is written mode 0600.
167
+ */
168
+ async function rewriteConfig(configPath: string, parsed: unknown): Promise<void> {
169
+ const bak = `${configPath}.bak`;
170
+ if (!(await exists(bak))) {
171
+ const original = await readFile(configPath, 'utf-8');
172
+ await writeFile(bak, original, { encoding: 'utf-8', mode: 0o600 });
173
+ await chmod(bak, 0o600).catch(() => {});
174
+ }
175
+
176
+ const serialized = JSON.stringify(parsed, null, '\t') + '\n';
177
+ const tmp = `${configPath}.tmp-${process.pid}`;
178
+ await writeFile(tmp, serialized, { encoding: 'utf-8', mode: 0o600 });
179
+ await chmod(tmp, 0o600).catch(() => {});
180
+ await rename(tmp, configPath);
181
+ await chmod(configPath, 0o600).catch(() => {});
182
+ }
183
+
184
+ /**
185
+ * Self-heal inline secrets in the parsed config, mutating `parsed` in place so
186
+ * the subsequent `resolveSecretRefs` pass picks up the new `file:` refs.
187
+ *
188
+ * Behavior (no-inline-secrets policy):
189
+ * - For each inline secret: warn to stderr (never echo the value), write a RAW
190
+ * token file under `~/.ssh/tokens/`, and rewrite the config so the field is a
191
+ * `file:` ref with the inline literal removed.
192
+ * - Existing token files are reused, never overwritten.
193
+ * - Config is backed up once to `<config>.bak`; writes are atomic; mode 0600.
194
+ * - On ANY error (read-only FS, perms, unwritable config) → warn only and keep
195
+ * the inline value for this run. Never throws, never loses the credential.
196
+ *
197
+ * Only runs against the real on-disk config path (the resolved
198
+ * `~/.mcp_mokomysql.json` or its `MYSQL_MCP_CONFIG` override).
199
+ */
200
+ export async function migrateInlineSecrets(
201
+ parsed: { connections?: Record<string, unknown> },
202
+ configPath: string,
203
+ ): Promise<void> {
204
+ try {
205
+ if (!parsed.connections || typeof parsed.connections !== 'object') return;
206
+
207
+ const inline = findInlineSecrets(parsed.connections);
208
+ if (inline.length === 0) return;
209
+
210
+ // Only touch the config if it is the real on-disk file we can read.
211
+ if (!(await exists(configPath))) {
212
+ warn(
213
+ `${inline.length} inline secret(s) found but config path is not a readable file ` +
214
+ `(${configPath}); leaving inline for this run.`,
215
+ );
216
+ return;
217
+ }
218
+
219
+ let migratedAny = false;
220
+ for (const field of inline) {
221
+ try {
222
+ warn(
223
+ `inline secret detected at ${field.label}; migrating to a file-backed secret ` +
224
+ `(no-inline-secrets policy, MokoCLI #1043). The value is NOT shown.`,
225
+ );
226
+ const tokenPath = await writeTokenFile(field.slug, field.value);
227
+ field.apply(`file:${tildeify(tokenPath)}`);
228
+ migratedAny = true;
229
+ } catch (err) {
230
+ const msg = err instanceof Error ? err.message : String(err);
231
+ warn(
232
+ `failed to migrate ${field.label} to a file (${msg}); ` +
233
+ `keeping the inline value for this run.`,
234
+ );
235
+ // Revert any ref we may have set on this field so resolution uses the literal.
236
+ field.apply(field.value);
237
+ }
238
+ }
239
+
240
+ if (migratedAny) {
241
+ try {
242
+ await rewriteConfig(configPath, parsed);
243
+ warn(
244
+ `config rewritten with file-backed secret ref(s): ${configPath} ` +
245
+ `(backup at ${configPath}.bak).`,
246
+ );
247
+ } catch (err) {
248
+ const msg = err instanceof Error ? err.message : String(err);
249
+ warn(
250
+ `token file(s) written but rewriting the config failed (${msg}); ` +
251
+ `the config still references the secret value(s) inline. ` +
252
+ `The file-backed ref(s) are used for this run only.`,
253
+ );
254
+ }
255
+ }
256
+ } catch (err) {
257
+ // Absolute backstop — migration must never crash the server.
258
+ const msg = err instanceof Error ? err.message : String(err);
259
+ warn(`secret auto-migration aborted (${msg}); continuing with config as-is.`);
260
+ }
261
+ }
262
+
263
+ export const __test = { sanitize, isInlineSecret, isFileRef, findInlineSecrets, tildeify };
package/source/secrets.ts CHANGED
@@ -1,70 +1,78 @@
1
- /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
- *
3
- * This file is part of a Moko Consulting project.
4
- *
5
- * SPDX-License-Identifier: GPL-3.0-or-later
6
- *
7
- * FILE INFORMATION
8
- * DEFGROUP: mokomysql-mcp.Secrets
9
- * INGROUP: mokomysql-mcp
10
- * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
11
- * PATH: /source/secrets.ts
12
- * BRIEF: Resolve file-backed secret references in a parsed config object.
13
- */
14
-
15
- import { readFile } from 'node:fs/promises';
16
- import { homedir } from 'node:os';
17
- import { resolve } from 'node:path';
18
-
19
- /** Expand a leading "~" to the user's home directory, then resolve to absolute. */
20
- function expandHome(p: string): string {
21
- if (p === '~') return homedir();
22
- if (p.startsWith('~/') || p.startsWith('~\\')) {
23
- return resolve(homedir(), p.slice(2));
24
- }
25
- return resolve(p);
26
- }
27
-
28
- /**
29
- * Resolve a single config string.
30
- * - "file:<path>" -> the trimmed contents of <path> (a leading "~" expands to $HOME).
31
- * - anything else -> returned unchanged, so inline literals keep working.
32
- *
33
- * A missing/unreadable file throws (fail loud) — a secret must never silently
34
- * degrade to the literal "file:..." string.
35
- */
36
- async function resolveRef(value: string): Promise<string> {
37
- const m = /^file:(.+)$/s.exec(value.trim());
38
- if (!m) return value;
39
- const path = expandHome(m[1].trim());
40
- try {
41
- const raw = await readFile(path, 'utf-8');
42
- return raw.replace(/\r?\n+$/, '');
43
- } catch (err) {
44
- const msg = err instanceof Error ? err.message : String(err);
45
- throw new Error(`Failed to read secret file "${path}" (referenced as "${value}"): ${msg}`);
46
- }
47
- }
48
-
49
- /**
50
- * Recursively resolve every "file:" secret reference in a parsed config object,
51
- * in place. Lets secrets (tokens, passwords, API keys) live in files such as
52
- * ~/.ssh/tokens/* instead of inline in the config JSON. Plain values are left
53
- * untouched, so pre-existing inline configs remain valid.
54
- */
55
- export async function resolveSecretRefs(obj: unknown): Promise<void> {
56
- if (Array.isArray(obj)) {
57
- for (let i = 0; i < obj.length; i++) {
58
- const v = obj[i];
59
- if (typeof v === 'string') obj[i] = await resolveRef(v);
60
- else if (v && typeof v === 'object') await resolveSecretRefs(v);
61
- }
62
- } else if (obj && typeof obj === 'object') {
63
- const rec = obj as Record<string, unknown>;
64
- for (const k of Object.keys(rec)) {
65
- const v = rec[k];
66
- if (typeof v === 'string') rec[k] = await resolveRef(v);
67
- else if (v && typeof v === 'object') await resolveSecretRefs(v);
68
- }
69
- }
70
- }
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ *
3
+ * This file is part of a Moko Consulting project.
4
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ *
7
+ * FILE INFORMATION
8
+ * DEFGROUP: mokomysql-mcp.Secrets
9
+ * INGROUP: mokomysql-mcp
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
11
+ * PATH: /source/secrets.ts
12
+ * BRIEF: Resolve file-backed secret references in a parsed config object.
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { homedir } from 'node:os';
17
+ import { resolve } from 'node:path';
18
+
19
+ /** Expand a leading "~" to the user's home directory, then resolve to absolute. */
20
+ function expandHome(p: string): string {
21
+ if (p === '~') return homedir();
22
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
23
+ return resolve(homedir(), p.slice(2));
24
+ }
25
+ return resolve(p);
26
+ }
27
+
28
+ /**
29
+ * Resolve a single config string.
30
+ * - "file:<path>" -> the trimmed contents of <path> (a leading "~" expands to $HOME).
31
+ * - anything else -> returned unchanged, so inline literals keep working.
32
+ *
33
+ * A missing/unreadable file throws (fail loud) — a secret must never silently
34
+ * degrade to the literal "file:..." string. An empty or whitespace-only file
35
+ * also throws, so a blank secret file never goes out as an empty value.
36
+ */
37
+ async function resolveRef(value: string): Promise<string> {
38
+ const m = /^file:(.+)$/s.exec(value.trim());
39
+ if (!m) return value;
40
+ const path = expandHome(m[1].trim());
41
+ let trimmed: string;
42
+ try {
43
+ const raw = await readFile(path, 'utf-8');
44
+ trimmed = raw.replace(/\r?\n+$/, '');
45
+ } catch (err) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ throw new Error(`Failed to read secret file "${path}" (referenced as "${value}"): ${msg}`);
48
+ }
49
+ if (trimmed.trim() === '') {
50
+ throw new Error(
51
+ `Secret file "${path}" (referenced as "${value}") is empty or whitespace-only; refusing to use an empty secret value.`
52
+ );
53
+ }
54
+ return trimmed;
55
+ }
56
+
57
+ /**
58
+ * Recursively resolve every "file:" secret reference in a parsed config object,
59
+ * in place. Lets secrets (tokens, passwords, API keys) live in files such as
60
+ * ~/.ssh/tokens/* instead of inline in the config JSON. Plain values are left
61
+ * untouched, so pre-existing inline configs remain valid.
62
+ */
63
+ export async function resolveSecretRefs(obj: unknown): Promise<void> {
64
+ if (Array.isArray(obj)) {
65
+ for (let i = 0; i < obj.length; i++) {
66
+ const v = obj[i];
67
+ if (typeof v === 'string') obj[i] = await resolveRef(v);
68
+ else if (v && typeof v === 'object') await resolveSecretRefs(v);
69
+ }
70
+ } else if (obj && typeof obj === 'object') {
71
+ const rec = obj as Record<string, unknown>;
72
+ for (const k of Object.keys(rec)) {
73
+ const v = rec[k];
74
+ if (typeof v === 'string') rec[k] = await resolveRef(v);
75
+ else if (v && typeof v === 'object') await resolveSecretRefs(v);
76
+ }
77
+ }
78
+ }
package/source/state.ts CHANGED
@@ -1,62 +1,62 @@
1
- /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
- *
3
- * This file is part of a Moko Consulting project.
4
- *
5
- * SPDX-License-Identifier: GPL-3.0-or-later
6
- *
7
- * FILE INFORMATION
8
- * DEFGROUP: mokomysql.State
9
- * INGROUP: mokomysql
10
- * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
11
- * PATH: /source/state.ts
12
- * BRIEF: Sidecar state store for learned Joomla markers (per host:port/database).
13
- */
14
-
15
- import { readFile, writeFile } from 'node:fs/promises';
16
- import { getStatePath } from './config.js';
17
- import type { JoomlaMarker, MySqlState } from './types.js';
18
-
19
- let cache: MySqlState | null = null;
20
- /** Serializes write-through so concurrent setMarker calls can't clobber the file. */
21
- let writeChain: Promise<void> = Promise.resolve();
22
-
23
- /** Stable key for a learned verdict: identifies the physical database target. */
24
- export function markerKey(host: string, port: number, database: string): string {
25
- return `${host}:${port}/${database}`;
26
- }
27
-
28
- /** Load the sidecar once into memory; a missing/corrupt file is an empty store. */
29
- export async function loadState(): Promise<MySqlState> {
30
- if (cache) return cache;
31
- try {
32
- const parsed = JSON.parse(await readFile(getStatePath(), 'utf-8')) as Partial<MySqlState>;
33
- cache = parsed && typeof parsed.markers === 'object' && parsed.markers !== null
34
- ? { markers: parsed.markers }
35
- : { markers: {} };
36
- } catch {
37
- cache = { markers: {} };
38
- }
39
- return cache;
40
- }
41
-
42
- export async function getMarker(key: string): Promise<JoomlaMarker | undefined> {
43
- return (await loadState()).markers[key];
44
- }
45
-
46
- /**
47
- * Record a learned verdict and persist it. The write is best-effort: an unwritable
48
- * sidecar (read-only home, permissions) must not break queries — the in-memory
49
- * cache still carries the verdict for this process, we just can't remember it next time.
50
- */
51
- export async function setMarker(key: string, marker: JoomlaMarker): Promise<void> {
52
- const state = await loadState();
53
- state.markers[key] = marker;
54
- writeChain = writeChain.then(async () => {
55
- try {
56
- await writeFile(getStatePath(), JSON.stringify(state, null, '\t') + '\n', 'utf-8');
57
- } catch (e) {
58
- process.stderr.write(`mokomysql: could not write state file ${getStatePath()}: ${e}\n`);
59
- }
60
- });
61
- await writeChain;
62
- }
1
+ /* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ *
3
+ * This file is part of a Moko Consulting project.
4
+ *
5
+ * SPDX-License-Identifier: GPL-3.0-or-later
6
+ *
7
+ * FILE INFORMATION
8
+ * DEFGROUP: mokomysql.State
9
+ * INGROUP: mokomysql
10
+ * REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
11
+ * PATH: /source/state.ts
12
+ * BRIEF: Sidecar state store for learned Joomla markers (per host:port/database).
13
+ */
14
+
15
+ import { readFile, writeFile } from 'node:fs/promises';
16
+ import { getStatePath } from './config.js';
17
+ import type { JoomlaMarker, MySqlState } from './types.js';
18
+
19
+ let cache: MySqlState | null = null;
20
+ /** Serializes write-through so concurrent setMarker calls can't clobber the file. */
21
+ let writeChain: Promise<void> = Promise.resolve();
22
+
23
+ /** Stable key for a learned verdict: identifies the physical database target. */
24
+ export function markerKey(host: string, port: number, database: string): string {
25
+ return `${host}:${port}/${database}`;
26
+ }
27
+
28
+ /** Load the sidecar once into memory; a missing/corrupt file is an empty store. */
29
+ export async function loadState(): Promise<MySqlState> {
30
+ if (cache) return cache;
31
+ try {
32
+ const parsed = JSON.parse(await readFile(getStatePath(), 'utf-8')) as Partial<MySqlState>;
33
+ cache = parsed && typeof parsed.markers === 'object' && parsed.markers !== null
34
+ ? { markers: parsed.markers }
35
+ : { markers: {} };
36
+ } catch {
37
+ cache = { markers: {} };
38
+ }
39
+ return cache;
40
+ }
41
+
42
+ export async function getMarker(key: string): Promise<JoomlaMarker | undefined> {
43
+ return (await loadState()).markers[key];
44
+ }
45
+
46
+ /**
47
+ * Record a learned verdict and persist it. The write is best-effort: an unwritable
48
+ * sidecar (read-only home, permissions) must not break queries — the in-memory
49
+ * cache still carries the verdict for this process, we just can't remember it next time.
50
+ */
51
+ export async function setMarker(key: string, marker: JoomlaMarker): Promise<void> {
52
+ const state = await loadState();
53
+ state.markers[key] = marker;
54
+ writeChain = writeChain.then(async () => {
55
+ try {
56
+ await writeFile(getStatePath(), JSON.stringify(state, null, '\t') + '\n', 'utf-8');
57
+ } catch (e) {
58
+ process.stderr.write(`mokomysql: could not write state file ${getStatePath()}: ${e}\n`);
59
+ }
60
+ });
61
+ await writeChain;
62
+ }