kiwoom-mcp-server 0.8.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +227 -0
  3. package/README.md +217 -0
  4. package/dist/config.js +61 -0
  5. package/dist/context.js +16 -0
  6. package/dist/index.js +15 -0
  7. package/dist/isa/classify-etf.js +84 -0
  8. package/dist/isa/classify.js +198 -0
  9. package/dist/isa/realized.js +55 -0
  10. package/dist/isa/tax.js +53 -0
  11. package/dist/kiwoom/api.js +372 -0
  12. package/dist/kiwoom/auth.js +89 -0
  13. package/dist/kiwoom/client.js +104 -0
  14. package/dist/kiwoom/errors.js +15 -0
  15. package/dist/kiwoom/master-list.js +25 -0
  16. package/dist/kiwoom/types.js +385 -0
  17. package/dist/server.js +54 -0
  18. package/dist/tools/account-balance.js +36 -0
  19. package/dist/tools/account-holdings.js +52 -0
  20. package/dist/tools/etf-info.js +44 -0
  21. package/dist/tools/foreign-holding.js +61 -0
  22. package/dist/tools/helpers.js +19 -0
  23. package/dist/tools/investor-trend.js +77 -0
  24. package/dist/tools/isa-tax-status.js +245 -0
  25. package/dist/tools/market-index.js +41 -0
  26. package/dist/tools/orderbook.js +50 -0
  27. package/dist/tools/pending-orders.js +67 -0
  28. package/dist/tools/ping.js +14 -0
  29. package/dist/tools/ranking.js +83 -0
  30. package/dist/tools/short-selling.js +66 -0
  31. package/dist/tools/stock-chart.js +94 -0
  32. package/dist/tools/stock-price.js +66 -0
  33. package/dist/tools/stock-search.js +63 -0
  34. package/dist/tools/theme.js +120 -0
  35. package/dist/tools/trading-journal.js +61 -0
  36. package/dist/tools/transactions.js +80 -0
  37. package/dist/tools/watchlist.js +115 -0
  38. package/dist/utils/date.js +15 -0
  39. package/dist/utils/num.js +67 -0
  40. package/dist/utils/redact.js +14 -0
  41. package/dist/utils/sleep.js +3 -0
  42. package/package.json +55 -0
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchTransactions } from "../kiwoom/api.js";
4
+ import { normalizeStockCode } from "../kiwoom/types.js";
5
+ import { formatDateDashed, kstDaysAgo, todayInKst } from "../utils/date.js";
6
+ import { formatKRW, parseKiwoomNumber } from "../utils/num.js";
7
+ import { runTool, textResult } from "./helpers.js";
8
+ const DEFAULT_LOOKBACK_DAYS = 30;
9
+ export function formatTransactions(rows, query, modeLabel, truncated = false) {
10
+ const filtered = query.stockCode
11
+ ? rows.filter((r) => normalizeStockCode(r.stk_cd) === query.stockCode)
12
+ : rows;
13
+ const period = `${formatDateDashed(query.fromDate)} ~ ${formatDateDashed(query.toDate)}`;
14
+ const scope = query.stockCode ? `, 종목 ${query.stockCode}` : "";
15
+ if (filtered.length === 0) {
16
+ return `[${modeLabel}] 거래내역이 없습니다 (${period}${scope}).`;
17
+ }
18
+ const n = parseKiwoomNumber;
19
+ const lines = [
20
+ `[${modeLabel}] 계좌 거래내역 (${period}${scope}, ${filtered.length}건)`,
21
+ "",
22
+ "| 체결일 | 구분 | 종목 | 수량 | 단가 | 정산금액 | 수수료 |",
23
+ "|---|---|---|---:|---:|---:|---:|",
24
+ ];
25
+ for (const row of filtered) {
26
+ const kind = row.io_tp_nm || row.trde_kind_nm || row.rmrk_nm || "-";
27
+ const stock = row.stk_nm ? `${row.stk_nm} (${normalizeStockCode(row.stk_cd)})` : "-";
28
+ const cells = [
29
+ formatDateDashed(row.cntr_dt || row.trde_dt),
30
+ kind,
31
+ stock,
32
+ n(row.trde_qty_jwa_cnt)?.toLocaleString("ko-KR") ?? "-",
33
+ formatKRW(n(row.trde_unit)),
34
+ formatKRW(n(row.exct_amt)),
35
+ formatKRW(n(row.cmsn)),
36
+ ];
37
+ lines.push(`| ${cells.join(" | ")} |`);
38
+ }
39
+ if (truncated) {
40
+ lines.push("", "⚠️ 거래가 많아 조회 상한에 도달했습니다 — 오래된 일부 거래가 누락되었을 수 있습니다. " +
41
+ "조회 기간(from_date~to_date)을 좁혀 다시 조회하세요.");
42
+ }
43
+ lines.push("", "※ 조회 기간과 결제는 체결 2영업일 후 기준이라, 최근 1~2영업일의 매매는 아직 결제 전이라 " +
44
+ "조회에 빠질 수 있습니다. 정산금액은 수수료·세금 반영 금액입니다.");
45
+ return lines.join("\n");
46
+ }
47
+ export function registerTransactionsTool(server) {
48
+ server.registerTool("get_transactions", {
49
+ title: "계좌 거래내역 조회",
50
+ description: "계좌의 거래내역(매수/매도 등)을 기간별로 조회합니다 (키움 kt00015). 기본 조회 기간은 " +
51
+ "최근 30일이며 from_date/to_date로 변경, stock_code로 특정 종목만 필터링할 수 있습니다. " +
52
+ "일자는 결제일(D+2) 기준입니다.",
53
+ inputSchema: {
54
+ from_date: z
55
+ .string()
56
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
57
+ .optional()
58
+ .describe("조회 시작일 (기본값: 30일 전)"),
59
+ to_date: z
60
+ .string()
61
+ .regex(/^\d{4}-?\d{2}-?\d{2}$/, "yyyy-MM-dd 또는 yyyyMMdd 형식이어야 합니다")
62
+ .optional()
63
+ .describe("조회 종료일 (기본값: 오늘)"),
64
+ stock_code: z
65
+ .string()
66
+ .regex(/^\d{6}$/, "6자리 종목코드여야 합니다")
67
+ .optional()
68
+ .describe("특정 종목만 조회할 때의 6자리 종목코드"),
69
+ },
70
+ }, async ({ from_date, to_date, stock_code }) => runTool(async () => {
71
+ const { client, config } = getKiwoomContext();
72
+ const query = {
73
+ fromDate: (from_date ?? kstDaysAgo(DEFAULT_LOOKBACK_DAYS)).replaceAll("-", ""),
74
+ toDate: (to_date ?? todayInKst()).replaceAll("-", ""),
75
+ stockCode: stock_code,
76
+ };
77
+ const { rows, truncated } = await fetchTransactions(client, query.fromDate, query.toDate);
78
+ return textResult(formatTransactions(rows, query, config.modeLabel, truncated));
79
+ }));
80
+ }
@@ -0,0 +1,115 @@
1
+ import { z } from "zod";
2
+ import { getKiwoomContext } from "../context.js";
3
+ import { fetchWatchlistGroupDetail, fetchWatchlistGroups } from "../kiwoom/api.js";
4
+ import { KiwoomApiError } from "../kiwoom/errors.js";
5
+ import { loadMasterList } from "../kiwoom/master-list.js";
6
+ import { normalizeStockCode, } from "../kiwoom/types.js";
7
+ import { formatKRW, parseKiwoomPrice } from "../utils/num.js";
8
+ import { runTool, textResult } from "./helpers.js";
9
+ const normalizeName = (s) => s.replaceAll(/\s+/g, "").toLowerCase();
10
+ export function formatWatchlistGroups(groups, modeLabel) {
11
+ if (groups.length === 0) {
12
+ return (`[${modeLabel}] 등록된 관심종목 그룹이 없습니다. ` +
13
+ `영웅문(HTS)에서 관심종목 그룹을 먼저 만들어 주세요.`);
14
+ }
15
+ const [first] = groups;
16
+ const lines = [
17
+ `[${modeLabel}] 관심종목 그룹 (${groups.length}개)`,
18
+ "",
19
+ "| 그룹코드 | 그룹명 |",
20
+ "|---|---|",
21
+ ];
22
+ for (const g of groups) {
23
+ lines.push(`| ${g.gcod || "-"} | ${g.name || "-"} |`);
24
+ }
25
+ lines.push("", `특정 그룹의 종목을 보려면 get_watchlist에 그룹코드나 그룹명을 넘기세요 ` +
26
+ `(예: group="${first?.gcod ?? ""}" 또는 "${first?.name ?? ""}").`);
27
+ return lines.join("\n");
28
+ }
29
+ /** Matches by exact group code first, then by (whitespace-insensitive) name. */
30
+ export function findWatchlistGroup(groups, query) {
31
+ const trimmed = query.trim();
32
+ if (trimmed === "")
33
+ return undefined;
34
+ const q = normalizeName(trimmed);
35
+ return (groups.find((g) => g.gcod === trimmed) ?? groups.find((g) => normalizeName(g.name) === q));
36
+ }
37
+ export function formatWatchlist(group, stocks, nameIndex, modeLabel) {
38
+ const title = `[${modeLabel}] 관심종목: ${group.name || "-"} (${group.gcod}) — ${stocks.length}종목`;
39
+ if (stocks.length === 0) {
40
+ return `${title}\n\n(이 그룹에 등록된 종목이 없습니다.)`;
41
+ }
42
+ const lines = [title, "", "| 코드 | 종목명 | 시장 | 전일종가 |", "|---|---|---|---:|"];
43
+ let missing = 0;
44
+ for (const s of stocks) {
45
+ const code = normalizeStockCode(s.cod2);
46
+ const info = nameIndex.get(code);
47
+ if (!info)
48
+ missing += 1;
49
+ const bookmark = s.bgb && s.bgb !== "0" ? "⭐ " : "";
50
+ const name = `${bookmark}${info?.name || "-"}`;
51
+ const market = info?.marketName || "-";
52
+ const price = info ? formatKRW(parseKiwoomPrice(info.lastPrice)) : "-";
53
+ lines.push(`| ${code} | ${name} | ${market} | ${price} |`);
54
+ }
55
+ if (nameIndex.size === 0) {
56
+ lines.push("", "※ 종목 마스터를 불러오지 못해 코드만 표시했습니다.");
57
+ }
58
+ else if (missing > 0) {
59
+ lines.push("", `※ ${missing}개 종목은 상장 마스터에 없어 상세정보를 표시하지 못했습니다.`);
60
+ }
61
+ return lines.join("\n");
62
+ }
63
+ /** Best-effort code→종목 index for name/전일종가 enrichment (no extra per-stock calls). */
64
+ async function buildNameIndex(client, stocks) {
65
+ if (stocks.length === 0)
66
+ return new Map();
67
+ try {
68
+ const items = await loadMasterList(client);
69
+ return new Map(items.map((i) => [i.code, i]));
70
+ }
71
+ catch {
72
+ // Enrichment is optional — fall back to code-only rather than failing the tool.
73
+ return new Map();
74
+ }
75
+ }
76
+ export function registerWatchlistGroupsTool(server) {
77
+ server.registerTool("get_watchlist_groups", {
78
+ title: "관심종목 그룹 목록",
79
+ description: "영웅문(HTS)에 저장한 관심종목 그룹 목록(그룹코드+그룹명)을 조회합니다 (키움 ka01300, 읽기 전용). " +
80
+ "특정 그룹의 종목은 get_watchlist로 조회하세요. " +
81
+ "그룹 편집(추가/삭제)은 키움 REST API가 지원하지 않아 조회만 가능합니다.",
82
+ inputSchema: {},
83
+ }, async () => runTool(async () => {
84
+ const { client, config } = getKiwoomContext();
85
+ const groups = await fetchWatchlistGroups(client);
86
+ return textResult(formatWatchlistGroups(groups, config.modeLabel));
87
+ }));
88
+ }
89
+ export function registerWatchlistTool(server) {
90
+ server.registerTool("get_watchlist", {
91
+ title: "관심종목 그룹 상세",
92
+ description: "관심종목 그룹에 담긴 종목 목록을 조회합니다 (키움 ka01301, 읽기 전용). " +
93
+ "그룹코드(예: '000') 또는 그룹명(예: 'etf')을 넘기세요. 그룹을 모르면 get_watchlist_groups로 먼저 확인하세요. " +
94
+ "종목명·전일종가·시장은 종목 마스터에서 보강해 함께 표시합니다.",
95
+ inputSchema: {
96
+ group: z
97
+ .string()
98
+ .min(1)
99
+ .describe("관심종목 그룹코드 또는 그룹명 (get_watchlist_groups로 확인)"),
100
+ },
101
+ }, async ({ group }) => runTool(async () => {
102
+ const { client, config } = getKiwoomContext();
103
+ const groups = await fetchWatchlistGroups(client);
104
+ const matched = findWatchlistGroup(groups, group);
105
+ if (!matched) {
106
+ const available = groups.length
107
+ ? groups.map((g) => `${g.gcod}(${g.name})`).join(", ")
108
+ : "(없음)";
109
+ throw new KiwoomApiError(`관심종목 그룹 "${group}"을(를) 찾을 수 없습니다. 사용 가능한 그룹: ${available}`, { apiId: "ka01301" });
110
+ }
111
+ const stocks = await fetchWatchlistGroupDetail(client, matched.gcod);
112
+ const nameIndex = await buildNameIndex(client, stocks);
113
+ return textResult(formatWatchlist(matched, stocks, nameIndex, config.modeLabel));
114
+ }));
115
+ }
@@ -0,0 +1,15 @@
1
+ /** Date helpers pinned to KST — the timezone of every Kiwoom API date field. */
2
+ /** Today's date in KST as yyyyMMdd (sv-SE locale gives ISO ordering). */
3
+ export function todayInKst() {
4
+ return new Date().toLocaleDateString("sv-SE", { timeZone: "Asia/Seoul" }).replaceAll("-", "");
5
+ }
6
+ /** KST date `days` days before today, yyyyMMdd. */
7
+ export function kstDaysAgo(days) {
8
+ return new Date(Date.now() - days * 86_400_000)
9
+ .toLocaleDateString("sv-SE", { timeZone: "Asia/Seoul" })
10
+ .replaceAll("-", "");
11
+ }
12
+ /** yyyyMMdd → yyyy-MM-dd for display. */
13
+ export function formatDateDashed(yyyymmdd) {
14
+ return `${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`;
15
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Kiwoom REST API returns every numeric value as a string, often zero-padded
3
+ * and/or sign-prefixed (e.g. "000000061300", "+61300", "-00013000").
4
+ * Some TRs double the sign ("--23722054", live-observed on ka10061) — the
5
+ * leading sign run collapses to its first character. A few fields arrive
6
+ * comma-grouped ("20,190", kt00015 trde_unit). An empty string means
7
+ * "no value".
8
+ */
9
+ export function parseKiwoomNumber(raw) {
10
+ if (raw == null)
11
+ return null;
12
+ const trimmed = raw.trim().replaceAll(",", "").replace(/^([+-])[+-]+/, "$1");
13
+ if (trimmed === "" || trimmed === "+" || trimmed === "-")
14
+ return null;
15
+ const value = Number(trimmed);
16
+ return Number.isFinite(value) ? value : null;
17
+ }
18
+ /**
19
+ * For price fields (cur_prc, open_pric, ...) Kiwoom encodes the direction
20
+ * versus yesterday as a +/- sign on the price itself. The actual price is the
21
+ * absolute value; direction should be read from pre_sig / pred_pre instead.
22
+ */
23
+ export function parseKiwoomPrice(raw) {
24
+ const value = parseKiwoomNumber(raw);
25
+ return value === null ? null : Math.abs(value);
26
+ }
27
+ export function formatKRW(value) {
28
+ if (value === null)
29
+ return "-";
30
+ return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 2 })}원`;
31
+ }
32
+ export function formatSignedKRW(value) {
33
+ if (value === null)
34
+ return "-";
35
+ const sign = value > 0 ? "+" : "";
36
+ return `${sign}${value.toLocaleString("ko-KR", { maximumFractionDigits: 2 })}원`;
37
+ }
38
+ export function formatQuantity(value) {
39
+ if (value === null)
40
+ return "-";
41
+ return `${value.toLocaleString("ko-KR")}주`;
42
+ }
43
+ export function formatPercent(value) {
44
+ if (value === null)
45
+ return "-";
46
+ const sign = value > 0 ? "+" : "";
47
+ return `${sign}${value.toFixed(2)}%`;
48
+ }
49
+ /** Non-directional ratio as a percent (no forced + sign): 46.58 → "46.58%". */
50
+ export function formatRatioPercent(value) {
51
+ if (value === null)
52
+ return "-";
53
+ return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 2 })}%`;
54
+ }
55
+ /** Plain locale-formatted number without a unit (indices, ratios, counts). */
56
+ export function formatNumber(value, maxFractionDigits = 2) {
57
+ if (value === null)
58
+ return "-";
59
+ return value.toLocaleString("ko-KR", { maximumFractionDigits: maxFractionDigits });
60
+ }
61
+ /** Signed locale-formatted number without a unit (investor flows etc.). */
62
+ export function formatSigned(value, maxFractionDigits = 2) {
63
+ if (value === null)
64
+ return "-";
65
+ const sign = value > 0 ? "+" : "";
66
+ return `${sign}${value.toLocaleString("ko-KR", { maximumFractionDigits: maxFractionDigits })}`;
67
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Removes secret values (app key/secret, tokens) from text that may be logged
3
+ * or returned to the MCP client, e.g. raw API response snippets in errors.
4
+ */
5
+ export function redactSecrets(text, secrets) {
6
+ let out = text;
7
+ for (const secret of secrets) {
8
+ // Very short strings would over-redact; real keys/tokens are much longer.
9
+ if (secret && secret.length >= 8) {
10
+ out = out.replaceAll(secret, "***REDACTED***");
11
+ }
12
+ }
13
+ return out;
14
+ }
@@ -0,0 +1,3 @@
1
+ export function sleep(ms) {
2
+ return new Promise((resolve) => setTimeout(resolve, ms));
3
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "kiwoom-mcp-server",
3
+ "version": "0.8.0",
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
+ "private": false,
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "author": "ChunSam",
9
+ "homepage": "https://github.com/ChunSam/kiwoom-mcp-server#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/ChunSam/kiwoom-mcp-server.git"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/ChunSam/kiwoom-mcp-server/issues"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "model-context-protocol",
20
+ "claude",
21
+ "kiwoom",
22
+ "kiwoom-securities",
23
+ "stock",
24
+ "korea-stock",
25
+ "isa",
26
+ "read-only"
27
+ ],
28
+ "engines": {
29
+ "node": ">=20.12"
30
+ },
31
+ "bin": {
32
+ "kiwoom-mcp-server": "dist/index.js"
33
+ },
34
+ "files": [
35
+ "dist"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsc",
39
+ "dev": "tsx src/index.ts",
40
+ "start": "node dist/index.js",
41
+ "typecheck": "tsc --noEmit",
42
+ "test": "vitest run --passWithNoTests",
43
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "zod": "^4.0.0"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^24.0.0",
51
+ "tsx": "^4.0.0",
52
+ "typescript": "^5.9.0",
53
+ "vitest": "^3.0.0"
54
+ }
55
+ }