@ecubelabs/mysql-mcp 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,849 @@
1
- # Temporary Holding Version
1
+ # @ecubelabs/mysql-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ SSH bastion을 거쳐야만 닿을 수 있는 MySQL을 위한 읽기 전용 MCP 서버. 환경마다
4
+ 이름 붙은 profile 하나씩을 둡니다.
5
+
6
+ Tunnel은 서버가 직접 엽니다. 이미 쓰고 있는 `~/.ssh/config` alias 하나만 가리켜
7
+ 주면 forwarding, connection pool, teardown까지 알아서 처리합니다. `prod`라는 이름의 profile은
8
+ 쓰기가 불가능하며, 이는 설정이 아니라 코드에서 결정됩니다.
9
+
10
+ ## 제공하는 것
11
+
12
+ - **SSH tunnel.** Connection pool이 생기기 전에 열리고, 종료 시 닫힙니다. 각 서버가
13
+ 자기 tunnel을 소유하므로 instance끼리 간섭하지 않습니다.
14
+ - **Profile.** `MYSQL_PROFILE`이 환경 이름을 정합니다. 모든 응답이 어느 환경에서
15
+ 온 답인지 밝힙니다.
16
+ - **기본 읽기 전용, prod는 깨뜨릴 수 없음.** `prod`/`production`은 설정과
17
+ 무관하게 모든 쓰기를 거부합니다.
18
+ - **Map은 탐색이 아니라 선언.** 각 profile이 어떤 schema가 어떤 application의
19
+ 것인지, 데이터가 어느 git branch에 대응하는지를 명시하므로, 모델은 이를
20
+ 뒤져서 알아내는 대신 툴 설명에서 읽습니다.
21
+ - **로컬 schema catalog.** 선언된 schema의 table 목록을 저장해 두고 TTL이 지나면
22
+ 다시 읽습니다. 실제 query에 등장한 table의 column·index·foreign key는 필요할 때만
23
+ 수집합니다.
24
+ - **실행 상한과 타임아웃 보고서.** 읽기 쿼리는 10초·5000행에서 끊깁니다. 시간
25
+ 상한에 걸리면 실행 계획을 한 번 읽어, 플랜과 인덱스 목록과 선택지를 돌려줍니다.
26
+ 판정은 서버가 하지 않고 모델이 합니다.
27
+ - **stdio에 안전한 로깅.** 모든 진단 출력이 stderr로 가므로
28
+ `ENABLE_LOGGING=true`가 MCP 스트림을 망가뜨리는 일이 없습니다.
29
+
30
+ ## 요구 사항
31
+
32
+ - Node.js 18 이상
33
+ - Bastion에 대한 SSH 접근 권한, 그리고 이미 동작하는 키(`ssh <alias>`가 성공할 것)
34
+ - 대상 데이터베이스의 MySQL 사용자 계정
35
+
36
+ ## 설치
37
+
38
+ ### 1. `~/.ssh/config`에 bastion 기술하기
39
+
40
+ 서버는 필요한 정보를 Host alias에서 모두 읽어올 수 있습니다. 이렇게 하면
41
+ 호스트명과 SSH 키를 MCP 클라이언트 설정 바깥에 온전히 둘 수 있습니다:
42
+
43
+ ```sshconfig
44
+ Host ecubelabs2-stage-db
45
+ HostName <stage bastion 호스트명>
46
+ User dev
47
+ LocalForward 3307 <stage RDS reader 엔드포인트>:3306
48
+ Host ecubelabs2-prod-db
49
+ HostName <prod bastion 호스트명>
50
+ User dev
51
+ LocalForward 3308 <prod RDS reader 엔드포인트>:3306
52
+ ```
53
+
54
+ `HostName`, `User`, `Port`, `IdentityFile`, 그리고 첫 번째 `LocalForward`를
55
+ 읽어갑니다. 다음 단계로 넘어가기 전에 alias 자체가 동작하는지 확인하세요:
56
+
57
+ ```bash
58
+ ssh ecubelabs2-stage-db "echo ok"
59
+ ```
60
+
61
+ ### 2. MCP 클라이언트에 등록하기
62
+
63
+ - `MYSQL_PROFILE`은 빼지 마세요. prod 쓰기 금지를 이 값이 정합니다.
64
+ - `MYSQL_APP_SCHEMAS` 형식은 [아래 절](#mysql_app_schemas--어떤-schema가-어떤-앱의-것인가)에 있습니다.
65
+ - 나머지 변수는 [환경 변수](#환경-변수) 절에 있습니다.
66
+
67
+ #### Claude Code
68
+
69
+ ```bash
70
+ claude mcp add mysql-stage -s user \
71
+ -e MYSQL_PROFILE=stage \
72
+ -e MYSQL_SSH_CONFIG_HOST=ecubelabs2-stage-db \
73
+ -e "MYSQL_APP_SCHEMAS=core-api:haulla:Main product service - the primary schema;
74
+ core-api:haulla-shared:Tenant-shared data, also owned by core-api;
75
+ customer-tycoon-api:tycoon:Internal sales/CRM tool;
76
+ hubspot-adapter:hubspot:HubSpot integration - mirrored CRM objects;
77
+ call-analyzer:call:Call transcription and analysis output;
78
+ simulator:simulator:HubSpot API simulator, stage only" \
79
+ -e MYSQL_DOCS_REPO=/path/to/haulla \
80
+ -e MYSQL_CODE_BRANCH=develop \
81
+ -e MYSQL_USER=<user> \
82
+ -e MYSQL_PASS=<password> \
83
+ -- npx -y @ecubelabs/mysql-mcp
84
+
85
+ claude mcp add mysql-prod -s user \
86
+ -e MYSQL_PROFILE=prod \
87
+ -e MYSQL_SSH_CONFIG_HOST=ecubelabs2-prod-db \
88
+ -e "MYSQL_APP_SCHEMAS=core-api:haulla:Main product service - the primary schema;
89
+ core-api:haulla-shared:Tenant-shared data, also owned by core-api;
90
+ customer-tycoon-api:tycoon:Internal sales/CRM tool;
91
+ hubspot-adapter:hubspot:HubSpot integration - mirrored CRM objects;
92
+ call-analyzer:call:Call transcription and analysis output" \
93
+ -e MYSQL_DOCS_REPO=/path/to/haulla \
94
+ -e MYSQL_CODE_BRANCH=main \
95
+ -e MYSQL_USER=<user> \
96
+ -e MYSQL_PASS=<password> \
97
+ -- npx -y @ecubelabs/mysql-mcp
98
+ ```
99
+
100
+ #### Codex
101
+
102
+ ```bash
103
+ codex mcp add mysql-stage \
104
+ --env MYSQL_PROFILE=stage \
105
+ --env MYSQL_SSH_CONFIG_HOST=ecubelabs2-stage-db \
106
+ --env "MYSQL_APP_SCHEMAS=core-api:haulla:Main product service - the primary schema;
107
+ core-api:haulla-shared:Tenant-shared data, also owned by core-api;
108
+ customer-tycoon-api:tycoon:Internal sales/CRM tool;
109
+ hubspot-adapter:hubspot:HubSpot integration - mirrored CRM objects;
110
+ call-analyzer:call:Call transcription and analysis output;
111
+ simulator:simulator:HubSpot API simulator, stage only" \
112
+ --env MYSQL_DOCS_REPO=/path/to/haulla \
113
+ --env MYSQL_CODE_BRANCH=develop \
114
+ --env MYSQL_USER=<user> \
115
+ --env MYSQL_PASS=<password> \
116
+ -- npx -y @ecubelabs/mysql-mcp
117
+
118
+ codex mcp add mysql-prod \
119
+ --env MYSQL_PROFILE=prod \
120
+ --env MYSQL_SSH_CONFIG_HOST=ecubelabs2-prod-db \
121
+ --env "MYSQL_APP_SCHEMAS=core-api:haulla:Main product service - the primary schema;
122
+ core-api:haulla-shared:Tenant-shared data, also owned by core-api;
123
+ customer-tycoon-api:tycoon:Internal sales/CRM tool;
124
+ hubspot-adapter:hubspot:HubSpot integration - mirrored CRM objects;
125
+ call-analyzer:call:Call transcription and analysis output" \
126
+ --env MYSQL_DOCS_REPO=/path/to/haulla \
127
+ --env MYSQL_CODE_BRANCH=main \
128
+ --env MYSQL_USER=<user> \
129
+ --env MYSQL_PASS=<password> \
130
+ -- npx -y @ecubelabs/mysql-mcp
131
+ ```
132
+
133
+ ### 3. table과 문서 연결하기 (선택)
134
+
135
+ 2단계의 `MYSQL_DOCS_REPO`에 `model.md` 문서가 있는 저장소의 절대 경로를 넣었다면,
136
+ 모델이 table과 문서를 연결해 둡니다. 그 뒤로 `describe`하면 연결된 문서의 경로와
137
+ 읽는 명령이 함께 옵니다. 모델에게 연결을 요청하세요:
138
+
139
+ ```text
140
+ mysql-stage catalog의 table을 model.md 문서와 모두 연결해줘
141
+ ```
142
+
143
+ - 서버는 그 저장소의 `origin/<MYSQL_CODE_BRANCH>`를 읽고 `fetch`하지 않습니다.
144
+ 최신 문서를 반영하려면 저장소에서 `git fetch`하세요.
145
+ - `docs_list`는 후보를 `apps/<앱 이름>/` 아래의 `model.md`로 좁힙니다. 앱 이름은
146
+ `MYSQL_APP_SCHEMAS`에 적은 이름이고, 그 아래에 문서가 없으면 전체 목록을 보여 줍니다.
147
+
148
+ ## 사용법
149
+
150
+ Profile 이름으로 물어보면 답변에 환경이 함께 돌아옵니다:
151
+
152
+ ```text
153
+ stage db에 Active Account가 몇 개 있는지 알려줘
154
+ ```
155
+
156
+ ```text
157
+ [profile: STAGE | read-only | database: multi-db | code: develop branch | ssh tunnel: dev@<stage bastion 호스트명> -> <stage RDS reader 엔드포인트>:3306]
158
+ [
159
+ {
160
+ "c": 4004
161
+ }
162
+ ]
163
+ ```
164
+
165
+ - 모든 툴 응답이 성공이든 거부든 이 banner를 달고 나오므로, stage 결과를 prod
166
+ 결과로 착각할 수 없습니다.
167
+ - `mysql_query` 툴 설명에도 환경 이름이 들어갑니다. 모델이 툴을 고르기 전에 읽는
168
+ 것이 바로 툴 설명이기 때문입니다.
169
+
170
+ ## 모델에게 무엇이 어디 있는지 알려주기
171
+
172
+ 내버려 두면 모델은 "lead는 어느 schema에 있지?"라는 질문에 `SHOW DATABASES`와
173
+ `information_schema` 쿼리 몇 번, 그리고 추측으로 답합니다. 하나하나가 왕복
174
+ 비용이고, 추측은 가끔 틀립니다. 모델에게 없는 아래 두 가지 사실은 운영자가 이미
175
+ 알고 있는 것이므로, profile이 이를 명시합니다.
176
+
177
+ ### `MYSQL_APP_SCHEMAS` — 어떤 schema가 어떤 앱의 것인가
178
+
179
+ 각 항목은 `app:schema`, 또는 `app:schema:설명` 형식입니다. 항목 구분자는 `;` 또는
180
+ 개행입니다. 쉼표가 아니므로 설명을 문장처럼 쓸 수 있습니다:
181
+
182
+ ```dotenv
183
+ MYSQL_APP_SCHEMAS="core-api:haulla:Main product service - the primary schema;
184
+ core-api:haulla-shared:Tenant-shared data, also owned by core-api;
185
+ customer-tycoon-api:tycoon:Internal sales/CRM tool;
186
+ hubspot-adapter:hubspot:HubSpot integration - mirrored CRM objects;
187
+ call-analyzer:call:Call transcription and analysis output;
188
+ simulator:simulator:HubSpot API simulator, stage only"
189
+ ```
190
+
191
+ - 한 앱이 여러 번 나올 수 있습니다. 테넌트별 schema와 공용 schema를 함께 갖는
192
+ 서비스는 둘 다 적으세요. 뒤엣것만 남기면 모델에게 필요한 schema가 사라집니다.
193
+ - 같은 `app:schema` 쌍이 반복될 때만 실수로 보고 첫 항목만 남깁니다. 대소문자는
194
+ 가리지 않습니다.
195
+ - 설명에는 `;`를 쓸 수 없습니다. 항목이 쪼개집니다.
196
+ - 이 map은 쿼리 범위를 제한하지 않습니다. map에 없는 schema도 쿼리할 수 있습니다.
197
+
198
+ 이 map은 `mysql_query` 툴 설명에 붙습니다. 모델이 첫 호출 전에 읽는 것이 툴
199
+ 설명입니다:
200
+
201
+ ```text
202
+ APP -> SCHEMA (authoritative — use these directly; do not run SHOW DATABASES or search information_schema to find a schema):
203
+ - core-api -> haulla — Main product service - the primary schema
204
+ - core-api -> haulla-shared — Tenant-shared data, also owned by core-api
205
+ - customer-tycoon-api -> tycoon — Internal sales/CRM tool
206
+ - hubspot-adapter -> hubspot — HubSpot integration - mirrored CRM objects
207
+ - call-analyzer -> call — Call transcription and analysis output
208
+ - simulator -> simulator — HubSpot API simulator, stage only
209
+ Qualify every table with its schema (schema.table). Any schema not listed here is either absent from this environment or not owned by an application.
210
+ A schema name that is not a bare identifier must be backtick-quoted, e.g. SELECT ... FROM `haulla-shared`.some_table.
211
+ ```
212
+
213
+ **여기에는 migration을 넘겨서도 살아남는 사실만 적으세요.**
214
+
215
+ - schema의 테이블 목록을 나열하면 `SHOW TABLES` 한 번을 아끼는 대신 조용히
216
+ 낡습니다.
217
+ - 이 map은 authoritative하다고 선언되므로, **틀린 목록은 없는 목록보다
218
+ 나쁩니다.** 모델이 "그런 테이블은 없다"고 단정해 버립니다.
219
+ - 어떤 앱이 어떤 schema를 쓰는지는 사람만 알고 데이터베이스에 물어볼 수 없는
220
+ 사실입니다. 그 schema에 어떤 테이블이 있는지는 [schema catalog](#schema-catalog)가
221
+ 데이터베이스에서 자동으로 갱신합니다.
222
+
223
+ ### `MYSQL_CODE_BRANCH` — 데이터가 어느 branch에 대응하는가
224
+
225
+ 행과 코드를 대조하는 모델에게는 올바른 checkout이 필요한데, 틀렸을 때 조용히
226
+ 실패합니다. stage column을 `main` 기준으로 읽으면 branch가 잘못된 것이 아니라
227
+ migration이 빠진 것처럼 보이기 때문입니다. 그래서 각 profile이 자기 branch를
228
+ 명시하며, 이 값은 툴 설명과 응답 banner 양쪽에 나타납니다:
229
+
230
+ ```dotenv
231
+ MYSQL_CODE_BRANCH=develop # stage profile
232
+ MYSQL_CODE_BRANCH=main # prod profile
233
+ ```
234
+
235
+ 설정하지 않으면 profile에 따라 기본값이 정해집니다.
236
+
237
+ | Profile | 기본 branch |
238
+ | --------------------------------------- | ----------- |
239
+ | `stage` / `staging` / `dev` / `develop` | `develop` |
240
+ | `prod` / `production` | `main` |
241
+ | 그 밖 | 말하지 않음 |
242
+
243
+ ## Schema catalog
244
+
245
+ 이미 알아낸 구조를 다시 찾지 않도록, 서버는 `MYSQL_APP_SCHEMAS`에 선언된 schema만
246
+ 로컬 JSON catalog에 기록합니다.
247
+
248
+ ### 수집 시점
249
+
250
+ - **table 이름·comment·예상 row 수**는 시작할 때 갱신합니다. catalog가 비어 있거나
251
+ `MYSQL_CATALOG_TTL_HOURS`(기본 24시간)가 지났을 때만입니다.
252
+ - **column, primary key, index, foreign key**는 table이 실제 SQL에 처음 등장한 뒤
253
+ 비동기로 수집합니다. 이 작업은 `mysql_query`의 실행 시간에 포함되지 않습니다.
254
+ - Query가 없는 column이나 table을 참조해 실패하면 서버가 그 항목을 즉시 다시
255
+ 수집합니다.
256
+
257
+ **Migration 직후에는 모델에게 catalog `refresh`를 요청하세요.** migration으로
258
+ column·index·foreign key가 **추가**되면 오류가 나지 않으므로, catalog는 TTL이 지날
259
+ 때까지 그 사실을 모릅니다. 그동안 모델이 새로 생긴 `deletedAt`을 모른 채 쓴 SQL은
260
+ 오류 없이 삭제된 row까지 함께 반환합니다.
261
+
262
+ ### `mysql_catalog` action
263
+
264
+ 모델은 이 툴로 DB를 다시 탐색하지 않고 구조를 읽습니다.
265
+
266
+ **구조 읽기**
267
+
268
+ - `map`: application, schema, 자주 쓰는 table의 개요를 봅니다.
269
+ - `search`: table 이름·comment, 수집된 column, memo, alias를 keyword로 찾습니다.
270
+ - `describe`: 한 table의 column, key, index, foreign key를 봅니다. 상세 정보가
271
+ 아직 없거나 TTL이 지났다면 이 호출이 갱신을 기다립니다.
272
+
273
+ **문서 연결**
274
+
275
+ - `docs_list`: schema를 소유한 application 아래의 `model.md` 후보만 봅니다.
276
+ - 범위는 `MYSQL_APP_SCHEMAS`의 application 이름을 저장소의 `apps/<application>/`
277
+ directory로 보고 좁힙니다.
278
+ - 응답의 `scopedToApp`이 이 규약이 적용됐는지 알려주고, 범위 밖 문서 수를 함께
279
+ 보고합니다.
280
+ - 규약에 맞는 문서가 없으면 전체 목록을 반환하고 그 이유를 `notice`에 적습니다.
281
+ - 범위 밖 경로도 `docs_read`로 읽을 수 있습니다.
282
+ - `docs_read`: shell을 쓸 수 없는 클라이언트에서도 선택한 문서의 최신 내용을
283
+ 읽습니다.
284
+ - `link`: 모델이 판단한 table↔문서 연결을 배열로 한꺼번에 저장합니다.
285
+ - `unlink`: 연결을 지우거나 해당 table에 연결할 문서가 없음을 기록합니다.
286
+
287
+ **기록 관리**
288
+
289
+ - `note`: `target` table에 memo 또는 alias 하나를 저장합니다. 둘 중 하나만 보냅니다.
290
+ Table당 memo 50개, alias 20개까지이며 한도에 닿으면 저장을 거절합니다. 사람과
291
+ 모델이 쓴 내용을 서버가 조용히 버리지 않기 위한 것이고, 정리는 `forget`으로 합니다.
292
+ - `forget`: `target` table의 `notes`, `aliases`, `usage`, `joins`, `metadata` 중 한
293
+ 범위를 지웁니다. `metadata`는 다음 `describe` 또는 정상 query 뒤에 다시 수집됩니다.
294
+ 문서 판단은 지우지 않으며, 그 작업에는 `unlink`를 사용합니다.
295
+ - `refresh`: 지정한 범위를 강제로 다시 수집합니다. `target`을 생략하면 inventory와
296
+ 문서를 함께 갱신합니다.
297
+
298
+ | `target` | 다시 수집하는 것 |
299
+ | ------------------ | ----------------------------------- |
300
+ | `schema.table` | 그 table의 column·index·foreign key |
301
+ | 선언된 schema 이름 | table inventory |
302
+ | `docs` | 문서 ref |
303
+
304
+ ### 문서 catalog
305
+
306
+ - 서버 시작 시 git을 실행하지 않고, 문서가 처음 필요해질 때 깨어납니다.
307
+ - 항상 local working tree가 아니라 `origin/<MYSQL_CODE_BRANCH>`를 읽습니다. 서버가
308
+ `fetch`, `checkout`, `pull`을 실행하는 일은 없습니다.
309
+ - 문서 내용이 아니라 경로와 ref commit만 저장합니다.
310
+ - Ref가 바뀌면 추가와 삭제를 반영하고, git이 보고한 rename은 기존 table 연결에도
311
+ 그대로 적용합니다. 내용 수정은 경로를 낡게 만들지 않으므로 별도 작업을 하지
312
+ 않습니다.
313
+ - 연결된 문서는 `describe` 응답에서 경로, ref, 바로 실행할 수 있는 `git show`
314
+ 명령을 함께 제공합니다.
315
+ - 마지막 ref commit이 30일보다 오래됐으면 동작은 계속하되 응답에 경고를 붙입니다.
316
+
317
+ ### 사용 기록과 hot table
318
+
319
+ Query 뒤에는 참조한 table의 호출 횟수와 최근 시각을 기록합니다. 실패한 query도
320
+ 횟수에 들어가며, 성공과 실패를 따로 셉니다. 최근 시각은 성공한 query만 갱신합니다.
321
+
322
+ Hot table 순위는 **성공한 query 횟수**를 기준으로 정하고 최근 시각으로 동률을
323
+ 정합니다. 실패한 query는 모델이 그 table을 읽지 못했다는 뜻이라 순위에 반영하지
324
+ 않습니다. 이 순위가 두 곳에 쓰입니다:
325
+
326
+ - **`map`**: schema마다 상위 10개를 보여줍니다. 관측이 없는 table은 예상 row 수로
327
+ 이어 붙입니다.
328
+ - **`mysql_query` 도구 설명**: 전체 상위 최대 10개를 넣되 **한 번이라도 읽힌
329
+ table만** 담습니다. 읽힌 table이 없으면 목록 자체를 넣지 않습니다. 예상 row 수로
330
+ 빈자리를 채우면 가장 큰 event·log table이 열 칸을 차지해서, 모델을 시작하면 안
331
+ 되는 곳으로 밀기 때문입니다.
332
+
333
+ 사용 기록은 catalog 파일에 남아 다음 세션으로 이어집니다. 세션에서 첫 query가
334
+ 성공하면 서버가 `tools/list_changed` 알림을 한 번 보내, 클라이언트가 갱신된 툴
335
+ 설명을 다시 읽게 합니다.
336
+
337
+ ### 관측한 join
338
+
339
+ - Query에서 두 column을 `=`로 비교한 곳은 `ON`이든 `WHERE`든 join으로 봅니다.
340
+ - 양 끝과 횟수, 마지막 관측 시각만 저장합니다. 성공한 query의 join만 누적합니다.
341
+ - `describe`의 `observedJoins`에서 해당 table과 연결된 경로를 자주 관측한 순서로
342
+ 20개까지 확인할 수 있습니다. 이는 DB의 foreign key와 별개인 관측 사실입니다.
343
+ - 간선은 500개까지 유지하고, 넘으면 가장 오래 관측되지 않은 것부터 버립니다.
344
+ 횟수가 아니라 시각을 기준으로 버리는 이유는, 드문 간선을 먼저 버리면 나중에
345
+ 발견한 join 경로가 한도를 넘어 들어올 수 없게 되기 때문입니다.
346
+
347
+ ### 저장 위치와 저장하지 않는 것
348
+
349
+ Catalog는 기본적으로
350
+ `~/.cache/mysql-mcp/catalog/<profile>-<database-target-hash>.json`에
351
+ 저장됩니다.
352
+
353
+ - Hash에는 DB 대상과 MySQL user가 들어가므로, 서로 다른 환경이 같은 catalog를
354
+ 읽지 않습니다.
355
+ - 위치는 `MYSQL_CATALOG_PATH`로 바꿀 수 있습니다.
356
+ - 디스크에 쓸 수 없으면 catalog만 꺼지고 query는 계속 됩니다.
357
+
358
+ Catalog에는 schema metadata와 사용 기록만 들어갑니다.
359
+
360
+ - SQL은 어떤 형태로도 저장하지 않습니다. Query에서 뽑아 쓰는 것은 참조한 table
361
+ 이름, join으로 이어진 column 짝, 결과에 나온 column 이름뿐입니다.
362
+ - literal은 catalog에 닿기 전에 버려집니다.
363
+ - Row data도 저장하지 않습니다.
364
+
365
+ ### Catalog를 끄면
366
+
367
+ `MYSQL_CATALOG_ENABLED=false`로 끄거나 `MYSQL_APP_SCHEMAS`가 비어 있으면
368
+ `mysql_catalog` 툴이 사라지고, resource는 `information_schema`를 직접 읽습니다.
369
+ Catalog는 선언된 schema만 훑으므로, 선언이 없으면 스스로 꺼지고 stderr에 이유를
370
+ 남깁니다.
371
+
372
+ ## Resource
373
+
374
+ 툴만 읽는 클라이언트라면 이 절은 건너뛰어도 됩니다. Resource를 읽는 클라이언트를
375
+ 위해 세 가지를 제공합니다.
376
+
377
+ | URI | 내용 |
378
+ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
379
+ | `mysql://tables` | table 목록. 이름, schema, comment, 예상 row 수. catalog가 켜져 있으면 선언된 schema의 저장된 inventory로 답하고, 꺼져 있거나 아직 비어 있으면 `information_schema`에서 읽을 수 있는 모든 table을 읽습니다 — 이때는 크기와 생성·수정 시각도 함께 옵니다. |
380
+ | `mysql://tables/<schema>/<table>` | 한 table의 구조. catalog가 켜져 있으면 `mysql_catalog describe`와 같은 내용이고, 실패하면 `information_schema`의 column 목록으로 물러납니다. catalog가 꺼져 있으면 `information_schema`의 column 목록을 바로 돌려줍니다. |
381
+ | `mysql://schemas` | 선언된 app -> schema map. `profile`, `apps`, 그리고 branch가 정해져 있으면 `codeBranch`를 담은 JSON입니다. `MYSQL_APP_SCHEMAS`가 비어 있으면 목록에 오르지 않습니다. |
382
+
383
+ ## 환경 변수
384
+
385
+ 경로를 담는 변수는 절대 경로로 쓰세요. npx는 클라이언트가 열어 둔 프로젝트를
386
+ 작업 디렉터리로 서버를 띄우므로, 상대 경로는 프로젝트마다 다른 곳을 가리킵니다.
387
+
388
+ ### Profile
389
+
390
+ | 변수 | 기본값 | 의미 |
391
+ | ------------------------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
392
+ | `MYSQL_PROFILE` | _(미설정)_ | 환경 label. `prod`/`production`은 읽기 전용을 강제합니다. |
393
+ | `MYSQL_APP_SCHEMAS` | _(미설정)_ | `;`로 구분된 `app:schema[:설명]` 항목. 툴 설명과 `mysql://schemas`에 노출됩니다. |
394
+ | `MYSQL_CODE_BRANCH` | profile별 | 이 환경의 데이터가 대응하는 git branch. `stage`→`develop`, `prod`→`main`. |
395
+ | `MYSQL_CATALOG_ENABLED` | `true` | 로컬 schema catalog를 켭니다. `false`이거나 `MYSQL_APP_SCHEMAS`가 비어 있으면 catalog가 꺼지고 `mysql_catalog` 툴도 제공되지 않습니다. |
396
+ | `MYSQL_CATALOG_PATH` | `~/.cache/mysql-mcp/catalog` | profile·대상·user별 JSON 파일을 둘 directory입니다. |
397
+ | `MYSQL_CATALOG_TTL_HOURS` | `24` | DB metadata를 stale로 보는 시간입니다. |
398
+ | `MYSQL_DOCS_REPO` | _(미설정)_ | `model.md`가 있는 git 저장소. 문서 catalog는 이 값과 `MYSQL_CODE_BRANCH`가 **둘 다** 있어야 켜집니다 — ref를 branch에서 가져오기 때문입니다. 둘 중 하나라도 비면 문서 축만 비활성입니다. |
399
+
400
+ ### SSH tunnel
401
+
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에 붙습니다. 아래 경고를 참고하세요. |
412
+
413
+ - `MYSQL_SSH_CONFIG_HOST`나 `MYSQL_SSH_HOST`가 있으면 접속 전에 tunnel을 엽니다. 둘
414
+ 다 없으면 tunnel 없이 `MYSQL_HOST`로 직접 접속합니다.
415
+ - 위 표의 변수를 명시하면 alias 값보다 우선하므로, `~/.ssh/config`를 건드리지 않고
416
+ 한 profile에서 필드 하나만 덮어쓸 수 있습니다.
417
+ - forwarding 대상만은 반대로 alias의 `LocalForward`가 `MYSQL_HOST`/`MYSQL_PORT`보다
418
+ 우선합니다.
419
+
420
+ ### MySQL
421
+
422
+ | 변수 | 기본값 | 의미 |
423
+ | ---------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
424
+ | `MYSQL_HOST` | `127.0.0.1` | 직접 접속할 주소. tunnel을 쓸 때는 **bastion에서 바라본** 데이터베이스 — pool이 접속하는 주소가 아니라 forwarding 대상입니다. alias의 `LocalForward` 대상이 우선하고, 둘 다 없으면 서버가 시작하지 않습니다. |
425
+ | `MYSQL_PORT` | `3306` | 위와 같습니다. |
426
+ | `MYSQL_USER` / `MYSQL_PASS` | — | 데이터베이스 credentials. 필수입니다. |
427
+ | `MYSQL_DB` | _(비어 있음)_ | 비어 있으면 multi-DB 모드가 켜집니다. |
428
+ | `MYSQL_SOCKET_PATH` | _(미설정)_ | Unix socket으로 직접 접속합니다. host/port 대신 쓰이며, tunnel을 열면 무시합니다. |
429
+ | `MYSQL_POOL_SIZE` | `10` | 동시에 열어 두는 커넥션 수. tunnel을 쓰면 SSH 세션 하나 위의 채널이 그만큼 생깁니다. |
430
+ | `MYSQL_QUEUE_LIMIT` | `100` | pool이 꽉 찼을 때 대기열에 쌓아 둘 요청 수. |
431
+ | `MYSQL_CONNECT_TIMEOUT` | `10000` | 접속 timeout(ms). |
432
+ | `MYSQL_BIG_NUMBER_STRINGS` | `false` | BIGINT/DECIMAL을 문자열로 반환합니다. schema가 snowflake ID를 쓴다면 켜세요. |
433
+ | `MYSQL_DATE_STRINGS` | `false` | 날짜를 `Date`가 아닌 문자열로 반환합니다. |
434
+ | `MYSQL_TIMEZONE` | _(미설정)_ | mysql2에 넘기는 timezone. 날짜 값을 해석하는 기준입니다. |
435
+ | `MYSQL_SSL` | `false` | MySQL로의 TLS(SSH tunnel과는 별개). 아래 네 변수는 이 값이 `true`일 때만 적용됩니다. |
436
+ | `MYSQL_SSL_REJECT_UNAUTHORIZED` | `false` | 서버 인증서를 검증합니다. |
437
+ | `MYSQL_SSL_CA` | _(미설정)_ | CA 인증서 PEM 경로. |
438
+ | `MYSQL_SSL_CERT` / `MYSQL_SSL_KEY` | _(미설정)_ | mTLS용 클라이언트 인증서와 개인 키 PEM 경로. |
439
+ | `MYSQL_MAX_TIMEOUT_SECONDS` | `30` | `timeout_seconds` 인자의 상한. 기본 상한 10초도 이 값 아래로 함께 내려갑니다. |
440
+ | `MYSQL_MAX_RESPONSE_ROWS` | `5000` | 한 응답에 담기는 최대 행 수. 쿼리가 고르는 행이 아니라 **돌려주는** 행입니다. |
441
+ | `MYSQL_CATALOG_TIMEOUT_SECONDS` | `60` | catalog와 resource가 `information_schema`를 읽을 때 걸리는 시간 상한. |
442
+
443
+ ### 쓰기
444
+
445
+ 모두 기본값은 `false`이며, 쓰기 금지 profile에서는 어떤 값을 넣든 `false`로
446
+ 강제됩니다.
447
+
448
+ | 변수 | 의미 |
449
+ | ------------------------ | --------------------------------------------------------------------------------------------- |
450
+ | `ALLOW_INSERT_OPERATION` | INSERT 허용. |
451
+ | `ALLOW_UPDATE_OPERATION` | UPDATE 허용. |
452
+ | `ALLOW_DELETE_OPERATION` | DELETE 허용. |
453
+ | `ALLOW_DDL_OPERATION` | CREATE/ALTER/DROP/TRUNCATE 허용. |
454
+ | `SCHEMA_*_PERMISSIONS` | schema별 override, `"db1:true,db2:false"`. 문장이 참조하는 schema가 모두 허용돼야 실행합니다. |
455
+
456
+ ### 진단
457
+
458
+ | 변수 | 기본값 | 의미 |
459
+ | -------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
460
+ | `ENABLE_LOGGING` | `false` | 진단 출력을 stderr로. `1`도 켠 것으로 봅니다. stdout을 쓰지 않으므로 MCP 스트림을 깨지 않습니다. 모델이 보낸 쿼리의 원문과 그 오류 메시지는 남기지 않습니다. |
461
+ | `MYSQL_DISABLE_READ_ONLY_TRANSACTIONS` | `false` | 탈출구입니다. 읽기를 `START TRANSACTION READ ONLY` 대신 평범한 트랜잭션으로 엽니다. `START TRANSACTION READ ONLY`를 받지 못하는 proxy 뒤에 있을 때만 켜세요. 쓰기 차단은 쿼리 routing이 따로 하므로 이 값으로 쓰기가 열리지는 않지만, 마지막 한 겹이 사라집니다. |
462
+
463
+ ## 읽기 전용 강제
464
+
465
+ Profile 이름이 `prod` 또는 `production`이면 `src/config/index.ts`가 두 가지를 합니다.
466
+
467
+ - `ALLOW_INSERT_OPERATION`, `ALLOW_UPDATE_OPERATION`, `ALLOW_DELETE_OPERATION`,
468
+ `ALLOW_DDL_OPERATION`을 `false`로 강제합니다.
469
+ - `SCHEMA_*_PERMISSIONS` override를 비웁니다.
470
+
471
+ 핵심은 override를 비우는 부분입니다. 이들은 전역 flag에 대한 schema별
472
+ 예외이므로, 그대로 두면 `SCHEMA_UPDATE_PERMISSIONS=some_db:true`가 방금 전역
473
+ 거부로 닫은 문을 다시 열어버립니다. routing logic이 앞으로 refactoring될 경우를
474
+ 대비한 2차 방어선으로, `executeWriteQuery`도 별도로 거부합니다.
475
+
476
+ 실질적인 귀결: 어떤 env 파일도, shell export도, MCP 클라이언트 설정도 prod
477
+ profile이 쓰게 만들 수 없습니다. 시도하면 무시된 flag를 stderr에 기록하고
478
+ 읽기 전용으로 계속 동작합니다.
479
+
480
+ 다른 profile은 기본적으로 읽기 전용이지만 켤 수 있습니다. 그쪽의 `ALLOW_*`
481
+ flag는 평범한 설정이기 때문입니다.
482
+
483
+ 어느 profile이든 실행하는 문장 종류는 허용 목록으로 정합니다. 읽기는 `SELECT`,
484
+ `SHOW`, `DESCRIBE`, `EXPLAIN`뿐이고, 쓰기는 `INSERT`, `UPDATE`, `DELETE`와
485
+ DDL(`CREATE`/`ALTER`/`DROP`/`TRUNCATE`)이 위 flag를 통과할 때만 실행합니다. 그 밖의
486
+ 문장은 모두 거부합니다. `RENAME TABLE`, `GRANT`처럼 implicit commit을 일으키는 문장은
487
+ `START TRANSACTION READ ONLY`로도 막히지 않고, `SET`, `LOCK TABLES`, `USE`는 pool에
488
+ 반납된 connection에 상태를 남기기 때문입니다. 다른 schema의 table은 `USE` 대신
489
+ `schema.table`로 적습니다. `SELECT … INTO OUTFILE`·`INTO DUMPFILE`도 DB 서버에 파일을
490
+ 쓰므로 거부합니다.
491
+
492
+ schema별 쓰기 권한은 문장이 참조하는 schema 전부에 대해 판정합니다. 하나라도
493
+ 막혀 있으면 거부합니다. 읽기만 하는 table의 schema도 포함하므로,
494
+ `INSERT INTO a.t SELECT … FROM b.u`는 `a`와 `b` 둘 다 INSERT가 허용돼야 합니다.
495
+
496
+ ## 결과는 있는 그대로 반환합니다
497
+
498
+ Row 값도, column 이름도, index 이름도 가리지 않습니다. 원본 프로젝트의 PII
499
+ redaction 계층은 제거했습니다.
500
+
501
+ 이 서버는 신뢰하는 운영자 한 사람이 자기 환경에 붙여 쓰는 도구이고, 실제 보호는
502
+ redaction이 아니라 그 앞단에 있습니다 — read-only 강제, profile별 권한, SSH
503
+ tunnel, 그리고 애초에 어떤 계정으로 접속하는지입니다. Column 이름 목록을 손으로
504
+ 관리해서 얻는 masking 한 겹은 그 위에 얹을 값이 없었습니다.
505
+
506
+ ## Tunnel이 동작하는 방식
507
+
508
+ Forwarding은 `ssh -N -L`을 spawn 하는 대신 `ssh2`를 통해 프로세스 내부에서
509
+ 이뤄집니다. loopback listener 하나가 SSH 연결 하나를 앞에서 받고, 수락된 socket마다
510
+ 자기 `forwardOut` channel을 갖습니다. 그 결과 pool은 SSH 세션 하나 위에서 서로
511
+ 독립적인 MySQL connection 여러 개를 갖게 됩니다.
512
+
513
+ 자식 프로세스 대신 이 방식을 고른 이유는 세 가지입니다:
514
+
515
+ 1. **Teardown이 구조적으로 보장됩니다.** tunnel의 수명이 곧 프로세스의 수명입니다.
516
+ 서버보다 오래 살아남을 자식이 없으므로, forwarding된 포트를 붙잡은 채 남아
517
+ 있는 `ssh`라는 실패 양상 자체가 존재하지 않습니다.
518
+ 2. **준비 완료 시점이 정확합니다.** "포트가 listening을 시작한 뒤에만 pool을
519
+ 만든다"가 polling이 아니라 `server.listen()` callback입니다.
520
+ 3. **남의 stderr를 떠안지 않습니다.** spawn된 `ssh`는 자기 경고를 뱉고, 어떤
521
+ bastion은 말이 많습니다. 여기서는 그것이 stdout에 닿을 수 없습니다.
522
+
523
+ 알아둘 만한 동작:
524
+
525
+ - Listener는 `127.0.0.1`에만 bind 합니다. forwarding된 데이터베이스에 인증 없이
526
+ 접근할 수 있게 해주므로, 네트워크에서 절대 닿을 수 있어서는 안 됩니다.
527
+ - **각 서버는 자기 tunnel을 소유합니다.** `MYSQL_SSH_LOCAL_PORT`는 요구 사항이
528
+ 아니라 선호일 뿐입니다. 포트가 이미 쓰이고 있으면 이 서버는 OS가 할당한
529
+ 포트에 자기 tunnel을 엽니다. pool은 어디로 접속할지 전달받으므로 뒷단에서는
530
+ 포트 번호에 신경 쓰지 않습니다.
531
+ - 예기치 않게 끊기면 SSH transport를 지수 backoff(1초, 2초, 4초)로 최대 세 번
532
+ 다시 세웁니다. 그 뒤에는 알 수 없는 `ECONNRESET` 대신 명시적인 메시지와
533
+ 함께 쿼리가 실패합니다.
534
+ - Teardown은 `SIGINT`, `SIGTERM`, 그리고 클라이언트가 stdin을 닫을 때 일어납니다.
535
+
536
+ ### 기본값에서 tunnel을 공유하지 않는 이유
537
+
538
+ 이전 버전은 SSH 세션을 하나 더 여는 것을 피하려고, profile 포트에 이미 떠 있는
539
+ forward가 있으면 거기에 붙었습니다. 결과적으로 잘못된 선택이었습니다.
540
+
541
+ 빌려 쓴 tunnel은 그것을 연 프로세스와 생사를 함께하는데, MCP 클라이언트는 서버를
542
+ 쉴 새 없이 띄우고 내립니다 — `codex exec`는 호출이 끝날 때마다 자기 서버를
543
+ 내립니다. 그래서 소유자가 먼저 종료되는 일이 흔하고, 그때마다 빌려 쓰던 쪽의
544
+ 진행 중인 쿼리가 `PROTOCOL_CONNECTION_LOST`로 죽습니다. 겉보기에는 간헐적이고
545
+ 재현되지 않는 툴 실패로 나타납니다.
546
+
547
+ 서버마다 tunnel을 소유하면 SSH 세션 하나를 더 쓰는 대신 이 실패 양상이 통째로
548
+ 사라집니다. `MYSQL_SSH_REUSE_EXISTING=true`는 forward가 외부에서 관리되고 모든
549
+ 클라이언트보다 오래 사는 경우 — 직접 띄워 둔 장수 `ssh -L` 같은 경우 — 에만
550
+ 켜세요.
551
+
552
+ ## 실행 상한과 타임아웃 진단
553
+
554
+ 읽기 쿼리 하나는 시간과 행 수 양쪽에 상한을 두고 돕니다. 상한에 걸려 취소된
555
+ 쿼리는 실행 계획을 함께 담은 보고서로 돌아옵니다.
556
+
557
+ ### 시간 상한
558
+
559
+ 모든 읽기는 10초 뒤에 취소됩니다. 대화형 도구이고 사람이 답을 기다리므로 짧게
560
+ 잡았습니다.
561
+
562
+ - 호출마다 `mysql_query`의 `timeout_seconds` 인자로 올릴 수 있습니다. 상한은
563
+ `MYSQL_MAX_TIMEOUT_SECONDS`(기본 30)입니다.
564
+ - 범위 밖 값은 거부하지 않고 하드 클램프합니다. 100을 보내면 30이 됩니다. 인자가
565
+ 존재하는 이유는 폭주하는 쿼리를 끊는 것이지 값을 두고 실랑이하는 것이 아니기
566
+ 때문입니다.
567
+ - 상한 30초는 MCP 클라이언트의 기본 tool timeout보다 낮게 유지하세요. 클라이언트가
568
+ 먼저 포기하면 모델에게는 보고서가 아니라 아무것도 남지 않습니다.
569
+
570
+ ### 행 수 상한
571
+
572
+ 한 응답에는 최대 5000행이 담깁니다.
573
+
574
+ - 잘렸다면 경고가 별도 블록으로 붙습니다. 경고 없이 조용히 자르면 모델이 부분
575
+ 결과를 전체로 착각합니다.
576
+ - `MYSQL_MAX_RESPONSE_ROWS`로 올릴 수 있지만 **운영자만 조정합니다.** 호출자가
577
+ 자기 상한을 푸는 인자는 없습니다. 상한이 보호하는 당사자가 상한을 푸는 것은
578
+ 말이 되지 않기 때문입니다.
579
+ - 이름이 `RESULT`가 아니라 `RESPONSE`인 이유는 쿼리가 **고르는** 행이 아니라
580
+ **돌려주는** 행을 세기 때문입니다. `LIMIT n`과는 층위가 달라서, 자기 `LIMIT`을
581
+ 더 크게 들고 온 쿼리도 5000행에서 함께 잘립니다.
582
+
583
+ ### 행 수 상한을 받지 않는 쿼리
584
+
585
+ **행 수 상한은 조회 대상이 아니라 결과가 어디로 가는지로 갈립니다.**
586
+
587
+ - 모델에게 돌아가는 결과는 무엇을 조회했든 잘립니다. `information_schema`도
588
+ 예외가 아니어서, 모델이 직접 보낸 `SELECT ... FROM information_schema.columns`는
589
+ 다른 쿼리와 똑같이 잘리고 경고가 붙습니다.
590
+ - 예외는 **서버가 스스로 보내는 쿼리** 하나뿐입니다. catalog가 table 목록과
591
+ column·index를 모을 때 쓰는 조회는 결과가 모델에게 가지 않고, 서버가 삼켜 catalog
592
+ 파일에 구조 정보로 들어갑니다.
593
+
594
+ 그래서 catalog 조회는 상한을 걷었다가 되돌립니다. `pool`을 읽기 경로와 공유하므로,
595
+ 남아 있는 상한이 inventory 수집을 조용히 잘라낼 수 있기 때문입니다. 그렇게 만들어진
596
+ catalog는 고장 난 것이 아니라 조용히 틀린 것이 됩니다.
597
+
598
+ 시간 상한은 catalog에도 걸되 별도 값(`MYSQL_CATALOG_TIMEOUT_SECONDS`, 기본 60)을
599
+ 씁니다. Resource 조회도 같은 경로를 쓰므로 행 수 상한 없이 이 시간 상한만 받습니다.
600
+
601
+ ### 무엇으로 거는가
602
+
603
+ 두 상한 모두 MySQL이 원래 갖고 있는 세션 변수로 겁니다.
604
+
605
+ | 상한 | 세션 변수 |
606
+ | ----- | -------------------- |
607
+ | 시간 | `max_execution_time` |
608
+ | 행 수 | `sql_select_limit` |
609
+
610
+ 이 둘은 MySQL 소유라 profile에 적는 값이 아닙니다. 여기 적어 두는 이유는
611
+ `SHOW VARIABLES`로 확인할 때 보이는 이름이 그것이기 때문입니다. profile에 적는 것은
612
+ `MYSQL_`로 시작하는 환경변수뿐입니다.
613
+
614
+ 행 수는 상한보다 하나 크게 겁니다(기본값이면 5001). 5001번째 행이 도착했다는 것
615
+ 자체가 잘렸다는 증거이기 때문입니다. 다만 자기 `LIMIT`을 들고 온 쿼리는
616
+ `sql_select_limit`을 무시하므로, 잘라내기는 응답을 조립할 때 한 번 더 적용됩니다.
617
+
618
+ ### 취소된 쿼리의 보고서
619
+
620
+ MySQL이 상한 초과로 문장을 취소하면(에러코드 `3024`) 서버는 **그때만** 같은
621
+ 커넥션에서 `EXPLAIN FORMAT=JSON`을 한 번 실행합니다. 정상 쿼리에는 EXPLAIN이
622
+ 돌지 않습니다. 매 쿼리 앞에 EXPLAIN을 세우면 멀쩡한 쿼리마다 왕복 한 번(약
623
+ 137ms)을 물리게 되는데, 비용은 그것을 유발한 쿼리가 내야 합니다.
624
+
625
+ **서버는 플랜을 판정하지 않습니다.** 사실만 돌려주고 판단은 모델이 합니다.
626
+ 응답에 담기는 것은 다음 다섯 가지입니다.
627
+
628
+ 1. 몇 초 상한에 걸려 취소됐는지, 그리고 결과 행이 없다는 사실
629
+ 2. **EXPLAIN을 이미 돌렸다**는 명시와, 다시 돌리지도 같은 문장을 재시도하지도
630
+ 말라는 금지
631
+ 3. 테이블별 요약 — 조인 순서대로 `access_type`·행 수·`key`·조건
632
+ 4. 카탈로그가 아는 인덱스 목록
633
+ 5. 플랜 원문 (너무 크면 생략하고 그렇다고 밝힙니다)
634
+
635
+ EXPLAIN마저 실패하면 3~5번 없이 플랜을 읽지 못했다는 사실만 알립니다.
636
+
637
+ 그리고 선택지 세 개를 줍니다. 어느 것을 고를지는 말하지 않습니다.
638
+
639
+ 1. 고쳐 쓰고 한 번 재시도
640
+ 2. `timeout_seconds`를 올려 그대로 재시도(1회 한정). 이미 상한으로 돌았다면 이
641
+ 선택지 대신 금지 문구가 붙습니다.
642
+ 3. 사용자에게 선택지를 제시하며 질문
643
+
644
+ 모델은 쿼리 전문과 스키마와 사용자가 실제로 물은 것을 함께 보지만 서버는 플랜
645
+ JSON만 보므로, 판단은 더 많이 아는 쪽에 맡깁니다.
646
+
647
+ 보고서를 읽을 때 알아둘 것:
648
+
649
+ - **보고서는 리터럴을 가리지 않습니다.** EXPLAIN은 쿼리가 필터에 쓴 값을 플랜에
650
+ 그대로 싣고, 3번의 조건과 5번의 원문에도 그대로 실려 돌아갑니다. 마스킹 계층은
651
+ PII redaction과 함께 걷어냈고, 그 근거는 [결과는 있는 그대로
652
+ 반환합니다](#결과는-있는-그대로-반환합니다)와 같습니다 — 이 플랜을 받는 쪽은
653
+ 그 리터럴을 방금 자기가 쓴 호출자뿐입니다.
654
+ - **인덱스 목록에서 없음은 사실이 아니라 무지로 보고합니다.** 카탈로그에 없는
655
+ 테이블은 "인덱스가 없다"가 아니라 "여기서는 알 수 없다"로 적습니다. 둘을
656
+ 뭉뚱그리면 모델이 사용자에게 잘못된 포기를 권하게 됩니다.
657
+ - **인덱스를 탔는지는 `key`가 아니라 `access_type`으로 판단합니다.** 보고서에도 이
658
+ 읽는 법 한 줄이 함께 붙습니다. `ALL`과 `index`는 전부 읽는 것이고,
659
+ `const`·`eq_ref`·`ref`·`range`는 범위를 좁힌 것입니다. stage 실측:
660
+ `WHERE SUBSTRING(accountNumber,1,3)='ABC'`는 `key`에 인덱스 이름이 찍히면서도
661
+ 9200행을 전부 읽습니다(`access_type: "index"`, `filtered: "100.00"`). `key`만
662
+ 보면 정상으로 오판합니다.
663
+ - **보고서는 영어로 쓰입니다.** `executeReadOnlyQuery`의 다른 거부 메시지가 모두
664
+ 영어라서, 그 옆에 한국어를 섞으면 톤이 깨집니다. 모델이 사용자에게 물을 때 세션
665
+ 언어로 옮깁니다.
666
+
667
+ ### 왕복 횟수
668
+
669
+ 읽기 쿼리 한 건은 DB 왕복 3회로 끝납니다. tunnel 왕복이 1회당 약 137ms(stage
670
+ 실측)이므로 이 세 번이 빠른 쿼리 비용의 대부분입니다.
671
+
672
+ 1. `START TRANSACTION READ ONLY`
673
+ 2. 쿼리
674
+ 3. `ROLLBACK`
675
+
676
+ - 접근 모드를 세션이 아니라 트랜잭션 범위로 선언하므로 끝나고 원복할 것이
677
+ 없습니다.
678
+ - `MYSQL_DISABLE_READ_ONLY_TRANSACTIONS=true`를 켜면 1번이 평범한
679
+ `START TRANSACTION`으로 바뀝니다. 왕복 수는 같습니다.
680
+ - 세션 변수는 물리 커넥션이 처음 만들어질 때 한 번 겁니다. `pool`의 `connection`
681
+ 이벤트는 커넥션이 호출자에게 넘어가기 전에 발생하고 명령은 큐 순서대로
682
+ 실행되므로, 첫 쿼리가 도는 시점에는 이미 설정이 끝나 있습니다.
683
+ - 기본값이 아닌 `timeout_seconds`를 쓰면 그 쿼리와, 같은 커넥션에서 이어지는
684
+ 기본값 쿼리에 왕복이 한 번씩 추가됩니다.
685
+
686
+ `ENABLE_LOGGING=true`로 확인할 수 있습니다:
687
+
688
+ ```text
689
+ [info] Read query completed in 3 DB round trips
690
+ ```
691
+
692
+ ## 문제 해결
693
+
694
+ ### 클라이언트가 handshake 또는 JSON parsing 오류를 보고함
695
+
696
+ 무언가가 MCP framing을 나르는 stdout에 썼다는 뜻입니다. 이 서버는 모든 로깅을
697
+ stderr로 보내므로, 시작할 때 뭔가를 출력하는 shell profile(`~/.zshrc`,
698
+ `~/.bash_profile`)이나 custom wrapper를 의심하세요. 등록할 때와 같은 env로 직접
699
+ 띄워 stdout을 보세요:
700
+
701
+ ```bash
702
+ MYSQL_PROFILE=stage MYSQL_SSH_CONFIG_HOST=ecubelabs2-stage-db \
703
+ MYSQL_USER=<user> MYSQL_PASS=<password> npx -y @ecubelabs/mysql-mcp < /dev/null | head
704
+ ```
705
+
706
+ 출력이 조금이라도 있다면 그것이 버그입니다.
707
+
708
+ ### `SSH private key not found` 또는 권한 오류
709
+
710
+ 키 경로가 틀렸거나 읽을 수 없습니다. 오류 메시지에 서버가 고른 경로가 나옵니다.
711
+
712
+ - 서버는 키를 `MYSQL_SSH_PRIVATE_KEY_PATH`, alias의 `IdentityFile`, `~/.ssh/id_rsa`
713
+ 순으로 고릅니다.
714
+ - 키는 `chmod 600`이어야 합니다.
715
+ - 암호화된 키라면 `MYSQL_SSH_PASSPHRASE`가 필요합니다.
716
+
717
+ ### `local port N is taken` 로그
718
+
719
+ ```text
720
+ [error] [ssh] local port N is taken; opening our own tunnel on an auto-assigned port instead.
721
+ ```
722
+
723
+ `ENABLE_LOGGING=true`일 때만 보입니다. 오류가 아니라 정상 동작입니다.
724
+
725
+ - 서버는 남의 tunnel을 빌리지 않고 OS가 고른 포트에 자기 tunnel을 엽니다. pool은
726
+ 접속할 주소를 전달받으므로 포트 번호가 바뀌어도 아무 영향이 없습니다.
727
+ - 선호 포트를 꼭 되찾아야 한다면 `lsof -nP -i :N`으로 점유한 쪽을 찾으세요.
728
+ - 포트가 이미 쓰이는 것 말고 다른 이유(예: 권한)로 listener를 열지 못하거나 자동
729
+ 할당마저 실패하면 `Failed to open local tunnel listener`가 나옵니다.
730
+
731
+ ### 한동안 잘 되다가 쿼리가 실패함
732
+
733
+ tunnel이 끊겼고 재연결이 포기한 것입니다. 오류 메시지에 bastion 이름이 담깁니다.
734
+ bastion에 닿을 수 있게 되면 서버를 재시작하세요.
735
+
736
+ ### 서버가 즉시 종료됨
737
+
738
+ 등록할 때와 같은 env로 직접 띄워 보세요. 시작 실패는 `ENABLE_LOGGING`과 무관하게
739
+ 이유를 stderr에 찍습니다:
740
+
741
+ ```bash
742
+ MYSQL_PROFILE=stage MYSQL_SSH_CONFIG_HOST=ecubelabs2-stage-db \
743
+ MYSQL_USER=<user> MYSQL_PASS=<password> npx -y @ecubelabs/mysql-mcp
744
+ ```
745
+
746
+ ### `PROTOCOL_CONNECTION_LOST` 또는 `Connection lost`로 쿼리가 실패함
747
+
748
+ 쿼리 도중 tunnel이 사라졌습니다.
749
+
750
+ - 기본 설정에서는 각 서버가 자기 tunnel을 소유하므로 bastion이나 네트워크를
751
+ 의심하세요.
752
+ - `MYSQL_SSH_REUSE_EXISTING=true`를 켜 뒀다면, 공유 forward를 소유한 프로세스가
753
+ 종료된 것이 훨씬 유력한 원인입니다. flag를 다시 끄세요.
754
+
755
+ ### `git push`가 "refusing to allow an OAuth App to create or update workflow"로 거부됨
756
+
757
+ `workflow` scope가 없는 토큰으로 HTTPS remote를 쓰고 있습니다. SSH remote를
758
+ 쓰세요:
759
+
760
+ ```bash
761
+ git remote set-url origin git@github.com:<owner>/<repo>.git
762
+ ```
763
+
764
+ ## 개발
765
+
766
+ 이 절은 이 패키지를 고치는 사람을 위한 것입니다. 개발에는 devDependency인 vitest
767
+ 5가 요구하는 Node.js(`^22.12.0 || ^24.0.0 || >=26.0.0`)가 필요합니다. 서버 실행은
768
+ 18이면 됩니다.
769
+
770
+ ```bash
771
+ git clone git@github.com:Ecube-Labs/skynet.git
772
+ cd skynet
773
+ yarn install # yarn workspace라 의존성은 skynet 루트에서 한 번에 설치됩니다
774
+ cd packages/mysql-mcp
775
+ yarn build
776
+ ```
777
+
778
+ 빌드는 `dist/index.js`를 만들며, npx가 실행하는 것도 이 파일입니다.
779
+
780
+ | 스크립트 | 하는 일 |
781
+ | --------------------------------- | -------------------------------------------------------------------------- |
782
+ | `yarn build` | `tsc`로 `dist/`를 만들고 진입점에 실행 권한을 줍니다. |
783
+ | `yarn watch` | `tsc --watch`. |
784
+ | `yarn test` | `tsconfig.test.json`으로 타입을 검사한 뒤 vitest를 한 번 돌립니다. |
785
+ | `yarn test:watch` | vitest watch 모드. |
786
+ | `yarn lint` | oxfmt 포맷 검사, oxlint, eslint를 차례로 돌립니다. |
787
+ | `yarn lint:ci` | `yarn lint`와 같되 경고는 숨기고 오류만 보고합니다. |
788
+ | `yarn lint:fix` | 같은 세 도구로 고칠 수 있는 것을 고칩니다. |
789
+ | `yarn start:stage` / `start:prod` | 해당 wrapper를 직접 실행합니다. `./bin/mcp-mysql-<profile>.sh`와 같습니다. |
790
+
791
+ ### 로컬 빌드 띄우기
792
+
793
+ env 파일은 개발에서만 씁니다. `bin/`의 wrapper가 패키지 디렉터리의
794
+ `.env.<profile>`을 shell로 읽어 export한 뒤 로컬 빌드를 띄웁니다. 서버 자신은
795
+ 이때도 파일을 읽지 않습니다.
796
+
797
+ 1. `.env.example`을 `.env.stage`로 복사해 채웁니다. `.env.*`는 gitignore 대상입니다.
798
+ 2. `./bin/mcp-mysql-stage.sh`로 띄웁니다.
799
+ 3. 고친 빌드를 MCP 클라이언트에서 써 보려면 npx 대신 wrapper의 절대 경로를
800
+ 등록합니다.
801
+
802
+ ### 배포
803
+
804
+ 배포는 release workflow가 semantic-release로 합니다.
805
+
806
+ - `main`에 merge되면 정식 버전이 나갑니다.
807
+ - `develop`에 push되면 npm `next` 태그로 prerelease가 나갑니다.
808
+ - 버전은 commit message로 정해집니다. 서버는 그 버전을 `package.json`에서 읽어 MCP
809
+ handshake와 툴 설명에 씁니다.
810
+
811
+ ## 구성
812
+
813
+ ```text
814
+ index.ts MCP 서버, tool + resource handler, 종료 처리
815
+ src/config/ 환경 변수 해석, profile 정책, mysql2 옵션
816
+ src/catalog/ 로컬 schema catalog: DB metadata 수집, 문서 연결, 사용 기록, 검색
817
+ src/db/ 쿼리 routing, 권한 검사, pool, 타임아웃 진단
818
+ src/ssh/config.ts ~/.ssh/config Host alias parser
819
+ src/ssh/tunnel.ts tunnel lifecycle: 열기, 재사용, 재연결, 닫기
820
+ src/types/ 공유 타입
821
+ src/utils/ stderr 전용 로거, table resource URI
822
+ bin/ 개발용 profile wrapper (`.env.<profile>`로 로컬 빌드를 띄운다)
823
+ test/ vitest suite (`yarn test`)
824
+ references/ 되돌린 결정의 이력 (AGENTS.md가 가리킨다)
825
+ ```
826
+
827
+ ## 출처
828
+
829
+ [benborla/mcp-server-mysql](https://github.com/benborla/mcp-server-mysql)(MIT)에서
830
+ 파생됐습니다. MCP 서버, 쿼리 routing, 권한 모델은 그 프로젝트에서 왔습니다.
831
+
832
+ 이 패키지에서 새로 추가된 것:
833
+
834
+ - SSH tunnel 계층(`src/ssh/`)
835
+ - profile 체계
836
+ - wrapper 스크립트
837
+ - schema catalog(`src/catalog/`)
838
+ - 실행 상한과 타임아웃 진단
839
+ - stderr 전용 로깅
840
+
841
+ 원격 HTTP transport, eval harness, Docker packaging은 걷어냈습니다. 이 서버는 로컬
842
+ MCP 클라이언트에 stdio로 서비스할 뿐, 그 외의 일은 하지 않습니다.
843
+
844
+ Test suite는 상속받은 것을 걷어낸 뒤 이 패키지의 결정을 고정하는 쪽으로 다시
845
+ 썼습니다. `yarn test`가 타입 검사와 vitest를 함께 돌립니다.
846
+
847
+ ## 라이선스
848
+
849
+ MIT. [LICENSE.md](LICENSE.md)를 참고하세요.