@ecubelabs/mysql-mcp 0.0.1 → 1.0.0-next.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/README.md CHANGED
@@ -51,13 +51,18 @@ Host ecubelabs2-prod-db
51
51
  LocalForward 3308 <prod RDS reader 엔드포인트>:3306
52
52
  ```
53
53
 
54
- `HostName`, `User`, `Port`, `IdentityFile`, 그리고 첫 번째 `LocalForward`를
55
- 읽어갑니다. 다음 단계로 넘어가기 전에 alias 자체가 동작하는지 확인하세요:
54
+ `HostName`, `User`, `Port`, `IdentityFile`, `UserKnownHostsFile`, `HostKeyAlias`,
55
+ 그리고 첫 번째 `LocalForward`를 읽어갑니다. 다음 단계로 넘어가기 전에 alias로 한 번
56
+ 접속하세요:
56
57
 
57
58
  ```bash
58
59
  ssh ecubelabs2-stage-db "echo ok"
59
60
  ```
60
61
 
62
+ - alias가 동작하는지 확인합니다.
63
+ - bastion 호스트 키를 `~/.ssh/known_hosts`에 기록합니다. 서버는 기록된 키만 받고
64
+ 처음 보는 키는 거부합니다.
65
+
61
66
  ### 2. MCP 클라이언트에 등록하기
62
67
 
63
68
  - `MYSQL_PROFILE`은 빼지 마세요. prod 쓰기 금지를 이 값이 정합니다.
@@ -399,16 +404,16 @@ Catalog는 선언된 schema만 훑으므로, 선언이 없으면 스스로 꺼
399
404
 
400
405
  ### SSH tunnel
401
406
 
402
- | 변수 | 기본값 | 의미 |
403
- | ---------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
404
- | `MYSQL_SSH_CONFIG_HOST` | _(미설정)_ | `HostName`, `User`, `Port`, `IdentityFile`, 첫 `LocalForward`를 읽어올 `~/.ssh/config`의 Host alias. |
405
- | `MYSQL_SSH_HOST` | alias에서 | bastion 호스트명. |
406
- | `MYSQL_SSH_PORT` | alias `Port`, 없으면 `22` | bastion SSH 포트. |
407
- | `MYSQL_SSH_USER` | alias에서 | bastion 사용자. |
408
- | `MYSQL_SSH_PRIVATE_KEY_PATH` | alias `IdentityFile`, 없으면 `~/.ssh/id_rsa` | bastion 인증에 쓰는 키. |
409
- | `MYSQL_SSH_PASSPHRASE` | _(미설정)_ | 암호화된 키일 때만 필요합니다. |
410
- | `MYSQL_SSH_LOCAL_PORT` | alias `LocalForward`, 없으면 `0` | 선호하는 loopback 포트. `0`은 자동 할당. 포트가 이미 쓰이고 있으면 자동 할당된 포트를 대신 씁니다. |
411
- | `MYSQL_SSH_REUSE_EXISTING` | `false` | 직접 열지 않고 해당 포트에 이미 떠 있는 forward에 붙습니다. 아래 경고를 참고하세요. |
407
+ | 변수 | 기본값 | 의미 |
408
+ | ---------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
409
+ | `MYSQL_SSH_CONFIG_HOST` | _(미설정)_ | `HostName`, `User`, `Port`, `IdentityFile`, `UserKnownHostsFile`, `HostKeyAlias`, 첫 `LocalForward`를 읽어올 `~/.ssh/config`의 Host alias. |
410
+ | `MYSQL_SSH_HOST` | alias에서 | bastion 호스트명. |
411
+ | `MYSQL_SSH_PORT` | alias `Port`, 없으면 `22` | bastion SSH 포트. |
412
+ | `MYSQL_SSH_USER` | alias에서 | bastion 사용자. |
413
+ | `MYSQL_SSH_PRIVATE_KEY_PATH` | alias `IdentityFile`, 없으면 `~/.ssh/id_rsa` | bastion 인증에 쓰는 키. |
414
+ | `MYSQL_SSH_PASSPHRASE` | _(미설정)_ | 암호화된 키일 때만 필요합니다. |
415
+ | `MYSQL_SSH_LOCAL_PORT` | alias `LocalForward`, 없으면 `0` | 선호하는 loopback 포트. `0`은 자동 할당. 포트가 이미 쓰이고 있으면 자동 할당된 포트를 대신 씁니다. |
416
+ | `MYSQL_SSH_REUSE_EXISTING` | `false` | 직접 열지 않고 해당 포트에 이미 떠 있는 forward에 붙습니다. 아래 경고를 참고하세요. |
412
417
 
413
418
  - `MYSQL_SSH_CONFIG_HOST`나 `MYSQL_SSH_HOST`가 있으면 접속 전에 tunnel을 엽니다. 둘
414
419
  다 없으면 tunnel 없이 `MYSQL_HOST`로 직접 접속합니다.
@@ -450,9 +455,16 @@ Catalog는 선언된 schema만 훑으므로, 선언이 없으면 스스로 꺼
450
455
  | `ALLOW_INSERT_OPERATION` | INSERT 허용. |
451
456
  | `ALLOW_UPDATE_OPERATION` | UPDATE 허용. |
452
457
  | `ALLOW_DELETE_OPERATION` | DELETE 허용. |
453
- | `ALLOW_DDL_OPERATION` | CREATE/ALTER/DROP/TRUNCATE 허용. |
458
+ | `ALLOW_DDL_OPERATION` | CREATE/ALTER/DROP/TRUNCATE/RENAME 허용. |
454
459
  | `SCHEMA_*_PERMISSIONS` | schema별 override, `"db1:true,db2:false"`. 문장이 참조하는 schema가 모두 허용돼야 실행합니다. |
455
460
 
461
+ - schema별 값이 `ALLOW_*`보다 우선합니다. 적은 schema는 그 값으로, 적지 않은 schema는
462
+ `ALLOW_*`로 판정합니다.
463
+ - 그래서 `ALLOW_UPDATE_OPERATION`을 끈 채 `SCHEMA_UPDATE_PERMISSIONS=haulla:true`만 적으면
464
+ `haulla`에서만 UPDATE가 열립니다.
465
+ - 쓰기가 하나라도 열리면 도구 설명에 `with support for: UPDATE (haulla only) …`처럼 적고
466
+ `readOnlyHint`를 `false`로 알립니다.
467
+
456
468
  ### 진단
457
469
 
458
470
  | 변수 | 기본값 | 의미 |
@@ -482,17 +494,31 @@ flag는 평범한 설정이기 때문입니다.
482
494
 
483
495
  어느 profile이든 실행하는 문장 종류는 허용 목록으로 정합니다. 읽기는 `SELECT`,
484
496
  `SHOW`, `DESCRIBE`, `EXPLAIN`뿐이고, 쓰기는 `INSERT`, `UPDATE`, `DELETE`와
485
- DDL(`CREATE`/`ALTER`/`DROP`/`TRUNCATE`)이 위 flag를 통과할 때만 실행합니다. 그 밖의
486
- 문장은 모두 거부합니다. `RENAME TABLE`, `GRANT`처럼 implicit commit을 일으키는 문장은
497
+ DDL(`CREATE`/`ALTER`/`DROP`/`TRUNCATE`/`RENAME`)이 위 flag를 통과할 때만 실행합니다. 그
498
+ 밖의 문장은 모두 거부합니다. `GRANT`처럼 implicit commit을 일으키는 문장은
487
499
  `START TRANSACTION READ ONLY`로도 막히지 않고, `SET`, `LOCK TABLES`, `USE`는 pool에
488
500
  반납된 connection에 상태를 남기기 때문입니다. 다른 schema의 table은 `USE` 대신
489
501
  `schema.table`로 적습니다. `SELECT … INTO OUTFILE`·`INTO DUMPFILE`도 DB 서버에 파일을
490
502
  쓰므로 거부합니다.
491
503
 
504
+ SQL parser가 주석으로 버리지만 MySQL이 따르는 주석도 거부합니다.
505
+
506
+ - `/*! … */` 실행 주석. MySQL은 그 안을 문장으로 실행합니다. 검사는 parser가 본 문장으로
507
+ 하므로, `/*! RENAME TABLE … */`는 빈 문장으로 보여 허용 목록을 통과합니다.
508
+ - `/*+ MAX_EXECUTION_TIME(…) */`와 `/*+ SET_VAR(…) */` hint. 아래 시간 상한을
509
+ 덮어씁니다.
510
+ - 텍스트로 찾으므로 문자열 리터럴 안의 같은 글자도 거부합니다.
511
+
492
512
  schema별 쓰기 권한은 문장이 참조하는 schema 전부에 대해 판정합니다. 하나라도
493
513
  막혀 있으면 거부합니다. 읽기만 하는 table의 schema도 포함하므로,
494
514
  `INSERT INTO a.t SELECT … FROM b.u`는 `a`와 `b` 둘 다 INSERT가 허용돼야 합니다.
495
515
 
516
+ - schema를 적지 않은 table은 기본 schema(`MYSQL_DB`)로 봅니다. 같은 문장에 같은
517
+ 이름의 다른 schema table이 있어도 그렇습니다. MySQL이 그렇게 실행하기 때문입니다.
518
+ - 예외는 다중 table DELETE의 대상 목록입니다. `DELETE o FROM a.t AS o …`의 `o`는
519
+ FROM이 붙인 이름이므로 `a`로 판정합니다.
520
+ - `RENAME TABLE a.t TO b.t`는 `a`와 `b` 둘 다 DDL이 허용돼야 합니다.
521
+
496
522
  ## 결과는 있는 그대로 반환합니다
497
523
 
498
524
  Row 값도, column 이름도, index 이름도 가리지 않습니다. 원본 프로젝트의 PII
@@ -524,6 +550,15 @@ Forwarding은 `ssh -N -L`을 spawn 하는 대신 `ssh2`를 통해 프로세스
524
550
 
525
551
  - Listener는 `127.0.0.1`에만 bind 합니다. forwarding된 데이터베이스에 인증 없이
526
552
  접근할 수 있게 해주므로, 네트워크에서 절대 닿을 수 있어서는 안 됩니다.
553
+ - **bastion 호스트 키는 OpenSSH `known_hosts`로 검증합니다.** `ssh2`는 스스로
554
+ `known_hosts`를 읽지 않고, 검증하지 않으면 어떤 키든 받습니다. 그러면 경로 위의
555
+ 공격자가 bastion을 사칭해 DB 비밀번호를 받아 갈 수 있습니다.
556
+ - alias의 `UserKnownHostsFile`(없으면 `~/.ssh/known_hosts`, `~/.ssh/known_hosts2`)와
557
+ `/etc/ssh/ssh_known_hosts`를 읽습니다. alias에 `HostKeyAlias`가 있으면 그 이름으로
558
+ 찾습니다.
559
+ - 기록된 키 종류로만 협상합니다. 처음 보는 호스트는 기록하지 않고 거부합니다. 지문을
560
+ 확인할 수 있는 `ssh`로 한 번 접속해 기록하세요.
561
+ - `@cert-authority` 인증서 기반 호스트 키는 지원하지 않습니다.
527
562
  - **각 서버는 자기 tunnel을 소유합니다.** `MYSQL_SSH_LOCAL_PORT`는 요구 사항이
528
563
  아니라 선호일 뿐입니다. 포트가 이미 쓰이고 있으면 이 서버는 OS가 할당한
529
564
  포트에 자기 tunnel을 엽니다. pool은 어디로 접속할지 전달받으므로 뒷단에서는
@@ -566,6 +601,8 @@ forward가 있으면 거기에 붙었습니다. 결과적으로 잘못된 선택
566
601
  때문입니다.
567
602
  - 상한 30초는 MCP 클라이언트의 기본 tool timeout보다 낮게 유지하세요. 클라이언트가
568
603
  먼저 포기하면 모델에게는 보고서가 아니라 아무것도 남지 않습니다.
604
+ - SQL 안의 `/*+ MAX_EXECUTION_TIME(…) */`·`/*+ SET_VAR(…) */` hint는 거부합니다.
605
+ MySQL은 이 hint를 세션 상한보다 우선하므로, 허용하면 상한이 풀립니다.
569
606
 
570
607
  ### 행 수 상한
571
608
 
@@ -714,6 +751,24 @@ MYSQL_USER=<user> MYSQL_PASS=<password> npx -y @ecubelabs/mysql-mcp < /dev/null
714
751
  - 키는 `chmod 600`이어야 합니다.
715
752
  - 암호화된 키라면 `MYSQL_SSH_PASSPHRASE`가 필요합니다.
716
753
 
754
+ ### `No host key for <host> in known_hosts`
755
+
756
+ bastion 호스트 키가 `known_hosts`에 기록돼 있지 않습니다. 오류 메시지가 실행할 명령을
757
+ 알려 줍니다.
758
+
759
+ - alias로 한 번 접속해 지문을 확인하고 기록하세요: `ssh ecubelabs2-stage-db "echo ok"`.
760
+ - 기록돼 있는지 확인하려면: `ssh-keygen -F <bastion 호스트명>`. 22번이 아닌 포트는
761
+ `ssh-keygen -F "[<bastion 호스트명>]:<port>"`입니다.
762
+
763
+ ### `the host key for <host> does not match known_hosts`
764
+
765
+ bastion이 기록과 다른 키를 내밀었습니다. bastion을 다시 만들었거나, 누군가 접속을
766
+ 가로채고 있습니다.
767
+
768
+ - 같은 alias로 `ssh`를 실행해 OpenSSH의 경고를 확인하세요.
769
+ - 키가 바뀐 것이 확실할 때만 `ssh-keygen -R <bastion 호스트명>`으로 옛 키를 지우고 다시
770
+ 접속해 기록하세요.
771
+
717
772
  ### `local port N is taken` 로그
718
773
 
719
774
  ```text
@@ -816,6 +871,7 @@ src/config/ 환경 변수 해석, profile 정책, mysql2 옵션
816
871
  src/catalog/ 로컬 schema catalog: DB metadata 수집, 문서 연결, 사용 기록, 검색
817
872
  src/db/ 쿼리 routing, 권한 검사, pool, 타임아웃 진단
818
873
  src/ssh/config.ts ~/.ssh/config Host alias parser
874
+ src/ssh/known-hosts.ts known_hosts 조회와 bastion 호스트 키 검증
819
875
  src/ssh/tunnel.ts tunnel lifecycle: 열기, 재사용, 재연결, 닫기
820
876
  src/types/ 공유 타입
821
877
  src/utils/ stderr 전용 로거, table resource URI
package/dist/index.js CHANGED
@@ -4,10 +4,11 @@ import { realpathSync } from 'fs';
4
4
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
5
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
6
  import { CallToolRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
7
- import { log, tableResourceUri } from './src/utils/index.js';
8
- import { ALLOW_DELETE_OPERATION, ALLOW_DDL_OPERATION, ALLOW_INSERT_OPERATION, ALLOW_UPDATE_OPERATION, SCHEMA_DELETE_PERMISSIONS, SCHEMA_DDL_PERMISSIONS, SCHEMA_INSERT_PERMISSIONS, SCHEMA_UPDATE_PERMISSIONS, isMultiDbMode, mcpConfig as config, MYSQL_PROFILE, PROFILE_LABEL, IS_WRITE_FORBIDDEN_PROFILE, APP_SCHEMAS, CODE_BRANCH, MYSQL_CATALOG_ENABLED, MYSQL_CATALOG_PATH, MYSQL_CATALOG_TTL_HOURS, MYSQL_DOCS_REPO, MYSQL_DEFAULT_TIMEOUT_SECONDS, MYSQL_MAX_TIMEOUT_SECONDS, MAX_RESPONSE_ROWS, MYSQL_SSL_ENABLED, MYSQL_SSL_CA_PATH, MYSQL_SSL_CERT_PATH, MYSQL_SSL_KEY_PATH, PACKAGE_VERSION, SSH_ENABLED, } from './src/config/index.js';
7
+ import { log, sharedOnce, tableResourceUri } from './src/utils/index.js';
8
+ import { writeCapabilities } from './src/db/permissions.js';
9
+ import { SCHEMA_DELETE_PERMISSIONS, SCHEMA_DDL_PERMISSIONS, SCHEMA_INSERT_PERMISSIONS, SCHEMA_UPDATE_PERMISSIONS, isMultiDbMode, mcpConfig as config, MYSQL_PROFILE, PROFILE_LABEL, IS_WRITE_FORBIDDEN_PROFILE, APP_SCHEMAS, CODE_BRANCH, MYSQL_CATALOG_ENABLED, MYSQL_CATALOG_PATH, MYSQL_CATALOG_TTL_HOURS, MYSQL_DOCS_REPO, MYSQL_DEFAULT_TIMEOUT_SECONDS, MYSQL_MAX_TIMEOUT_SECONDS, MAX_RESPONSE_ROWS, MYSQL_SSL_ENABLED, MYSQL_SSL_CA_PATH, MYSQL_SSL_CERT_PATH, MYSQL_SSL_KEY_PATH, PACKAGE_VERSION, SSH_ENABLED, } from './src/config/index.js';
9
10
  import { catalogIdentity, SchemaCatalog } from './src/catalog/index.js';
10
- import { describeConnection, getPool, executeQuery, executeReadOnlyQuery, setQueryDiagnosticsSource, clampTimeoutSeconds, poolPromise, profileBanner, } from './src/db/index.js';
11
+ import { describeConnection, getPool, executeQuery, executeReadOnlyQuery, setQueryDiagnosticsSource, clampTimeoutSeconds, describeQueryError, poolPromise, profileBanner, } from './src/db/index.js';
11
12
  import { describeTunnel, ensureTunnel, resolveTunnelConfig, stopTunnel } from './src/ssh/tunnel.js';
12
13
  log('info', `Starting MySQL MCP server v${PACKAGE_VERSION}...`);
13
14
  const toolVersion = `MySQL MCP Server [v${PACKAGE_VERSION}]`;
@@ -21,21 +22,10 @@ if (MYSQL_PROFILE) {
21
22
  if (isMultiDbMode) {
22
23
  baseToolDescription += ' (Multi-DB mode enabled)';
23
24
  }
24
- if (ALLOW_INSERT_OPERATION || ALLOW_UPDATE_OPERATION || ALLOW_DELETE_OPERATION || ALLOW_DDL_OPERATION) {
25
- baseToolDescription += ' with support for:';
26
- if (ALLOW_INSERT_OPERATION) {
27
- baseToolDescription += ' INSERT,';
28
- }
29
- if (ALLOW_UPDATE_OPERATION) {
30
- baseToolDescription += ' UPDATE,';
31
- }
32
- if (ALLOW_DELETE_OPERATION) {
33
- baseToolDescription += ' DELETE,';
34
- }
35
- if (ALLOW_DDL_OPERATION) {
36
- baseToolDescription += ' DDL,';
37
- }
38
- baseToolDescription = baseToolDescription.replace(/,$/, '') + ' and READ operations';
25
+ const writes = writeCapabilities();
26
+ if (writes.length > 0) {
27
+ const supported = writes.map(({ label, schemas }) => (schemas ? `${label} (${schemas.join(', ')} only)` : label));
28
+ baseToolDescription += ` with support for: ${supported.join(', ')} and READ operations`;
39
29
  if (Object.keys(SCHEMA_INSERT_PERMISSIONS).length > 0 ||
40
30
  Object.keys(SCHEMA_UPDATE_PERMISSIONS).length > 0 ||
41
31
  Object.keys(SCHEMA_DELETE_PERMISSIONS).length > 0 ||
@@ -50,7 +40,8 @@ baseToolDescription +=
50
40
  `\n\nLIMITS: every read is cancelled after ${MYSQL_DEFAULT_TIMEOUT_SECONDS}s ` +
51
41
  `(raise per call with timeout_seconds, up to ${MYSQL_MAX_TIMEOUT_SECONDS}) and returns at most ` +
52
42
  `${MAX_RESPONSE_ROWS.toLocaleString('en-US')} rows. Aggregate in SQL rather than pulling rows to count them. ` +
53
- `USE and SET are rejected; write schema.table instead of USE.`;
43
+ `USE and SET are rejected; write schema.table instead of USE. ` +
44
+ `Executable comments (/*! ... */) and MAX_EXECUTION_TIME or SET_VAR hints are rejected; use timeout_seconds instead.`;
54
45
  if (CODE_BRANCH) {
55
46
  baseToolDescription +=
56
47
  `\n\nCODE REVISION: this database matches the \`${CODE_BRANCH}\` branch. ` +
@@ -74,7 +65,7 @@ if (APP_SCHEMAS.length > 0) {
74
65
  `e.g. SELECT ... FROM \`${needsQuoting.schema}\`.some_table.`;
75
66
  }
76
67
  }
77
- const isReadOnly = !(ALLOW_INSERT_OPERATION || ALLOW_UPDATE_OPERATION || ALLOW_DELETE_OPERATION || ALLOW_DDL_OPERATION);
68
+ const isReadOnly = writes.length === 0;
78
69
  const mysqlCatalogTool = {
79
70
  name: 'mysql_catalog',
80
71
  description: 'Read the local schema catalog without rediscovering database metadata. ' +
@@ -514,6 +505,9 @@ export default function createMcpServer() {
514
505
  throw new Error(`Unknown tool: ${request.params.name}`);
515
506
  }
516
507
  const sql = request.params.arguments?.sql;
508
+ if (typeof sql !== 'string' || sql.trim() === '') {
509
+ throw new Error('The "sql" argument is required and must be a non-empty string.');
510
+ }
517
511
  const timeoutSeconds = clampTimeoutSeconds(request.params.arguments?.timeout_seconds);
518
512
  const references = catalog.prepareQuery(sql);
519
513
  let result;
@@ -537,7 +531,7 @@ export default function createMcpServer() {
537
531
  }
538
532
  catch (err) {
539
533
  const error = err;
540
- log('error', 'Error in CallToolRequest handler:', error);
534
+ log('error', 'Error in CallToolRequest handler:', describeQueryError(error));
541
535
  return {
542
536
  content: [
543
537
  {
@@ -597,8 +591,8 @@ export default function createMcpServer() {
597
591
  process.exit(1);
598
592
  }
599
593
  })();
600
- const shutdown = async (signal) => {
601
- log('error', `Received ${signal}. Shutting down...`);
594
+ const shutdown = sharedOnce(async (signal) => {
595
+ log('info', `Received ${signal}. Shutting down...`);
602
596
  try {
603
597
  await catalog.close();
604
598
  if (poolPromise) {
@@ -612,7 +606,7 @@ export default function createMcpServer() {
612
606
  finally {
613
607
  await stopTunnel();
614
608
  }
615
- };
609
+ });
616
610
  const shutdownAndExit = async (reason) => {
617
611
  try {
618
612
  await shutdown(reason);
@@ -2,7 +2,7 @@ import { performance } from 'perf_hooks';
2
2
  import * as mysql2 from 'mysql2/promise';
3
3
  import { mcpConfig as config, describeConfiguredTarget, SSH_ENABLED, MYSQL_PROFILE, PROFILE_LABEL, CODE_BRANCH, IS_WRITE_FORBIDDEN_PROFILE, MYSQL_DISABLE_READ_ONLY_TRANSACTIONS, MYSQL_DEFAULT_TIMEOUT_SECONDS, MYSQL_MAX_TIMEOUT_SECONDS, MYSQL_CATALOG_TIMEOUT_SECONDS, MAX_RESPONSE_ROWS, } from './../config/index.js';
4
4
  import { isDDLAllowedForSchema, isInsertAllowedForSchema, isUpdateAllowedForSchema, isDeleteAllowedForSchema, } from './permissions.js';
5
- import { extractReferencedSchemas, findFileExport, findUnsupportedStatementType, getQueryTypes, isUnparseableIntrospection, extractQualifiers, } from './utils.js';
5
+ import { DDL_STATEMENT_TYPES, extractReferencedSchemas, findFileExport, findLimitOverrideHint, findUnsupportedStatementType, getQueryTypes, hasExecutableComment, isUnparseableIntrospection, extractQualifiers, } from './utils.js';
6
6
  import { isQueryTimeoutError, parseExplainPayload, renderTimeoutDiagnostic } from './diagnose.js';
7
7
  import { log } from './../utils/index.js';
8
8
  import { ensureTunnel, getTunnelEndpoint, getTunnelFatalError, describeTunnel } from './../ssh/tunnel.js';
@@ -89,6 +89,14 @@ function describeQueryError(error) {
89
89
  }
90
90
  return error.message;
91
91
  }
92
+ async function rollbackQuietly(connection) {
93
+ try {
94
+ await connection.rollback();
95
+ }
96
+ catch (error) {
97
+ log('error', 'Rollback failed; reporting the original error instead:', describeQueryError(error));
98
+ }
99
+ }
92
100
  function assertTunnelHealthy() {
93
101
  const fatal = getTunnelFatalError();
94
102
  if (fatal) {
@@ -140,7 +148,7 @@ async function executeQuery(sql, params = []) {
140
148
  log('error', 'Error restoring session limits:', restoreError);
141
149
  }
142
150
  connection.release();
143
- log('error', 'Connection released');
151
+ log('info', 'Connection released');
144
152
  }
145
153
  }
146
154
  }
@@ -163,7 +171,7 @@ async function executeWriteQuery(sql) {
163
171
  assertTunnelHealthy();
164
172
  const pool = await getPool();
165
173
  connection = await pool.getConnection();
166
- log('error', 'Write connection acquired');
174
+ log('info', 'Write connection acquired');
167
175
  const schema = (extractReferencedSchemas(sql) ?? [null]).map((name) => name ?? 'default').join(', ');
168
176
  await connection.beginTransaction();
169
177
  try {
@@ -178,7 +186,7 @@ async function executeWriteQuery(sql) {
178
186
  const isUpdateOperation = queryTypes.some((type) => ['update'].includes(type));
179
187
  const isInsertOperation = queryTypes.some((type) => ['insert'].includes(type));
180
188
  const isDeleteOperation = queryTypes.some((type) => ['delete'].includes(type));
181
- const isDDLOperation = queryTypes.some((type) => ['create', 'alter', 'drop', 'truncate'].includes(type));
189
+ const isDDLOperation = queryTypes.some((type) => DDL_STATEMENT_TYPES.has(type));
182
190
  if (isInsertOperation) {
183
191
  const resultHeader = response;
184
192
  responseText = `Insert successful on schema '${schema}'. Affected rows: ${resultHeader.affectedRows}, Last insert ID: ${resultHeader.insertId}`;
@@ -213,7 +221,7 @@ async function executeWriteQuery(sql) {
213
221
  }
214
222
  catch (error) {
215
223
  log('error', 'Error executing write query:', describeQueryError(error));
216
- await connection.rollback();
224
+ await rollbackQuietly(connection);
217
225
  return {
218
226
  content: [
219
227
  {
@@ -240,7 +248,7 @@ async function executeWriteQuery(sql) {
240
248
  finally {
241
249
  if (connection) {
242
250
  connection.release();
243
- log('error', 'Write connection released');
251
+ log('info', 'Write connection released');
244
252
  }
245
253
  }
246
254
  }
@@ -297,6 +305,34 @@ async function executeReadOnlyQuery(sql, options = {}) {
297
305
  let connection;
298
306
  try {
299
307
  assertTunnelHealthy();
308
+ if (hasExecutableComment(sql)) {
309
+ log('error', 'Refusing a statement with an executable comment.');
310
+ return {
311
+ content: [
312
+ {
313
+ type: 'text',
314
+ text: 'Error: executable comments (/*! ... */) are not allowed because MySQL runs their ' +
315
+ 'contents. Write the statement without them.',
316
+ },
317
+ ],
318
+ isError: true,
319
+ };
320
+ }
321
+ const limitHint = findLimitOverrideHint(sql);
322
+ if (limitHint) {
323
+ log('error', `Refusing a statement with the ${limitHint} optimizer hint.`);
324
+ return {
325
+ content: [
326
+ {
327
+ type: 'text',
328
+ text: `Error: the ${limitHint} optimizer hint is not allowed because it overrides the ` +
329
+ `server's execution limit. Remove it and pass timeout_seconds (up to ` +
330
+ `${MYSQL_MAX_TIMEOUT_SECONDS}) if the query needs more time.`,
331
+ },
332
+ ],
333
+ isError: true,
334
+ };
335
+ }
300
336
  const bypassesQueryTypeChecks = isUnparseableIntrospection(sql);
301
337
  let queryTypes = [];
302
338
  let isUpdateOperation = false;
@@ -305,6 +341,12 @@ async function executeReadOnlyQuery(sql, options = {}) {
305
341
  let isDDLOperation = false;
306
342
  if (!bypassesQueryTypeChecks) {
307
343
  queryTypes = await getQueryTypes(sql);
344
+ if (queryTypes.length === 0) {
345
+ return {
346
+ content: [{ type: 'text', text: 'Error: no SQL statement found outside comments.' }],
347
+ isError: true,
348
+ };
349
+ }
308
350
  const unsupported = findUnsupportedStatementType(queryTypes);
309
351
  if (unsupported) {
310
352
  const statement = unsupported.toUpperCase();
@@ -338,7 +380,7 @@ async function executeReadOnlyQuery(sql, options = {}) {
338
380
  isUpdateOperation = queryTypes.some((type) => ['update'].includes(type));
339
381
  isInsertOperation = queryTypes.some((type) => ['insert'].includes(type));
340
382
  isDeleteOperation = queryTypes.some((type) => ['delete'].includes(type));
341
- isDDLOperation = queryTypes.some((type) => ['create', 'alter', 'drop', 'truncate'].includes(type));
383
+ isDDLOperation = queryTypes.some((type) => DDL_STATEMENT_TYPES.has(type));
342
384
  }
343
385
  const writeChecks = [
344
386
  { enabled: isInsertOperation, label: 'INSERT', allowed: isInsertAllowedForSchema },
@@ -370,7 +412,7 @@ async function executeReadOnlyQuery(sql, options = {}) {
370
412
  }
371
413
  const pool = await getPool();
372
414
  connection = await pool.getConnection();
373
- log('error', 'Read-only connection acquired');
415
+ log('info', 'Read-only connection acquired');
374
416
  const timeoutSeconds = clampTimeoutSeconds(options.timeoutSeconds);
375
417
  let roundTrips = await applySessionLimits(connection, {
376
418
  maxExecutionTimeMs: timeoutSeconds * 1_000,
@@ -427,7 +469,7 @@ async function executeReadOnlyQuery(sql, options = {}) {
427
469
  }
428
470
  catch (error) {
429
471
  log('error', 'Error executing read-only query:', describeQueryError(error));
430
- await connection.rollback();
472
+ await rollbackQuietly(connection);
431
473
  if (isQueryTimeoutError(error)) {
432
474
  const diagnostic = await diagnoseTimeout(connection, sql, timeoutSeconds);
433
475
  log('info', `Read query cancelled at ${timeoutSeconds}s after ${roundTrips + 3} DB round trips (including one EXPLAIN)`);
@@ -441,21 +483,16 @@ async function executeReadOnlyQuery(sql, options = {}) {
441
483
  }
442
484
  catch (error) {
443
485
  log('error', 'Error in read-only query transaction:', describeQueryError(error));
444
- try {
445
- if (connection) {
446
- await connection.rollback();
447
- }
448
- }
449
- catch (cleanupError) {
450
- log('error', 'Error during cleanup:', cleanupError);
486
+ if (connection) {
487
+ await rollbackQuietly(connection);
451
488
  }
452
489
  throw error;
453
490
  }
454
491
  finally {
455
492
  if (connection) {
456
493
  connection.release();
457
- log('error', 'Read-only connection released');
494
+ log('info', 'Read-only connection released');
458
495
  }
459
496
  }
460
497
  }
461
- export { profileBanner, describeConnection, executeQuery, getPool, executeWriteQuery, executeReadOnlyQuery, setQueryDiagnosticsSource, clampTimeoutSeconds, describeQueryError, poolPromise, };
498
+ export { profileBanner, describeConnection, executeQuery, getPool, executeWriteQuery, executeReadOnlyQuery, setQueryDiagnosticsSource, clampTimeoutSeconds, describeQueryError, rollbackQuietly, poolPromise, };
@@ -23,4 +23,24 @@ function isDDLAllowedForSchema(schema) {
23
23
  }
24
24
  return schema in SCHEMA_DDL_PERMISSIONS ? SCHEMA_DDL_PERMISSIONS[schema] : ALLOW_DDL_OPERATION;
25
25
  }
26
- export { isInsertAllowedForSchema, isUpdateAllowedForSchema, isDeleteAllowedForSchema, isDDLAllowedForSchema };
26
+ function writeCapabilities() {
27
+ const kinds = [
28
+ { label: 'INSERT', global: ALLOW_INSERT_OPERATION, perSchema: SCHEMA_INSERT_PERMISSIONS },
29
+ { label: 'UPDATE', global: ALLOW_UPDATE_OPERATION, perSchema: SCHEMA_UPDATE_PERMISSIONS },
30
+ { label: 'DELETE', global: ALLOW_DELETE_OPERATION, perSchema: SCHEMA_DELETE_PERMISSIONS },
31
+ { label: 'DDL', global: ALLOW_DDL_OPERATION, perSchema: SCHEMA_DDL_PERMISSIONS },
32
+ ];
33
+ const capabilities = [];
34
+ for (const { label, global, perSchema } of kinds) {
35
+ if (global) {
36
+ capabilities.push({ label, schemas: null });
37
+ continue;
38
+ }
39
+ const opened = Object.keys(perSchema).filter((schema) => perSchema[schema]);
40
+ if (opened.length > 0) {
41
+ capabilities.push({ label, schemas: opened });
42
+ }
43
+ }
44
+ return capabilities;
45
+ }
46
+ export { isInsertAllowedForSchema, isUpdateAllowedForSchema, isDeleteAllowedForSchema, isDDLAllowedForSchema, writeCapabilities, };
@@ -4,10 +4,26 @@ import { log } from './../utils/index.js';
4
4
  const { Parser } = SqlParser;
5
5
  const parser = new Parser();
6
6
  const READ_STATEMENT_TYPES = new Set(['select', 'show', 'desc', 'explain']);
7
- const WRITE_STATEMENT_TYPES = new Set(['insert', 'update', 'delete', 'create', 'alter', 'drop', 'truncate']);
7
+ const DDL_STATEMENT_TYPES = new Set(['create', 'alter', 'drop', 'truncate', 'rename']);
8
+ const WRITE_STATEMENT_TYPES = new Set(['insert', 'update', 'delete', ...DDL_STATEMENT_TYPES]);
8
9
  function findUnsupportedStatementType(queryTypes) {
9
10
  return queryTypes.find((type) => !READ_STATEMENT_TYPES.has(type) && !WRITE_STATEMENT_TYPES.has(type)) ?? null;
10
11
  }
12
+ const EXECUTABLE_COMMENT_RE = /\/\*!/;
13
+ function hasExecutableComment(sql) {
14
+ return EXECUTABLE_COMMENT_RE.test(sql);
15
+ }
16
+ const OPTIMIZER_HINT_RE = /\/\*\+([\s\S]*?)\*\//g;
17
+ const LIMIT_OVERRIDE_HINT_RE = /\b(MAX_EXECUTION_TIME|SET_VAR)\s*\(/i;
18
+ function findLimitOverrideHint(sql) {
19
+ for (const [, body] of sql.matchAll(OPTIMIZER_HINT_RE)) {
20
+ const hint = body.match(LIMIT_OVERRIDE_HINT_RE);
21
+ if (hint) {
22
+ return hint[1].toUpperCase();
23
+ }
24
+ }
25
+ return null;
26
+ }
11
27
  function findFileExport(sql) {
12
28
  let astOrArray;
13
29
  try {
@@ -56,8 +72,9 @@ function extractReferencedSchemas(sql) {
56
72
  }
57
73
  const defaultSchema = MYSQL_DB ?? null;
58
74
  const schemas = new Set();
59
- const qualified = new Map();
60
- const unqualified = [];
75
+ const names = new Map();
76
+ const deleteTargets = new Set();
77
+ const targetNames = [];
61
78
  const visit = (node) => {
62
79
  if (!isRecord(node)) {
63
80
  return;
@@ -65,17 +82,23 @@ function extractReferencedSchemas(sql) {
65
82
  if ((node.keyword === 'database' || node.keyword === 'schema') && typeof node.type === 'string') {
66
83
  schemas.add(databaseStatementName(node));
67
84
  }
85
+ if (node.type === 'delete' && Array.isArray(node.table)) {
86
+ for (const target of node.table) {
87
+ deleteTargets.add(target);
88
+ }
89
+ }
68
90
  const name = typeof node.table === 'string' ? node.table : typeof node.view === 'string' ? node.view : null;
69
91
  if (node.type !== 'column_ref' && name && (typeof node.db === 'string' || node.db === null)) {
70
- if (typeof node.db === 'string' && node.db) {
71
- schemas.add(node.db);
72
- qualified.set(name.toLowerCase(), node.db);
73
- if (typeof node.as === 'string' && node.as) {
74
- qualified.set(node.as.toLowerCase(), node.db);
75
- }
92
+ if (deleteTargets.has(node) && !node.db) {
93
+ targetNames.push(name.toLowerCase());
76
94
  }
77
95
  else {
78
- unqualified.push(name.toLowerCase());
96
+ const schema = typeof node.db === 'string' && node.db ? node.db : defaultSchema;
97
+ schemas.add(schema);
98
+ const key = (typeof node.as === 'string' && node.as ? node.as : name).toLowerCase();
99
+ const known = names.get(key) ?? new Set();
100
+ known.add(schema);
101
+ names.set(key, known);
79
102
  }
80
103
  }
81
104
  for (const value of Object.values(node)) {
@@ -90,8 +113,10 @@ function extractReferencedSchemas(sql) {
90
113
  }
91
114
  };
92
115
  visit(astOrArray);
93
- for (const name of unqualified) {
94
- schemas.add(qualified.get(name) ?? defaultSchema);
116
+ for (const name of targetNames) {
117
+ for (const schema of names.get(name) ?? [defaultSchema]) {
118
+ schemas.add(schema);
119
+ }
95
120
  }
96
121
  if (schemas.size === 0) {
97
122
  schemas.add(null);
@@ -261,4 +286,4 @@ function isUnparseableIntrospection(sql) {
261
286
  const kind = isIntrospectionQuery(sql).kind;
262
287
  return kind !== null && kind !== 'information_schema' && kind !== 'mysql_schema';
263
288
  }
264
- export { findUnsupportedStatementType, findFileExport, extractReferencedSchemas, getQueryTypes, isIntrospectionQuery, isUnparseableIntrospection, extractQualifiers, stripExplainModifiers, };
289
+ export { DDL_STATEMENT_TYPES, findUnsupportedStatementType, hasExecutableComment, findLimitOverrideHint, findFileExport, extractReferencedSchemas, getQueryTypes, isIntrospectionQuery, isUnparseableIntrospection, extractQualifiers, stripExplainModifiers, };
@@ -122,6 +122,15 @@ export function readSSHConfigHost(alias, configPath = path.join(os.homedir(), '.
122
122
  case 'identityfile':
123
123
  entry.identityFile ??= expandHome(value.replace(/^"|"$/g, ''));
124
124
  break;
125
+ case 'userknownhostsfile':
126
+ entry.userKnownHostsFiles ??=
127
+ value.toLowerCase() === 'none'
128
+ ? []
129
+ : value.split(/\s+/).map((file) => expandHome(file.replace(/^"|"$/g, '')));
130
+ break;
131
+ case 'hostkeyalias':
132
+ entry.hostKeyAlias ??= value;
133
+ break;
125
134
  case 'localforward': {
126
135
  const forward = parseLocalForward(value);
127
136
  if (forward && !entry.localForward) {
@@ -0,0 +1,105 @@
1
+ import { createHmac } from 'crypto';
2
+ import * as fs from 'fs';
3
+ import { expandHome } from './config.js';
4
+ export const DEFAULT_USER_KNOWN_HOSTS_FILES = ['~/.ssh/known_hosts', '~/.ssh/known_hosts2'];
5
+ const GLOBAL_KNOWN_HOSTS_FILES = ['/etc/ssh/ssh_known_hosts', '/etc/ssh/ssh_known_hosts2'];
6
+ export function knownHostsName(host, port) {
7
+ return port === 22 ? host : `[${host}]:${port}`;
8
+ }
9
+ function matchesWildcard(pattern, name) {
10
+ const source = pattern
11
+ .split('')
12
+ .map((ch) => (ch === '*' ? '.*' : ch === '?' ? '.' : ch.replace(/[\\^$.|+()[\]{}]/g, '\\$&')))
13
+ .join('');
14
+ return new RegExp(`^${source}$`, 'i').test(name);
15
+ }
16
+ function matchesHashed(entry, name) {
17
+ const [, , salt, hash] = entry.split('|');
18
+ if (!salt || !hash) {
19
+ return false;
20
+ }
21
+ const digest = createHmac('sha1', Buffer.from(salt, 'base64')).update(name).digest('base64');
22
+ return digest === hash;
23
+ }
24
+ function hostFieldMatches(field, name) {
25
+ if (field.startsWith('|1|')) {
26
+ return matchesHashed(field, name);
27
+ }
28
+ let matched = false;
29
+ for (const pattern of field.split(',')) {
30
+ if (pattern.startsWith('!')) {
31
+ if (matchesWildcard(pattern.slice(1), name)) {
32
+ return false;
33
+ }
34
+ }
35
+ else if (matchesWildcard(pattern, name)) {
36
+ matched = true;
37
+ }
38
+ }
39
+ return matched;
40
+ }
41
+ export function lookupKnownHosts(host, port, files) {
42
+ const name = knownHostsName(host, port);
43
+ const result = { name, files: [], keys: [], revoked: [] };
44
+ for (const file of [...files.map(expandHome), ...GLOBAL_KNOWN_HOSTS_FILES]) {
45
+ let raw;
46
+ try {
47
+ raw = fs.readFileSync(file, 'utf8');
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ result.files.push(file);
53
+ for (const line of raw.split(/\r?\n/)) {
54
+ const fields = line.trim().split(/\s+/);
55
+ if (!fields[0] || fields[0].startsWith('#')) {
56
+ continue;
57
+ }
58
+ const marker = fields[0].startsWith('@') ? fields.shift() : undefined;
59
+ if (marker === '@cert-authority') {
60
+ continue;
61
+ }
62
+ const [hosts, type, key] = fields;
63
+ if (!hosts || !type || !key || !hostFieldMatches(hosts, name)) {
64
+ continue;
65
+ }
66
+ const blob = Buffer.from(key, 'base64');
67
+ if (marker === '@revoked') {
68
+ result.revoked.push(blob);
69
+ }
70
+ else {
71
+ result.keys.push({ type, key: blob });
72
+ }
73
+ }
74
+ }
75
+ return result;
76
+ }
77
+ export function hostKeyAlgorithms(keys) {
78
+ const algorithms = [];
79
+ const add = (algorithm) => {
80
+ if (!algorithms.includes(algorithm)) {
81
+ algorithms.push(algorithm);
82
+ }
83
+ };
84
+ for (const { type } of keys) {
85
+ if (type === 'ssh-rsa') {
86
+ add('rsa-sha2-512');
87
+ add('rsa-sha2-256');
88
+ add('ssh-rsa');
89
+ }
90
+ else if (type === 'ssh-ed25519' ||
91
+ type === 'ecdsa-sha2-nistp256' ||
92
+ type === 'ecdsa-sha2-nistp384' ||
93
+ type === 'ecdsa-sha2-nistp521' ||
94
+ type === 'ssh-dss') {
95
+ add(type);
96
+ }
97
+ }
98
+ return algorithms;
99
+ }
100
+ export function isKnownHostKey(lookup, presented) {
101
+ if (lookup.revoked.some((key) => key.equals(presented))) {
102
+ return false;
103
+ }
104
+ return lookup.keys.some(({ key }) => key.equals(presented));
105
+ }
@@ -4,6 +4,7 @@ import { Client as SSHClient } from 'ssh2';
4
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
5
  import { log } from '../utils/index.js';
6
6
  import { expandHome, readSSHConfigHost } from './config.js';
7
+ import { DEFAULT_USER_KNOWN_HOSTS_FILES, hostKeyAlgorithms, isKnownHostKey, lookupKnownHosts } from './known-hosts.js';
7
8
  class LocalPortTakenError extends Error {
8
9
  }
9
10
  const RECONNECT_MAX_ATTEMPTS = 3;
@@ -61,6 +62,9 @@ export function resolveTunnelConfig() {
61
62
  sshUser: sshUser,
62
63
  privateKeyPath,
63
64
  passphrase: SSH_PASSPHRASE,
65
+ knownHostsFiles: fromConfig?.userKnownHostsFiles ?? DEFAULT_USER_KNOWN_HOSTS_FILES,
66
+ hostKeyName: fromConfig?.hostKeyAlias ?? sshHost,
67
+ sshTarget: configHost ?? `${sshPort === 22 ? '' : `-p ${sshPort} `}${sshUser}@${sshHost}`,
64
68
  localPort,
65
69
  remoteHost: remoteHost,
66
70
  remotePort,
@@ -95,8 +99,17 @@ let shuttingDown = false;
95
99
  let fatalError = null;
96
100
  function connectSSH(cfg) {
97
101
  return new Promise((resolve, reject) => {
102
+ const known = lookupKnownHosts(cfg.hostKeyName, cfg.sshPort, cfg.knownHostsFiles);
103
+ const algorithms = hostKeyAlgorithms(known.keys);
104
+ if (algorithms.length === 0) {
105
+ const searched = known.files.length > 0 ? known.files.join(', ') : cfg.knownHostsFiles.join(', ');
106
+ reject(new Error(`No host key for ${known.name} in known_hosts (${searched}). ` +
107
+ `Run \`ssh ${cfg.sshTarget}\` once to verify the bastion's fingerprint and record it, then retry.`));
108
+ return;
109
+ }
98
110
  const client = new SSHClient();
99
111
  let settled = false;
112
+ let hostKeyRejection = null;
100
113
  const timer = setTimeout(() => {
101
114
  if (settled) {
102
115
  return;
@@ -120,7 +133,7 @@ function connectSSH(cfg) {
120
133
  settled = true;
121
134
  clearTimeout(timer);
122
135
  client.destroy();
123
- reject(new Error(`SSH connection to ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} failed: ${err.message}`));
136
+ reject(new Error(`SSH connection to ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} failed: ${hostKeyRejection ?? err.message}`));
124
137
  });
125
138
  let privateKey;
126
139
  try {
@@ -141,6 +154,16 @@ function connectSSH(cfg) {
141
154
  keepaliveInterval: 15000,
142
155
  keepaliveCountMax: 3,
143
156
  ...(cfg.passphrase ? { passphrase: cfg.passphrase } : {}),
157
+ algorithms: { serverHostKey: algorithms },
158
+ hostVerifier: (key) => {
159
+ if (isKnownHostKey(known, key)) {
160
+ return true;
161
+ }
162
+ hostKeyRejection =
163
+ `the host key for ${known.name} does not match known_hosts. The bastion's key changed or ` +
164
+ `the connection is being intercepted. Check with \`ssh ${cfg.sshTarget}\` before retrying.`;
165
+ return false;
166
+ },
144
167
  };
145
168
  client.connect(connectConfig);
146
169
  });
@@ -161,7 +184,7 @@ async function reconnect(cfg) {
161
184
  sshClient = client;
162
185
  fatalError = null;
163
186
  attachDropHandler(client, cfg);
164
- log('error', `[ssh] tunnel reconnected on attempt ${attempt}`);
187
+ log('info', `[ssh] tunnel reconnected on attempt ${attempt}`);
165
188
  return;
166
189
  }
167
190
  catch (err) {
@@ -252,7 +275,7 @@ export function ensureTunnel() {
252
275
  activeEndpoint = { host: '127.0.0.1', port: cfg.localPort, reused: true };
253
276
  return activeEndpoint;
254
277
  }
255
- log('error', `[ssh] opening tunnel ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} -> ${cfg.remoteHost}:${cfg.remotePort} ` +
278
+ log('info', `[ssh] opening tunnel ${cfg.sshUser}@${cfg.sshHost}:${cfg.sshPort} -> ${cfg.remoteHost}:${cfg.remotePort} ` +
256
279
  `(local port ${cfg.localPort === 0 ? 'auto' : cfg.localPort})`);
257
280
  const client = await connectSSH(cfg);
258
281
  sshClient = client;
@@ -277,7 +300,7 @@ export function ensureTunnel() {
277
300
  }
278
301
  }
279
302
  attachDropHandler(client, cfg);
280
- log('error', `[ssh] tunnel listening on 127.0.0.1:${port}`);
303
+ log('info', `[ssh] tunnel listening on 127.0.0.1:${port}`);
281
304
  activeEndpoint = { host: '127.0.0.1', port, reused: false };
282
305
  return activeEndpoint;
283
306
  })();
@@ -2,6 +2,10 @@ import { ENABLE_LOGGING } from '../config/index.js';
2
2
  export function tableResourceUri(schema, table) {
3
3
  return `mysql://tables/${encodeURIComponent(schema)}/${encodeURIComponent(table)}`;
4
4
  }
5
+ export function sharedOnce(fn) {
6
+ let running;
7
+ return (...args) => (running ??= fn(...args));
8
+ }
5
9
  export function log(type = 'info', ...args) {
6
10
  if (!ENABLE_LOGGING) {
7
11
  return;
package/package.json CHANGED
@@ -1,53 +1,53 @@
1
1
  {
2
- "name": "@ecubelabs/mysql-mcp",
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
- }
53
- }
2
+ "name": "@ecubelabs/mysql-mcp",
3
+ "version": "1.0.0-next.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": "workspace:^",
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
+ }
53
+ }