@mokoconsulting/mokomysql-mcp 2.5.4 → 2.6.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/LICENSE +696 -696
- package/README.md +122 -112
- package/config.example.json +27 -26
- package/dist/README.md +7 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/migrate.d.ts +57 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +224 -0
- package/dist/migrate.js.map +1 -0
- package/dist/secrets.d.ts.map +1 -1
- package/dist/secrets.js +8 -2
- package/dist/secrets.js.map +1 -1
- package/package.json +1 -1
- package/source/client.ts +210 -210
- package/source/config.ts +104 -97
- package/source/index.ts +1105 -1100
- package/source/migrate.ts +263 -0
- package/source/secrets.ts +78 -70
- package/source/state.ts +62 -62
- package/source/types.ts +91 -91
- package/tsconfig.json +19 -19
package/source/client.ts
CHANGED
|
@@ -1,210 +1,210 @@
|
|
|
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.Client
|
|
9
|
-
* INGROUP: mokomysql
|
|
10
|
-
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
-
* PATH: /src/client.ts
|
|
12
|
-
* VERSION: 01.00.00
|
|
13
|
-
* BRIEF: MySQL client with connection pool management
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import mysql from 'mysql2/promise';
|
|
17
|
-
import type { Pool, PoolOptions, FieldPacket, ResultSetHeader } from 'mysql2/promise';
|
|
18
|
-
import { getConnection } from './config.js';
|
|
19
|
-
import { getMarker, markerKey, setMarker } from './state.js';
|
|
20
|
-
import type { MySqlConfig, MySqlConnection } from './types.js';
|
|
21
|
-
|
|
22
|
-
/** Result of probing a (connection, database) pair for Joomla-ness. */
|
|
23
|
-
export interface JoomlaDetection {
|
|
24
|
-
/** Whether this target is treated as a Joomla database. */
|
|
25
|
-
isJoomla: boolean;
|
|
26
|
-
/** The live table prefix (e.g. "jos_", "vkw4n_"), when known. */
|
|
27
|
-
prefix: string | null;
|
|
28
|
-
/** How the verdict was reached — for surfacing in warnings. */
|
|
29
|
-
source: 'config' | 'marker' | 'auto' | 'none';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class MysqlClient {
|
|
33
|
-
private pools: Map<string, Pool> = new Map();
|
|
34
|
-
private config: MySqlConfig;
|
|
35
|
-
/** Memoized Joomla verdicts, keyed by `${connectionKey}::${schema}`. */
|
|
36
|
-
private joomlaCache: Map<string, JoomlaDetection> = new Map();
|
|
37
|
-
|
|
38
|
-
constructor(config: MySqlConfig) {
|
|
39
|
-
this.config = config;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Resolve the canonical identity of a target for binding/keying: the config
|
|
44
|
-
* connection key and the effective schema (explicit database or the connection
|
|
45
|
-
* default). Keeps callers consistent with how detection and markers are keyed.
|
|
46
|
-
*/
|
|
47
|
-
resolveIdentity(connectionName?: string, database?: string): { connKey: string; schema: string } {
|
|
48
|
-
const { key, conn } = getConnection(this.config, connectionName);
|
|
49
|
-
return { connKey: key, schema: database ?? conn.database ?? '' };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Determine whether a target is a Joomla database, so writes can be gated.
|
|
54
|
-
* Resolution order: in-memory cache → explicit per-connection `joomla` flag
|
|
55
|
-
* (authoritative) → persisted sidecar marker (learned on a prior connect) →
|
|
56
|
-
* live schema probe for the Joomla signature (`<prefix>extensions` paired with
|
|
57
|
-
* `<prefix>assets`). A fresh probe result is persisted as a marker so future
|
|
58
|
-
* connects skip the round-trip entirely.
|
|
59
|
-
*/
|
|
60
|
-
async detectJoomla(connectionName?: string, database?: string): Promise<JoomlaDetection> {
|
|
61
|
-
const { key, conn } = getConnection(this.config, connectionName);
|
|
62
|
-
const schema = database ?? conn.database ?? '';
|
|
63
|
-
const cacheKey = `${key}::${schema}`;
|
|
64
|
-
const cached = this.joomlaCache.get(cacheKey);
|
|
65
|
-
if (cached) return cached;
|
|
66
|
-
|
|
67
|
-
// Explicit config flag is authoritative — no probe, and it overrides any marker.
|
|
68
|
-
if (conn.joomla === true) {
|
|
69
|
-
return this.cache(cacheKey, { isJoomla: true, prefix: conn.joomlaPrefix ?? null, source: 'config' });
|
|
70
|
-
}
|
|
71
|
-
if (conn.joomla === false) {
|
|
72
|
-
return this.cache(cacheKey, { isJoomla: false, prefix: null, source: 'config' });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// Without a resolvable target schema we cannot probe or key a marker.
|
|
76
|
-
if (!schema) {
|
|
77
|
-
return this.cache(cacheKey, { isJoomla: false, prefix: null, source: 'none' });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Learned marker from a previous connect — instant, no round-trip.
|
|
81
|
-
const mKey = markerKey(conn.host, conn.port ?? 3306, schema);
|
|
82
|
-
const marker = await getMarker(mKey);
|
|
83
|
-
if (marker) {
|
|
84
|
-
return this.cache(cacheKey, { isJoomla: marker.joomla, prefix: marker.prefix, source: 'marker' });
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
const { rows } = await this.query(
|
|
89
|
-
"SELECT TABLE_NAME AS name FROM information_schema.TABLES " +
|
|
90
|
-
"WHERE TABLE_SCHEMA = ? AND TABLE_NAME REGEXP '(extensions|assets)$'",
|
|
91
|
-
[schema], connectionName,
|
|
92
|
-
);
|
|
93
|
-
const names = new Set(rows.map((r) => String((r as Record<string, unknown>).name)));
|
|
94
|
-
let prefix: string | null = null;
|
|
95
|
-
for (const name of names) {
|
|
96
|
-
if (!name.endsWith('extensions')) continue;
|
|
97
|
-
const candidate = name.slice(0, -'extensions'.length);
|
|
98
|
-
if (names.has(`${candidate}assets`)) { prefix = candidate; break; }
|
|
99
|
-
}
|
|
100
|
-
const verdict: JoomlaDetection = prefix !== null
|
|
101
|
-
? { isJoomla: true, prefix, source: 'auto' }
|
|
102
|
-
: { isJoomla: false, prefix: null, source: 'auto' };
|
|
103
|
-
// Persist what we learned so the next connect just knows.
|
|
104
|
-
await setMarker(mKey, { joomla: verdict.isJoomla, prefix: verdict.prefix, detectedAt: new Date().toISOString() });
|
|
105
|
-
return this.cache(cacheKey, verdict);
|
|
106
|
-
} catch {
|
|
107
|
-
// Probe failed (permissions, missing schema). Degrade to "none" and do
|
|
108
|
-
// not cache or persist, so a later successful probe can still classify it.
|
|
109
|
-
return { isJoomla: false, prefix: null, source: 'none' };
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
private cache(cacheKey: string, verdict: JoomlaDetection): JoomlaDetection {
|
|
114
|
-
this.joomlaCache.set(cacheKey, verdict);
|
|
115
|
-
return verdict;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
private poolFor(connectionName?: string): Pool {
|
|
119
|
-
const { key, conn } = getConnection(this.config, connectionName);
|
|
120
|
-
let pool = this.pools.get(key);
|
|
121
|
-
if (!pool) {
|
|
122
|
-
const opts: PoolOptions = {
|
|
123
|
-
host: conn.host,
|
|
124
|
-
port: conn.port ?? 3306,
|
|
125
|
-
user: conn.user,
|
|
126
|
-
password: conn.password,
|
|
127
|
-
database: conn.database,
|
|
128
|
-
connectionLimit: 5,
|
|
129
|
-
waitForConnections: true,
|
|
130
|
-
timezone: conn.timezone ?? 'Z',
|
|
131
|
-
multipleStatements: false,
|
|
132
|
-
};
|
|
133
|
-
if (conn.ssl) {
|
|
134
|
-
// Default to verified TLS. A bare `ssl: true` is never silently
|
|
135
|
-
// downgraded — callers who must skip verification (trusted internal
|
|
136
|
-
// hosts with self-signed certs) opt out explicitly via
|
|
137
|
-
// `ssl: { rejectUnauthorized: false }` in their config.
|
|
138
|
-
opts.ssl = typeof conn.ssl === 'object'
|
|
139
|
-
? conn.ssl
|
|
140
|
-
: { rejectUnauthorized: true };
|
|
141
|
-
}
|
|
142
|
-
pool = mysql.createPool(opts);
|
|
143
|
-
this.pools.set(key, pool);
|
|
144
|
-
// On first connect, learn (and persist) the Joomla verdict for the
|
|
145
|
-
// connection's default database so the write-gate knows immediately.
|
|
146
|
-
// Best-effort and non-blocking — never let it break pool creation.
|
|
147
|
-
if (conn.database) {
|
|
148
|
-
void this.detectJoomla(connectionName, conn.database).catch(() => undefined);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return pool;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
async query(
|
|
155
|
-
sql: string,
|
|
156
|
-
params?: unknown[],
|
|
157
|
-
connectionName?: string,
|
|
158
|
-
database?: string,
|
|
159
|
-
): Promise<{ rows: Record<string, unknown>[]; fields: FieldPacket[] }> {
|
|
160
|
-
const pool = this.poolFor(connectionName);
|
|
161
|
-
const p = (params ?? []) as (string | number | null)[];
|
|
162
|
-
if (database) {
|
|
163
|
-
const conn = await pool.getConnection();
|
|
164
|
-
try {
|
|
165
|
-
await conn.query(`USE \`${database.replace(/`/g, '``')}\``);
|
|
166
|
-
const [rows, fields] = await conn.query(sql, p);
|
|
167
|
-
return { rows: rows as Record<string, unknown>[], fields };
|
|
168
|
-
} finally {
|
|
169
|
-
conn.release();
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
const [rows, fields] = await pool.query(sql, p);
|
|
173
|
-
return { rows: rows as Record<string, unknown>[], fields };
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async execute(
|
|
177
|
-
sql: string,
|
|
178
|
-
params?: unknown[],
|
|
179
|
-
connectionName?: string,
|
|
180
|
-
database?: string,
|
|
181
|
-
): Promise<{ affectedRows: number; insertId: number; changedRows: number }> {
|
|
182
|
-
const pool = this.poolFor(connectionName);
|
|
183
|
-
const p = (params ?? []) as (string | number | null)[];
|
|
184
|
-
if (database) {
|
|
185
|
-
const conn = await pool.getConnection();
|
|
186
|
-
try {
|
|
187
|
-
await conn.query(`USE \`${database.replace(/`/g, '``')}\``);
|
|
188
|
-
const [result] = await conn.execute(sql, p);
|
|
189
|
-
const r = result as ResultSetHeader;
|
|
190
|
-
return { affectedRows: r.affectedRows ?? 0, insertId: r.insertId ?? 0, changedRows: r.changedRows ?? 0 };
|
|
191
|
-
} finally {
|
|
192
|
-
conn.release();
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
const [result] = await pool.execute(sql, p);
|
|
196
|
-
const r = result as ResultSetHeader;
|
|
197
|
-
return {
|
|
198
|
-
affectedRows: r.affectedRows ?? 0,
|
|
199
|
-
insertId: r.insertId ?? 0,
|
|
200
|
-
changedRows: r.changedRows ?? 0,
|
|
201
|
-
};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async closeAll(): Promise<void> {
|
|
205
|
-
for (const [, pool] of this.pools) {
|
|
206
|
-
await pool.end();
|
|
207
|
-
}
|
|
208
|
-
this.pools.clear();
|
|
209
|
-
}
|
|
210
|
-
}
|
|
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.Client
|
|
9
|
+
* INGROUP: mokomysql
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
+
* PATH: /src/client.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: MySQL client with connection pool management
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import mysql from 'mysql2/promise';
|
|
17
|
+
import type { Pool, PoolOptions, FieldPacket, ResultSetHeader } from 'mysql2/promise';
|
|
18
|
+
import { getConnection } from './config.js';
|
|
19
|
+
import { getMarker, markerKey, setMarker } from './state.js';
|
|
20
|
+
import type { MySqlConfig, MySqlConnection } from './types.js';
|
|
21
|
+
|
|
22
|
+
/** Result of probing a (connection, database) pair for Joomla-ness. */
|
|
23
|
+
export interface JoomlaDetection {
|
|
24
|
+
/** Whether this target is treated as a Joomla database. */
|
|
25
|
+
isJoomla: boolean;
|
|
26
|
+
/** The live table prefix (e.g. "jos_", "vkw4n_"), when known. */
|
|
27
|
+
prefix: string | null;
|
|
28
|
+
/** How the verdict was reached — for surfacing in warnings. */
|
|
29
|
+
source: 'config' | 'marker' | 'auto' | 'none';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class MysqlClient {
|
|
33
|
+
private pools: Map<string, Pool> = new Map();
|
|
34
|
+
private config: MySqlConfig;
|
|
35
|
+
/** Memoized Joomla verdicts, keyed by `${connectionKey}::${schema}`. */
|
|
36
|
+
private joomlaCache: Map<string, JoomlaDetection> = new Map();
|
|
37
|
+
|
|
38
|
+
constructor(config: MySqlConfig) {
|
|
39
|
+
this.config = config;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the canonical identity of a target for binding/keying: the config
|
|
44
|
+
* connection key and the effective schema (explicit database or the connection
|
|
45
|
+
* default). Keeps callers consistent with how detection and markers are keyed.
|
|
46
|
+
*/
|
|
47
|
+
resolveIdentity(connectionName?: string, database?: string): { connKey: string; schema: string } {
|
|
48
|
+
const { key, conn } = getConnection(this.config, connectionName);
|
|
49
|
+
return { connKey: key, schema: database ?? conn.database ?? '' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Determine whether a target is a Joomla database, so writes can be gated.
|
|
54
|
+
* Resolution order: in-memory cache → explicit per-connection `joomla` flag
|
|
55
|
+
* (authoritative) → persisted sidecar marker (learned on a prior connect) →
|
|
56
|
+
* live schema probe for the Joomla signature (`<prefix>extensions` paired with
|
|
57
|
+
* `<prefix>assets`). A fresh probe result is persisted as a marker so future
|
|
58
|
+
* connects skip the round-trip entirely.
|
|
59
|
+
*/
|
|
60
|
+
async detectJoomla(connectionName?: string, database?: string): Promise<JoomlaDetection> {
|
|
61
|
+
const { key, conn } = getConnection(this.config, connectionName);
|
|
62
|
+
const schema = database ?? conn.database ?? '';
|
|
63
|
+
const cacheKey = `${key}::${schema}`;
|
|
64
|
+
const cached = this.joomlaCache.get(cacheKey);
|
|
65
|
+
if (cached) return cached;
|
|
66
|
+
|
|
67
|
+
// Explicit config flag is authoritative — no probe, and it overrides any marker.
|
|
68
|
+
if (conn.joomla === true) {
|
|
69
|
+
return this.cache(cacheKey, { isJoomla: true, prefix: conn.joomlaPrefix ?? null, source: 'config' });
|
|
70
|
+
}
|
|
71
|
+
if (conn.joomla === false) {
|
|
72
|
+
return this.cache(cacheKey, { isJoomla: false, prefix: null, source: 'config' });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Without a resolvable target schema we cannot probe or key a marker.
|
|
76
|
+
if (!schema) {
|
|
77
|
+
return this.cache(cacheKey, { isJoomla: false, prefix: null, source: 'none' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Learned marker from a previous connect — instant, no round-trip.
|
|
81
|
+
const mKey = markerKey(conn.host, conn.port ?? 3306, schema);
|
|
82
|
+
const marker = await getMarker(mKey);
|
|
83
|
+
if (marker) {
|
|
84
|
+
return this.cache(cacheKey, { isJoomla: marker.joomla, prefix: marker.prefix, source: 'marker' });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const { rows } = await this.query(
|
|
89
|
+
"SELECT TABLE_NAME AS name FROM information_schema.TABLES " +
|
|
90
|
+
"WHERE TABLE_SCHEMA = ? AND TABLE_NAME REGEXP '(extensions|assets)$'",
|
|
91
|
+
[schema], connectionName,
|
|
92
|
+
);
|
|
93
|
+
const names = new Set(rows.map((r) => String((r as Record<string, unknown>).name)));
|
|
94
|
+
let prefix: string | null = null;
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (!name.endsWith('extensions')) continue;
|
|
97
|
+
const candidate = name.slice(0, -'extensions'.length);
|
|
98
|
+
if (names.has(`${candidate}assets`)) { prefix = candidate; break; }
|
|
99
|
+
}
|
|
100
|
+
const verdict: JoomlaDetection = prefix !== null
|
|
101
|
+
? { isJoomla: true, prefix, source: 'auto' }
|
|
102
|
+
: { isJoomla: false, prefix: null, source: 'auto' };
|
|
103
|
+
// Persist what we learned so the next connect just knows.
|
|
104
|
+
await setMarker(mKey, { joomla: verdict.isJoomla, prefix: verdict.prefix, detectedAt: new Date().toISOString() });
|
|
105
|
+
return this.cache(cacheKey, verdict);
|
|
106
|
+
} catch {
|
|
107
|
+
// Probe failed (permissions, missing schema). Degrade to "none" and do
|
|
108
|
+
// not cache or persist, so a later successful probe can still classify it.
|
|
109
|
+
return { isJoomla: false, prefix: null, source: 'none' };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private cache(cacheKey: string, verdict: JoomlaDetection): JoomlaDetection {
|
|
114
|
+
this.joomlaCache.set(cacheKey, verdict);
|
|
115
|
+
return verdict;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private poolFor(connectionName?: string): Pool {
|
|
119
|
+
const { key, conn } = getConnection(this.config, connectionName);
|
|
120
|
+
let pool = this.pools.get(key);
|
|
121
|
+
if (!pool) {
|
|
122
|
+
const opts: PoolOptions = {
|
|
123
|
+
host: conn.host,
|
|
124
|
+
port: conn.port ?? 3306,
|
|
125
|
+
user: conn.user,
|
|
126
|
+
password: conn.password,
|
|
127
|
+
database: conn.database,
|
|
128
|
+
connectionLimit: 5,
|
|
129
|
+
waitForConnections: true,
|
|
130
|
+
timezone: conn.timezone ?? 'Z',
|
|
131
|
+
multipleStatements: false,
|
|
132
|
+
};
|
|
133
|
+
if (conn.ssl) {
|
|
134
|
+
// Default to verified TLS. A bare `ssl: true` is never silently
|
|
135
|
+
// downgraded — callers who must skip verification (trusted internal
|
|
136
|
+
// hosts with self-signed certs) opt out explicitly via
|
|
137
|
+
// `ssl: { rejectUnauthorized: false }` in their config.
|
|
138
|
+
opts.ssl = typeof conn.ssl === 'object'
|
|
139
|
+
? conn.ssl
|
|
140
|
+
: { rejectUnauthorized: true };
|
|
141
|
+
}
|
|
142
|
+
pool = mysql.createPool(opts);
|
|
143
|
+
this.pools.set(key, pool);
|
|
144
|
+
// On first connect, learn (and persist) the Joomla verdict for the
|
|
145
|
+
// connection's default database so the write-gate knows immediately.
|
|
146
|
+
// Best-effort and non-blocking — never let it break pool creation.
|
|
147
|
+
if (conn.database) {
|
|
148
|
+
void this.detectJoomla(connectionName, conn.database).catch(() => undefined);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return pool;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async query(
|
|
155
|
+
sql: string,
|
|
156
|
+
params?: unknown[],
|
|
157
|
+
connectionName?: string,
|
|
158
|
+
database?: string,
|
|
159
|
+
): Promise<{ rows: Record<string, unknown>[]; fields: FieldPacket[] }> {
|
|
160
|
+
const pool = this.poolFor(connectionName);
|
|
161
|
+
const p = (params ?? []) as (string | number | null)[];
|
|
162
|
+
if (database) {
|
|
163
|
+
const conn = await pool.getConnection();
|
|
164
|
+
try {
|
|
165
|
+
await conn.query(`USE \`${database.replace(/`/g, '``')}\``);
|
|
166
|
+
const [rows, fields] = await conn.query(sql, p);
|
|
167
|
+
return { rows: rows as Record<string, unknown>[], fields };
|
|
168
|
+
} finally {
|
|
169
|
+
conn.release();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const [rows, fields] = await pool.query(sql, p);
|
|
173
|
+
return { rows: rows as Record<string, unknown>[], fields };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async execute(
|
|
177
|
+
sql: string,
|
|
178
|
+
params?: unknown[],
|
|
179
|
+
connectionName?: string,
|
|
180
|
+
database?: string,
|
|
181
|
+
): Promise<{ affectedRows: number; insertId: number; changedRows: number }> {
|
|
182
|
+
const pool = this.poolFor(connectionName);
|
|
183
|
+
const p = (params ?? []) as (string | number | null)[];
|
|
184
|
+
if (database) {
|
|
185
|
+
const conn = await pool.getConnection();
|
|
186
|
+
try {
|
|
187
|
+
await conn.query(`USE \`${database.replace(/`/g, '``')}\``);
|
|
188
|
+
const [result] = await conn.execute(sql, p);
|
|
189
|
+
const r = result as ResultSetHeader;
|
|
190
|
+
return { affectedRows: r.affectedRows ?? 0, insertId: r.insertId ?? 0, changedRows: r.changedRows ?? 0 };
|
|
191
|
+
} finally {
|
|
192
|
+
conn.release();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const [result] = await pool.execute(sql, p);
|
|
196
|
+
const r = result as ResultSetHeader;
|
|
197
|
+
return {
|
|
198
|
+
affectedRows: r.affectedRows ?? 0,
|
|
199
|
+
insertId: r.insertId ?? 0,
|
|
200
|
+
changedRows: r.changedRows ?? 0,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async closeAll(): Promise<void> {
|
|
205
|
+
for (const [, pool] of this.pools) {
|
|
206
|
+
await pool.end();
|
|
207
|
+
}
|
|
208
|
+
this.pools.clear();
|
|
209
|
+
}
|
|
210
|
+
}
|
package/source/config.ts
CHANGED
|
@@ -1,97 +1,104 @@
|
|
|
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.Config
|
|
9
|
-
* INGROUP: mokomysql
|
|
10
|
-
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
-
* PATH: /src/config.ts
|
|
12
|
-
* VERSION: 01.00.00
|
|
13
|
-
* BRIEF: Configuration loader for MySQL MCP connections
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { readFile } from 'node:fs/promises';
|
|
17
|
-
import { resolve } from 'node:path';
|
|
18
|
-
import { homedir } from 'node:os';
|
|
19
|
-
import type { MySqlConfig, MySqlConnection } from './types.js';
|
|
20
|
-
import { resolveSecretRefs } from './secrets.js';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
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.Config
|
|
9
|
+
* INGROUP: mokomysql
|
|
10
|
+
* REPO: https://git.mokoconsulting.tech/MokoConsulting/mokomysql-mcp
|
|
11
|
+
* PATH: /src/config.ts
|
|
12
|
+
* VERSION: 01.00.00
|
|
13
|
+
* BRIEF: Configuration loader for MySQL MCP connections
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
import type { MySqlConfig, MySqlConnection } from './types.js';
|
|
20
|
+
import { resolveSecretRefs } from './secrets.js';
|
|
21
|
+
import { migrateInlineSecrets } from './migrate.js';
|
|
22
|
+
|
|
23
|
+
const CONFIG_FILENAME = '.mcp_mokomysql.json';
|
|
24
|
+
|
|
25
|
+
/** Absolute path to the active config file (`MYSQL_MCP_CONFIG` or ~/.mcp_mokomysql.json). */
|
|
26
|
+
export function getConfigPath(): string {
|
|
27
|
+
return process.env.MYSQL_MCP_CONFIG
|
|
28
|
+
? resolve(process.env.MYSQL_MCP_CONFIG)
|
|
29
|
+
: resolve(homedir(), CONFIG_FILENAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Absolute path to the sidecar state file — the MCP-managed learned-marker store.
|
|
34
|
+
* `MYSQL_MCP_STATE` overrides it outright; otherwise it sits beside the config as
|
|
35
|
+
* `<config>.state.json`, so learned state always follows the config that produced it.
|
|
36
|
+
*/
|
|
37
|
+
export function getStatePath(): string {
|
|
38
|
+
if (process.env.MYSQL_MCP_STATE) return resolve(process.env.MYSQL_MCP_STATE);
|
|
39
|
+
return getConfigPath().replace(/\.json$/i, '') + '.state.json';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function loadConfig(): Promise<MySqlConfig> {
|
|
43
|
+
const config_path = getConfigPath();
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const raw = await readFile(config_path, 'utf-8');
|
|
47
|
+
const parsed = JSON.parse(raw) as Partial<MySqlConfig>;
|
|
48
|
+
|
|
49
|
+
// Self-heal: migrate any INLINE secret (no-inline-secrets policy) to a
|
|
50
|
+
// file-backed ref and rewrite the config. Warn-only on failure — never
|
|
51
|
+
// crashes, never loses the credential. Runs before resolveSecretRefs so
|
|
52
|
+
// the freshly written "file:" refs are resolved below.
|
|
53
|
+
await migrateInlineSecrets(parsed, config_path);
|
|
54
|
+
|
|
55
|
+
// Resolve any "file:<path>" secret references (e.g. passwords in ~/.ssh/tokens).
|
|
56
|
+
await resolveSecretRefs(parsed);
|
|
57
|
+
|
|
58
|
+
if (!parsed.connections || Object.keys(parsed.connections).length === 0) {
|
|
59
|
+
throw new Error('No connections defined in config');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
connections: parsed.connections,
|
|
64
|
+
defaultConnection: parsed.defaultConnection ?? Object.keys(parsed.connections)[0],
|
|
65
|
+
};
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
throw new Error(
|
|
69
|
+
`Failed to load config from ${config_path}: ${message}\n` +
|
|
70
|
+
`Create ${config_path} — see config.example.json for format.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Normalize a name for tag matching: lowercase and strip every character
|
|
77
|
+
* that isn't a letter or digit. This makes "moko.live", "moko-live",
|
|
78
|
+
* "Moko Live" and "mokolive" all collapse to the same key.
|
|
79
|
+
*/
|
|
80
|
+
export function normalizeTag(value: string): string {
|
|
81
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getConnection(config: MySqlConfig, name?: string): { key: string; conn: MySqlConnection } {
|
|
85
|
+
const requested = name ?? config.defaultConnection;
|
|
86
|
+
|
|
87
|
+
// 1. Exact key match — fast path, preserves prior behavior exactly.
|
|
88
|
+
const direct = config.connections[requested];
|
|
89
|
+
if (direct) return { key: requested, conn: direct };
|
|
90
|
+
|
|
91
|
+
// 2. Normalized match against connection keys and their tag shortcuts.
|
|
92
|
+
// Returns the canonical key, never the alias/tag the caller typed.
|
|
93
|
+
const target = normalizeTag(requested);
|
|
94
|
+
for (const [key, conn] of Object.entries(config.connections)) {
|
|
95
|
+
if (normalizeTag(key) === target) return { key, conn };
|
|
96
|
+
if (conn.tags?.some((tag) => normalizeTag(tag) === target)) return { key, conn };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 3. Not found — list keys plus their tags so the caller can self-correct.
|
|
100
|
+
const available = Object.entries(config.connections)
|
|
101
|
+
.map(([key, conn]) => (conn.tags?.length ? `${key} [${conn.tags.join(', ')}]` : key))
|
|
102
|
+
.join(', ');
|
|
103
|
+
throw new Error(`Connection "${requested}" not found. Available: ${available}`);
|
|
104
|
+
}
|