kiwoom-mcp-server 0.16.0 → 0.18.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
@@ -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,44 @@ 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` (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.
234
+ - ⚠️ **Kiwoom API calls originate from wherever this server runs.** REAL mode is
235
+ bound to Kiwoom's designated-terminal (8050) IP registration, so running it
236
+ outside a registered IP (e.g. in the cloud) can fail auth. Validate remote
237
+ setups with a VIRTUAL key first.
238
+
239
+ The stdio behavior (Claude Desktop/Code) is unchanged when run without arguments.
240
+
198
241
  ## Smoke test
199
242
 
200
243
  You can verify over stdio without an MCP client:
package/README.md CHANGED
@@ -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,40 @@ 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`를 입력합니다 (고급 설정의 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
+ 연결이 유지됩니다.
222
+ - ⚠️ **키움 API 호출은 이 서버가 실행되는 곳에서 나갑니다.** REAL 모드는 키움 지정단말기
223
+ 인증(8050)이 IP에 묶이므로, 등록된 IP가 아닌 곳(클라우드 등)에서 실행하면 인증 오류가
224
+ 날 수 있습니다. 원격 노출은 모의투자로 먼저 검증하세요.
225
+
226
+ 기존 stdio 동작(Claude Desktop/Code 연결)은 인자 없이 실행하면 그대로입니다.
227
+
189
228
  ## 동작 확인
190
229
 
191
230
  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,158 @@
1
+ import http from "node:http";
2
+ import path from "node:path";
3
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
+ import { handleOAuthRequest, OAuthProvider, requestBaseUrl, timingSafeStringEqual, } from "./oauth.js";
5
+ import { createServer, SERVER_NAME, SERVER_VERSION } from "./server.js";
6
+ const DEFAULT_PORT = 8000;
7
+ const DEFAULT_HOST = "127.0.0.1";
8
+ const MCP_PATH = "/mcp";
9
+ const HEALTH_PATH = "/healthz";
10
+ /**
11
+ * Decides the transport from CLI args + env. Pure (unit-testable): pass
12
+ * process.argv.slice(2) and process.env. Defaults to stdio so existing
13
+ * Claude Desktop/Code installs are untouched; HTTP is strictly opt-in.
14
+ *
15
+ * The auth token is env-only (MCP_AUTH_TOKEN) — a CLI flag would leak the
16
+ * secret to `ps`. HTTP mode refuses to start without a token unless the
17
+ * operator explicitly passes --no-auth: this server fronts a brokerage
18
+ * account, so an unauthenticated public endpoint must be a deliberate act.
19
+ */
20
+ export function chooseTransport(argv, env) {
21
+ const flags = new Set();
22
+ const values = new Map();
23
+ for (let i = 0; i < argv.length; i += 1) {
24
+ const arg = argv[i];
25
+ if (arg === "--port" || arg === "--host") {
26
+ const value = argv[i + 1];
27
+ if (value === undefined || value.startsWith("--")) {
28
+ throw new Error(`${arg} 옵션에 값이 없습니다 (예: ${arg === "--port" ? "--port 8000" : "--host 127.0.0.1"}).`);
29
+ }
30
+ values.set(arg, value);
31
+ i += 1;
32
+ }
33
+ else if (arg === "--http" || arg === "--stdio" || arg === "--no-auth") {
34
+ flags.add(arg);
35
+ }
36
+ else {
37
+ throw new Error(`알 수 없는 옵션입니다: ${arg} (지원: --http, --stdio, --port <n>, --host <addr>, --no-auth)`);
38
+ }
39
+ }
40
+ const envTransport = env.MCP_TRANSPORT?.trim().toLowerCase();
41
+ if (envTransport !== undefined && envTransport !== "" && envTransport !== "stdio" && envTransport !== "http") {
42
+ throw new Error(`MCP_TRANSPORT 값이 잘못되었습니다: "${env.MCP_TRANSPORT}" (stdio 또는 http).`);
43
+ }
44
+ const wantsHttp = flags.has("--http") || (!flags.has("--stdio") && envTransport === "http");
45
+ if (!wantsHttp)
46
+ return { mode: "stdio" };
47
+ const portRaw = values.get("--port") ?? env.MCP_HTTP_PORT?.trim();
48
+ let port = DEFAULT_PORT;
49
+ if (portRaw !== undefined && portRaw !== "") {
50
+ port = Number(portRaw);
51
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
52
+ throw new Error(`포트가 잘못되었습니다: "${portRaw}" (0~65535 정수).`);
53
+ }
54
+ }
55
+ const host = values.get("--host") ?? (env.MCP_HTTP_HOST?.trim() || DEFAULT_HOST);
56
+ const allowNoAuth = flags.has("--no-auth") || env.MCP_HTTP_NO_AUTH?.trim().toLowerCase() === "true";
57
+ const authToken = env.MCP_AUTH_TOKEN?.trim() || undefined;
58
+ if (!authToken && !allowNoAuth) {
59
+ throw new Error("HTTP 모드에는 MCP_AUTH_TOKEN 환경변수가 필요합니다 (모든 /mcp 요청의 Bearer 토큰). " +
60
+ "인증 없이 열려면 --no-auth를 명시하세요 — 계좌 조회 도구가 인터넷에 그대로 노출되므로 " +
61
+ "신뢰할 수 있는 네트워크/모의투자(VIRTUAL) 환경에서만 권장합니다.");
62
+ }
63
+ const publicUrl = env.MCP_PUBLIC_URL?.trim() || undefined;
64
+ return { mode: "http", options: { port, host, authToken, allowNoAuth, publicUrl } };
65
+ }
66
+ /** Extracts the bearer credential from an Authorization header, if any. */
67
+ export function bearerToken(authorizationHeader) {
68
+ if (!authorizationHeader)
69
+ return undefined;
70
+ const match = /^Bearer\s+(.+)$/i.exec(authorizationHeader.trim());
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);
77
+ }
78
+ function deny(res, status, message) {
79
+ res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify({
80
+ jsonrpc: "2.0",
81
+ error: { code: -32000, message },
82
+ id: null,
83
+ }));
84
+ }
85
+ /**
86
+ * Streamable HTTP endpoint for remote MCP clients (claude.ai custom
87
+ * connectors, Claude Desktop/mobile via the account-brokered connector).
88
+ * Stateless per the MCP spec: each request gets a fresh McpServer+transport
89
+ * pair; the expensive state (Kiwoom token cache, ka10099 master-list cache)
90
+ * lives at module level and is shared across requests.
91
+ */
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;
101
+ return http.createServer((req, res) => {
102
+ void handleRequest(req, res, options, oauth).catch((error) => {
103
+ console.error("HTTP request handling failed:", error);
104
+ if (!res.headersSent)
105
+ deny(res, 500, "internal server error");
106
+ });
107
+ });
108
+ }
109
+ async function handleRequest(req, res, options, oauth) {
110
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
111
+ if (url.pathname === HEALTH_PATH) {
112
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
113
+ return;
114
+ }
115
+ if (oauth && (await handleOAuthRequest(req, res, oauth, options.publicUrl))) {
116
+ return;
117
+ }
118
+ if (url.pathname !== MCP_PATH) {
119
+ deny(res, 404, `not found — MCP endpoint is ${MCP_PATH}`);
120
+ return;
121
+ }
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
+ }
134
+ }
135
+ const server = createServer();
136
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
137
+ res.on("close", () => {
138
+ void transport.close();
139
+ void server.close();
140
+ });
141
+ await server.connect(transport);
142
+ await transport.handleRequest(req, res);
143
+ }
144
+ export async function startHttpServer(options) {
145
+ const server = createHttpServer(options);
146
+ await new Promise((resolve, reject) => {
147
+ server.once("error", reject);
148
+ server.listen(options.port, options.host, resolve);
149
+ });
150
+ const address = server.address();
151
+ const boundPort = typeof address === "object" && address ? address.port : options.port;
152
+ console.error(`${SERVER_NAME} v${SERVER_VERSION} listening on http://${options.host}:${boundPort}${MCP_PATH} ` +
153
+ `(auth: ${options.authToken ? "bearer token" : "NONE — --no-auth"})`);
154
+ if (!options.authToken) {
155
+ console.error("⚠️ 인증 없이 실행 중입니다. 이 서버는 계좌 조회 도구를 노출하므로 공개 네트워크에 직접 연결하지 마세요.");
156
+ }
157
+ return server;
158
+ }
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);
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("&", "&amp;")
35
+ .replaceAll("<", "&lt;")
36
+ .replaceAll(">", "&gt;")
37
+ .replaceAll('"', "&quot;")
38
+ .replaceAll("'", "&#39;");
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.16.0";
30
+ export const SERVER_VERSION = "0.18.0";
31
31
  export function createServer() {
32
32
  const server = new McpServer({
33
33
  name: SERVER_NAME,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiwoom-mcp-server",
3
- "version": "0.16.0",
3
+ "version": "0.18.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",