@ecubelabs/mysql-mcp 0.0.0-stage → 0.0.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.
- package/LICENSE.md +21 -0
- package/README.md +848 -2
- package/dist/index.js +684 -0
- package/dist/src/catalog/collect.js +220 -0
- package/dist/src/catalog/docs.js +328 -0
- package/dist/src/catalog/index.js +685 -0
- package/dist/src/catalog/render.js +177 -0
- package/dist/src/catalog/store.js +617 -0
- package/dist/src/catalog/types.js +59 -0
- package/dist/src/catalog/usage.js +164 -0
- package/dist/src/config/index.js +244 -0
- package/dist/src/db/diagnose.js +189 -0
- package/dist/src/db/index.js +461 -0
- package/dist/src/db/permissions.js +26 -0
- package/dist/src/db/utils.js +264 -0
- package/dist/src/ssh/config.js +137 -0
- package/dist/src/ssh/tunnel.js +334 -0
- package/dist/src/types/index.js +1 -0
- package/dist/src/utils/index.js +11 -0
- package/package.json +50 -3
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as net from 'net';
|
|
3
|
+
import { Client as SSHClient } from 'ssh2';
|
|
4
|
+
import { MYSQL_HOST, MYSQL_PORT, SSH_CONFIG_HOST, SSH_ENABLED, SSH_HOST, SSH_LOCAL_PORT, SSH_PASSPHRASE, SSH_PORT, SSH_PRIVATE_KEY_PATH, SSH_REUSE_EXISTING, SSH_USER, } from '../config/index.js';
|
|
5
|
+
import { log } from '../utils/index.js';
|
|
6
|
+
import { expandHome, readSSHConfigHost } from './config.js';
|
|
7
|
+
class LocalPortTakenError extends Error {
|
|
8
|
+
}
|
|
9
|
+
const RECONNECT_MAX_ATTEMPTS = 3;
|
|
10
|
+
const RECONNECT_BASE_DELAY_MS = 1000;
|
|
11
|
+
const PROBE_TIMEOUT_MS = 750;
|
|
12
|
+
const SSH_READY_TIMEOUT_MS = 20000;
|
|
13
|
+
let resolvedTunnelConfig = null;
|
|
14
|
+
export function resolveTunnelConfig() {
|
|
15
|
+
if (resolvedTunnelConfig) {
|
|
16
|
+
return resolvedTunnelConfig;
|
|
17
|
+
}
|
|
18
|
+
const configHost = SSH_CONFIG_HOST;
|
|
19
|
+
let fromConfig = null;
|
|
20
|
+
if (configHost) {
|
|
21
|
+
fromConfig = readSSHConfigHost(configHost);
|
|
22
|
+
if (!fromConfig) {
|
|
23
|
+
throw new Error(`MYSQL_SSH_CONFIG_HOST="${configHost}" was not found as a Host alias in ~/.ssh/config. ` +
|
|
24
|
+
`Either add the alias or set MYSQL_SSH_HOST / MYSQL_SSH_USER explicitly.`);
|
|
25
|
+
}
|
|
26
|
+
log('info', `[ssh] resolved alias "${configHost}" from ~/.ssh/config: ` +
|
|
27
|
+
JSON.stringify({
|
|
28
|
+
hostName: fromConfig.hostName,
|
|
29
|
+
user: fromConfig.user,
|
|
30
|
+
port: fromConfig.port,
|
|
31
|
+
localForward: fromConfig.localForward,
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
34
|
+
const sshHost = SSH_HOST ?? fromConfig?.hostName;
|
|
35
|
+
const sshUser = SSH_USER ?? fromConfig?.user;
|
|
36
|
+
const sshPort = SSH_PORT ?? fromConfig?.port ?? 22;
|
|
37
|
+
const privateKeyPath = expandHome(SSH_PRIVATE_KEY_PATH ?? fromConfig?.identityFile ?? '~/.ssh/id_rsa');
|
|
38
|
+
const localPort = SSH_LOCAL_PORT ?? fromConfig?.localForward?.localPort ?? 0;
|
|
39
|
+
const remoteHost = fromConfig?.localForward?.remoteHost ?? MYSQL_HOST;
|
|
40
|
+
const remotePort = fromConfig?.localForward?.remotePort ?? MYSQL_PORT;
|
|
41
|
+
const missing = [];
|
|
42
|
+
if (!sshHost) {
|
|
43
|
+
missing.push('MYSQL_SSH_HOST (or a HostName in the alias)');
|
|
44
|
+
}
|
|
45
|
+
if (!sshUser) {
|
|
46
|
+
missing.push('MYSQL_SSH_USER (or a User in the alias)');
|
|
47
|
+
}
|
|
48
|
+
if (!remoteHost) {
|
|
49
|
+
missing.push('MYSQL_HOST (or a LocalForward target in the alias)');
|
|
50
|
+
}
|
|
51
|
+
if (missing.length > 0) {
|
|
52
|
+
throw new Error(`SSH tunnel is enabled but underspecified. Missing: ${missing.join(', ')}.`);
|
|
53
|
+
}
|
|
54
|
+
if (!fs.existsSync(privateKeyPath)) {
|
|
55
|
+
throw new Error(`SSH private key not found at "${privateKeyPath}". ` +
|
|
56
|
+
`Set MYSQL_SSH_PRIVATE_KEY_PATH to the key that authenticates ${sshUser}@${sshHost}.`);
|
|
57
|
+
}
|
|
58
|
+
resolvedTunnelConfig = {
|
|
59
|
+
sshHost: sshHost,
|
|
60
|
+
sshPort,
|
|
61
|
+
sshUser: sshUser,
|
|
62
|
+
privateKeyPath,
|
|
63
|
+
passphrase: SSH_PASSPHRASE,
|
|
64
|
+
localPort,
|
|
65
|
+
remoteHost: remoteHost,
|
|
66
|
+
remotePort,
|
|
67
|
+
};
|
|
68
|
+
return resolvedTunnelConfig;
|
|
69
|
+
}
|
|
70
|
+
function probeLocalPort(port) {
|
|
71
|
+
return new Promise((resolve) => {
|
|
72
|
+
const socket = new net.Socket();
|
|
73
|
+
let settled = false;
|
|
74
|
+
const finish = (result) => {
|
|
75
|
+
if (settled) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
settled = true;
|
|
79
|
+
socket.destroy();
|
|
80
|
+
resolve(result);
|
|
81
|
+
};
|
|
82
|
+
socket.setTimeout(PROBE_TIMEOUT_MS);
|
|
83
|
+
socket.once('connect', () => finish(true));
|
|
84
|
+
socket.once('timeout', () => finish(false));
|
|
85
|
+
socket.once('error', () => finish(false));
|
|
86
|
+
socket.connect(port, '127.0.0.1');
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
let endpointPromise = null;
|
|
90
|
+
let localServer = null;
|
|
91
|
+
let sshClient = null;
|
|
92
|
+
let activeConfig = null;
|
|
93
|
+
let activeEndpoint = null;
|
|
94
|
+
let shuttingDown = false;
|
|
95
|
+
let fatalError = null;
|
|
96
|
+
function connectSSH(cfg) {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const client = new SSHClient();
|
|
99
|
+
let settled = false;
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
if (settled) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
settled = true;
|
|
105
|
+
client.destroy();
|
|
106
|
+
reject(new Error(`SSH handshake with ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} did not complete within ${SSH_READY_TIMEOUT_MS}ms.`));
|
|
107
|
+
}, SSH_READY_TIMEOUT_MS);
|
|
108
|
+
client.once('ready', () => {
|
|
109
|
+
if (settled) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
settled = true;
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
resolve(client);
|
|
115
|
+
});
|
|
116
|
+
client.once('error', (err) => {
|
|
117
|
+
if (settled) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
settled = true;
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
client.destroy();
|
|
123
|
+
reject(new Error(`SSH connection to ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} failed: ${err.message}`));
|
|
124
|
+
});
|
|
125
|
+
let privateKey;
|
|
126
|
+
try {
|
|
127
|
+
privateKey = fs.readFileSync(cfg.privateKeyPath);
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
clearTimeout(timer);
|
|
131
|
+
settled = true;
|
|
132
|
+
reject(new Error(`Failed to read SSH private key "${cfg.privateKeyPath}": ${err.message}`));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const connectConfig = {
|
|
136
|
+
host: cfg.sshHost,
|
|
137
|
+
port: cfg.sshPort,
|
|
138
|
+
username: cfg.sshUser,
|
|
139
|
+
privateKey,
|
|
140
|
+
readyTimeout: SSH_READY_TIMEOUT_MS,
|
|
141
|
+
keepaliveInterval: 15000,
|
|
142
|
+
keepaliveCountMax: 3,
|
|
143
|
+
...(cfg.passphrase ? { passphrase: cfg.passphrase } : {}),
|
|
144
|
+
};
|
|
145
|
+
client.connect(connectConfig);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async function reconnect(cfg) {
|
|
149
|
+
for (let attempt = 1; attempt <= RECONNECT_MAX_ATTEMPTS; attempt++) {
|
|
150
|
+
if (shuttingDown) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const delay = RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1);
|
|
154
|
+
log('error', `[ssh] tunnel dropped; reconnect attempt ${attempt}/${RECONNECT_MAX_ATTEMPTS} in ${delay}ms`);
|
|
155
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
156
|
+
if (shuttingDown) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const client = await connectSSH(cfg);
|
|
161
|
+
sshClient = client;
|
|
162
|
+
fatalError = null;
|
|
163
|
+
attachDropHandler(client, cfg);
|
|
164
|
+
log('error', `[ssh] tunnel reconnected on attempt ${attempt}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
log('error', `[ssh] reconnect attempt ${attempt} failed: ${err.message}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
fatalError = new Error(`SSH tunnel to ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} could not be re-established after ${RECONNECT_MAX_ATTEMPTS} attempts. ` +
|
|
172
|
+
`Restart the MCP server once the bastion is reachable again.`);
|
|
173
|
+
log('error', `[ssh] ${fatalError.message}`);
|
|
174
|
+
}
|
|
175
|
+
let reconnecting = false;
|
|
176
|
+
function attachDropHandler(client, cfg) {
|
|
177
|
+
const onDrop = () => {
|
|
178
|
+
if (shuttingDown || reconnecting) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (sshClient !== client) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
sshClient = null;
|
|
185
|
+
reconnecting = true;
|
|
186
|
+
void reconnect(cfg).finally(() => {
|
|
187
|
+
reconnecting = false;
|
|
188
|
+
});
|
|
189
|
+
};
|
|
190
|
+
client.once('close', onDrop);
|
|
191
|
+
client.once('error', (err) => {
|
|
192
|
+
log('error', `[ssh] transport error: ${err.message}`);
|
|
193
|
+
onDrop();
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
export function forwardSocket(socket, client, cfg, unavailable = null) {
|
|
197
|
+
socket.on('error', (err) => {
|
|
198
|
+
log('error', `[ssh] local tunnel socket closed with error: ${err.message}`);
|
|
199
|
+
});
|
|
200
|
+
if (!client) {
|
|
201
|
+
socket.destroy(unavailable ?? new Error('SSH tunnel is not currently connected; retry shortly.'));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
client.forwardOut('127.0.0.1', 0, cfg.remoteHost, cfg.remotePort, (err, stream) => {
|
|
205
|
+
if (err) {
|
|
206
|
+
log('error', `[ssh] forwardOut to ${cfg.remoteHost}:${cfg.remotePort} failed: ${err.message}`);
|
|
207
|
+
socket.destroy(err);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
socket.pipe(stream).pipe(socket);
|
|
211
|
+
const cleanup = () => {
|
|
212
|
+
stream.destroy();
|
|
213
|
+
socket.destroy();
|
|
214
|
+
};
|
|
215
|
+
socket.once('error', cleanup);
|
|
216
|
+
stream.once('error', cleanup);
|
|
217
|
+
socket.once('close', () => stream.destroy());
|
|
218
|
+
stream.once('close', () => socket.destroy());
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
function startLocalServer(cfg, requestedPort) {
|
|
222
|
+
return new Promise((resolve, reject) => {
|
|
223
|
+
const server = net.createServer((socket) => forwardSocket(socket, sshClient, cfg, fatalError));
|
|
224
|
+
server.once('error', (err) => {
|
|
225
|
+
if (err.code === 'EADDRINUSE') {
|
|
226
|
+
reject(new LocalPortTakenError(`Local port ${requestedPort} was claimed by another process.`));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
reject(new Error(`Failed to open local tunnel listener on port ${requestedPort}: ${err.message}`));
|
|
230
|
+
});
|
|
231
|
+
server.listen(requestedPort, '127.0.0.1', () => {
|
|
232
|
+
const address = server.address();
|
|
233
|
+
if (!address || typeof address === 'string') {
|
|
234
|
+
reject(new Error('Local tunnel listener reported no TCP address.'));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
localServer = server;
|
|
238
|
+
resolve(address.port);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
export function ensureTunnel() {
|
|
243
|
+
if (!SSH_ENABLED) {
|
|
244
|
+
return Promise.resolve(null);
|
|
245
|
+
}
|
|
246
|
+
if (!endpointPromise) {
|
|
247
|
+
endpointPromise = (async () => {
|
|
248
|
+
const cfg = resolveTunnelConfig();
|
|
249
|
+
activeConfig = cfg;
|
|
250
|
+
if (SSH_REUSE_EXISTING && cfg.localPort !== 0 && (await probeLocalPort(cfg.localPort))) {
|
|
251
|
+
log('error', `[ssh] 127.0.0.1:${cfg.localPort} is already accepting connections; reusing it (MYSQL_SSH_REUSE_EXISTING=true).`);
|
|
252
|
+
activeEndpoint = { host: '127.0.0.1', port: cfg.localPort, reused: true };
|
|
253
|
+
return activeEndpoint;
|
|
254
|
+
}
|
|
255
|
+
log('error', `[ssh] opening tunnel ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} -> ${cfg.remoteHost}:${cfg.remotePort} ` +
|
|
256
|
+
`(local port ${cfg.localPort === 0 ? 'auto' : cfg.localPort})`);
|
|
257
|
+
const client = await connectSSH(cfg);
|
|
258
|
+
sshClient = client;
|
|
259
|
+
let port;
|
|
260
|
+
try {
|
|
261
|
+
port = await startLocalServer(cfg, cfg.localPort);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
if (!(err instanceof LocalPortTakenError)) {
|
|
265
|
+
client.destroy();
|
|
266
|
+
sshClient = null;
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
log('error', `[ssh] local port ${cfg.localPort} is taken; opening our own tunnel on an auto-assigned port instead.`);
|
|
270
|
+
try {
|
|
271
|
+
port = await startLocalServer(cfg, 0);
|
|
272
|
+
}
|
|
273
|
+
catch (fallbackErr) {
|
|
274
|
+
client.destroy();
|
|
275
|
+
sshClient = null;
|
|
276
|
+
throw fallbackErr;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
attachDropHandler(client, cfg);
|
|
280
|
+
log('error', `[ssh] tunnel listening on 127.0.0.1:${port}`);
|
|
281
|
+
activeEndpoint = { host: '127.0.0.1', port, reused: false };
|
|
282
|
+
return activeEndpoint;
|
|
283
|
+
})();
|
|
284
|
+
endpointPromise.catch(() => {
|
|
285
|
+
endpointPromise = null;
|
|
286
|
+
activeConfig = null;
|
|
287
|
+
activeEndpoint = null;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return endpointPromise;
|
|
291
|
+
}
|
|
292
|
+
export function getTunnelEndpoint() {
|
|
293
|
+
return activeEndpoint;
|
|
294
|
+
}
|
|
295
|
+
export function getTunnelFatalError() {
|
|
296
|
+
return fatalError;
|
|
297
|
+
}
|
|
298
|
+
export function describeTunnel() {
|
|
299
|
+
if (!SSH_ENABLED) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
if (!activeConfig) {
|
|
303
|
+
return 'ssh tunnel: enabled (not yet established)';
|
|
304
|
+
}
|
|
305
|
+
const { sshUser, sshHost, remoteHost, remotePort } = activeConfig;
|
|
306
|
+
return `ssh tunnel: ${sshUser}@${sshHost} -> ${remoteHost}:${remotePort}`;
|
|
307
|
+
}
|
|
308
|
+
export async function stopTunnel() {
|
|
309
|
+
shuttingDown = true;
|
|
310
|
+
const server = localServer;
|
|
311
|
+
localServer = null;
|
|
312
|
+
if (server) {
|
|
313
|
+
await new Promise((resolve) => {
|
|
314
|
+
server.close(() => resolve());
|
|
315
|
+
server.unref();
|
|
316
|
+
setTimeout(resolve, 1000);
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const client = sshClient;
|
|
320
|
+
sshClient = null;
|
|
321
|
+
if (client) {
|
|
322
|
+
try {
|
|
323
|
+
client.end();
|
|
324
|
+
client.destroy();
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
log('error', `[ssh] error closing SSH client: ${err.message}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
endpointPromise = null;
|
|
331
|
+
activeConfig = null;
|
|
332
|
+
activeEndpoint = null;
|
|
333
|
+
shuttingDown = false;
|
|
334
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ENABLE_LOGGING } from '../config/index.js';
|
|
2
|
+
export function tableResourceUri(schema, table) {
|
|
3
|
+
return `mysql://tables/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`;
|
|
4
|
+
}
|
|
5
|
+
export function log(type = 'info', ...args) {
|
|
6
|
+
if (!ENABLE_LOGGING) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const prefix = type === 'error' ? '[error]' : '[info]';
|
|
10
|
+
console.error(prefix, ...args);
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecubelabs/mysql-mcp",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Read-only MySQL MCP server that reaches its database through an SSH bastion, with per-environment profiles",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/Ecube-Labs/skynet.git",
|
|
8
|
+
"directory": "packages/mysql-mcp"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"main": "dist/index.js",
|
|
18
|
+
"bin": "dist/index.js",
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"lint": "oxfmt --check . && oxlint && eslint index.ts \"src/**/*.ts\"",
|
|
24
|
+
"lint:ci": "oxfmt --check . && oxlint --quiet && eslint --quiet index.ts \"src/**/*.ts\"",
|
|
25
|
+
"lint:fix": "oxfmt . && oxlint --fix --quiet && eslint --fix index.ts \"src/**/*.ts\"",
|
|
26
|
+
"build": "tsc && shx chmod +x dist/index.js",
|
|
27
|
+
"prepack": "yarn build",
|
|
28
|
+
"watch": "tsc --watch",
|
|
29
|
+
"test": "tsc -p tsconfig.test.json && vitest run",
|
|
30
|
+
"test:watch": "vitest",
|
|
31
|
+
"start:stage": "./bin/mcp-mysql-stage.sh",
|
|
32
|
+
"start:prod": "./bin/mcp-mysql-prod.sh"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.31.0",
|
|
36
|
+
"mysql2": "^3.14.1",
|
|
37
|
+
"node-sql-parser": "^5.3.9",
|
|
38
|
+
"ssh2": "^1.17.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@semantic-release/npm": "^13.1.5",
|
|
42
|
+
"@types/node": "^22.20.2",
|
|
43
|
+
"@types/ssh2": "^1.15.5",
|
|
44
|
+
"eslint": "^9.27.0",
|
|
45
|
+
"eslint-config-skynet": "^0.0.0",
|
|
46
|
+
"semantic-release": "^25.0.9",
|
|
47
|
+
"semantic-release-monorepo": "^8.0.2",
|
|
48
|
+
"shx": "^0.3.4",
|
|
49
|
+
"typescript": "^5.8.3",
|
|
50
|
+
"vite": "^8.3.0",
|
|
51
|
+
"vitest": "^5.0.0"
|
|
52
|
+
}
|
|
6
53
|
}
|