@sigma4life/mysql-mcp-server 1.0.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/.env.example +35 -0
- package/.github/workflows/ci.yml +18 -0
- package/AUTHENTICATION.md +24 -0
- package/CLAUDE.md +37 -0
- package/CONTRIBUTING.md +19 -0
- package/LICENSE +21 -0
- package/QUERY_ACCESS_SETUP.md +65 -0
- package/README.md +144 -0
- package/SECURITY.md +10 -0
- package/TESTING.md +54 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +197 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/cache.d.ts +11 -0
- package/dist/core/cache.d.ts.map +1 -0
- package/dist/core/cache.js +30 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/config.d.ts +24 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +63 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/database-url.d.ts +11 -0
- package/dist/core/database-url.d.ts.map +1 -0
- package/dist/core/database-url.js +37 -0
- package/dist/core/database-url.js.map +1 -0
- package/dist/core/database-url.test.d.ts +2 -0
- package/dist/core/database-url.test.d.ts.map +1 -0
- package/dist/core/database-url.test.js +22 -0
- package/dist/core/database-url.test.js.map +1 -0
- package/dist/core/logger.d.ts +3 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +17 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/mcp.d.ts +14 -0
- package/dist/core/mcp.d.ts.map +1 -0
- package/dist/core/mcp.js +23 -0
- package/dist/core/mcp.js.map +1 -0
- package/dist/core/query-safety.d.ts +10 -0
- package/dist/core/query-safety.d.ts.map +1 -0
- package/dist/core/query-safety.js +50 -0
- package/dist/core/query-safety.js.map +1 -0
- package/dist/core/query-safety.test.d.ts +2 -0
- package/dist/core/query-safety.test.d.ts.map +1 -0
- package/dist/core/query-safety.test.js +30 -0
- package/dist/core/query-safety.test.js.map +1 -0
- package/dist/core/schema-types.d.ts +53 -0
- package/dist/core/schema-types.d.ts.map +1 -0
- package/dist/core/schema-types.js +2 -0
- package/dist/core/schema-types.js.map +1 -0
- package/dist/core/security/access-control.d.ts +32 -0
- package/dist/core/security/access-control.d.ts.map +1 -0
- package/dist/core/security/access-control.js +244 -0
- package/dist/core/security/access-control.js.map +1 -0
- package/dist/core/security/config-loader.d.ts +20 -0
- package/dist/core/security/config-loader.d.ts.map +1 -0
- package/dist/core/security/config-loader.js +227 -0
- package/dist/core/security/config-loader.js.map +1 -0
- package/dist/core/security/types.d.ts +64 -0
- package/dist/core/security/types.d.ts.map +1 -0
- package/dist/core/security/types.js +28 -0
- package/dist/core/security/types.js.map +1 -0
- package/dist/core/sql-parser.d.ts +23 -0
- package/dist/core/sql-parser.d.ts.map +1 -0
- package/dist/core/sql-parser.js +460 -0
- package/dist/core/sql-parser.js.map +1 -0
- package/dist/core/sql-parser.test.d.ts +2 -0
- package/dist/core/sql-parser.test.d.ts.map +1 -0
- package/dist/core/sql-parser.test.js +21 -0
- package/dist/core/sql-parser.test.js.map +1 -0
- package/dist/handlers/accessible-schema.d.ts +53 -0
- package/dist/handlers/accessible-schema.d.ts.map +1 -0
- package/dist/handlers/accessible-schema.js +192 -0
- package/dist/handlers/accessible-schema.js.map +1 -0
- package/dist/handlers/data.d.ts +17 -0
- package/dist/handlers/data.d.ts.map +1 -0
- package/dist/handlers/data.js +30 -0
- package/dist/handlers/data.js.map +1 -0
- package/dist/handlers/relationships.d.ts +14 -0
- package/dist/handlers/relationships.d.ts.map +1 -0
- package/dist/handlers/relationships.js +77 -0
- package/dist/handlers/relationships.js.map +1 -0
- package/dist/handlers/schema.d.ts +14 -0
- package/dist/handlers/schema.d.ts.map +1 -0
- package/dist/handlers/schema.js +104 -0
- package/dist/handlers/schema.js.map +1 -0
- package/dist/handlers/search.d.ts +14 -0
- package/dist/handlers/search.d.ts.map +1 -0
- package/dist/handlers/search.js +18 -0
- package/dist/handlers/search.js.map +1 -0
- package/dist/handlers/validation.d.ts +32 -0
- package/dist/handlers/validation.d.ts.map +1 -0
- package/dist/handlers/validation.js +116 -0
- package/dist/handlers/validation.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +294 -0
- package/dist/index.js.map +1 -0
- package/dist/mysql/connection.d.ts +15 -0
- package/dist/mysql/connection.d.ts.map +1 -0
- package/dist/mysql/connection.js +57 -0
- package/dist/mysql/connection.js.map +1 -0
- package/dist/mysql/identifiers.d.ts +3 -0
- package/dist/mysql/identifiers.d.ts.map +1 -0
- package/dist/mysql/identifiers.js +10 -0
- package/dist/mysql/identifiers.js.map +1 -0
- package/dist/mysql/identifiers.test.d.ts +2 -0
- package/dist/mysql/identifiers.test.d.ts.map +1 -0
- package/dist/mysql/identifiers.test.js +11 -0
- package/dist/mysql/identifiers.test.js.map +1 -0
- package/dist/mysql/queries.d.ts +49 -0
- package/dist/mysql/queries.d.ts.map +1 -0
- package/dist/mysql/queries.js +206 -0
- package/dist/mysql/queries.js.map +1 -0
- package/docs/clients/claude-code.md +28 -0
- package/docs/clients/claude-desktop.md +35 -0
- package/docs/clients/codex.md +28 -0
- package/docs/clients/cursor.md +26 -0
- package/docs/clients/opencode.md +35 -0
- package/docs/clients/vscode.md +25 -0
- package/package.json +49 -0
- package/query-access.example.json +21 -0
- package/src/cli.ts +221 -0
- package/src/core/cache.ts +41 -0
- package/src/core/config.ts +97 -0
- package/src/core/database-url.test.ts +28 -0
- package/src/core/database-url.ts +47 -0
- package/src/core/logger.ts +24 -0
- package/src/core/mcp.ts +23 -0
- package/src/core/query-safety.test.ts +36 -0
- package/src/core/query-safety.ts +63 -0
- package/src/core/schema-types.ts +58 -0
- package/src/core/security/access-control.ts +321 -0
- package/src/core/security/config-loader.ts +315 -0
- package/src/core/security/types.ts +114 -0
- package/src/core/sql-parser.test.ts +37 -0
- package/src/core/sql-parser.ts +572 -0
- package/src/handlers/accessible-schema.ts +314 -0
- package/src/handlers/data.ts +66 -0
- package/src/handlers/relationships.ts +114 -0
- package/src/handlers/schema.ts +154 -0
- package/src/handlers/search.ts +34 -0
- package/src/handlers/validation.ts +165 -0
- package/src/index.ts +337 -0
- package/src/mysql/connection.ts +68 -0
- package/src/mysql/identifiers.test.ts +12 -0
- package/src/mysql/identifiers.ts +10 -0
- package/src/mysql/queries.ts +285 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { resolveDatabase, resolveSchema } from '../core/config.js';
|
|
2
|
+
import { logger } from '../core/logger.js';
|
|
3
|
+
import {
|
|
4
|
+
getAccessControlConfig,
|
|
5
|
+
getTableConfigForSchema,
|
|
6
|
+
isAccessControlInitialized,
|
|
7
|
+
} from '../core/security/access-control.js';
|
|
8
|
+
import {
|
|
9
|
+
AccessControlConfig,
|
|
10
|
+
ColumnAccessPolicy,
|
|
11
|
+
TableConfig,
|
|
12
|
+
} from '../core/security/types.js';
|
|
13
|
+
import { getTableInfo } from './schema.js';
|
|
14
|
+
import { findTables } from './search.js';
|
|
15
|
+
|
|
16
|
+
export interface AccessibleColumn {
|
|
17
|
+
name: string;
|
|
18
|
+
dataType: string;
|
|
19
|
+
nullable: boolean;
|
|
20
|
+
isIdentity: boolean;
|
|
21
|
+
isPrimaryKey: boolean;
|
|
22
|
+
isForeignKey: boolean;
|
|
23
|
+
description?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AccessibleTable {
|
|
27
|
+
schema: string;
|
|
28
|
+
name: string;
|
|
29
|
+
type: 'TABLE' | 'VIEW';
|
|
30
|
+
columnAccessMode?: 'inclusion' | 'exclusion';
|
|
31
|
+
accessibleColumns: AccessibleColumn[];
|
|
32
|
+
blockedColumns?: string[];
|
|
33
|
+
allowedColumnsList?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AccessibleSchemaResult {
|
|
37
|
+
database: string;
|
|
38
|
+
requireExplicitColumns: boolean;
|
|
39
|
+
configuredSchemas: string[];
|
|
40
|
+
tables: AccessibleTable[];
|
|
41
|
+
notes?: string[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AccessibleColumnInfo extends AccessibleColumn {
|
|
45
|
+
isAccessible: boolean;
|
|
46
|
+
accessDeniedReason?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AccessibleTableInfo {
|
|
50
|
+
database: string;
|
|
51
|
+
schema: string;
|
|
52
|
+
table: string;
|
|
53
|
+
type: 'TABLE' | 'VIEW';
|
|
54
|
+
isAccessible: boolean;
|
|
55
|
+
accessDeniedReason?: string;
|
|
56
|
+
columnAccessMode?: 'inclusion' | 'exclusion';
|
|
57
|
+
columns?: AccessibleColumnInfo[];
|
|
58
|
+
indexes?: unknown[];
|
|
59
|
+
foreignKeys?: unknown[];
|
|
60
|
+
accessibleColumnCount?: number;
|
|
61
|
+
totalColumnCount?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function requireAccessConfig(database: string): AccessControlConfig {
|
|
65
|
+
if (!isAccessControlInitialized()) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
'Access control not configured. Set QUERY_ACCESS_CONFIG to enable accessible schema introspection.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const config = getAccessControlConfig();
|
|
72
|
+
if (!config.databases[database.toUpperCase()]) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`Database '${database}' is not configured in query access control. ` +
|
|
75
|
+
`Configured databases: ${Object.keys(config.databases).join(', ') || '(none)'}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return config;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function getConfiguredSchemas(config: AccessControlConfig, database: string): string[] {
|
|
83
|
+
const dbConfig = config.databases[database.toUpperCase()];
|
|
84
|
+
if (!dbConfig) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
return dbConfig.schemas ? Object.keys(dbConfig.schemas) : [database];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isTableAccessible(
|
|
91
|
+
tableName: string,
|
|
92
|
+
tableConfig: TableConfig,
|
|
93
|
+
): { accessible: boolean; reason?: string } {
|
|
94
|
+
const listLower = tableConfig.list.map((table) => table.toLowerCase());
|
|
95
|
+
const tableNameLower = tableName.toLowerCase();
|
|
96
|
+
|
|
97
|
+
if (tableConfig.mode === 'whitelist' && !listLower.includes(tableNameLower)) {
|
|
98
|
+
return {
|
|
99
|
+
accessible: false,
|
|
100
|
+
reason: `Table not in whitelist. Allowed tables: ${tableConfig.list.join(', ') || '(none)'}`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (tableConfig.mode === 'blacklist' && listLower.includes(tableNameLower)) {
|
|
105
|
+
return { accessible: false, reason: 'Table is in blacklist' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { accessible: true };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function findColumnPolicy(
|
|
112
|
+
tableName: string,
|
|
113
|
+
columnAccess: Record<string, ColumnAccessPolicy>,
|
|
114
|
+
): ColumnAccessPolicy | null {
|
|
115
|
+
for (const [table, policy] of Object.entries(columnAccess)) {
|
|
116
|
+
if (table.toLowerCase() === tableName.toLowerCase()) {
|
|
117
|
+
return policy;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function filterColumns(
|
|
124
|
+
columns: AccessibleColumn[],
|
|
125
|
+
tableName: string,
|
|
126
|
+
columnAccess: Record<string, ColumnAccessPolicy>,
|
|
127
|
+
): {
|
|
128
|
+
accessibleColumns: AccessibleColumn[];
|
|
129
|
+
blockedColumns?: string[];
|
|
130
|
+
allowedColumnsList?: string[];
|
|
131
|
+
mode?: 'inclusion' | 'exclusion';
|
|
132
|
+
} {
|
|
133
|
+
const policy = findColumnPolicy(tableName, columnAccess);
|
|
134
|
+
if (!policy) {
|
|
135
|
+
return { accessibleColumns: columns };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const policyColumns = policy.columns.map((column) => column.toLowerCase());
|
|
139
|
+
if (policy.mode === 'inclusion') {
|
|
140
|
+
return {
|
|
141
|
+
accessibleColumns: columns.filter((column) => policyColumns.includes(column.name.toLowerCase())),
|
|
142
|
+
allowedColumnsList: policy.columns,
|
|
143
|
+
mode: 'inclusion',
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
accessibleColumns: columns.filter((column) => !policyColumns.includes(column.name.toLowerCase())),
|
|
149
|
+
blockedColumns: policy.columns,
|
|
150
|
+
mode: 'exclusion',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function annotateColumnsWithAccess(
|
|
155
|
+
columns: AccessibleColumn[],
|
|
156
|
+
tableName: string,
|
|
157
|
+
columnAccess: Record<string, ColumnAccessPolicy>,
|
|
158
|
+
): {
|
|
159
|
+
annotatedColumns: AccessibleColumnInfo[];
|
|
160
|
+
mode?: 'inclusion' | 'exclusion';
|
|
161
|
+
} {
|
|
162
|
+
const policy = findColumnPolicy(tableName, columnAccess);
|
|
163
|
+
if (!policy) {
|
|
164
|
+
return {
|
|
165
|
+
annotatedColumns: columns.map((column) => ({ ...column, isAccessible: true })),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const policyColumns = policy.columns.map((column) => column.toLowerCase());
|
|
170
|
+
return {
|
|
171
|
+
annotatedColumns: columns.map((column) => {
|
|
172
|
+
const listed = policyColumns.includes(column.name.toLowerCase());
|
|
173
|
+
const isAccessible = policy.mode === 'inclusion' ? listed : !listed;
|
|
174
|
+
return {
|
|
175
|
+
...column,
|
|
176
|
+
isAccessible,
|
|
177
|
+
accessDeniedReason: isAccessible
|
|
178
|
+
? undefined
|
|
179
|
+
: policy.mode === 'inclusion'
|
|
180
|
+
? `Column not in inclusion list. Allowed: ${policy.columns.join(', ')}`
|
|
181
|
+
: `Column in exclusion list: ${policy.columns.join(', ')}`,
|
|
182
|
+
};
|
|
183
|
+
}),
|
|
184
|
+
mode: policy.mode,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function getAccessibleSchema(args: {
|
|
189
|
+
database?: string;
|
|
190
|
+
schema?: string;
|
|
191
|
+
}): Promise<AccessibleSchemaResult> {
|
|
192
|
+
const database = resolveDatabase(args.database);
|
|
193
|
+
const schema = resolveSchema(args.schema);
|
|
194
|
+
const config = requireAccessConfig(database);
|
|
195
|
+
const notes: string[] = [];
|
|
196
|
+
|
|
197
|
+
const schemaConfig = getTableConfigForSchema(config, database, schema);
|
|
198
|
+
if (!schemaConfig) {
|
|
199
|
+
throw new Error(`Schema '${schema}' is not configured for query access in database '${database}'.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const { tableConfig, columnAccess } = schemaConfig;
|
|
203
|
+
const tableNames = tableConfig.mode === 'whitelist'
|
|
204
|
+
? tableConfig.list
|
|
205
|
+
: (await findTables({ database })).map((table) => table.tableName);
|
|
206
|
+
const blacklist = tableConfig.mode === 'blacklist'
|
|
207
|
+
? new Set(tableConfig.list.map((table) => table.toLowerCase()))
|
|
208
|
+
: new Set<string>();
|
|
209
|
+
const accessibleTableNames = tableNames.filter((table) => !blacklist.has(table.toLowerCase()));
|
|
210
|
+
|
|
211
|
+
if (tableConfig.mode === 'blacklist' && tableConfig.list.length > 0) {
|
|
212
|
+
notes.push(`${tableConfig.list.length} table(s) blocked by blacklist: ${tableConfig.list.join(', ')}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const tables: AccessibleTable[] = [];
|
|
216
|
+
for (const tableName of accessibleTableNames) {
|
|
217
|
+
try {
|
|
218
|
+
const tableInfo = await getTableInfo({ database, table: tableName });
|
|
219
|
+
const { accessibleColumns, blockedColumns, allowedColumnsList, mode } = filterColumns(
|
|
220
|
+
tableInfo.columns,
|
|
221
|
+
tableInfo.name,
|
|
222
|
+
columnAccess,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
tables.push({
|
|
226
|
+
schema,
|
|
227
|
+
name: tableInfo.name,
|
|
228
|
+
type: tableInfo.type,
|
|
229
|
+
accessibleColumns,
|
|
230
|
+
columnAccessMode: mode,
|
|
231
|
+
blockedColumns,
|
|
232
|
+
allowedColumnsList,
|
|
233
|
+
});
|
|
234
|
+
} catch (error) {
|
|
235
|
+
logger.warn(`Could not get info for table ${tableName}: ${error instanceof Error ? error.message : String(error)}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
database,
|
|
241
|
+
requireExplicitColumns: config.requireExplicitColumns,
|
|
242
|
+
configuredSchemas: getConfiguredSchemas(config, database),
|
|
243
|
+
tables,
|
|
244
|
+
notes: notes.length ? notes : undefined,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function getAccessibleTableInfo(args: {
|
|
249
|
+
database?: string;
|
|
250
|
+
table: string;
|
|
251
|
+
schema?: string;
|
|
252
|
+
}): Promise<AccessibleTableInfo> {
|
|
253
|
+
const database = resolveDatabase(args.database);
|
|
254
|
+
const schema = resolveSchema(args.schema);
|
|
255
|
+
const config = requireAccessConfig(database);
|
|
256
|
+
|
|
257
|
+
let tableInfo;
|
|
258
|
+
try {
|
|
259
|
+
tableInfo = await getTableInfo({ database, table: args.table });
|
|
260
|
+
} catch (error) {
|
|
261
|
+
return {
|
|
262
|
+
database,
|
|
263
|
+
schema,
|
|
264
|
+
table: args.table,
|
|
265
|
+
type: 'TABLE',
|
|
266
|
+
isAccessible: false,
|
|
267
|
+
accessDeniedReason: error instanceof Error ? error.message : String(error),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const schemaConfig = getTableConfigForSchema(config, database, schema);
|
|
272
|
+
if (!schemaConfig) {
|
|
273
|
+
return {
|
|
274
|
+
database,
|
|
275
|
+
schema,
|
|
276
|
+
table: tableInfo.name,
|
|
277
|
+
type: tableInfo.type,
|
|
278
|
+
isAccessible: false,
|
|
279
|
+
accessDeniedReason: `Schema '${schema}' is not configured for query access in database '${database}'`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const tableAccess = isTableAccessible(tableInfo.name, schemaConfig.tableConfig);
|
|
284
|
+
if (!tableAccess.accessible) {
|
|
285
|
+
return {
|
|
286
|
+
database,
|
|
287
|
+
schema,
|
|
288
|
+
table: tableInfo.name,
|
|
289
|
+
type: tableInfo.type,
|
|
290
|
+
isAccessible: false,
|
|
291
|
+
accessDeniedReason: tableAccess.reason,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { annotatedColumns, mode } = annotateColumnsWithAccess(
|
|
296
|
+
tableInfo.columns,
|
|
297
|
+
tableInfo.name,
|
|
298
|
+
schemaConfig.columnAccess,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
database,
|
|
303
|
+
schema,
|
|
304
|
+
table: tableInfo.name,
|
|
305
|
+
type: tableInfo.type,
|
|
306
|
+
isAccessible: true,
|
|
307
|
+
columnAccessMode: mode,
|
|
308
|
+
columns: annotatedColumns,
|
|
309
|
+
indexes: tableInfo.indexes,
|
|
310
|
+
foreignKeys: tableInfo.foreignKeys,
|
|
311
|
+
accessibleColumnCount: annotatedColumns.filter((column) => column.isAccessible).length,
|
|
312
|
+
totalColumnCount: annotatedColumns.length,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { appConfig, resolveDatabase } from '../core/config.js';
|
|
2
|
+
import { logger } from '../core/logger.js';
|
|
3
|
+
import {
|
|
4
|
+
QueryModificationResult,
|
|
5
|
+
enforceRowLimit,
|
|
6
|
+
validateQuerySafety,
|
|
7
|
+
} from '../core/query-safety.js';
|
|
8
|
+
import {
|
|
9
|
+
getAccessControlConfig,
|
|
10
|
+
isAccessControlInitialized,
|
|
11
|
+
validateQueryAccess,
|
|
12
|
+
} from '../core/security/access-control.js';
|
|
13
|
+
import { db } from '../mysql/connection.js';
|
|
14
|
+
|
|
15
|
+
export interface DataQueryResult {
|
|
16
|
+
originalQuery: string;
|
|
17
|
+
executedQuery: string;
|
|
18
|
+
wasModified: boolean;
|
|
19
|
+
modifications: string[];
|
|
20
|
+
rows: unknown[];
|
|
21
|
+
rowCount: number;
|
|
22
|
+
executionTimeMs: number;
|
|
23
|
+
limitReached: boolean;
|
|
24
|
+
columnNames?: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function executeQuery(args: {
|
|
28
|
+
database?: string;
|
|
29
|
+
query: string;
|
|
30
|
+
parameters?: Record<string, unknown>;
|
|
31
|
+
}): Promise<DataQueryResult> {
|
|
32
|
+
const database = resolveDatabase(args.database);
|
|
33
|
+
const startTime = Date.now();
|
|
34
|
+
|
|
35
|
+
logger.info(`Executing read query on database: ${database}`);
|
|
36
|
+
|
|
37
|
+
validateQuerySafety(args.query);
|
|
38
|
+
|
|
39
|
+
if (!isAccessControlInitialized()) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
'Access control not configured. Data queries are blocked until QUERY_ACCESS_CONFIG is set.',
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
validateQueryAccess(args.query, database, getAccessControlConfig());
|
|
46
|
+
|
|
47
|
+
const modResult: QueryModificationResult = enforceRowLimit(
|
|
48
|
+
args.query,
|
|
49
|
+
appConfig.query.maxRows,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const rows = await db.query<any>(modResult.modifiedQuery, args.parameters);
|
|
53
|
+
const executionTimeMs = Date.now() - startTime;
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
originalQuery: args.query,
|
|
57
|
+
executedQuery: modResult.modifiedQuery,
|
|
58
|
+
wasModified: modResult.wasModified,
|
|
59
|
+
modifications: modResult.modifications,
|
|
60
|
+
rows,
|
|
61
|
+
rowCount: rows.length,
|
|
62
|
+
executionTimeMs,
|
|
63
|
+
limitReached: rows.length === modResult.appliedLimitValue,
|
|
64
|
+
columnNames: rows.length ? Object.keys(rows[0]) : [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { cache } from '../core/cache.js';
|
|
2
|
+
import { resolveDatabase, resolveSchema } from '../core/config.js';
|
|
3
|
+
import { logger } from '../core/logger.js';
|
|
4
|
+
import {
|
|
5
|
+
getRelationships as getMysqlRelationships,
|
|
6
|
+
Relationship,
|
|
7
|
+
} from '../mysql/queries.js';
|
|
8
|
+
|
|
9
|
+
interface RelationshipPath {
|
|
10
|
+
path: Relationship[];
|
|
11
|
+
joinCondition: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getRelationships(args: {
|
|
15
|
+
database?: string;
|
|
16
|
+
fromTable: string;
|
|
17
|
+
toTable?: string;
|
|
18
|
+
maxDepth?: number;
|
|
19
|
+
schema?: string;
|
|
20
|
+
}): Promise<RelationshipPath[]> {
|
|
21
|
+
const database = resolveDatabase(args.database);
|
|
22
|
+
resolveSchema(args.schema);
|
|
23
|
+
const { fromTable, toTable, maxDepth = 2 } = args;
|
|
24
|
+
|
|
25
|
+
const cacheKey = `relationships:${database}:${fromTable}:${toTable || 'all'}:${maxDepth}`;
|
|
26
|
+
const cached = cache.get<RelationshipPath[]>(cacheKey);
|
|
27
|
+
if (cached) {
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const relationships = await getMysqlRelationships();
|
|
32
|
+
const paths = toTable
|
|
33
|
+
? findPaths(fromTable, toTable, relationships, maxDepth)
|
|
34
|
+
: relationships
|
|
35
|
+
.filter((rel) => rel.fromTable === fromTable || rel.toTable === fromTable)
|
|
36
|
+
.map((rel) => ({
|
|
37
|
+
path: [rel],
|
|
38
|
+
joinCondition: buildJoinCondition([rel]),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
cache.set(cacheKey, paths);
|
|
42
|
+
logger.info(`Found ${paths.length} relationship path(s) for ${fromTable}`);
|
|
43
|
+
return paths;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findPaths(
|
|
47
|
+
fromTable: string,
|
|
48
|
+
toTable: string,
|
|
49
|
+
relationships: Relationship[],
|
|
50
|
+
maxDepth: number,
|
|
51
|
+
): RelationshipPath[] {
|
|
52
|
+
const paths: RelationshipPath[] = [];
|
|
53
|
+
const visited = new Set<string>();
|
|
54
|
+
|
|
55
|
+
function dfs(currentTable: string, currentPath: Relationship[], depth: number): void {
|
|
56
|
+
if (depth > maxDepth) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (currentTable === toTable && currentPath.length > 0) {
|
|
61
|
+
paths.push({
|
|
62
|
+
path: [...currentPath],
|
|
63
|
+
joinCondition: buildJoinCondition(currentPath),
|
|
64
|
+
});
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
visited.add(currentTable);
|
|
69
|
+
|
|
70
|
+
for (const rel of relationships) {
|
|
71
|
+
if (rel.fromTable === currentTable && !visited.has(rel.toTable)) {
|
|
72
|
+
currentPath.push(rel);
|
|
73
|
+
dfs(rel.toTable, currentPath, depth + 1);
|
|
74
|
+
currentPath.pop();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (rel.toTable === currentTable && !visited.has(rel.fromTable)) {
|
|
78
|
+
currentPath.push(rel);
|
|
79
|
+
dfs(rel.fromTable, currentPath, depth + 1);
|
|
80
|
+
currentPath.pop();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
visited.delete(currentTable);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
dfs(fromTable, [], 0);
|
|
88
|
+
return paths;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildJoinCondition(path: Relationship[]): string {
|
|
92
|
+
if (path.length === 0) {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const conditions: string[] = [];
|
|
97
|
+
let currentTable = path[0].fromTable;
|
|
98
|
+
|
|
99
|
+
for (const rel of path) {
|
|
100
|
+
if (rel.fromTable === currentTable) {
|
|
101
|
+
conditions.push(
|
|
102
|
+
`JOIN ${rel.toTable} ON ${rel.fromTable}.${rel.fromColumn} = ${rel.toTable}.${rel.toColumn}`,
|
|
103
|
+
);
|
|
104
|
+
currentTable = rel.toTable;
|
|
105
|
+
} else {
|
|
106
|
+
conditions.push(
|
|
107
|
+
`JOIN ${rel.fromTable} ON ${rel.toTable}.${rel.toColumn} = ${rel.fromTable}.${rel.fromColumn}`,
|
|
108
|
+
);
|
|
109
|
+
currentTable = rel.fromTable;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return conditions.join('\n');
|
|
114
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { cache } from '../core/cache.js';
|
|
2
|
+
import { resolveDatabase, resolveSchema } from '../core/config.js';
|
|
3
|
+
import {
|
|
4
|
+
SchemaResult,
|
|
5
|
+
TableMetadata,
|
|
6
|
+
} from '../core/schema-types.js';
|
|
7
|
+
import { logger } from '../core/logger.js';
|
|
8
|
+
import {
|
|
9
|
+
getColumns,
|
|
10
|
+
getForeignKeys,
|
|
11
|
+
getIndexes,
|
|
12
|
+
getPrimaryKeys,
|
|
13
|
+
getStatistics,
|
|
14
|
+
listTables,
|
|
15
|
+
} from '../mysql/queries.js';
|
|
16
|
+
import { validateDatabaseObject } from './validation.js';
|
|
17
|
+
|
|
18
|
+
function byTable<T extends { tableName: string }>(rows: T[]): Map<string, T[]> {
|
|
19
|
+
const map = new Map<string, T[]>();
|
|
20
|
+
for (const row of rows) {
|
|
21
|
+
const list = map.get(row.tableName) || [];
|
|
22
|
+
list.push(row);
|
|
23
|
+
map.set(row.tableName, list);
|
|
24
|
+
}
|
|
25
|
+
return map;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function buildMetadata(
|
|
29
|
+
tableNames?: string[],
|
|
30
|
+
includeRelationships = true,
|
|
31
|
+
includeStatistics = false,
|
|
32
|
+
): Promise<TableMetadata[]> {
|
|
33
|
+
const tables = await listTables(tableNames);
|
|
34
|
+
const actualTableNames = tables.map((table) => table.tableName);
|
|
35
|
+
|
|
36
|
+
const [columns, primaryKeys, foreignKeys, indexes, statistics] = await Promise.all([
|
|
37
|
+
getColumns(actualTableNames),
|
|
38
|
+
getPrimaryKeys(actualTableNames),
|
|
39
|
+
includeRelationships ? getForeignKeys(actualTableNames) : Promise.resolve([]),
|
|
40
|
+
getIndexes(actualTableNames),
|
|
41
|
+
includeStatistics ? getStatistics(actualTableNames) : Promise.resolve([]),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
const columnsByTable = byTable(columns as any[]);
|
|
45
|
+
const pksByTable = byTable(primaryKeys);
|
|
46
|
+
const fksByTable = byTable(foreignKeys);
|
|
47
|
+
const indexesByTable = byTable(indexes);
|
|
48
|
+
const statsByTable = byTable(statistics);
|
|
49
|
+
|
|
50
|
+
return tables.map((table) => {
|
|
51
|
+
const metadata: TableMetadata = {
|
|
52
|
+
schema: table.schemaName,
|
|
53
|
+
name: table.tableName,
|
|
54
|
+
type: table.tableType === 'VIEW' ? 'VIEW' : 'TABLE',
|
|
55
|
+
columns: (columnsByTable.get(table.tableName) || []).map(({ tableName: _tableName, ...column }) => ({
|
|
56
|
+
...column,
|
|
57
|
+
nullable: Boolean(column.nullable),
|
|
58
|
+
isIdentity: Boolean(column.isIdentity),
|
|
59
|
+
isComputed: Boolean(column.isComputed),
|
|
60
|
+
isPrimaryKey: Boolean(column.isPrimaryKey),
|
|
61
|
+
isForeignKey: Boolean(column.isForeignKey),
|
|
62
|
+
})),
|
|
63
|
+
indexes: (indexesByTable.get(table.tableName) || []).map(({ tableName: _tableName, ...index }) => ({
|
|
64
|
+
...index,
|
|
65
|
+
isUnique: Boolean(index.isUnique),
|
|
66
|
+
isPrimaryKey: Boolean(index.isPrimaryKey),
|
|
67
|
+
})),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const primaryKey = pksByTable.get(table.tableName)?.[0];
|
|
71
|
+
if (primaryKey) {
|
|
72
|
+
const { tableName: _tableName, ...pk } = primaryKey;
|
|
73
|
+
metadata.primaryKey = pk;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (includeRelationships) {
|
|
77
|
+
metadata.foreignKeys = (fksByTable.get(table.tableName) || []).map(({ tableName: _tableName, ...fk }) => fk);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const stats = statsByTable.get(table.tableName)?.[0];
|
|
81
|
+
if (includeStatistics && stats) {
|
|
82
|
+
const { tableName: _tableName, ...tableStats } = stats;
|
|
83
|
+
metadata.statistics = tableStats;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return metadata;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function getSchema(args: {
|
|
91
|
+
database?: string;
|
|
92
|
+
tables?: string[];
|
|
93
|
+
schema?: string;
|
|
94
|
+
includeRelationships?: boolean;
|
|
95
|
+
includeStatistics?: boolean;
|
|
96
|
+
}): Promise<SchemaResult> {
|
|
97
|
+
const database = resolveDatabase(args.database);
|
|
98
|
+
resolveSchema(args.schema);
|
|
99
|
+
|
|
100
|
+
const {
|
|
101
|
+
tables,
|
|
102
|
+
includeRelationships = true,
|
|
103
|
+
includeStatistics = false,
|
|
104
|
+
} = args;
|
|
105
|
+
|
|
106
|
+
const cacheKey = `schema:${database}:${tables?.join(',') || 'all'}:${includeRelationships}:${includeStatistics}`;
|
|
107
|
+
const cached = cache.get<SchemaResult>(cacheKey);
|
|
108
|
+
if (cached) {
|
|
109
|
+
return cached;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (tables?.length) {
|
|
113
|
+
const validation = await Promise.all(
|
|
114
|
+
tables.map((table) => validateDatabaseObject(database, table)),
|
|
115
|
+
);
|
|
116
|
+
const missing = validation.filter((result) => !result.valid);
|
|
117
|
+
if (missing.length) {
|
|
118
|
+
throw new Error(missing.map((result) => result.message).join('\n'));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const result = {
|
|
123
|
+
schema: await buildMetadata(tables, includeRelationships, includeStatistics),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
cache.set(cacheKey, result);
|
|
127
|
+
logger.info(`Retrieved schema for ${result.schema.length} objects from ${database}`);
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function getTableInfo(args: {
|
|
132
|
+
database?: string;
|
|
133
|
+
table: string;
|
|
134
|
+
schema?: string;
|
|
135
|
+
}): Promise<TableMetadata> {
|
|
136
|
+
const database = resolveDatabase(args.database);
|
|
137
|
+
resolveSchema(args.schema);
|
|
138
|
+
|
|
139
|
+
const validation = await validateDatabaseObject(database, args.table);
|
|
140
|
+
if (!validation.valid || !validation.table?.actualName) {
|
|
141
|
+
throw new Error(validation.message);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const tableName = validation.table.actualName.includes('.')
|
|
145
|
+
? validation.table.actualName.split('.').pop()!
|
|
146
|
+
: validation.table.actualName;
|
|
147
|
+
const metadata = await buildMetadata([tableName], true, false);
|
|
148
|
+
|
|
149
|
+
if (!metadata[0]) {
|
|
150
|
+
throw new Error(`Table '${args.table}' not found in database '${database}'`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return metadata[0];
|
|
154
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { resolveDatabase, resolveSchema } from '../core/config.js';
|
|
2
|
+
import { logger } from '../core/logger.js';
|
|
3
|
+
import {
|
|
4
|
+
findTables as findMysqlTables,
|
|
5
|
+
searchObjects as searchMysqlObjects,
|
|
6
|
+
ObjectSearchResult,
|
|
7
|
+
TableSearchResult,
|
|
8
|
+
} from '../mysql/queries.js';
|
|
9
|
+
|
|
10
|
+
export async function findTables(args: {
|
|
11
|
+
database?: string;
|
|
12
|
+
pattern?: string;
|
|
13
|
+
hasColumn?: string;
|
|
14
|
+
schema?: string;
|
|
15
|
+
}): Promise<TableSearchResult[]> {
|
|
16
|
+
const database = resolveDatabase(args.database);
|
|
17
|
+
resolveSchema(args.schema);
|
|
18
|
+
const tables = await findMysqlTables(args.pattern, args.hasColumn);
|
|
19
|
+
logger.info(`Found ${tables.length} tables matching criteria in ${database}`);
|
|
20
|
+
return tables;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function searchObjects(args: {
|
|
24
|
+
database?: string;
|
|
25
|
+
search: string;
|
|
26
|
+
schema?: string;
|
|
27
|
+
type?: string;
|
|
28
|
+
}): Promise<ObjectSearchResult[]> {
|
|
29
|
+
const database = resolveDatabase(args.database);
|
|
30
|
+
resolveSchema(args.schema);
|
|
31
|
+
const results = await searchMysqlObjects(args.search, args.type);
|
|
32
|
+
logger.info(`Found ${results.length} matches for '${args.search}' in ${database}`);
|
|
33
|
+
return results;
|
|
34
|
+
}
|