@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 +71 -15
- package/dist/index.js +18 -24
- package/dist/src/db/index.js +55 -18
- package/dist/src/db/permissions.js +21 -1
- package/dist/src/db/utils.js +38 -13
- package/dist/src/ssh/config.js +9 -0
- package/dist/src/ssh/known-hosts.js +105 -0
- package/dist/src/ssh/tunnel.js +27 -4
- package/dist/src/utils/index.js +4 -0
- package/package.json +52 -52
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`,
|
|
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
|
-
문장은 모두 거부합니다. `
|
|
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 {
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 =
|
|
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('
|
|
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);
|
package/dist/src/db/index.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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) =>
|
|
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
|
|
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('
|
|
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) =>
|
|
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('
|
|
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
|
|
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
|
-
|
|
445
|
-
|
|
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('
|
|
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
|
-
|
|
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, };
|
package/dist/src/db/utils.js
CHANGED
|
@@ -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
|
|
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
|
|
60
|
-
const
|
|
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 (
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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, };
|
package/dist/src/ssh/config.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/src/ssh/tunnel.js
CHANGED
|
@@ -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('
|
|
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('
|
|
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('
|
|
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
|
})();
|
package/dist/src/utils/index.js
CHANGED
|
@@ -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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
+
}
|