kiwoom-mcp-server 0.14.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -23,8 +23,8 @@ allowance.
23
23
 
24
24
  | Tool | Description | Kiwoom TR |
25
25
  |---|---|---|
26
- | `search_stock` | Name → code search (KOSPI/KOSDAQ, incl. ETF/ETN) | ka10099 |
27
- | `get_stock_price` | Current price / change rate / volume / basic metrics | ka10001 |
26
+ | `search_stock` | Name → code search (KOSPI/KOSDAQ, incl. ETF/ETN) + trading-caution flags | ka10099 |
27
+ | `get_stock_price` | Current price / change rate / volume / basic metrics + sector, listing date, caution flags | ka10001, ka10099 |
28
28
  | `get_stock_chart` | Daily/weekly/monthly/yearly/minute/tick candles (adjusted price) | ka10079~83, ka10094 |
29
29
  | `get_orderbook` | 10-level ask/bid quotes and sizes | ka10004 |
30
30
  | `get_market_index` | KOSPI/KOSDAQ composite and sector indices | ka20003 |
@@ -47,7 +47,7 @@ allowance.
47
47
  | Tool | Description | Kiwoom TR |
48
48
  |---|---|---|
49
49
  | `get_watchlist_groups` | List of saved watchlist groups (code + name) | ka01300 |
50
- | `get_watchlist` | Stocks in a group (enriched with name / prev close / market) | ka01301, ka10099 |
50
+ | `get_watchlist` | Stocks in a group (enriched with name / prev close / market / caution flags) | ka01301, ka10099 |
51
51
 
52
52
  > The Kiwoom REST API exposes no watchlist **edit** (add/remove) TR — read-only.
53
53
 
@@ -123,6 +123,11 @@ npm test # unit tests (no network required)
123
123
  | `ISA_ENABLED` | | `true` registers the `calc_isa_tax_status` tool. Default `false` (general-account-first) |
124
124
  | `ISA_TYPE` | | `GENERAL` (general, 2,000,000 KRW limit, default) or `SEOMIN` (low-income/farmer-fisher, 4,000,000 KRW). Only used when `ISA_ENABLED=true` |
125
125
  | `ISA_OPENED_ON` | | ISA account opening date `yyyy-MM-dd` — default aggregation start for `calc_isa_tax_status`. Only used when `ISA_ENABLED=true` |
126
+ | `MCP_TRANSPORT` | | `stdio` (default) or `http` — see [Remote access](#remote-access-http-mode--claudeai-webmobile) |
127
+ | `MCP_AUTH_TOKEN` | HTTP mode ✅ | Bearer token every `/mcp` request must present in HTTP mode |
128
+ | `MCP_HTTP_PORT` | | HTTP-mode port (default `8000`) |
129
+ | `MCP_HTTP_HOST` | | HTTP-mode bind address (default `127.0.0.1`) |
130
+ | `MCP_HTTP_NO_AUTH` | | `true` allows starting HTTP mode without auth (discouraged — see the security notes below) |
126
131
 
127
132
  The default is **general-account-first**: without extra config, only the market-data
128
133
  and account-inquiry tools are exposed. To use the tax-free-limit tool on an ISA
@@ -195,6 +200,38 @@ From source — app key from the project-root `.env`:
195
200
  claude mcp add kiwoom -- node /absolute/path/kiwoom-mcp-server/dist/index.js
196
201
  ```
197
202
 
203
+ ## Remote access (HTTP mode) — claude.ai web/mobile
204
+
205
+ claude.ai **custom connectors** (web/mobile) cannot attach to a local stdio server;
206
+ they need a **Streamable HTTP** MCP server reachable over public HTTPS. Start this
207
+ server in HTTP mode with the `--http` flag (or `MCP_TRANSPORT=http`):
208
+
209
+ ```sh
210
+ MCP_AUTH_TOKEN="$(openssl rand -hex 32)" npx -y kiwoom-mcp-server --http --port 8000
211
+ # endpoint: http://127.0.0.1:8000/mcp · health check: /healthz
212
+ ```
213
+
214
+ - **Auth is required by default.** Without `MCP_AUTH_TOKEN` the server refuses to
215
+ start — every `/mcp` request must carry `Authorization: Bearer <token>`. To run
216
+ without auth you must pass `--no-auth` explicitly; the account-inquiry tools are
217
+ then exposed as-is, so do that only on a trusted network or with a paper-trading
218
+ key (`KIWOOM_MODE=VIRTUAL`).
219
+ - The default bind is `127.0.0.1`, assuming a tunnel in front. To expose directly
220
+ (container/VPS), opt in with `--host 0.0.0.0`.
221
+ - Get a public HTTPS URL with e.g. [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/):
222
+ `cloudflared tunnel --url http://localhost:8000` (ephemeral URL — use a named
223
+ tunnel for anything permanent).
224
+ - Register on claude.ai under **Settings → Connectors → Add custom connector** with
225
+ `https://<your-domain>/mcp`. The bearer token can be supplied via the connector's
226
+ request-header settings (beta). A connector added once is available across the
227
+ web, mobile, and desktop clients.
228
+ - ⚠️ **Kiwoom API calls originate from wherever this server runs.** REAL mode is
229
+ bound to Kiwoom's designated-terminal (8050) IP registration, so running it
230
+ outside a registered IP (e.g. in the cloud) can fail auth. Validate remote
231
+ setups with a VIRTUAL key first.
232
+
233
+ The stdio behavior (Claude Desktop/Code) is unchanged when run without arguments.
234
+
198
235
  ## Smoke test
199
236
 
200
237
  You can verify over stdio without an MCP client:
package/README.md CHANGED
@@ -21,8 +21,8 @@ Claude Desktop / Claude Code에서 자연어로 국내 주식 시세·차트·
21
21
 
22
22
  | Tool | 설명 | 키움 TR |
23
23
  |---|---|---|
24
- | `search_stock` | 종목명→코드 검색 (코스피/코스닥, ETF/ETN 포함) | ka10099 |
25
- | `get_stock_price` | 현재가/등락률/거래량/기본지표 | ka10001 |
24
+ | `search_stock` | 종목명→코드 검색 (코스피/코스닥, ETF/ETN 포함) + 투자유의 표시 | ka10099 |
25
+ | `get_stock_price` | 현재가/등락률/거래량/기본지표 + 업종·상장일·투자유의 | ka10001, ka10099 |
26
26
  | `get_stock_chart` | 일/주/월/년/분/틱봉 캔들 차트 (수정주가 반영) | ka10079~83, ka10094 |
27
27
  | `get_orderbook` | 10단계 매도/매수 호가·잔량 | ka10004 |
28
28
  | `get_market_index` | 코스피/코스닥 종합·업종 지수 | ka20003 |
@@ -45,7 +45,7 @@ Claude Desktop / Claude Code에서 자연어로 국내 주식 시세·차트·
45
45
  | Tool | 설명 | 키움 TR |
46
46
  |---|---|---|
47
47
  | `get_watchlist_groups` | HTS 관심종목 그룹 목록 (그룹코드+그룹명) | ka01300 |
48
- | `get_watchlist` | 그룹 내 종목 목록 (종목명·전일종가·시장 보강) | ka01301, ka10099 |
48
+ | `get_watchlist` | 그룹 내 종목 목록 (종목명·전일종가·시장·투자유의 보강) | ka01301, ka10099 |
49
49
 
50
50
  > 키움 REST API에는 관심종목 **편집(추가/삭제)** TR이 없어 조회만 가능합니다.
51
51
 
@@ -115,6 +115,11 @@ npm test # 단위 테스트 (네트워크 불필요)
115
115
  | `ISA_ENABLED` | | `true`면 `calc_isa_tax_status` tool 활성화. 기본값 `false`(일반 계좌 기준) |
116
116
  | `ISA_TYPE` | | `GENERAL`(일반형, 한도 200만원, 기본값) 또는 `SEOMIN`(서민형/농어민형, 400만원). `ISA_ENABLED=true`일 때만 사용 |
117
117
  | `ISA_OPENED_ON` | | ISA 계좌 개설일 `yyyy-MM-dd` — `calc_isa_tax_status` 집계 시작일 기본값. `ISA_ENABLED=true`일 때만 사용 |
118
+ | `MCP_TRANSPORT` | | `stdio`(기본값) 또는 `http` — [원격 연결](#원격-연결-http-모드--claudeai-웹모바일) 참조 |
119
+ | `MCP_AUTH_TOKEN` | HTTP 모드 ✅ | HTTP 모드에서 모든 `/mcp` 요청이 제시해야 하는 Bearer 토큰 |
120
+ | `MCP_HTTP_PORT` | | HTTP 모드 포트 (기본값 `8000`) |
121
+ | `MCP_HTTP_HOST` | | HTTP 모드 바인드 주소 (기본값 `127.0.0.1`) |
122
+ | `MCP_HTTP_NO_AUTH` | | `true`면 인증 없이 HTTP 모드 기동 허용 (비권장 — 아래 보안 주의 참조) |
118
123
 
119
124
  기본값은 **일반(비-ISA) 계좌 기준**입니다 — 별도 설정이 없으면 시장·계좌 조회 tool만
120
125
  노출됩니다. ISA 계좌를 연결해 비과세 한도 tool을 쓰려면 `ISA_ENABLED=true`로 켜고
@@ -186,6 +191,34 @@ claude mcp add kiwoom \
186
191
  claude mcp add kiwoom -- node /절대/경로/kiwoom-mcp-server/dist/index.js
187
192
  ```
188
193
 
194
+ ## 원격 연결 (HTTP 모드) — claude.ai 웹/모바일
195
+
196
+ claude.ai(웹/모바일)의 **커스텀 커넥터**는 로컬 stdio 서버에 직접 붙을 수 없고, 공개
197
+ HTTPS로 접근 가능한 **Streamable HTTP** MCP 서버가 필요합니다. `--http` 플래그(또는
198
+ `MCP_TRANSPORT=http`)로 이 서버를 HTTP 모드로 띄울 수 있습니다:
199
+
200
+ ```sh
201
+ MCP_AUTH_TOKEN="$(openssl rand -hex 32)" npx -y kiwoom-mcp-server --http --port 8000
202
+ # 엔드포인트: http://127.0.0.1:8000/mcp · 헬스체크: /healthz
203
+ ```
204
+
205
+ - **인증이 기본 필수입니다.** `MCP_AUTH_TOKEN`이 없으면 기동을 거부합니다 — 모든 `/mcp`
206
+ 요청에 `Authorization: Bearer <토큰>` 헤더가 있어야 합니다. 인증 없이 열려면
207
+ `--no-auth`를 명시해야 하며, 계좌 조회 도구가 그대로 노출되므로 신뢰할 수 있는
208
+ 네트워크나 모의투자(`KIWOOM_MODE=VIRTUAL`)에서만 사용하세요.
209
+ - 기본 바인드는 `127.0.0.1`입니다 — 터널을 앞에 두는 구성을 전제합니다. 컨테이너/서버에
210
+ 직접 노출하려면 `--host 0.0.0.0`을 명시하세요.
211
+ - 공개 HTTPS URL은 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 등으로 만듭니다:
212
+ `cloudflared tunnel --url http://localhost:8000` (임시 URL — 상시 운영은 named tunnel 권장).
213
+ - claude.ai 등록: **Settings → Connectors → Add custom connector**에
214
+ `https://<도메인>/mcp`를 입력합니다. Bearer 토큰은 Advanced settings의 요청 헤더
215
+ 설정(베타)으로 전달할 수 있습니다. 등록한 커넥터는 웹/모바일/데스크톱에서 공용입니다.
216
+ - ⚠️ **키움 API 호출은 이 서버가 실행되는 곳에서 나갑니다.** REAL 모드는 키움 지정단말기
217
+ 인증(8050)이 IP에 묶이므로, 등록된 IP가 아닌 곳(클라우드 등)에서 실행하면 인증 오류가
218
+ 날 수 있습니다. 원격 노출은 모의투자로 먼저 검증하세요.
219
+
220
+ 기존 stdio 동작(Claude Desktop/Code 연결)은 인자 없이 실행하면 그대로입니다.
221
+
189
222
  ## 동작 확인
190
223
 
191
224
  MCP 클라이언트 없이 stdio로 직접 확인할 수 있습니다:
package/dist/config.js CHANGED
@@ -41,7 +41,7 @@ const MODE_LABELS = {
41
41
  REAL: "실전투자",
42
42
  };
43
43
  let cached = null;
44
- function loadDotEnv() {
44
+ export function loadDotEnv() {
45
45
  // Claude Desktop launches this server with an arbitrary cwd, so resolve .env
46
46
  // relative to the project root (parent of dist/ or src/) first.
47
47
  const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
package/dist/http.js ADDED
@@ -0,0 +1,135 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import http from "node:http";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
5
+ const DEFAULT_PORT = 8000;
6
+ const DEFAULT_HOST = "127.0.0.1";
7
+ const MCP_PATH = "/mcp";
8
+ const HEALTH_PATH = "/healthz";
9
+ /**
10
+ * Decides the transport from CLI args + env. Pure (unit-testable): pass
11
+ * process.argv.slice(2) and process.env. Defaults to stdio so existing
12
+ * Claude Desktop/Code installs are untouched; HTTP is strictly opt-in.
13
+ *
14
+ * The auth token is env-only (MCP_AUTH_TOKEN) — a CLI flag would leak the
15
+ * secret to `ps`. HTTP mode refuses to start without a token unless the
16
+ * operator explicitly passes --no-auth: this server fronts a brokerage
17
+ * account, so an unauthenticated public endpoint must be a deliberate act.
18
+ */
19
+ export function chooseTransport(argv, env) {
20
+ const flags = new Set();
21
+ const values = new Map();
22
+ for (let i = 0; i < argv.length; i += 1) {
23
+ const arg = argv[i];
24
+ if (arg === "--port" || arg === "--host") {
25
+ const value = argv[i + 1];
26
+ if (value === undefined || value.startsWith("--")) {
27
+ throw new Error(`${arg} 옵션에 값이 없습니다 (예: ${arg === "--port" ? "--port 8000" : "--host 127.0.0.1"}).`);
28
+ }
29
+ values.set(arg, value);
30
+ i += 1;
31
+ }
32
+ else if (arg === "--http" || arg === "--stdio" || arg === "--no-auth") {
33
+ flags.add(arg);
34
+ }
35
+ else {
36
+ throw new Error(`알 수 없는 옵션입니다: ${arg} (지원: --http, --stdio, --port <n>, --host <addr>, --no-auth)`);
37
+ }
38
+ }
39
+ const envTransport = env.MCP_TRANSPORT?.trim().toLowerCase();
40
+ if (envTransport !== undefined && envTransport !== "" && envTransport !== "stdio" && envTransport !== "http") {
41
+ throw new Error(`MCP_TRANSPORT 값이 잘못되었습니다: "${env.MCP_TRANSPORT}" (stdio 또는 http).`);
42
+ }
43
+ const wantsHttp = flags.has("--http") || (!flags.has("--stdio") && envTransport === "http");
44
+ if (!wantsHttp)
45
+ return { mode: "stdio" };
46
+ const portRaw = values.get("--port") ?? env.MCP_HTTP_PORT?.trim();
47
+ let port = DEFAULT_PORT;
48
+ if (portRaw !== undefined && portRaw !== "") {
49
+ port = Number(portRaw);
50
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
51
+ throw new Error(`포트가 잘못되었습니다: "${portRaw}" (0~65535 정수).`);
52
+ }
53
+ }
54
+ const host = values.get("--host") ?? (env.MCP_HTTP_HOST?.trim() || DEFAULT_HOST);
55
+ const allowNoAuth = flags.has("--no-auth") || env.MCP_HTTP_NO_AUTH?.trim().toLowerCase() === "true";
56
+ const authToken = env.MCP_AUTH_TOKEN?.trim() || undefined;
57
+ if (!authToken && !allowNoAuth) {
58
+ throw new Error("HTTP 모드에는 MCP_AUTH_TOKEN 환경변수가 필요합니다 (모든 /mcp 요청의 Bearer 토큰). " +
59
+ "인증 없이 열려면 --no-auth를 명시하세요 — 계좌 조회 도구가 인터넷에 그대로 노출되므로 " +
60
+ "신뢰할 수 있는 네트워크/모의투자(VIRTUAL) 환경에서만 권장합니다.");
61
+ }
62
+ return { mode: "http", options: { port, host, authToken, allowNoAuth } };
63
+ }
64
+ /** Timing-safe bearer-token check (hash first so lengths never short-circuit). */
65
+ export function isAuthorized(authorizationHeader, token) {
66
+ if (!authorizationHeader)
67
+ return false;
68
+ const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
69
+ if (!match)
70
+ return false;
71
+ const presented = createHash("sha256").update(match[1]).digest();
72
+ const expected = createHash("sha256").update(token).digest();
73
+ return timingSafeEqual(presented, expected);
74
+ }
75
+ function deny(res, status, message) {
76
+ res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify({
77
+ jsonrpc: "2.0",
78
+ error: { code: -32000, message },
79
+ id: null,
80
+ }));
81
+ }
82
+ /**
83
+ * Streamable HTTP endpoint for remote MCP clients (claude.ai custom
84
+ * connectors, Claude Desktop/mobile via the account-brokered connector).
85
+ * Stateless per the MCP spec: each request gets a fresh McpServer+transport
86
+ * pair; the expensive state (Kiwoom token cache, ka10099 master-list cache)
87
+ * lives at module level and is shared across requests.
88
+ */
89
+ export function createHttpServer(options) {
90
+ return http.createServer((req, res) => {
91
+ void handleRequest(req, res, options).catch((error) => {
92
+ console.error("HTTP request handling failed:", error);
93
+ if (!res.headersSent)
94
+ deny(res, 500, "internal server error");
95
+ });
96
+ });
97
+ }
98
+ async function handleRequest(req, res, options) {
99
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
100
+ if (url.pathname === HEALTH_PATH) {
101
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
102
+ return;
103
+ }
104
+ if (url.pathname !== MCP_PATH) {
105
+ deny(res, 404, `not found — MCP endpoint is ${MCP_PATH}`);
106
+ return;
107
+ }
108
+ if (options.authToken && !isAuthorized(req.headers.authorization, options.authToken)) {
109
+ deny(res, 401, "unauthorized — Authorization: Bearer <MCP_AUTH_TOKEN> 헤더가 필요합니다");
110
+ return;
111
+ }
112
+ const server = createServer();
113
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
114
+ res.on("close", () => {
115
+ void transport.close();
116
+ void server.close();
117
+ });
118
+ await server.connect(transport);
119
+ await transport.handleRequest(req, res);
120
+ }
121
+ export async function startHttpServer(options) {
122
+ const server = createHttpServer(options);
123
+ await new Promise((resolve, reject) => {
124
+ server.once("error", reject);
125
+ server.listen(options.port, options.host, resolve);
126
+ });
127
+ const address = server.address();
128
+ const boundPort = typeof address === "object" && address ? address.port : options.port;
129
+ console.error(`${SERVER_NAME} v${SERVER_VERSION} listening on http://${options.host}:${boundPort}${MCP_PATH} ` +
130
+ `(auth: ${options.authToken ? "bearer token" : "NONE — --no-auth"})`);
131
+ if (!options.authToken) {
132
+ console.error("⚠️ 인증 없이 실행 중입니다. 이 서버는 계좌 조회 도구를 노출하므로 공개 네트워크에 직접 연결하지 마세요.");
133
+ }
134
+ return server;
135
+ }
package/dist/index.js CHANGED
@@ -1,7 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadDotEnv } from "./config.js";
4
+ import { chooseTransport, startHttpServer } from "./http.js";
3
5
  import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
4
6
  async function main() {
7
+ // Load .env before reading MCP_* transport vars (credential validation stays
8
+ // lazy in getConfig). Real environment variables win over the file.
9
+ loadDotEnv();
10
+ const choice = chooseTransport(process.argv.slice(2), process.env);
11
+ if (choice.mode === "http") {
12
+ await startHttpServer(choice.options);
13
+ return;
14
+ }
5
15
  const server = createServer();
6
16
  const transport = new StdioServerTransport();
7
17
  await server.connect(transport);
@@ -23,3 +23,30 @@ export async function loadMasterList(client) {
23
23
  export function clearMasterListCache() {
24
24
  cache = null;
25
25
  }
26
+ /**
27
+ * ka10099 orderWarning(투자유의종목여부) codes. "5" arrives on rows whose
28
+ * auditInfo is 투자경고 (mock-probed 2026-07-11), so the openapi description's
29
+ * "투자경과" is read as a typo for 투자경고. "1" is ETF-only.
30
+ */
31
+ const ORDER_WARNING_LABELS = {
32
+ "1": "ETF투자주의요망",
33
+ "2": "정리매매",
34
+ "3": "단기과열",
35
+ "4": "투자위험",
36
+ "5": "투자경고",
37
+ };
38
+ /**
39
+ * Abnormal-status labels for a master-list row — 감리구분(auditInfo)이 "정상"이
40
+ * 아니면 그 값을, orderWarning이 "0"이 아니면 라벨을 모은다. 둘이 같은 상태를
41
+ * 가리키는 경우(예: auditInfo "단기과열" + orderWarning "3")는 한 번만 담는다.
42
+ * 정상 종목은 빈 배열.
43
+ */
44
+ export function masterItemWarnings(item) {
45
+ const warnings = [];
46
+ if (item.auditInfo && item.auditInfo !== "정상")
47
+ warnings.push(item.auditInfo);
48
+ const orderLabel = ORDER_WARNING_LABELS[item.orderWarning];
49
+ if (orderLabel && !warnings.includes(orderLabel))
50
+ warnings.push(orderLabel);
51
+ return warnings;
52
+ }
@@ -134,10 +134,12 @@ export const stockListItemSchema = z.looseObject({
134
134
  lastPrice: str(), // 전일종가, zero-padded
135
135
  marketName: str(), // "거래소" | "코스닥" | "ETF" | ...
136
136
  upName: str(), // 업종명 (ETF/ETN은 빈 값)
137
- upSizeName: str(), // 대형주/중형주/소형주
137
+ upSizeName: str(), // 대형주/중형주/소형주 (우선주/ETF 등은 빈 값)
138
138
  state: str(), // "증거금20%|담보대출|신용가능"
139
- auditInfo: str(), // "정상" 등
140
- orderWarning: str(), // "0"=정상
139
+ auditInfo: str(), // "정상"/"거래정지"/"관리종목"/"투자주의환기종목"/"단기과열"/"투자경고"
140
+ orderWarning: str(), // 투자유의: "0"=해당없음, 나머지는 master-list.ts ORDER_WARNING_LABELS
141
+ regDay: str(), // 상장일 yyyyMMdd
142
+ companyClassName: str(), // 코스닥 전용 기업분류: 벤처기업/우량기업/스팩 등 (그 외 빈 값)
141
143
  });
142
144
  export const stockListResponseSchema = z.looseObject({
143
145
  ...envelope,
package/dist/server.js CHANGED
@@ -27,7 +27,7 @@ import { registerTransactionsTool } from "./tools/transactions.js";
27
27
  import { registerViStocksTool } from "./tools/vi-stocks.js";
28
28
  import { registerWatchlistGroupsTool, registerWatchlistTool } from "./tools/watchlist.js";
29
29
  export const SERVER_NAME = "kiwoom-mcp-server";
30
- export const SERVER_VERSION = "0.14.0";
30
+ export const SERVER_VERSION = "0.17.0";
31
31
  export function createServer() {
32
32
  const server = new McpServer({
33
33
  name: SERVER_NAME,
@@ -2,10 +2,14 @@ import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchEtfInfo, fetchEtfNav, fetchStockInfo } from "../kiwoom/api.js";
4
4
  import { formatKRW, formatPercent, formatQuantity, formatRatioPercent, formatSigned, parseKiwoomNumber, parseKiwoomPrice, } from "../utils/num.js";
5
+ import { formatNonEtfNotice } from "./etf-returns.js";
5
6
  import { runTool, textResult } from "./helpers.js";
6
7
  export function formatEtfInfo(etf, quote, nav, stockCode, modeLabel) {
7
- if (!etf.stk_nm) {
8
- return `[${modeLabel}] ${stockCode}의 ETF 정보가 없습니다. ETF 종목코드인지 확인해 주세요.`;
8
+ // 비ETF/미존재 코드 가드 — get_etf_returns와 동일한 ka40002 판별자
9
+ // (비ETF는 stk_nm만 채워지고 추적지수명이 빈값, 없는 코드는 다 빈값).
10
+ // 이전에는 비ETF에 "-"투성이 카드가 나갔다 (2026-07-10 GUI 테스트 watch item).
11
+ if (!etf.stk_nm || !etf.etfobjt_idex_nm) {
12
+ return formatNonEtfNotice(etf.stk_nm, stockCode, modeLabel, "ETF 정보를");
9
13
  }
10
14
  const lines = [
11
15
  `[${modeLabel}] ${etf.stk_nm} (${stockCode}) ETF 정보`,
@@ -10,10 +10,11 @@ const DEFAULT_BENCHMARK = "201";
10
10
  * 비ETF 가드 안내 (2026-07-10 GUI 테스트 발견 대응): ka40001은 일반 종목 코드에도
11
11
  * 수익률을 돌려주므로 ka40002로 선별한다 — 비ETF는 stk_nm은 있지만 추적지수명이
12
12
  * 빈값, 존재하지 않는 코드는 둘 다 빈값 (mock-probed 2026-07-10).
13
+ * get_etf_info도 같은 판별자를 쓴다 — featurePhrase는 조사까지 포함한 목적어구.
13
14
  */
14
- export function formatNonEtfNotice(stockName, stockCode, modeLabel) {
15
+ export function formatNonEtfNotice(stockName, stockCode, modeLabel, featurePhrase = "ETF 기간 수익률을") {
15
16
  if (stockName) {
16
- return (`[${modeLabel}] ${stockName} (${stockCode})은(는) ETF가 아니어서 ETF 기간 수익률을 제공하지 않습니다. ` +
17
+ return (`[${modeLabel}] ${stockName} (${stockCode})은(는) ETF가 아니어서 ${featurePhrase} 제공하지 않습니다. ` +
17
18
  `일반 종목 시세는 get_stock_price / get_stock_chart를 이용하세요.`);
18
19
  }
19
20
  return (`[${modeLabel}] ${stockCode}의 ETF 정보를 찾을 수 없습니다. ` +
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchStockInfo } from "../kiwoom/api.js";
4
+ import { loadMasterList, masterItemWarnings } from "../kiwoom/master-list.js";
5
+ import { formatDateDashed } from "../utils/date.js";
4
6
  import { formatKRW, formatPercent, parseKiwoomNumber, parseKiwoomPrice, } from "../utils/num.js";
5
7
  import { runTool, textResult } from "./helpers.js";
6
8
  const PRE_SIG_LABELS = {
@@ -10,7 +12,7 @@ const PRE_SIG_LABELS = {
10
12
  "4": "하한",
11
13
  "5": "하락",
12
14
  };
13
- export function formatStockInfo(info, modeLabel) {
15
+ export function formatStockInfo(info, modeLabel, master) {
14
16
  const cur = parseKiwoomPrice(info.cur_prc);
15
17
  const change = parseKiwoomNumber(info.pred_pre);
16
18
  const fluRt = parseKiwoomNumber(info.flu_rt);
@@ -45,12 +47,28 @@ export function formatStockInfo(info, modeLabel) {
45
47
  if (fundamentals.length > 0) {
46
48
  lines.push(`- ${fundamentals.join(" / ")}`);
47
49
  }
50
+ // ka10099 마스터 캐시에서 얻는 부가 정보 — 조회 실패 시 이 블록만 조용히 빠진다.
51
+ if (master) {
52
+ const marketParts = [master.marketName, master.upName].filter(Boolean).join(" · ");
53
+ const sizeClass = [master.upSizeName, master.companyClassName].filter(Boolean).join(", ");
54
+ if (marketParts) {
55
+ lines.push(`- 시장/업종: ${marketParts}${sizeClass ? ` (${sizeClass})` : ""}`);
56
+ }
57
+ if (/^\d{8}$/.test(master.regDay)) {
58
+ lines.push(`- 상장일: ${formatDateDashed(master.regDay)}`);
59
+ }
60
+ const warnings = masterItemWarnings(master);
61
+ if (warnings.length > 0) {
62
+ lines.push(`- ⚠️ 투자유의: ${warnings.join(" · ")}`);
63
+ }
64
+ }
48
65
  return lines.join("\n");
49
66
  }
50
67
  export function registerStockPriceTool(server) {
51
68
  server.registerTool("get_stock_price", {
52
69
  title: "종목 현재가 조회",
53
70
  description: "6자리 종목코드로 국내 주식/ETF의 현재가, 등락률, 거래량, 기본 지표를 조회합니다 (키움 ka10001). " +
71
+ "업종·상장일과 거래정지/관리종목/투자경고 같은 투자유의 상태도 함께 표시됩니다. " +
54
72
  "종목명만 알고 있다면 search_stock으로 먼저 코드를 찾으세요.",
55
73
  inputSchema: {
56
74
  stock_code: z
@@ -60,7 +78,14 @@ export function registerStockPriceTool(server) {
60
78
  },
61
79
  }, async ({ stock_code }) => runTool(async () => {
62
80
  const { client, config } = getKiwoomContext();
63
- const info = await fetchStockInfo(client, stock_code);
64
- return textResult(formatStockInfo(info, config.modeLabel));
81
+ // 마스터 조회는 best-effort 부가정보 — 실패해도 시세 응답은 그대로 나간다.
82
+ // 캐시가 따뜻하면 추가 API 콜 없음(12h TTL), 콜드면 ka10099 2콜이 병렬로 얹힌다.
83
+ const [info, master] = await Promise.all([
84
+ fetchStockInfo(client, stock_code),
85
+ loadMasterList(client)
86
+ .then((items) => items.find((i) => i.code === stock_code))
87
+ .catch(() => undefined),
88
+ ]);
89
+ return textResult(formatStockInfo(info, config.modeLabel, master));
65
90
  }));
66
91
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
- import { loadMasterList } from "../kiwoom/master-list.js";
3
+ import { loadMasterList, masterItemWarnings } from "../kiwoom/master-list.js";
4
4
  import { formatKRW, parseKiwoomPrice } from "../utils/num.js";
5
5
  import { runTool, textResult } from "./helpers.js";
6
6
  const MAX_RESULTS = 10;
@@ -38,11 +38,12 @@ export function formatSearchResults(results, query, modeLabel) {
38
38
  const lines = [
39
39
  `[${modeLabel}] 종목 검색: "${query}" (${results.length}건${results.length >= MAX_RESULTS ? ", 상위만 표시" : ""})`,
40
40
  "",
41
- "| 코드 | 종목명 | 시장 | 전일종가 | 업종 |",
42
- "|---|---|---|---:|---|",
41
+ "| 코드 | 종목명 | 시장 | 전일종가 | 업종 | 비고 |",
42
+ "|---|---|---|---:|---|---|",
43
43
  ];
44
44
  for (const r of results) {
45
- lines.push(`| ${r.code} | ${r.name} | ${r.marketName || "-"} | ${formatKRW(parseKiwoomPrice(r.lastPrice))} | ${r.upName || "-"} |`);
45
+ const warnings = masterItemWarnings(r);
46
+ lines.push(`| ${r.code} | ${r.name} | ${r.marketName || "-"} | ${formatKRW(parseKiwoomPrice(r.lastPrice))} | ${r.upName || "-"} | ${warnings.join("·") || "-"} |`);
46
47
  }
47
48
  return lines.join("\n");
48
49
  }
@@ -51,6 +52,7 @@ export function registerStockSearchTool(server) {
51
52
  title: "종목 검색 (이름→코드)",
52
53
  description: "종목명(부분 일치)이나 6자리 코드로 코스피/코스닥 상장 종목(ETF/ETN 포함)을 검색해 " +
53
54
  "종목코드를 찾습니다 (키움 ka10099). 다른 tool에 넘길 종목코드를 모를 때 먼저 사용하세요. " +
55
+ "거래정지·관리종목·투자경고 같은 투자유의 상태는 비고 컬럼에 표시됩니다. " +
54
56
  "첫 호출은 종목 마스터를 내려받아 몇 초 걸리고, 이후 12시간 동안 캐시됩니다.",
55
57
  inputSchema: {
56
58
  query: z.string().min(1).describe("종목명 일부(예: '삼성전자', 'KODEX 미국') 또는 6자리 종목코드"),
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { getKiwoomContext } from "../context.js";
3
3
  import { fetchWatchlistGroupDetail, fetchWatchlistGroups } from "../kiwoom/api.js";
4
4
  import { KiwoomApiError } from "../kiwoom/errors.js";
5
- import { loadMasterList } from "../kiwoom/master-list.js";
5
+ import { loadMasterList, masterItemWarnings } from "../kiwoom/master-list.js";
6
6
  import { normalizeStockCode, } from "../kiwoom/types.js";
7
7
  import { formatKRW, parseKiwoomPrice } from "../utils/num.js";
8
8
  import { runTool, textResult } from "./helpers.js";
@@ -39,7 +39,7 @@ export function formatWatchlist(group, stocks, nameIndex, modeLabel) {
39
39
  if (stocks.length === 0) {
40
40
  return `${title}\n\n(이 그룹에 등록된 종목이 없습니다.)`;
41
41
  }
42
- const lines = [title, "", "| 코드 | 종목명 | 시장 | 전일종가 |", "|---|---|---|---:|"];
42
+ const lines = [title, "", "| 코드 | 종목명 | 시장 | 전일종가 | 비고 |", "|---|---|---|---:|---|"];
43
43
  let missing = 0;
44
44
  for (const s of stocks) {
45
45
  const code = normalizeStockCode(s.cod2);
@@ -50,7 +50,8 @@ export function formatWatchlist(group, stocks, nameIndex, modeLabel) {
50
50
  const name = `${bookmark}${info?.name || "-"}`;
51
51
  const market = info?.marketName || "-";
52
52
  const price = info ? formatKRW(parseKiwoomPrice(info.lastPrice)) : "-";
53
- lines.push(`| ${code} | ${name} | ${market} | ${price} |`);
53
+ const flags = info ? masterItemWarnings(info).join("·") : "";
54
+ lines.push(`| ${code} | ${name} | ${market} | ${price} | ${flags || "-"} |`);
54
55
  }
55
56
  if (nameIndex.size === 0) {
56
57
  lines.push("", "※ 종목 마스터를 불러오지 못해 코드만 표시했습니다.");
@@ -91,7 +92,7 @@ export function registerWatchlistTool(server) {
91
92
  title: "관심종목 그룹 상세",
92
93
  description: "관심종목 그룹에 담긴 종목 목록을 조회합니다 (키움 ka01301, 읽기 전용). " +
93
94
  "그룹코드(예: '000') 또는 그룹명(예: 'etf')을 넘기세요. 그룹을 모르면 get_watchlist_groups로 먼저 확인하세요. " +
94
- "종목명·전일종가·시장은 종목 마스터에서 보강해 함께 표시합니다.",
95
+ "종목명·전일종가·시장과 거래정지/관리종목 같은 투자유의 상태를 종목 마스터에서 보강해 함께 표시합니다.",
95
96
  inputSchema: {
96
97
  group: z
97
98
  .string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiwoom-mcp-server",
3
- "version": "0.14.0",
3
+ "version": "0.17.0",
4
4
  "description": "Read-only MCP server exposing the Kiwoom Securities REST API (market data, account inquiry, ISA tax-allowance calculator) to Claude Desktop / Claude Code.",
5
5
  "private": false,
6
6
  "type": "module",