@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,164 @@
|
|
|
1
|
+
import SqlParser from 'node-sql-parser';
|
|
2
|
+
const { Parser } = SqlParser;
|
|
3
|
+
const parser = new Parser();
|
|
4
|
+
function cteNames(ast) {
|
|
5
|
+
const names = new Set();
|
|
6
|
+
const visit = (node) => {
|
|
7
|
+
if (node == null || typeof node !== 'object' || node instanceof Date) {
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const obj = node;
|
|
11
|
+
if (Array.isArray(obj.with)) {
|
|
12
|
+
for (const entry of obj.with) {
|
|
13
|
+
if (!entry || typeof entry !== 'object') {
|
|
14
|
+
continue;
|
|
15
|
+
}
|
|
16
|
+
const name = entry.name;
|
|
17
|
+
if (name && typeof name === 'object') {
|
|
18
|
+
const value = name.value;
|
|
19
|
+
if (typeof value === 'string') {
|
|
20
|
+
names.add(value.toLowerCase());
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
for (const value of Object.values(obj)) {
|
|
26
|
+
if (Array.isArray(value)) {
|
|
27
|
+
for (const item of value) {
|
|
28
|
+
visit(item);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
visit(value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
visit(ast);
|
|
37
|
+
return names;
|
|
38
|
+
}
|
|
39
|
+
function referencesFromTableList(sql, commonTableExpressions) {
|
|
40
|
+
const seen = new Set();
|
|
41
|
+
const references = [];
|
|
42
|
+
for (const encoded of parser.tableList(sql, { database: 'mysql' })) {
|
|
43
|
+
const parts = encoded.split('::');
|
|
44
|
+
const schema = parts[1] && parts[1] !== 'null' ? parts[1] : null;
|
|
45
|
+
const table = parts.slice(2).join('::');
|
|
46
|
+
if (!table) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!schema && commonTableExpressions.has(table.toLowerCase())) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const key = `${schema ?? ''}.${table}`.toLowerCase();
|
|
53
|
+
if (seen.has(key)) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
seen.add(key);
|
|
57
|
+
references.push({ schema, table });
|
|
58
|
+
}
|
|
59
|
+
return references;
|
|
60
|
+
}
|
|
61
|
+
function collectAliases(node, aliases) {
|
|
62
|
+
if (node == null || typeof node !== 'object' || node instanceof Date) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const obj = node;
|
|
66
|
+
if (obj.type !== 'column_ref' && typeof obj.table === 'string' && (typeof obj.db === 'string' || obj.db === null)) {
|
|
67
|
+
const reference = {
|
|
68
|
+
schema: typeof obj.db === 'string' ? obj.db : null,
|
|
69
|
+
table: obj.table,
|
|
70
|
+
};
|
|
71
|
+
aliases.set(obj.table.toLowerCase(), reference);
|
|
72
|
+
if (typeof obj.as === 'string') {
|
|
73
|
+
aliases.set(obj.as.toLowerCase(), reference);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const value of Object.values(obj)) {
|
|
77
|
+
if (Array.isArray(value)) {
|
|
78
|
+
for (const item of value) {
|
|
79
|
+
collectAliases(item, aliases);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
collectAliases(value, aliases);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function columnEndpoint(node, aliases, references) {
|
|
88
|
+
if (!node || typeof node !== 'object') {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const obj = node;
|
|
92
|
+
if (obj.type !== 'column_ref' || typeof obj.column !== 'string') {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
if (typeof obj.table === 'string') {
|
|
96
|
+
const table = aliases.get(obj.table.toLowerCase());
|
|
97
|
+
return table ? { ...table, column: obj.column } : null;
|
|
98
|
+
}
|
|
99
|
+
return references.length === 1 ? { ...references[0], column: obj.column } : null;
|
|
100
|
+
}
|
|
101
|
+
function collectJoins(node, aliases, references, seen, joins) {
|
|
102
|
+
if (node == null || typeof node !== 'object' || node instanceof Date) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const obj = node;
|
|
106
|
+
if (obj.type === 'binary_expr' && obj.operator === '=') {
|
|
107
|
+
const left = columnEndpoint(obj.left, aliases, references);
|
|
108
|
+
const right = columnEndpoint(obj.right, aliases, references);
|
|
109
|
+
if (left && right) {
|
|
110
|
+
const a = `${left.schema ?? ''}.${left.table}.${left.column}`;
|
|
111
|
+
const b = `${right.schema ?? ''}.${right.table}.${right.column}`;
|
|
112
|
+
const key = [a, b].sort().join('\u0000').toLowerCase();
|
|
113
|
+
if (!seen.has(key)) {
|
|
114
|
+
seen.add(key);
|
|
115
|
+
joins.push({ a: left, b: right });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const value of Object.values(obj)) {
|
|
120
|
+
if (Array.isArray(value)) {
|
|
121
|
+
for (const item of value) {
|
|
122
|
+
collectJoins(item, aliases, references, seen, joins);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
collectJoins(value, aliases, references, seen, joins);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function prepareCatalogQuery(sql) {
|
|
131
|
+
try {
|
|
132
|
+
const ast = parser.astify(sql, { database: 'mysql' });
|
|
133
|
+
const references = referencesFromTableList(sql, cteNames(ast));
|
|
134
|
+
const aliases = new Map();
|
|
135
|
+
collectAliases(ast, aliases);
|
|
136
|
+
const joins = [];
|
|
137
|
+
collectJoins(ast, aliases, references, new Set(), joins);
|
|
138
|
+
return { references, joins };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { references: [], joins: [] };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export function resultColumnNames(payload) {
|
|
145
|
+
try {
|
|
146
|
+
const parsed = JSON.parse(payload);
|
|
147
|
+
if (!Array.isArray(parsed)) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const names = new Set();
|
|
151
|
+
for (const row of parsed) {
|
|
152
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
for (const name of Object.keys(row)) {
|
|
156
|
+
names.add(name);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return [...names];
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
function parseSchemaPermissions(permissionsString) {
|
|
5
|
+
const permissions = {};
|
|
6
|
+
if (!permissionsString) {
|
|
7
|
+
return permissions;
|
|
8
|
+
}
|
|
9
|
+
for (const pair of permissionsString.split(',')) {
|
|
10
|
+
const [schema, value] = pair.split(':');
|
|
11
|
+
if (schema && value) {
|
|
12
|
+
permissions[schema.trim()] = value.trim() === 'true';
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return permissions;
|
|
16
|
+
}
|
|
17
|
+
function readSSLFile(filePath, label) {
|
|
18
|
+
try {
|
|
19
|
+
if (!fs.existsSync(filePath)) {
|
|
20
|
+
throw new Error(`SSL ${label} file not found: ${filePath}`);
|
|
21
|
+
}
|
|
22
|
+
const data = fs.readFileSync(filePath);
|
|
23
|
+
if (data.length === 0) {
|
|
24
|
+
throw new Error(`SSL ${label} file is empty: ${filePath}`);
|
|
25
|
+
}
|
|
26
|
+
return data;
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error instanceof Error) {
|
|
30
|
+
if (error.message.startsWith('SSL ')) {
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`Failed to read SSL ${label}: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function readCACertificate(filePath) {
|
|
39
|
+
return readSSLFile(filePath, 'CA certificate');
|
|
40
|
+
}
|
|
41
|
+
const PACKAGE_NAME = '@ecubelabs/mysql-mcp';
|
|
42
|
+
export function readPackageVersion(startDir) {
|
|
43
|
+
let dir = startDir;
|
|
44
|
+
for (;;) {
|
|
45
|
+
try {
|
|
46
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
47
|
+
if (manifest.name === PACKAGE_NAME && typeof manifest.version === 'string') {
|
|
48
|
+
return manifest.version;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
}
|
|
53
|
+
const parent = path.dirname(dir);
|
|
54
|
+
if (parent === dir) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
dir = parent;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const MYSQL_PROFILE = (process.env.MYSQL_PROFILE ?? '').trim().toLowerCase();
|
|
61
|
+
const WRITE_FORBIDDEN_PROFILES = new Set(['prod', 'production']);
|
|
62
|
+
export const IS_WRITE_FORBIDDEN_PROFILE = WRITE_FORBIDDEN_PROFILES.has(MYSQL_PROFILE);
|
|
63
|
+
export const PROFILE_LABEL = MYSQL_PROFILE ? MYSQL_PROFILE.toUpperCase() : 'UNSPECIFIED';
|
|
64
|
+
const DEFAULT_CODE_BRANCH = {
|
|
65
|
+
stage: 'develop',
|
|
66
|
+
staging: 'develop',
|
|
67
|
+
dev: 'develop',
|
|
68
|
+
develop: 'develop',
|
|
69
|
+
prod: 'main',
|
|
70
|
+
production: 'main',
|
|
71
|
+
};
|
|
72
|
+
export const CODE_BRANCH = process.env.MYSQL_CODE_BRANCH?.trim() || DEFAULT_CODE_BRANCH[MYSQL_PROFILE] || '';
|
|
73
|
+
function parseAppSchemas(raw) {
|
|
74
|
+
if (!raw) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
const entries = [];
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
for (const entry of raw.split(/[;\n]/)) {
|
|
80
|
+
const trimmed = entry.trim();
|
|
81
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const firstColon = trimmed.indexOf(':');
|
|
85
|
+
if (firstColon === -1) {
|
|
86
|
+
console.error(`[config] ignoring MYSQL_APP_SCHEMAS entry "${trimmed}": expected "app:schema" or "app:schema:description".`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const app = trimmed.slice(0, firstColon).trim();
|
|
90
|
+
const rest = trimmed.slice(firstColon + 1);
|
|
91
|
+
const secondColon = rest.indexOf(':');
|
|
92
|
+
const schema = (secondColon === -1 ? rest : rest.slice(0, secondColon)).trim();
|
|
93
|
+
const description = secondColon === -1 ? undefined : rest.slice(secondColon + 1).trim() || undefined;
|
|
94
|
+
if (!app || !schema) {
|
|
95
|
+
console.error(`[config] ignoring MYSQL_APP_SCHEMAS entry "${trimmed}": app and schema must both be non-empty.`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const pair = `${app.toLowerCase()}\u0000${schema.toLowerCase()}`;
|
|
99
|
+
if (seen.has(pair)) {
|
|
100
|
+
console.error(`[config] MYSQL_APP_SCHEMAS repeats "${app}:${schema}"; keeping the first.`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
seen.add(pair);
|
|
104
|
+
entries.push({ app, schema, description });
|
|
105
|
+
}
|
|
106
|
+
return entries;
|
|
107
|
+
}
|
|
108
|
+
export const APP_SCHEMAS = parseAppSchemas(process.env.MYSQL_APP_SCHEMAS);
|
|
109
|
+
export const MYSQL_CATALOG_ENABLED = process.env.MYSQL_CATALOG_ENABLED !== 'false';
|
|
110
|
+
export const MYSQL_CATALOG_PATH = process.env.MYSQL_CATALOG_PATH?.trim() || undefined;
|
|
111
|
+
function parseCatalogTtl(raw) {
|
|
112
|
+
if (!raw) {
|
|
113
|
+
return 24;
|
|
114
|
+
}
|
|
115
|
+
const value = Number(raw);
|
|
116
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
117
|
+
console.error(`[config] ignoring MYSQL_CATALOG_TTL_HOURS="${raw}": expected a positive number; using 24.`);
|
|
118
|
+
return 24;
|
|
119
|
+
}
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
export const MYSQL_CATALOG_TTL_HOURS = parseCatalogTtl(process.env.MYSQL_CATALOG_TTL_HOURS);
|
|
123
|
+
export const MYSQL_DOCS_REPO = process.env.MYSQL_DOCS_REPO?.trim() || undefined;
|
|
124
|
+
export const ENABLE_LOGGING = process.env.ENABLE_LOGGING === 'true' || process.env.ENABLE_LOGGING === '1';
|
|
125
|
+
export const PACKAGE_VERSION = readPackageVersion(path.dirname(fileURLToPath(import.meta.url))) ?? 'unknown';
|
|
126
|
+
export const MYSQL_DB = process.env.MYSQL_DB?.trim() || undefined;
|
|
127
|
+
const envFlag = (name) => process.env[name] === 'true';
|
|
128
|
+
const writeFlag = (name) => (IS_WRITE_FORBIDDEN_PROFILE ? false : envFlag(name));
|
|
129
|
+
if (IS_WRITE_FORBIDDEN_PROFILE) {
|
|
130
|
+
const requested = [
|
|
131
|
+
...['ALLOW_INSERT_OPERATION', 'ALLOW_UPDATE_OPERATION', 'ALLOW_DELETE_OPERATION', 'ALLOW_DDL_OPERATION'].filter(envFlag),
|
|
132
|
+
...[
|
|
133
|
+
'SCHEMA_INSERT_PERMISSIONS',
|
|
134
|
+
'SCHEMA_UPDATE_PERMISSIONS',
|
|
135
|
+
'SCHEMA_DELETE_PERMISSIONS',
|
|
136
|
+
'SCHEMA_DDL_PERMISSIONS',
|
|
137
|
+
].filter((name) => Boolean(process.env[name]?.trim())),
|
|
138
|
+
];
|
|
139
|
+
if (requested.length > 0) {
|
|
140
|
+
console.error(`[config] profile "${MYSQL_PROFILE}" is read-only by policy; ignoring ${requested.join(', ')}.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export const ALLOW_INSERT_OPERATION = writeFlag('ALLOW_INSERT_OPERATION');
|
|
144
|
+
export const ALLOW_UPDATE_OPERATION = writeFlag('ALLOW_UPDATE_OPERATION');
|
|
145
|
+
export const ALLOW_DELETE_OPERATION = writeFlag('ALLOW_DELETE_OPERATION');
|
|
146
|
+
export const ALLOW_DDL_OPERATION = writeFlag('ALLOW_DDL_OPERATION');
|
|
147
|
+
export const MYSQL_DISABLE_READ_ONLY_TRANSACTIONS = process.env.MYSQL_DISABLE_READ_ONLY_TRANSACTIONS === 'true';
|
|
148
|
+
function parsePositiveInt(name, raw, fallback) {
|
|
149
|
+
if (!raw) {
|
|
150
|
+
return fallback;
|
|
151
|
+
}
|
|
152
|
+
const value = Number(raw);
|
|
153
|
+
if (!Number.isFinite(value) || value < 1) {
|
|
154
|
+
console.error(`[config] ignoring ${name}="${raw}": expected a positive integer; using ${fallback}.`);
|
|
155
|
+
return fallback;
|
|
156
|
+
}
|
|
157
|
+
return Math.floor(value);
|
|
158
|
+
}
|
|
159
|
+
function parsePortEnv(name, raw) {
|
|
160
|
+
const trimmed = raw?.trim();
|
|
161
|
+
if (!trimmed) {
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
const port = Number(trimmed);
|
|
165
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
166
|
+
console.error(`[config] ignoring ${name}="${raw}": expected an integer between 0 and 65535.`);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
return port;
|
|
170
|
+
}
|
|
171
|
+
export const MYSQL_MAX_TIMEOUT_SECONDS = parsePositiveInt('MYSQL_MAX_TIMEOUT_SECONDS', process.env.MYSQL_MAX_TIMEOUT_SECONDS, 30);
|
|
172
|
+
export const MYSQL_DEFAULT_TIMEOUT_SECONDS = Math.min(10, MYSQL_MAX_TIMEOUT_SECONDS);
|
|
173
|
+
export const MYSQL_CATALOG_TIMEOUT_SECONDS = parsePositiveInt('MYSQL_CATALOG_TIMEOUT_SECONDS', process.env.MYSQL_CATALOG_TIMEOUT_SECONDS, 60);
|
|
174
|
+
export const MAX_RESPONSE_ROWS = parsePositiveInt('MYSQL_MAX_RESPONSE_ROWS', process.env.MYSQL_MAX_RESPONSE_ROWS, 5000);
|
|
175
|
+
export const MYSQL_POOL_SIZE = parsePositiveInt('MYSQL_POOL_SIZE', process.env.MYSQL_POOL_SIZE, 10);
|
|
176
|
+
export const MYSQL_QUEUE_LIMIT = parsePositiveInt('MYSQL_QUEUE_LIMIT', process.env.MYSQL_QUEUE_LIMIT, 100);
|
|
177
|
+
export const MYSQL_CONNECT_TIMEOUT = parsePositiveInt('MYSQL_CONNECT_TIMEOUT', process.env.MYSQL_CONNECT_TIMEOUT, 10000);
|
|
178
|
+
export const MYSQL_SOCKET_PATH = process.env.MYSQL_SOCKET_PATH?.trim() || undefined;
|
|
179
|
+
export const MYSQL_HOST = process.env.MYSQL_HOST?.trim() || undefined;
|
|
180
|
+
export const MYSQL_PORT = parsePortEnv('MYSQL_PORT', process.env.MYSQL_PORT) ?? 3306;
|
|
181
|
+
export const DEFAULT_MYSQL_HOST = '127.0.0.1';
|
|
182
|
+
export const MYSQL_SSL_ENABLED = process.env.MYSQL_SSL === 'true';
|
|
183
|
+
export const MYSQL_SSL_REJECT_UNAUTHORIZED = process.env.MYSQL_SSL_REJECT_UNAUTHORIZED === 'true';
|
|
184
|
+
export const MYSQL_SSL_CA_PATH = process.env.MYSQL_SSL_CA?.trim() || undefined;
|
|
185
|
+
export const MYSQL_SSL_CERT_PATH = process.env.MYSQL_SSL_CERT?.trim() || undefined;
|
|
186
|
+
export const MYSQL_SSL_KEY_PATH = process.env.MYSQL_SSL_KEY?.trim() || undefined;
|
|
187
|
+
export const MYSQL_TIMEZONE = process.env.MYSQL_TIMEZONE?.trim() || undefined;
|
|
188
|
+
export const MYSQL_DATE_STRINGS = process.env.MYSQL_DATE_STRINGS === 'true';
|
|
189
|
+
export const MYSQL_BIG_NUMBER_STRINGS = process.env.MYSQL_BIG_NUMBER_STRINGS === 'true';
|
|
190
|
+
export const SSH_REUSE_EXISTING = process.env.MYSQL_SSH_REUSE_EXISTING === 'true';
|
|
191
|
+
export const SSH_CONFIG_HOST = process.env.MYSQL_SSH_CONFIG_HOST?.trim() || undefined;
|
|
192
|
+
export const SSH_HOST = process.env.MYSQL_SSH_HOST?.trim() || undefined;
|
|
193
|
+
export const SSH_USER = process.env.MYSQL_SSH_USER?.trim() || undefined;
|
|
194
|
+
export const SSH_PORT = parsePortEnv('MYSQL_SSH_PORT', process.env.MYSQL_SSH_PORT);
|
|
195
|
+
export const SSH_PRIVATE_KEY_PATH = process.env.MYSQL_SSH_PRIVATE_KEY_PATH?.trim() || undefined;
|
|
196
|
+
export const SSH_PASSPHRASE = process.env.MYSQL_SSH_PASSPHRASE?.trim() || undefined;
|
|
197
|
+
export const SSH_LOCAL_PORT = parsePortEnv('MYSQL_SSH_LOCAL_PORT', process.env.MYSQL_SSH_LOCAL_PORT);
|
|
198
|
+
export const SSH_ENABLED = Boolean(SSH_CONFIG_HOST || SSH_HOST);
|
|
199
|
+
const schemaPermissions = (name) => IS_WRITE_FORBIDDEN_PROFILE ? {} : parseSchemaPermissions(process.env[name]);
|
|
200
|
+
export const SCHEMA_INSERT_PERMISSIONS = schemaPermissions('SCHEMA_INSERT_PERMISSIONS');
|
|
201
|
+
export const SCHEMA_UPDATE_PERMISSIONS = schemaPermissions('SCHEMA_UPDATE_PERMISSIONS');
|
|
202
|
+
export const SCHEMA_DELETE_PERMISSIONS = schemaPermissions('SCHEMA_DELETE_PERMISSIONS');
|
|
203
|
+
export const SCHEMA_DDL_PERMISSIONS = schemaPermissions('SCHEMA_DDL_PERMISSIONS');
|
|
204
|
+
export const isMultiDbMode = !MYSQL_DB;
|
|
205
|
+
const MYSQL_PASS = process.env.MYSQL_PASS ?? '';
|
|
206
|
+
export const mcpConfig = {
|
|
207
|
+
mysql: {
|
|
208
|
+
...(MYSQL_SOCKET_PATH
|
|
209
|
+
? { socketPath: MYSQL_SOCKET_PATH }
|
|
210
|
+
: {
|
|
211
|
+
host: MYSQL_HOST || DEFAULT_MYSQL_HOST,
|
|
212
|
+
port: MYSQL_PORT,
|
|
213
|
+
}),
|
|
214
|
+
user: process.env.MYSQL_USER?.trim() || 'root',
|
|
215
|
+
password: MYSQL_PASS,
|
|
216
|
+
database: MYSQL_DB,
|
|
217
|
+
connectionLimit: MYSQL_POOL_SIZE,
|
|
218
|
+
waitForConnections: true,
|
|
219
|
+
queueLimit: MYSQL_QUEUE_LIMIT,
|
|
220
|
+
enableKeepAlive: true,
|
|
221
|
+
keepAliveInitialDelay: 0,
|
|
222
|
+
connectTimeout: MYSQL_CONNECT_TIMEOUT,
|
|
223
|
+
authPlugins: {
|
|
224
|
+
mysql_clear_password: () => () => Buffer.from(MYSQL_PASS),
|
|
225
|
+
},
|
|
226
|
+
...(MYSQL_SSL_ENABLED
|
|
227
|
+
? {
|
|
228
|
+
ssl: {
|
|
229
|
+
rejectUnauthorized: MYSQL_SSL_REJECT_UNAUTHORIZED,
|
|
230
|
+
...(MYSQL_SSL_CA_PATH ? { ca: readCACertificate(MYSQL_SSL_CA_PATH) } : {}),
|
|
231
|
+
...(MYSQL_SSL_CERT_PATH ? { cert: readSSLFile(MYSQL_SSL_CERT_PATH, 'client certificate') } : {}),
|
|
232
|
+
...(MYSQL_SSL_KEY_PATH ? { key: readSSLFile(MYSQL_SSL_KEY_PATH, 'client private key') } : {}),
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
: {}),
|
|
236
|
+
...(MYSQL_TIMEZONE ? { timezone: MYSQL_TIMEZONE } : {}),
|
|
237
|
+
...(MYSQL_DATE_STRINGS ? { dateStrings: true } : {}),
|
|
238
|
+
...(MYSQL_BIG_NUMBER_STRINGS ? { supportBigNumbers: true, bigNumberStrings: true } : {}),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
export function describeConfiguredTarget() {
|
|
242
|
+
return MYSQL_SOCKET_PATH ? `socket: ${MYSQL_SOCKET_PATH}` : `${MYSQL_HOST || DEFAULT_MYSQL_HOST}:${MYSQL_PORT}`;
|
|
243
|
+
}
|
|
244
|
+
export { readCACertificate, readSSLFile };
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const ER_QUERY_TIMEOUT = 3024;
|
|
2
|
+
export function isQueryTimeoutError(error) {
|
|
3
|
+
if (error == null || typeof error !== 'object') {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const candidate = error;
|
|
7
|
+
return candidate.errno === ER_QUERY_TIMEOUT || candidate.code === 'ER_QUERY_TIMEOUT';
|
|
8
|
+
}
|
|
9
|
+
const ROW_FIELDS = [
|
|
10
|
+
{ key: 'rows_examined_per_scan', unit: 'rows/scan' },
|
|
11
|
+
{ key: 'rows', unit: 'rows' },
|
|
12
|
+
{ key: 'rows_produced_per_join', unit: 'rows out' },
|
|
13
|
+
];
|
|
14
|
+
function readRows(node) {
|
|
15
|
+
for (const { key, unit } of ROW_FIELDS) {
|
|
16
|
+
const value = node[key];
|
|
17
|
+
const numeric = typeof value === 'string' ? Number(value) : value;
|
|
18
|
+
if (typeof numeric === 'number' && Number.isFinite(numeric)) {
|
|
19
|
+
return { value: numeric, unit };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
export function collectPlanTables(node) {
|
|
25
|
+
const out = [];
|
|
26
|
+
const visit = (value) => {
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
for (const item of value) {
|
|
29
|
+
visit(item);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (value == null || typeof value !== 'object') {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const obj = value;
|
|
37
|
+
if (typeof obj.access_type === 'string' && typeof obj.table_name === 'string') {
|
|
38
|
+
out.push({
|
|
39
|
+
name: obj.table_name,
|
|
40
|
+
accessType: obj.access_type,
|
|
41
|
+
rows: readRows(obj),
|
|
42
|
+
key: typeof obj.key === 'string' ? obj.key : null,
|
|
43
|
+
condition: typeof obj.attached_condition === 'string' && obj.attached_condition
|
|
44
|
+
? obj.attached_condition
|
|
45
|
+
: null,
|
|
46
|
+
synthetic: obj.table_name.startsWith('<') || 'materialized_from_subquery' in obj,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
for (const child of Object.values(obj)) {
|
|
50
|
+
visit(child);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
visit(node);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
export function parseExplainPayload(rows) {
|
|
57
|
+
const first = Array.isArray(rows) ? rows[0] : rows;
|
|
58
|
+
if (first == null || typeof first !== 'object') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const value = Object.values(first)[0];
|
|
62
|
+
if (typeof value === 'object' && value !== null) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
if (typeof value !== 'string') {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(value);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const MAX_PLAN_JSON_CHARS = 12_000;
|
|
76
|
+
function formatRows(rows) {
|
|
77
|
+
if (rows == null) {
|
|
78
|
+
return 'rows: unknown';
|
|
79
|
+
}
|
|
80
|
+
return `${rows.unit}: ~${Math.round(rows.value).toLocaleString('en-US')}`;
|
|
81
|
+
}
|
|
82
|
+
function describeIndexes(facts) {
|
|
83
|
+
if (facts.indexes.length === 0) {
|
|
84
|
+
return 'none recorded';
|
|
85
|
+
}
|
|
86
|
+
return facts.indexes.map((index) => `${index.name}(${index.columns.join(', ')})`).join(', ');
|
|
87
|
+
}
|
|
88
|
+
function labelTables(input, tables) {
|
|
89
|
+
return tables.map((table) => {
|
|
90
|
+
if (table.synthetic) {
|
|
91
|
+
return { table, canonical: table.name, label: table.name };
|
|
92
|
+
}
|
|
93
|
+
const facts = input.lookupIndexes(table.name);
|
|
94
|
+
const resolved = input.qualifiers.get(table.name.toLowerCase());
|
|
95
|
+
const canonical = facts
|
|
96
|
+
? `${facts.schema}.${facts.table}`
|
|
97
|
+
: resolved
|
|
98
|
+
? `${resolved.schema ? `${resolved.schema}.` : ''}${resolved.table}`
|
|
99
|
+
: table.name;
|
|
100
|
+
const alias = table.name.toLowerCase();
|
|
101
|
+
const named = canonical.toLowerCase() === alias || canonical.toLowerCase().endsWith(`.${alias}`);
|
|
102
|
+
return { table, canonical, label: named ? canonical : `${canonical} (${table.name})` };
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function renderIndexSection(input, tables) {
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
const lines = [];
|
|
108
|
+
for (const { table, canonical } of tables) {
|
|
109
|
+
if (table.synthetic) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(canonical.toLowerCase())) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
seen.add(canonical.toLowerCase());
|
|
116
|
+
const facts = input.lookupIndexes(table.name);
|
|
117
|
+
if (!facts) {
|
|
118
|
+
lines.push(` ${canonical}: not in the local catalog - its index list is unknown here, so absence of an index cannot be concluded.`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const staleness = facts.detailStale
|
|
122
|
+
? ' (marked stale - may be out of date)'
|
|
123
|
+
: facts.detailScannedAt === null
|
|
124
|
+
? ' (not scanned in detail yet - may be incomplete)'
|
|
125
|
+
: '';
|
|
126
|
+
lines.push(` ${canonical}: ${describeIndexes(facts)}${staleness}`);
|
|
127
|
+
}
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
export function renderTimeoutDiagnostic(input) {
|
|
131
|
+
const lines = [
|
|
132
|
+
`[ABORTED] Query cancelled after exceeding the ${input.timeoutSeconds}s execution limit. No rows were returned.`,
|
|
133
|
+
'',
|
|
134
|
+
];
|
|
135
|
+
if (!input.plan) {
|
|
136
|
+
lines.push('[EXPLAIN UNAVAILABLE] The server tried to read the execution plan and could not.', 'Do NOT run EXPLAIN yourself, and do NOT re-run this statement unchanged - it would be cancelled again.', '', ...renderNextSection(input, false));
|
|
137
|
+
return lines.join('\n');
|
|
138
|
+
}
|
|
139
|
+
const tables = labelTables(input, collectPlanTables(input.plan));
|
|
140
|
+
lines.push('[EXPLAIN ALREADY RUN] The server ran EXPLAIN FORMAT=JSON on this statement and attached the', 'result below. Do NOT run EXPLAIN yourself, and do NOT re-run this statement unchanged.', '');
|
|
141
|
+
if (tables.length > 0) {
|
|
142
|
+
lines.push('[PLAN BY TABLE] in join order.');
|
|
143
|
+
tables.forEach(({ table, label }, i) => {
|
|
144
|
+
lines.push(` ${i + 1}. ${label} - access: ${table.accessType}, ${formatRows(table.rows)}, key: ${table.key ?? 'none'}`);
|
|
145
|
+
if (table.condition) {
|
|
146
|
+
lines.push(` condition: ${table.condition}`);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
lines.push(' Reading access: ALL is a full table scan and `index` is a full index scan - both', ' read every row. A non-empty key does NOT mean the filter was narrowed; a full index', ' scan reports one too. const, eq_ref, ref and range did narrow the table.', ' rows/scan is per scan of that table, so an inner table of a join is read once for', ' every row the step above it produced - multiply before calling it cheap.', '');
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
lines.push('[PLAN BY TABLE] no table nodes were recognised in this plan - read the raw plan below.', '');
|
|
153
|
+
}
|
|
154
|
+
const indexLines = renderIndexSection(input, tables);
|
|
155
|
+
if (indexLines.length > 0) {
|
|
156
|
+
lines.push('[INDEXES] from the local schema catalog, for the tables above:', ...indexLines, '');
|
|
157
|
+
}
|
|
158
|
+
const planJson = JSON.stringify(input.plan, null, 2);
|
|
159
|
+
if (planJson.length <= MAX_PLAN_JSON_CHARS) {
|
|
160
|
+
lines.push('[FULL PLAN]:', planJson, '');
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
lines.push(`[FULL PLAN] omitted: ${planJson.length.toLocaleString('en-US')} characters, too large to include.`, 'The per-table summary above lists every table the plan touched.', '');
|
|
164
|
+
}
|
|
165
|
+
lines.push(...renderNextSection(input, true));
|
|
166
|
+
return lines.join('\n');
|
|
167
|
+
}
|
|
168
|
+
function renderNextSection(input, hasPlan) {
|
|
169
|
+
const canExtend = input.timeoutSeconds < input.maxTimeoutSeconds;
|
|
170
|
+
const lines = [
|
|
171
|
+
hasPlan
|
|
172
|
+
? '[NEXT] Decide from the plan and index lists above, then take exactly one of these:'
|
|
173
|
+
: '[NEXT] Without a plan, take exactly one of these:',
|
|
174
|
+
' 1. Rewrite the query so the optimizer can narrow the scan - an indexed filter, a column',
|
|
175
|
+
' compared directly instead of wrapped in a function, a tighter range, an aggregate',
|
|
176
|
+
' instead of raw rows, or a LIMIT - then retry once. No need to ask the user first,',
|
|
177
|
+
' as long as the rewrite still answers what they asked.',
|
|
178
|
+
];
|
|
179
|
+
if (canExtend) {
|
|
180
|
+
lines.push(` 2. Retry the statement unchanged with timeout_seconds: ${input.maxTimeoutSeconds}. Worth it only if`, hasPlan
|
|
181
|
+
? ' the plan looks sound and the query was merely slow. Available once - if it fails'
|
|
182
|
+
: ' you have reason to think the query was merely slow. Available once - if it fails', ' again, stop and report; do not raise the limit further.');
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
lines.push(` 2. A longer limit is NOT available: this already ran at the ${input.maxTimeoutSeconds}s ceiling, which is the`, ' maximum this server allows. Do not ask for more time.');
|
|
186
|
+
}
|
|
187
|
+
lines.push(' 3. Stop and ask the user how to proceed. Do this when no rewrite preserves what they', ' asked for - for example when no index covers the column they need to filter on.', ' Give them concrete choices in their own language and in domain terms: a different', ' column to filter on, requesting an index (not possible from this session), or', ' running the query outside this server. Never show them tool argument names.');
|
|
188
|
+
return lines;
|
|
189
|
+
}
|