kiwoom-mcp-server 0.17.0 → 0.19.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 +9 -3
- package/README.md +8 -2
- package/dist/http.js +38 -15
- package/dist/kiwoom/api.js +14 -1
- package/dist/kiwoom/types.js +15 -0
- package/dist/oauth.js +427 -0
- package/dist/server.js +1 -1
- package/dist/tools/account-balance.js +34 -7
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -222,9 +222,15 @@ MCP_AUTH_TOKEN="$(openssl rand -hex 32)" npx -y kiwoom-mcp-server --http --port
|
|
|
222
222
|
`cloudflared tunnel --url http://localhost:8000` (ephemeral URL — use a named
|
|
223
223
|
tunnel for anything permanent).
|
|
224
224
|
- Register on claude.ai under **Settings → Connectors → Add custom connector** with
|
|
225
|
-
`https://<your-domain>/mcp
|
|
226
|
-
|
|
227
|
-
|
|
225
|
+
`https://<your-domain>/mcp` (leave the OAuth fields in Advanced settings empty).
|
|
226
|
+
On connect, a browser **consent page** opens — enter your `MCP_AUTH_TOKEN` as the
|
|
227
|
+
access password and you are done: the server implements the MCP authorization spec
|
|
228
|
+
(OAuth 2.0 + PKCE with dynamic client registration), so no header configuration is
|
|
229
|
+
needed. A connector added once is available across the web, mobile, and desktop
|
|
230
|
+
clients. Header-capable clients (e.g. Claude Code `--header`) can still use the
|
|
231
|
+
static `Authorization: Bearer <MCP_AUTH_TOKEN>` path. OAuth tokens are persisted
|
|
232
|
+
to `.oauth-state.json` (0600) in the working directory, so restarts don't drop
|
|
233
|
+
the connection.
|
|
228
234
|
- ⚠️ **Kiwoom API calls originate from wherever this server runs.** REAL mode is
|
|
229
235
|
bound to Kiwoom's designated-terminal (8050) IP registration, so running it
|
|
230
236
|
outside a registered IP (e.g. in the cloud) can fail auth. Validate remote
|
package/README.md
CHANGED
|
@@ -211,8 +211,14 @@ MCP_AUTH_TOKEN="$(openssl rand -hex 32)" npx -y kiwoom-mcp-server --http --port
|
|
|
211
211
|
- 공개 HTTPS URL은 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 등으로 만듭니다:
|
|
212
212
|
`cloudflared tunnel --url http://localhost:8000` (임시 URL — 상시 운영은 named tunnel 권장).
|
|
213
213
|
- claude.ai 등록: **Settings → Connectors → Add custom connector**에
|
|
214
|
-
`https://<도메인>/mcp`를
|
|
215
|
-
|
|
214
|
+
`https://<도메인>/mcp`를 입력합니다 (고급 설정의 OAuth 필드는 비워둡니다). 연결 시
|
|
215
|
+
브라우저에 **승인 페이지**가 뜨고, `MCP_AUTH_TOKEN` 값을 접속 암호로 입력하면
|
|
216
|
+
완료됩니다 — 서버가 MCP 인증 스펙(OAuth 2.0 + PKCE, 동적 클라이언트 등록)을 내장하고
|
|
217
|
+
있어 별도 헤더 설정이 필요 없습니다. 등록한 커넥터는 웹/모바일/데스크톱에서 공용입니다.
|
|
218
|
+
헤더를 지정할 수 있는 클라이언트(예: Claude Code `--header`)는 기존처럼
|
|
219
|
+
`Authorization: Bearer <MCP_AUTH_TOKEN>` 정적 헤더로도 접속할 수 있습니다.
|
|
220
|
+
OAuth 토큰은 작업 디렉터리의 `.oauth-state.json`(0600)에 저장되어 서버를 재시작해도
|
|
221
|
+
연결이 유지됩니다.
|
|
216
222
|
- ⚠️ **키움 API 호출은 이 서버가 실행되는 곳에서 나갑니다.** REAL 모드는 키움 지정단말기
|
|
217
223
|
인증(8050)이 IP에 묶이므로, 등록된 IP가 아닌 곳(클라우드 등)에서 실행하면 인증 오류가
|
|
218
224
|
날 수 있습니다. 원격 노출은 모의투자로 먼저 검증하세요.
|
package/dist/http.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
1
|
import http from "node:http";
|
|
2
|
+
import path from "node:path";
|
|
3
3
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { handleOAuthRequest, OAuthProvider, requestBaseUrl, timingSafeStringEqual, } from "./oauth.js";
|
|
4
5
|
import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
|
|
5
6
|
const DEFAULT_PORT = 8000;
|
|
6
7
|
const DEFAULT_HOST = "127.0.0.1";
|
|
@@ -59,18 +60,20 @@ export function chooseTransport(argv, env) {
|
|
|
59
60
|
"인증 없이 열려면 --no-auth를 명시하세요 — 계좌 조회 도구가 인터넷에 그대로 노출되므로 " +
|
|
60
61
|
"신뢰할 수 있는 네트워크/모의투자(VIRTUAL) 환경에서만 권장합니다.");
|
|
61
62
|
}
|
|
62
|
-
|
|
63
|
+
const publicUrl = env.MCP_PUBLIC_URL?.trim() || undefined;
|
|
64
|
+
return { mode: "http", options: { port, host, authToken, allowNoAuth, publicUrl } };
|
|
63
65
|
}
|
|
64
|
-
/**
|
|
65
|
-
export function
|
|
66
|
+
/** Extracts the bearer credential from an Authorization header, if any. */
|
|
67
|
+
export function bearerToken(authorizationHeader) {
|
|
66
68
|
if (!authorizationHeader)
|
|
67
|
-
return
|
|
69
|
+
return undefined;
|
|
68
70
|
const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
return match?.[1];
|
|
72
|
+
}
|
|
73
|
+
/** Timing-safe static bearer-token check. */
|
|
74
|
+
export function isAuthorized(authorizationHeader, token) {
|
|
75
|
+
const presented = bearerToken(authorizationHeader);
|
|
76
|
+
return presented !== undefined && timingSafeStringEqual(presented, token);
|
|
74
77
|
}
|
|
75
78
|
function deny(res, status, message) {
|
|
76
79
|
res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify({
|
|
@@ -87,27 +90,47 @@ function deny(res, status, message) {
|
|
|
87
90
|
* lives at module level and is shared across requests.
|
|
88
91
|
*/
|
|
89
92
|
export function createHttpServer(options) {
|
|
93
|
+
// OAuth rides on the same consent secret as the static bearer: no token → no
|
|
94
|
+
// OAuth endpoints (that is the explicit --no-auth everything-open mode).
|
|
95
|
+
const oauth = options.authToken
|
|
96
|
+
? new OAuthProvider({
|
|
97
|
+
consentToken: options.authToken,
|
|
98
|
+
statePath: options.oauthStatePath ?? path.join(process.cwd(), ".oauth-state.json"),
|
|
99
|
+
})
|
|
100
|
+
: null;
|
|
90
101
|
return http.createServer((req, res) => {
|
|
91
|
-
void handleRequest(req, res, options).catch((error) => {
|
|
102
|
+
void handleRequest(req, res, options, oauth).catch((error) => {
|
|
92
103
|
console.error("HTTP request handling failed:", error);
|
|
93
104
|
if (!res.headersSent)
|
|
94
105
|
deny(res, 500, "internal server error");
|
|
95
106
|
});
|
|
96
107
|
});
|
|
97
108
|
}
|
|
98
|
-
async function handleRequest(req, res, options) {
|
|
109
|
+
async function handleRequest(req, res, options, oauth) {
|
|
99
110
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
100
111
|
if (url.pathname === HEALTH_PATH) {
|
|
101
112
|
res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
|
|
102
113
|
return;
|
|
103
114
|
}
|
|
115
|
+
if (oauth && (await handleOAuthRequest(req, res, oauth, options.publicUrl))) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
104
118
|
if (url.pathname !== MCP_PATH) {
|
|
105
119
|
deny(res, 404, `not found — MCP endpoint is ${MCP_PATH}`);
|
|
106
120
|
return;
|
|
107
121
|
}
|
|
108
|
-
if (options.authToken
|
|
109
|
-
|
|
110
|
-
|
|
122
|
+
if (options.authToken) {
|
|
123
|
+
const presented = bearerToken(req.headers.authorization);
|
|
124
|
+
const ok = presented !== undefined &&
|
|
125
|
+
(timingSafeStringEqual(presented, options.authToken) ||
|
|
126
|
+
(oauth?.validateAccessToken(presented) ?? false));
|
|
127
|
+
if (!ok) {
|
|
128
|
+
// RFC 9728: point 401s at the protected-resource metadata so MCP
|
|
129
|
+
// clients (claude.ai connectors) can discover the OAuth flow.
|
|
130
|
+
res.setHeader("WWW-Authenticate", `Bearer realm="${SERVER_NAME}", resource_metadata="${requestBaseUrl(req, options.publicUrl)}/.well-known/oauth-protected-resource"`);
|
|
131
|
+
deny(res, 401, "unauthorized — OAuth 승인 또는 Authorization: Bearer <MCP_AUTH_TOKEN> 헤더가 필요합니다");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
111
134
|
}
|
|
112
135
|
const server = createServer();
|
|
113
136
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
package/dist/kiwoom/api.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { sleep } from "../utils/sleep.js";
|
|
3
3
|
import { KiwoomApiError } from "./errors.js";
|
|
4
|
-
import { accountEvaluationResponseSchema, allIndexResponseSchema, brokerActivityResponseSchema, dailyChartItemSchema, depositResponseSchema, etfInfoResponseSchema, etfNavItemSchema, etfReturnItemSchema, foreignHoldingResponseSchema, investorDailyItemSchema, investorTotalItemSchema, lendingTrendResponseSchema, limitStockItemSchema, minuteChartItemSchema, newHighLowItemSchema, orderbookResponseSchema, pendingOrdersResponseSchema, priceChangeRankItemSchema, priceJumpItemSchema, programTradeItemSchema, realizedPnlResponseSchema, sectorPriceResponseSchema, sectorStocksResponseSchema, shortSellingResponseSchema, stockInfoResponseSchema, stockListResponseSchema, themeGroupsResponseSchema, tradingJournalResponseSchema, themeStocksResponseSchema, transactionsResponseSchema, valueRankItemSchema, viStockItemSchema, volumeRankItemSchema, watchlistGroupDetailResponseSchema, watchlistGroupsResponseSchema, } from "./types.js";
|
|
4
|
+
import { accountEvaluationResponseSchema, accountPeriodPlResponseSchema, allIndexResponseSchema, brokerActivityResponseSchema, dailyChartItemSchema, depositResponseSchema, etfInfoResponseSchema, etfNavItemSchema, etfReturnItemSchema, foreignHoldingResponseSchema, investorDailyItemSchema, investorTotalItemSchema, lendingTrendResponseSchema, limitStockItemSchema, minuteChartItemSchema, newHighLowItemSchema, orderbookResponseSchema, pendingOrdersResponseSchema, priceChangeRankItemSchema, priceJumpItemSchema, programTradeItemSchema, realizedPnlResponseSchema, sectorPriceResponseSchema, sectorStocksResponseSchema, shortSellingResponseSchema, stockInfoResponseSchema, stockListResponseSchema, themeGroupsResponseSchema, tradingJournalResponseSchema, themeStocksResponseSchema, transactionsResponseSchema, valueRankItemSchema, viStockItemSchema, volumeRankItemSchema, watchlistGroupDetailResponseSchema, watchlistGroupsResponseSchema, } from "./types.js";
|
|
5
5
|
const STOCK_INFO_PATH = "/api/dostk/stkinfo";
|
|
6
6
|
const ACCOUNT_PATH = "/api/dostk/acnt";
|
|
7
7
|
const CHART_PATH = "/api/dostk/chart";
|
|
@@ -72,6 +72,19 @@ export async function fetchAccountEvaluation(client, qryTp) {
|
|
|
72
72
|
// 남은 페이지가 있는데 상한에서 멈췄으면 데이터가 잘렸다는 뜻.
|
|
73
73
|
return { ...first, acnt_evlt_remn_indv_tot: holdings, truncated: res.hasNext };
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* kt00004 계좌평가현황요청 — consumed only for the 당일/당월/누적 투자손익 block
|
|
77
|
+
* (기간 손익); the deposit/evaluation scalars and per-stock list duplicate
|
|
78
|
+
* kt00001/kt00018. qry_tp "0" = 상장폐지 포함 전체 (probe-validated body).
|
|
79
|
+
*/
|
|
80
|
+
export async function fetchAccountPeriodPl(client) {
|
|
81
|
+
const res = await client.call({
|
|
82
|
+
path: ACCOUNT_PATH,
|
|
83
|
+
apiId: "kt00004",
|
|
84
|
+
body: { qry_tp: "0", dmst_stex_tp: "KRX" },
|
|
85
|
+
});
|
|
86
|
+
return accountPeriodPlResponseSchema.parse(res.json);
|
|
87
|
+
}
|
|
75
88
|
/**
|
|
76
89
|
* kt00015 위탁종합거래내역요청 — all transactions in [fromDate, toDate] (yyyyMMdd).
|
|
77
90
|
* NOTE (live-verified): only 매매 rows are returned for this account type; cash
|
package/dist/kiwoom/types.js
CHANGED
|
@@ -72,6 +72,21 @@ export const accountEvaluationResponseSchema = z.looseObject({
|
|
|
72
72
|
prsm_dpst_aset_amt: str(), // 추정예탁자산
|
|
73
73
|
acnt_evlt_remn_indv_tot: z.array(holdingItemSchema).default([]),
|
|
74
74
|
});
|
|
75
|
+
// ── kt00004: 계좌평가현황 (subset — 당일/당월/누적 기간 손익 필드만 소비) ──
|
|
76
|
+
// 예수금/평가액 계열과 종목별 리스트(stk_acnt_evlt_prst)는 kt00001/kt00018과
|
|
77
|
+
// 중복이라 소비하지 않는다. 필드명은 스펙 그대로(lspft 계열 명명이 뒤섞여 있음).
|
|
78
|
+
export const accountPeriodPlResponseSchema = z.looseObject({
|
|
79
|
+
...envelope,
|
|
80
|
+
tdy_lspft_amt: str(), // 당일투자원금
|
|
81
|
+
invt_bsamt: str(), // 당월투자원금
|
|
82
|
+
lspft_amt: str(), // 누적투자원금
|
|
83
|
+
tdy_lspft: str(), // 당일투자손익 (부호 유의미)
|
|
84
|
+
lspft2: str(), // 당월투자손익 (부호 유의미)
|
|
85
|
+
lspft: str(), // 누적투자손익 (부호 유의미)
|
|
86
|
+
tdy_lspft_rt: str(), // 당일손익율(%)
|
|
87
|
+
lspft_ratio: str(), // 당월손익율(%)
|
|
88
|
+
lspft_rt: str(), // 누적손익율(%)
|
|
89
|
+
});
|
|
75
90
|
// ── kt00015: 위탁종합거래내역 (subset) ──
|
|
76
91
|
// NOTE: trde_dt is the settlement date (D+2), not the trade date.
|
|
77
92
|
export const transactionRowSchema = z.looseObject({
|
package/dist/oauth.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* Minimal built-in OAuth 2.0 authorization server for the HTTP transport,
|
|
5
|
+
* implementing what the MCP authorization spec expects from a remote server
|
|
6
|
+
* (claude.ai custom connectors drive this flow):
|
|
7
|
+
*
|
|
8
|
+
* 401 + WWW-Authenticate → RFC 9728 protected-resource metadata
|
|
9
|
+
* → RFC 8414 authorization-server metadata → RFC 7591 dynamic client
|
|
10
|
+
* registration → authorization-code + PKCE(S256) → opaque bearer tokens.
|
|
11
|
+
*
|
|
12
|
+
* Consent model: this server fronts ONE owner's brokerage account, so the
|
|
13
|
+
* /authorize page asks for the pre-shared MCP_AUTH_TOKEN as an access
|
|
14
|
+
* password instead of a user login. One secret serves both auth paths
|
|
15
|
+
* (static bearer for header-capable clients, OAuth consent for claude.ai).
|
|
16
|
+
*/
|
|
17
|
+
const CODE_TTL_MS = 5 * 60 * 1000;
|
|
18
|
+
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
|
|
19
|
+
/** Consent brute-force guard: max failures per window, shared across IPs. */
|
|
20
|
+
const MAX_CONSENT_FAILURES = 10;
|
|
21
|
+
const CONSENT_FAILURE_WINDOW_MS = 10 * 60 * 1000;
|
|
22
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
23
|
+
const token = (bytes = 32) => randomBytes(bytes).toString("hex");
|
|
24
|
+
const sha256base64url = (value) => createHash("sha256").update(value).digest("base64url");
|
|
25
|
+
/** Timing-safe string comparison (hash first so lengths never short-circuit). */
|
|
26
|
+
export function timingSafeStringEqual(a, b) {
|
|
27
|
+
return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
|
|
28
|
+
}
|
|
29
|
+
/** PKCE S256: does sha256(verifier) match the stored challenge? */
|
|
30
|
+
export function verifyPkce(codeVerifier, codeChallenge) {
|
|
31
|
+
return timingSafeStringEqual(sha256base64url(codeVerifier), codeChallenge);
|
|
32
|
+
}
|
|
33
|
+
const escapeHtml = (s) => s
|
|
34
|
+
.replaceAll("&", "&")
|
|
35
|
+
.replaceAll("<", "<")
|
|
36
|
+
.replaceAll(">", ">")
|
|
37
|
+
.replaceAll('"', """)
|
|
38
|
+
.replaceAll("'", "'");
|
|
39
|
+
/** https-only redirect URIs, except loopback for local development. */
|
|
40
|
+
export function isAllowedRedirectUri(uri) {
|
|
41
|
+
let url;
|
|
42
|
+
try {
|
|
43
|
+
url = new URL(uri);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (url.protocol === "https:")
|
|
49
|
+
return true;
|
|
50
|
+
return url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
51
|
+
}
|
|
52
|
+
export class OAuthProvider {
|
|
53
|
+
consentToken;
|
|
54
|
+
statePath;
|
|
55
|
+
clients = new Map();
|
|
56
|
+
codes = new Map();
|
|
57
|
+
tokens = new Map(); // keyed by accessToken
|
|
58
|
+
refresh = new Map(); // keyed by refreshToken
|
|
59
|
+
consentFailures = 0;
|
|
60
|
+
consentWindowStart = 0;
|
|
61
|
+
constructor(options) {
|
|
62
|
+
this.consentToken = options.consentToken;
|
|
63
|
+
this.statePath = options.statePath;
|
|
64
|
+
this.load();
|
|
65
|
+
}
|
|
66
|
+
load() {
|
|
67
|
+
if (!this.statePath || !existsSync(this.statePath))
|
|
68
|
+
return;
|
|
69
|
+
try {
|
|
70
|
+
const state = JSON.parse(readFileSync(this.statePath, "utf8"));
|
|
71
|
+
for (const c of state.clients ?? [])
|
|
72
|
+
this.clients.set(c.clientId, c);
|
|
73
|
+
for (const t of state.tokens ?? []) {
|
|
74
|
+
this.tokens.set(t.accessToken, t);
|
|
75
|
+
this.refresh.set(t.refreshToken, t);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// A corrupt state file only means connectors must re-authorize.
|
|
80
|
+
console.error("OAuth state file unreadable — starting empty:", error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
save() {
|
|
84
|
+
if (!this.statePath)
|
|
85
|
+
return;
|
|
86
|
+
const state = {
|
|
87
|
+
clients: [...this.clients.values()],
|
|
88
|
+
tokens: [...this.refresh.values()],
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
writeFileSync(this.statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
console.error("Failed to persist OAuth state:", error);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
registerClient(meta) {
|
|
98
|
+
const uris = Array.isArray(meta.redirect_uris)
|
|
99
|
+
? meta.redirect_uris.filter((u) => typeof u === "string")
|
|
100
|
+
: [];
|
|
101
|
+
if (uris.length === 0 || !uris.every(isAllowedRedirectUri)) {
|
|
102
|
+
return { error: "redirect_uris must be https URLs (or http loopback)" };
|
|
103
|
+
}
|
|
104
|
+
const client = {
|
|
105
|
+
clientId: token(16),
|
|
106
|
+
redirectUris: uris,
|
|
107
|
+
clientName: typeof meta.client_name === "string" ? meta.client_name.slice(0, 200) : "",
|
|
108
|
+
createdAt: Date.now(),
|
|
109
|
+
};
|
|
110
|
+
this.clients.set(client.clientId, client);
|
|
111
|
+
this.save();
|
|
112
|
+
return client;
|
|
113
|
+
}
|
|
114
|
+
getClient(clientId) {
|
|
115
|
+
return this.clients.get(clientId);
|
|
116
|
+
}
|
|
117
|
+
consentRateLimited() {
|
|
118
|
+
if (Date.now() - this.consentWindowStart > CONSENT_FAILURE_WINDOW_MS)
|
|
119
|
+
return false;
|
|
120
|
+
return this.consentFailures >= MAX_CONSENT_FAILURES;
|
|
121
|
+
}
|
|
122
|
+
/** Validates the consent password; counts failures for the rate limit. */
|
|
123
|
+
checkConsent(password) {
|
|
124
|
+
if (timingSafeStringEqual(password, this.consentToken))
|
|
125
|
+
return true;
|
|
126
|
+
if (Date.now() - this.consentWindowStart > CONSENT_FAILURE_WINDOW_MS) {
|
|
127
|
+
this.consentWindowStart = Date.now();
|
|
128
|
+
this.consentFailures = 0;
|
|
129
|
+
}
|
|
130
|
+
this.consentFailures += 1;
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
createCode(clientId, redirectUri, codeChallenge) {
|
|
134
|
+
const code = {
|
|
135
|
+
code: token(24),
|
|
136
|
+
clientId,
|
|
137
|
+
redirectUri,
|
|
138
|
+
codeChallenge,
|
|
139
|
+
expiresAt: Date.now() + CODE_TTL_MS,
|
|
140
|
+
};
|
|
141
|
+
this.codes.set(code.code, code);
|
|
142
|
+
return code.code;
|
|
143
|
+
}
|
|
144
|
+
exchangeCode(params) {
|
|
145
|
+
const record = this.codes.get(params.code);
|
|
146
|
+
this.codes.delete(params.code); // single-use, even on failure
|
|
147
|
+
if (!record || record.expiresAt < Date.now())
|
|
148
|
+
return { error: "invalid_grant" };
|
|
149
|
+
if (record.clientId !== params.clientId || record.redirectUri !== params.redirectUri) {
|
|
150
|
+
return { error: "invalid_grant" };
|
|
151
|
+
}
|
|
152
|
+
if (!verifyPkce(params.codeVerifier, record.codeChallenge))
|
|
153
|
+
return { error: "invalid_grant" };
|
|
154
|
+
return this.issueTokens(record.clientId);
|
|
155
|
+
}
|
|
156
|
+
refreshTokens(refreshToken, clientId) {
|
|
157
|
+
const record = this.refresh.get(refreshToken);
|
|
158
|
+
if (!record || record.clientId !== clientId)
|
|
159
|
+
return { error: "invalid_grant" };
|
|
160
|
+
// Rotate: the presented refresh token is spent either way.
|
|
161
|
+
this.refresh.delete(refreshToken);
|
|
162
|
+
this.tokens.delete(record.accessToken);
|
|
163
|
+
return this.issueTokens(clientId);
|
|
164
|
+
}
|
|
165
|
+
issueTokens(clientId) {
|
|
166
|
+
const record = {
|
|
167
|
+
accessToken: token(32),
|
|
168
|
+
refreshToken: token(32),
|
|
169
|
+
clientId,
|
|
170
|
+
accessExpiresAt: Date.now() + ACCESS_TOKEN_TTL_MS,
|
|
171
|
+
createdAt: Date.now(),
|
|
172
|
+
};
|
|
173
|
+
this.tokens.set(record.accessToken, record);
|
|
174
|
+
this.refresh.set(record.refreshToken, record);
|
|
175
|
+
this.save();
|
|
176
|
+
return record;
|
|
177
|
+
}
|
|
178
|
+
validateAccessToken(accessToken) {
|
|
179
|
+
const record = this.tokens.get(accessToken);
|
|
180
|
+
return record !== undefined && record.accessExpiresAt > Date.now();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Base URL for metadata/issuer: explicit override, else the request's own host. */
|
|
184
|
+
export function requestBaseUrl(req, publicUrl) {
|
|
185
|
+
if (publicUrl)
|
|
186
|
+
return publicUrl.replace(/\/+$/, "");
|
|
187
|
+
const forwardedProto = req.headers["x-forwarded-proto"];
|
|
188
|
+
const proto = (Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto)?.split(",")[0]?.trim() ||
|
|
189
|
+
"http";
|
|
190
|
+
return `${proto}://${req.headers.host ?? "localhost"}`;
|
|
191
|
+
}
|
|
192
|
+
export function buildProtectedResourceMetadata(base) {
|
|
193
|
+
return {
|
|
194
|
+
resource: `${base}/mcp`,
|
|
195
|
+
authorization_servers: [base],
|
|
196
|
+
bearer_methods_supported: ["header"],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
export function buildAuthServerMetadata(base) {
|
|
200
|
+
return {
|
|
201
|
+
issuer: base,
|
|
202
|
+
authorization_endpoint: `${base}/authorize`,
|
|
203
|
+
token_endpoint: `${base}/token`,
|
|
204
|
+
registration_endpoint: `${base}/register`,
|
|
205
|
+
response_types_supported: ["code"],
|
|
206
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
207
|
+
code_challenge_methods_supported: ["S256"],
|
|
208
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
209
|
+
scopes_supported: [],
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
async function readBody(req) {
|
|
213
|
+
const chunks = [];
|
|
214
|
+
let size = 0;
|
|
215
|
+
for await (const chunk of req) {
|
|
216
|
+
size += chunk.length;
|
|
217
|
+
if (size > MAX_BODY_BYTES)
|
|
218
|
+
throw new Error("body too large");
|
|
219
|
+
chunks.push(chunk);
|
|
220
|
+
}
|
|
221
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
222
|
+
}
|
|
223
|
+
function parseBodyParams(raw, contentType) {
|
|
224
|
+
if (contentType?.includes("application/json")) {
|
|
225
|
+
const parsed = JSON.parse(raw);
|
|
226
|
+
const out = {};
|
|
227
|
+
if (parsed && typeof parsed === "object") {
|
|
228
|
+
for (const [k, v] of Object.entries(parsed))
|
|
229
|
+
if (typeof v === "string")
|
|
230
|
+
out[k] = v;
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
return Object.fromEntries(new URLSearchParams(raw));
|
|
235
|
+
}
|
|
236
|
+
function sendJson(res, status, body) {
|
|
237
|
+
res
|
|
238
|
+
.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" })
|
|
239
|
+
.end(JSON.stringify(body));
|
|
240
|
+
}
|
|
241
|
+
function sendHtml(res, status, body) {
|
|
242
|
+
res
|
|
243
|
+
.writeHead(status, {
|
|
244
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
245
|
+
"Cache-Control": "no-store",
|
|
246
|
+
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'",
|
|
247
|
+
})
|
|
248
|
+
.end(body);
|
|
249
|
+
}
|
|
250
|
+
function consentPage(fields, clientName, error) {
|
|
251
|
+
const hidden = Object.entries(fields)
|
|
252
|
+
.map(([k, v]) => `<input type="hidden" name="${escapeHtml(k)}" value="${escapeHtml(v)}">`)
|
|
253
|
+
.join("\n ");
|
|
254
|
+
return `<!doctype html>
|
|
255
|
+
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
256
|
+
<title>kiwoom-mcp-server 연결 승인</title>
|
|
257
|
+
<style>
|
|
258
|
+
body { font-family: -apple-system, sans-serif; max-width: 26rem; margin: 4rem auto; padding: 0 1rem; }
|
|
259
|
+
input[type=password] { width: 100%; padding: .6rem; font-size: 1rem; margin: .8rem 0; box-sizing: border-box; }
|
|
260
|
+
button { padding: .6rem 1.4rem; font-size: 1rem; }
|
|
261
|
+
.err { color: #c0392b; }
|
|
262
|
+
.meta { color: #666; font-size: .85rem; }
|
|
263
|
+
</style></head>
|
|
264
|
+
<body>
|
|
265
|
+
<h2>kiwoom-mcp-server 연결 승인</h2>
|
|
266
|
+
<p>클라이언트${clientName ? ` <strong>${escapeHtml(clientName)}</strong>` : ""}가 이 서버의
|
|
267
|
+
주식 조회 도구(읽기 전용)에 접근하려고 합니다.</p>
|
|
268
|
+
<p class="meta">승인하려면 서버의 <code>MCP_AUTH_TOKEN</code> 값을 입력하세요.</p>
|
|
269
|
+
${error ? `<p class="err">${escapeHtml(error)}</p>` : ""}
|
|
270
|
+
<form method="post" action="/authorize">
|
|
271
|
+
${hidden}
|
|
272
|
+
<input type="password" name="password" placeholder="접속 암호 (MCP_AUTH_TOKEN)" autofocus required>
|
|
273
|
+
<button type="submit">승인</button>
|
|
274
|
+
</form>
|
|
275
|
+
</body></html>`;
|
|
276
|
+
}
|
|
277
|
+
/** Shared /authorize validation for GET (render) and POST (approve). */
|
|
278
|
+
function validateAuthorizeParams(provider, q, res) {
|
|
279
|
+
const client = q.client_id ? provider.getClient(q.client_id) : undefined;
|
|
280
|
+
if (!client) {
|
|
281
|
+
sendHtml(res, 400, consentPage({}, "", "알 수 없는 client_id입니다. 커넥터를 다시 등록해 주세요."));
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const redirectUri = q.redirect_uri ?? "";
|
|
285
|
+
if (!client.redirectUris.includes(redirectUri)) {
|
|
286
|
+
sendHtml(res, 400, consentPage({}, client.clientName, "redirect_uri가 등록된 값과 다릅니다."));
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
// From here on the redirect target is trusted — protocol errors go back via redirect.
|
|
290
|
+
const redirectError = (error) => {
|
|
291
|
+
const url = new URL(redirectUri);
|
|
292
|
+
url.searchParams.set("error", error);
|
|
293
|
+
if (q.state)
|
|
294
|
+
url.searchParams.set("state", q.state);
|
|
295
|
+
res.writeHead(302, { Location: url.toString() }).end();
|
|
296
|
+
return null;
|
|
297
|
+
};
|
|
298
|
+
if (q.response_type !== "code")
|
|
299
|
+
return redirectError("unsupported_response_type");
|
|
300
|
+
if (!q.code_challenge || (q.code_challenge_method ?? "S256") !== "S256") {
|
|
301
|
+
return redirectError("invalid_request");
|
|
302
|
+
}
|
|
303
|
+
return { client, redirectUri, codeChallenge: q.code_challenge, state: q.state ?? "" };
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Handles OAuth endpoints. Returns true when the request was consumed.
|
|
307
|
+
* Wire this BEFORE the /mcp route; call only when auth is enabled.
|
|
308
|
+
*/
|
|
309
|
+
export async function handleOAuthRequest(req, res, provider, publicUrl) {
|
|
310
|
+
const base = requestBaseUrl(req, publicUrl);
|
|
311
|
+
const url = new URL(req.url ?? "/", base);
|
|
312
|
+
if (url.pathname === "/.well-known/oauth-protected-resource") {
|
|
313
|
+
sendJson(res, 200, buildProtectedResourceMetadata(base));
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
if (url.pathname === "/.well-known/oauth-authorization-server") {
|
|
317
|
+
sendJson(res, 200, buildAuthServerMetadata(base));
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
if (url.pathname === "/register" && req.method === "POST") {
|
|
321
|
+
let meta;
|
|
322
|
+
try {
|
|
323
|
+
meta = JSON.parse(await readBody(req));
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
sendJson(res, 400, { error: "invalid_client_metadata" });
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
const client = provider.registerClient(meta);
|
|
330
|
+
if ("error" in client) {
|
|
331
|
+
sendJson(res, 400, { error: "invalid_redirect_uri", error_description: client.error });
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
sendJson(res, 201, {
|
|
335
|
+
client_id: client.clientId,
|
|
336
|
+
client_id_issued_at: Math.floor(client.createdAt / 1000),
|
|
337
|
+
redirect_uris: client.redirectUris,
|
|
338
|
+
client_name: client.clientName || undefined,
|
|
339
|
+
token_endpoint_auth_method: "none",
|
|
340
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
341
|
+
response_types: ["code"],
|
|
342
|
+
});
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
if (url.pathname === "/authorize" && req.method === "GET") {
|
|
346
|
+
const q = Object.fromEntries(url.searchParams);
|
|
347
|
+
const params = validateAuthorizeParams(provider, q, res);
|
|
348
|
+
if (!params)
|
|
349
|
+
return true;
|
|
350
|
+
sendHtml(res, 200, consentPage({
|
|
351
|
+
client_id: params.client.clientId,
|
|
352
|
+
redirect_uri: params.redirectUri,
|
|
353
|
+
state: params.state,
|
|
354
|
+
code_challenge: params.codeChallenge,
|
|
355
|
+
code_challenge_method: "S256",
|
|
356
|
+
response_type: "code",
|
|
357
|
+
}, params.client.clientName));
|
|
358
|
+
return true;
|
|
359
|
+
}
|
|
360
|
+
if (url.pathname === "/authorize" && req.method === "POST") {
|
|
361
|
+
let form;
|
|
362
|
+
try {
|
|
363
|
+
form = parseBodyParams(await readBody(req), req.headers["content-type"]);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
sendHtml(res, 400, consentPage({}, "", "요청을 읽지 못했습니다. 다시 시도해 주세요."));
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
const params = validateAuthorizeParams(provider, form, res);
|
|
370
|
+
if (!params)
|
|
371
|
+
return true;
|
|
372
|
+
if (provider.consentRateLimited()) {
|
|
373
|
+
sendHtml(res, 429, consentPage({}, params.client.clientName, "시도 횟수를 초과했습니다. 10분 후 다시 시도해 주세요."));
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (!provider.checkConsent(form.password ?? "")) {
|
|
377
|
+
const { password: _password, ...fields } = form;
|
|
378
|
+
sendHtml(res, 401, consentPage(fields, params.client.clientName, "접속 암호가 일치하지 않습니다."));
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
const code = provider.createCode(params.client.clientId, params.redirectUri, params.codeChallenge);
|
|
382
|
+
const target = new URL(params.redirectUri);
|
|
383
|
+
target.searchParams.set("code", code);
|
|
384
|
+
if (params.state)
|
|
385
|
+
target.searchParams.set("state", params.state);
|
|
386
|
+
res.writeHead(302, { Location: target.toString() }).end();
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
if (url.pathname === "/token" && req.method === "POST") {
|
|
390
|
+
let form;
|
|
391
|
+
try {
|
|
392
|
+
form = parseBodyParams(await readBody(req), req.headers["content-type"]);
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
sendJson(res, 400, { error: "invalid_request" });
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
let result;
|
|
399
|
+
if (form.grant_type === "authorization_code") {
|
|
400
|
+
result = provider.exchangeCode({
|
|
401
|
+
code: form.code ?? "",
|
|
402
|
+
codeVerifier: form.code_verifier ?? "",
|
|
403
|
+
clientId: form.client_id ?? "",
|
|
404
|
+
redirectUri: form.redirect_uri ?? "",
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
else if (form.grant_type === "refresh_token") {
|
|
408
|
+
result = provider.refreshTokens(form.refresh_token ?? "", form.client_id ?? "");
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
sendJson(res, 400, { error: "unsupported_grant_type" });
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
if ("error" in result) {
|
|
415
|
+
sendJson(res, 400, { error: result.error });
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
sendJson(res, 200, {
|
|
419
|
+
access_token: result.accessToken,
|
|
420
|
+
token_type: "Bearer",
|
|
421
|
+
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
|
|
422
|
+
refresh_token: result.refreshToken,
|
|
423
|
+
});
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
return false;
|
|
427
|
+
}
|
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.
|
|
30
|
+
export const SERVER_VERSION = "0.19.0";
|
|
31
31
|
export function createServer() {
|
|
32
32
|
const server = new McpServer({
|
|
33
33
|
name: SERVER_NAME,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { getKiwoomContext } from "../context.js";
|
|
2
|
-
import { fetchAccountEvaluation, fetchDeposit } from "../kiwoom/api.js";
|
|
2
|
+
import { fetchAccountEvaluation, fetchAccountPeriodPl, fetchDeposit } from "../kiwoom/api.js";
|
|
3
3
|
import { formatKRW, formatPercent, formatSignedKRW, parseKiwoomNumber } from "../utils/num.js";
|
|
4
4
|
import { runTool, textResult } from "./helpers.js";
|
|
5
|
-
export function formatBalance(deposit, evaluation, modeLabel) {
|
|
5
|
+
export function formatBalance(deposit, evaluation, modeLabel, periodPl = null) {
|
|
6
6
|
const n = parseKiwoomNumber;
|
|
7
|
-
|
|
7
|
+
const lines = [
|
|
8
8
|
`[${modeLabel}] 계좌 잔고 요약`,
|
|
9
9
|
"",
|
|
10
10
|
"■ 예수금 (kt00001)",
|
|
@@ -17,20 +17,47 @@ export function formatBalance(deposit, evaluation, modeLabel) {
|
|
|
17
17
|
`- 총평가금액: ${formatKRW(n(evaluation.tot_evlt_amt))}`,
|
|
18
18
|
`- 총평가손익: ${formatSignedKRW(n(evaluation.tot_evlt_pl))} (${formatPercent(n(evaluation.tot_prft_rt))})`,
|
|
19
19
|
`- 추정예탁자산: ${formatKRW(n(evaluation.prsm_dpst_aset_amt))}`,
|
|
20
|
-
]
|
|
20
|
+
];
|
|
21
|
+
// 기간 손익 블록은 best-effort — kt00004 조회 실패 시 조용히 생략된다.
|
|
22
|
+
// 라벨은 키움 스펙 원문 그대로 (당일/당월/누적 투자손익·투자원금·손익율).
|
|
23
|
+
if (periodPl) {
|
|
24
|
+
const values = [
|
|
25
|
+
periodPl.tdy_lspft_amt, periodPl.invt_bsamt, periodPl.lspft_amt,
|
|
26
|
+
periodPl.tdy_lspft, periodPl.lspft2, periodPl.lspft,
|
|
27
|
+
periodPl.tdy_lspft_rt, periodPl.lspft_ratio, periodPl.lspft_rt,
|
|
28
|
+
];
|
|
29
|
+
const allZero = values.every((v) => !(n(v) ?? 0));
|
|
30
|
+
lines.push("", "■ 기간 손익 (kt00004)");
|
|
31
|
+
if (allZero) {
|
|
32
|
+
// REAL 실측(2026-07-13): 보유·이력이 있는 계좌도 9필드 전부 0으로 반환될 수
|
|
33
|
+
// 있다 (집계 시점/기준 미설정 추정). "손익 0원"으로 단정 표시하면 오해를
|
|
34
|
+
// 유발하므로, ka40009 NAV 선례처럼 값이 채워질 때만 수치를 렌더한다.
|
|
35
|
+
lines.push("- 키움이 기간 손익을 모두 0으로 반환했습니다 — 집계 전이거나 수익률 산출 기준이 없는 계좌일 수 있어 실제 손익이 0이라는 뜻은 아닐 수 있습니다.");
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
lines.push(`- 당일투자손익: ${formatSignedKRW(n(periodPl.tdy_lspft))} (${formatPercent(n(periodPl.tdy_lspft_rt))}) / 당일투자원금 ${formatKRW(n(periodPl.tdy_lspft_amt))}`, `- 당월투자손익: ${formatSignedKRW(n(periodPl.lspft2))} (${formatPercent(n(periodPl.lspft_ratio))}) / 당월투자원금 ${formatKRW(n(periodPl.invt_bsamt))}`, `- 누적투자손익: ${formatSignedKRW(n(periodPl.lspft))} (${formatPercent(n(periodPl.lspft_rt))}) / 누적투자원금 ${formatKRW(n(periodPl.lspft_amt))}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
21
42
|
}
|
|
22
43
|
export function registerAccountBalanceTool(server) {
|
|
23
44
|
server.registerTool("get_account_balance", {
|
|
24
45
|
title: "계좌 잔고 조회",
|
|
25
46
|
description: "계좌의 예수금(주문가능/출금가능 포함)과 총매입금액, 총평가금액, 총평가손익, " +
|
|
26
|
-
"
|
|
47
|
+
"추정예탁자산, 당일/당월/누적 투자손익을 조회합니다 (키움 kt00001 + kt00018 + kt00004). " +
|
|
48
|
+
"인자가 필요 없습니다.",
|
|
27
49
|
}, async () => runTool(async () => {
|
|
28
50
|
const { client, config } = getKiwoomContext();
|
|
29
51
|
// Different TRs — the per-TR rate limit allows these in parallel.
|
|
30
|
-
|
|
52
|
+
// kt00004 is best-effort: on failure only the 기간 손익 block is dropped.
|
|
53
|
+
const [deposit, evaluation, periodPl] = await Promise.all([
|
|
31
54
|
fetchDeposit(client),
|
|
32
55
|
fetchAccountEvaluation(client, "1"),
|
|
56
|
+
fetchAccountPeriodPl(client).catch((error) => {
|
|
57
|
+
console.error("kt00004 계좌평가현황 조회 실패 — 기간 손익 블록 생략:", error);
|
|
58
|
+
return null;
|
|
59
|
+
}),
|
|
33
60
|
]);
|
|
34
|
-
return textResult(formatBalance(deposit, evaluation, config.modeLabel));
|
|
61
|
+
return textResult(formatBalance(deposit, evaluation, config.modeLabel, periodPl));
|
|
35
62
|
}));
|
|
36
63
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kiwoom-mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|