diff --git a/README.md b/README.md index f314d69..5e70f84 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,21 @@ curl -X POST $CTK_URL/api/v1/analyze \ -d '{"feature":"sql_review","input":"SELECT * FROM T_ORDER ..."}' | jq ``` +### ๐Ÿ†• v4.7.2 ํŒจ์น˜ โ€” ADMIN 2FA ํ•„์ˆ˜ํ™” + ๋ณด์•ˆ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ + +๋ณด์•ˆ ์ •์ฑ… ๊ฐ•ํ™”: **ADMIN ๊ณ„์ •์˜ 2FA(TOTP)๋ฅผ ์„ ํƒ์ด ์•„๋‹Œ ํ•„์ˆ˜**๋กœ ์ „ํ™˜. ๋™์‹œ์— ์ธ์ฆยท2FA ํ•ต์‹ฌ ๋กœ์ง ์ „๋ฐ˜์— ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ด ํšŒ๊ท€ ์•ˆ์ „๋ง์„ ๊ตฌ์ถ•. + +- ๐Ÿ” **ADMIN 2FA ํ•„์ˆ˜ํ™”** โ€” `POST /account/disable-2fa` ์— ADMIN ์—ญํ•  ์ฐจ๋‹จ ์ถ”๊ฐ€ + - ADMIN ๊ณ„์ •์ด 2FA ๋น„ํ™œ์„ฑํ™” ์š”์ฒญ ์‹œ `{ "success": false, "error": "ADMIN ๊ณ„์ •์€ 2FA๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค." }` ๋ฐ˜ํ™˜ + - ๋กœ๊ทธ์ธ ํ๋ฆ„(`TwoFactorAuthHandler`)์€ ์ด๋ฏธ ADMIN ์„ ๊ฐ•์ œ๋กœ `/login/2fa` ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธํ•˜์—ฌ ๋ฏธ์„ค์ • ์‹œ ์ดˆ๊ธฐ ๋“ฑ๋ก ํŽ˜์ด์ง€ ํ‘œ์‹œ โ€” ์ด๋ฒˆ ํŒจ์น˜๋Š” *์‚ฌํ›„ ๋น„ํ™œ์„ฑํ™”* ๊ฒฝ๋กœ๋ฅผ ์ฐจ๋‹จํ•˜๋Š” ๋งˆ์ง€๋ง‰ ํผ์ฆ + - REVIEWER / VIEWER ๋Š” ๊ธฐ์กด๋Œ€๋กœ 2FA ์„ ํƒ ์ ์šฉ + +- ๐Ÿงช **๋‹จ์œ„ ํ…Œ์ŠคํŠธ 24๊ฐœ ์‹ ๊ทœ** โ€” `TotpServiceTest` / `TwoFactorAuthHandlerTest` / `AccountController2faTest` + `SecuritySmokeTests` 4๊ฑด ์ถ”๊ฐ€ + - **`TotpServiceTest` (14 ์ผ€์ด์Šค)** โ€” RFC 6238 TOTP ์•Œ๊ณ ๋ฆฌ์ฆ˜ ๊ฒ€์ฆ: `generateSecret()` Base32 ํฌ๋งทยท์œ ์ผ์„ฑ, Base32 ์™•๋ณต ๋ณ€ํ™˜, `verifyCode()` null/๋น„์ •์ƒ ์ฝ”๋“œ ๊ฑฐ๋ถ€, `buildOtpAuthUri()` ํ˜•์‹ + - **`TwoFactorAuthHandlerTest` (5 ์ผ€์ด์Šค)** โ€” ๋กœ๊ทธ์ธ ์„ฑ๊ณต ํ•ธ๋“ค๋Ÿฌ: ADMIN โ†’ `2fa_pending` ์„ธ์…˜ ์„ธํŒ… + `/login/2fa` ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ, Non-ADMIN โ†’ `/` ์งํ–‰, ์‹คํŒจ ์นด์šดํ„ฐ ๋ฆฌ์…‹, ์‚ฌ์šฉ์ž ๋ฏธ์กด์žฌ ์•ˆ์ „ ์ฒ˜๋ฆฌ + - **`AccountController2faTest` (5 ์ผ€์ด์Šค)** โ€” ADMIN ๋น„ํ™œ์„ฑํ™” ๊ฑฐ๋ถ€, REVIEWER/VIEWER ์ •์ƒ ๋น„ํ™œ์„ฑํ™”, ์‚ฌ์šฉ์ž ๋ฏธ์กด์žฌ no-op + - **`SecuritySmokeTests` (4 ์ผ€์ด์Šค ์ถ”๊ฐ€)** โ€” `2fa_pending=true` ์ธํ„ฐ์…‰ํ„ฐ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ๊ฒ€์ฆ + ADMIN disable-2fa API ์‘๋‹ต `success=false` ๊ฒ€์ฆ + ### ๐Ÿ†• v4.7.1 ํŒจ์น˜ โ€” ์šด์˜ ์ธํ”„๋ผ + ์‚ฌ์šฉ์ž ์›Œํฌํ”Œ๋กœ 5์ข… ๊ฐ•ํ™” v4.7.0 ๋ฉ”์ด์ € ๋ฆด๋ฆฌ์Šค ์งํ›„ ์šด์˜ํŒ€ / ์™ธ๋ถ€๊ฐ์‚ฌ์ธ / ์ผ๋ฐ˜ ์‚ฌ์šฉ์ž๊ฐ€ **ํ˜„์žฅ์—์„œ ๋ฐ”๋กœ ์ฒด๊ฐ**ํ•  ์ˆ˜ ์žˆ๋Š” 5๊ฐ€์ง€ ์˜์—ญ์„ ํ•œ ๋ฒˆ์— ๊ฐ•ํ™”ํ•œ ๋ˆ„์  ํŒจ์น˜. ๋ชจ๋‘ ๊ธฐ์กด ์ปดํฌ๋„ŒํŠธ ์žฌ์‚ฌ์šฉ ์œ„์ฃผ๋กœ ๋ฐฑ์—”๋“œ ๊ตฌ์กฐ ๋ณ€๊ฒฝ ์ตœ์†Œํ™”. diff --git a/claude-toolkit-mcp/Dockerfile b/claude-toolkit-mcp/Dockerfile new file mode 100644 index 0000000..dd4124b --- /dev/null +++ b/claude-toolkit-mcp/Dockerfile @@ -0,0 +1,54 @@ +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# claude-toolkit-mcp โ€” Streamable HTTP transport (์‚ฌ๋‚ด๋ง ๊ณต์œ ) +# โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +# Build context = repo root (docker-compose ๊ฐ€ ๊ทธ๋ ‡๊ฒŒ ํ˜ธ์ถœ). +# docker-compose build claude-toolkit-mcp +# +# Runtime: +# - 8028 listen, X-Api-Key per-request ํŒจ์Šค์Šค๋ฃจ +# - CTK_URL ์€ docker network DNS ๋กœ backend ์— ์—ฐ๊ฒฐ (default: http://claude-toolkit:8027) + +# โ”€โ”€ Stage 1: build โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +FROM node:20-alpine AS builder +WORKDIR /build + +# manifest ๋งŒ ๋จผ์ € โ€” npm ci layer ์บ์‹œ ์ตœ๋Œ€ํ™” +COPY claude-toolkit-mcp/package.json claude-toolkit-mcp/package-lock.json ./ +RUN npm ci + +# ์†Œ์Šค ๋ณต์‚ฌ + ๋นŒ๋“œ +COPY claude-toolkit-mcp/tsconfig.json ./ +COPY claude-toolkit-mcp/src ./src +RUN npm run build + +# ์šด์˜ ์˜์กด์„ฑ๋งŒ ์ถ”๋ฆฌ๊ธฐ โ€” ๋‹ค์Œ stage ๊ฐ€ ์“ธ ์ž‘์€ node_modules +RUN npm prune --omit=dev + + +# โ”€โ”€ Stage 2: runtime โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +FROM node:20-alpine +LABEL maintainer="Claude Java Toolkit" + +WORKDIR /app + +# tini ๋กœ PID 1 ์‹œ๊ทธ๋„ ์ฒ˜๋ฆฌ โ€” docker stop ์‹œ graceful shutdown +RUN apk add --no-cache tini curl + +COPY --from=builder /build/dist ./dist +COPY --from=builder /build/node_modules ./node_modules +COPY --from=builder /build/package.json ./ + +ENV NODE_ENV=production \ + MCP_TRANSPORT=http \ + MCP_HTTP_HOST=0.0.0.0 \ + MCP_HTTP_PORT=8028 \ + CTK_URL=http://claude-toolkit:8027 \ + TZ=Asia/Seoul + +EXPOSE 8028 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -fsS http://localhost:8028/healthz || exit 1 + +ENTRYPOINT ["/sbin/tini", "--"] +CMD ["node", "dist/index.js"] diff --git a/claude-toolkit-mcp/package-lock.json b/claude-toolkit-mcp/package-lock.json index 434c91d..5810ca9 100644 --- a/claude-toolkit-mcp/package-lock.json +++ b/claude-toolkit-mcp/package-lock.json @@ -9,13 +9,16 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.21.0" }, "bin": { "claude-toolkit-mcp": "dist/index.js" }, "devDependencies": { + "@types/express": "^4.17.21", "@types/node": "^20.10.0", + "cross-env": "^7.0.3", "typescript": "^5.3.0" }, "engines": { @@ -74,6 +77,336 @@ } } }, + "node_modules/@modelcontextprotocol/sdk/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", @@ -84,14 +417,61 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { "node": ">= 0.6" @@ -130,28 +510,76 @@ } } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", "license": "MIT", "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" } }, "node_modules/bytes": { @@ -193,16 +621,15 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "safe-buffer": "5.2.1" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "engines": { + "node": ">= 0.6" } }, "node_modules/content-type": { @@ -224,13 +651,10 @@ } }, "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" }, "node_modules/cors": { "version": "2.8.6", @@ -249,6 +673,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -264,20 +707,12 @@ } }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "ms": "2.0.0" } }, "node_modules/depd": { @@ -289,6 +724,16 @@ "node": ">= 0.8" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -385,42 +830,45 @@ } }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" }, "funding": { "type": "opencollective", @@ -468,24 +916,21 @@ "license": "BSD-3-Clause" }, "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", "license": "MIT", "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" }, "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8" } }, "node_modules/forwarded": { @@ -498,12 +943,12 @@ } }, "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, "node_modules/function-bind": { @@ -700,61 +1145,75 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", "license": "MIT", - "engines": { - "node": ">=18" - }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { - "mime-db": "^1.54.0" + "mime-db": "1.52.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.6" } }, "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -821,14 +1280,10 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" }, "node_modules/pkce-challenge": { "version": "5.0.1", @@ -853,9 +1308,9 @@ } }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -916,6 +1371,59 @@ "node": ">= 18" } }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/router/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -923,48 +1431,48 @@ "license": "MIT" }, "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", "license": "MIT", "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8.0" } }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", "license": "MIT", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">= 0.8.0" } }, "node_modules/setprototypeof": { @@ -1085,14 +1593,13 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" + "media-typer": "0.3.0", + "mime-types": "~2.1.24" }, "engines": { "node": ">= 0.6" @@ -1128,6 +1635,15 @@ "node": ">= 0.8" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", diff --git a/claude-toolkit-mcp/package.json b/claude-toolkit-mcp/package.json index c82c9fc..1970c98 100644 --- a/claude-toolkit-mcp/package.json +++ b/claude-toolkit-mcp/package.json @@ -15,13 +15,18 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", + "start:stdio": "cross-env MCP_TRANSPORT=stdio node dist/index.js", + "start:http": "cross-env MCP_TRANSPORT=http node dist/index.js", "dev": "tsc --watch" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0" + "@modelcontextprotocol/sdk": "^1.0.0", + "express": "^4.21.0" }, "devDependencies": { + "@types/express": "^4.17.21", "@types/node": "^20.10.0", + "cross-env": "^7.0.3", "typescript": "^5.3.0" }, "engines": { diff --git a/claude-toolkit-mcp/service/README.md b/claude-toolkit-mcp/service/README.md new file mode 100644 index 0000000..917b57d --- /dev/null +++ b/claude-toolkit-mcp/service/README.md @@ -0,0 +1,69 @@ +# claude-toolkit-mcp โ€” ์„œ๋น„์Šค ์‹คํ–‰ ์Šคํฌ๋ฆฝํŠธ + +์‚ฌ๋‚ด๋ง 1๋Œ€(๋˜๋Š” ์—ฌ๋Ÿฌ ๋Œ€ ์‚ฌ๋‚ด ํ˜ธ์ŠคํŠธ)์—์„œ MCP ์„œ๋ฒ„๋ฅผ **์ƒ์‹œ ๊ฐ€๋™**ํ•˜๊ธฐ ์œ„ํ•œ ์Šคํฌ๋ฆฝํŠธ ๋ชจ์Œ. + +## ํŒŒ์ผ + +| ํŒŒ์ผ | ๋Œ€์ƒ OS | ์„ค๋ช… | +|------|---------|------| +| `claude-toolkit-mcp.service` | Linux | systemd unit | +| `claude-toolkit-mcp.env.example` | ๊ณตํ†ต | ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํ…œํ”Œ๋ฆฟ | +| `install-windows-service.ps1` | Windows | NSSM ๊ธฐ๋ฐ˜ ์„œ๋น„์Šค ๋“ฑ๋ก | + +## Linux (systemd) + +```bash +# 1) ์‚ฌ์šฉ์ž/๋””๋ ‰ํ„ฐ๋ฆฌ ์ค€๋น„ +sudo useradd --system --no-create-home claude-toolkit +sudo mkdir -p /opt/claude-toolkit-mcp /var/log/claude-toolkit-mcp +sudo chown -R claude-toolkit:claude-toolkit /opt/claude-toolkit-mcp /var/log/claude-toolkit-mcp + +# 2) ๋นŒ๋“œ ์‚ฐ์ถœ๋ฌผ ๋ฐฐํฌ +sudo cp -r dist/ package.json package-lock.json /opt/claude-toolkit-mcp/ +cd /opt/claude-toolkit-mcp && sudo -u claude-toolkit npm ci --omit=dev + +# 3) ์„œ๋น„์Šค + ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์„ค์น˜ +sudo cp service/claude-toolkit-mcp.service /etc/systemd/system/ +sudo cp service/claude-toolkit-mcp.env.example /etc/claude-toolkit-mcp.env +sudo $EDITOR /etc/claude-toolkit-mcp.env # CTK_URL ๋“ฑ ํŽธ์ง‘ + +# 4) ๊ธฐ๋™ +sudo systemctl daemon-reload +sudo systemctl enable --now claude-toolkit-mcp + +# 5) ๊ฒ€์ฆ +sudo systemctl status claude-toolkit-mcp +curl http://localhost:8028/healthz +journalctl -u claude-toolkit-mcp -f +``` + +## Windows (NSSM) + +```powershell +# 1) ๋นŒ๋“œ +cd C:\Sangmoo\claude-java-toolkit\claude-toolkit-mcp +npm install +npm run build + +# 2) NSSM ์„ค์น˜ (๊ด€๋ฆฌ์ž PowerShell) +choco install nssm # ๋˜๋Š” https://nssm.cc/download ์—์„œ ์ˆ˜๋™ + +# 3) ์„œ๋น„์Šค ๋“ฑ๋ก (๊ด€๋ฆฌ์ž PowerShell) +.\service\install-windows-service.ps1 + +# ์˜ต์…˜ ๋ณ€๊ฒฝ ์˜ˆ +.\service\install-windows-service.ps1 -Port 8030 -BindHost 127.0.0.1 + +# 4) ๊ฒ€์ฆ +Get-Service ClaudeToolkitMcp +curl http://$env:COMPUTERNAME:8028/healthz +Get-Content C:\ProgramData\claude-toolkit-mcp\logs\err.log -Tail 50 -Wait +``` + +## ์šด์˜ ์ฒดํฌ๋ฆฌ์ŠคํŠธ + +- [ ] backend(`CTK_URL`, ๊ธฐ๋ณธ :8027) ๊ฐ€ ๊ฐ™์€ ํ˜ธ์ŠคํŠธ(๋˜๋Š” ๋„๋‹ฌ ๊ฐ€๋Šฅํ•œ ๊ณณ)์—์„œ LIVE +- [ ] ๋ฐฉํ™”๋ฒฝ inbound 8028 (์‚ฌ๋‚ด๋ง ํ•œ์ • โ€” ์™ธ๋ถ€ ์ธํ„ฐ๋„ท ๋…ธ์ถœ ๊ธˆ์ง€) +- [ ] `/admin/api-keys` ์—์„œ ์‚ฌ์šฉ์ž๋ณ„ ํ‚ค ๋ฐœ๊ธ‰ (audit ๋ถ„๋ฆฌ) +- [ ] `/healthz` 200 ์‘๋‹ต ํ™•์ธ +- [ ] ํด๋ผ์ด์–ธํŠธ PC 1๋Œ€์—์„œ `npx mcp-remote http://ํ˜ธ์ŠคํŠธ:8028/mcp --header X-Api-Key:...` ์œผ๋กœ ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ diff --git a/claude-toolkit-mcp/service/claude-toolkit-mcp.env.example b/claude-toolkit-mcp/service/claude-toolkit-mcp.env.example new file mode 100644 index 0000000..0476495 --- /dev/null +++ b/claude-toolkit-mcp/service/claude-toolkit-mcp.env.example @@ -0,0 +1,23 @@ +# claude-toolkit-mcp โ€” ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํ…œํ”Œ๋ฆฟ (์‚ฌ๋‚ด๋ง HTTP ๋ชจ๋“œ) +# ์ด ํŒŒ์ผ์„ /etc/claude-toolkit-mcp.env (Linux) ๋˜๋Š” +# C:\ProgramData\claude-toolkit-mcp\service.env (Windows) ๋กœ ๋ณต์‚ฌ ํ›„ ํŽธ์ง‘. + +# โ”€โ”€ ๊ณตํ†ต โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# ๋„๊ตฌ backend base URL +CTK_URL=http://localhost:8027 + +# transport mode โ€” http ๊ณ ์ • (์‚ฌ๋‚ด๋ง ๊ณต์œ ) +MCP_TRANSPORT=http + +# (์˜ต์…˜) Live DB ํ”„๋กœํ•„ default โ€” SQL tool ์‚ฌ์šฉ ์‹œ ์ž๋™ ์ปจํ…์ŠคํŠธ +# CTK_DB_PROFILE_ID=1 + +# โ”€โ”€ HTTP ๋ชจ๋“œ ์ „์šฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# bind host. ์‚ฌ๋‚ด๋ง ์ „์ฒด ๋…ธ์ถœ์ด๋ฉด 0.0.0.0, loopback only ๋ฉด 127.0.0.1 +MCP_HTTP_HOST=0.0.0.0 +MCP_HTTP_PORT=8028 + +# โ”€โ”€ ์ฃผ์˜ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# CTK_API_KEY ๋Š” HTTP ๋ชจ๋“œ์—์„œ *์„ค์ •ํ•˜์ง€ ์•Š์Œ* โ€” ํด๋ผ์ด์–ธํŠธ๊ฐ€ +# ์š”์ฒญ ํ—ค๋” X-Api-Key ๋กœ ์ž๊ธฐ ํ‚ค๋ฅผ ๋ณด๋‚ด๊ณ , MCP ์„œ๋ฒ„๋Š” ๊ทธ๋Œ€๋กœ backend ์— ํŒจ์Šค์Šค๋ฃจ. +# ํ™˜๊ฒฝ๋ณ€์ˆ˜์— ํ‚ค๋ฅผ ๋„ฃ์œผ๋ฉด ๋ชจ๋“  ์‚ฌ์šฉ์ž๊ฐ€ ํ•œ ํ‚ค๋ฅผ ๊ณต์œ ํ•˜๊ฒŒ ๋˜์–ด audit ๊ฐ€ ๋ง๊ฐ€์ง. diff --git a/claude-toolkit-mcp/service/claude-toolkit-mcp.service b/claude-toolkit-mcp/service/claude-toolkit-mcp.service new file mode 100644 index 0000000..ac53a61 --- /dev/null +++ b/claude-toolkit-mcp/service/claude-toolkit-mcp.service @@ -0,0 +1,42 @@ +; ----------------------------------------------------------------------------- +; claude-toolkit-mcp โ€” systemd unit (Linux ์‚ฌ๋‚ด ํ˜ธ์ŠคํŠธ์šฉ) +; ----------------------------------------------------------------------------- +; ์„ค์น˜: +; sudo cp service/claude-toolkit-mcp.service /etc/systemd/system/ +; sudo cp service/claude-toolkit-mcp.env.example /etc/claude-toolkit-mcp.env +; sudo $EDITOR /etc/claude-toolkit-mcp.env # ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํŽธ์ง‘ +; sudo systemctl daemon-reload +; sudo systemctl enable --now claude-toolkit-mcp +; +; ๋กœ๊ทธ: +; journalctl -u claude-toolkit-mcp -f +; ----------------------------------------------------------------------------- + +[Unit] +Description=Claude Toolkit MCP Server (Streamable HTTP) +Documentation=https://github.com/Sangmoo/Claude-Java-Toolkit +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=claude-toolkit +Group=claude-toolkit +WorkingDirectory=/opt/claude-toolkit-mcp +EnvironmentFile=/etc/claude-toolkit-mcp.env +ExecStart=/usr/bin/node /opt/claude-toolkit-mcp/dist/index.js +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +; --- hardening ---------------------------------------------------------------- +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +PrivateDevices=true +ReadWritePaths=/var/log/claude-toolkit-mcp + +[Install] +WantedBy=multi-user.target diff --git a/claude-toolkit-mcp/service/install-windows-service.ps1 b/claude-toolkit-mcp/service/install-windows-service.ps1 new file mode 100644 index 0000000..6a6a0ef --- /dev/null +++ b/claude-toolkit-mcp/service/install-windows-service.ps1 @@ -0,0 +1,89 @@ +# ----------------------------------------------------------------------------- +# claude-toolkit-mcp โ€” Windows ์„œ๋น„์Šค ์„ค์น˜ ์Šคํฌ๋ฆฝํŠธ (NSSM ๊ธฐ๋ฐ˜) +# ----------------------------------------------------------------------------- +# ์‚ฌ์ „ ์ค€๋น„: +# 1) NSSM ์„ค์น˜ (https://nssm.cc/download) โ€” choco install nssm ๋„ ๊ฐ€๋Šฅ +# 2) Node.js 18+ ์„ค์น˜ +# 3) ๋ณธ ์Šคํฌ๋ฆฝํŠธ๋Š” "๊ด€๋ฆฌ์ž" PowerShell ์—์„œ ์‹คํ–‰ +# +# ์‚ฌ์šฉ: +# .\install-windows-service.ps1 +# .\install-windows-service.ps1 -Port 8030 -Host 127.0.0.1 +# +# ํ™˜๊ฒฝ๋ณ€์ˆ˜ ํŒŒ์ผ (C:\ProgramData\claude-toolkit-mcp\service.env) ๊ฐ€ ์šฐ์„ , +# -Port / -Host ์ธ์ž๋Š” ๊ทธ ๋‹ค์Œ ์šฐ์„ ์ˆœ์œ„. +# ----------------------------------------------------------------------------- + +[CmdletBinding()] +param( + [string]$ServiceName = "ClaudeToolkitMcp", + [string]$ProjectRoot = "C:\Sangmoo\claude-java-toolkit\claude-toolkit-mcp", + [string]$NodeExe = "C:\Program Files\nodejs\node.exe", + [string]$CtkUrl = "http://localhost:8027", + [string]$BindHost = "0.0.0.0", + [int]$Port = 8028, + [string]$LogDir = "C:\ProgramData\claude-toolkit-mcp\logs" +) + +$ErrorActionPreference = "Stop" + +# 1) ์‚ฌ์ „ ๊ฒ€์ฆ +if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) { + throw "NSSM ๋ฏธ์„ค์น˜ โ€” https://nssm.cc/download ์—์„œ ๋ฐ›์•„ PATH ์— ์ถ”๊ฐ€ํ•˜์„ธ์š”." +} +if (-not (Test-Path $NodeExe)) { + throw "Node.js ๊ฐ€ $NodeExe ์— ์—†์Œ. -NodeExe ๋กœ ๊ฒฝ๋กœ ์ง€์ •ํ•˜๊ฑฐ๋‚˜ ์„ค์น˜ ํ›„ ์žฌ์‹œ๋„." +} +$entryJs = Join-Path $ProjectRoot "dist\index.js" +if (-not (Test-Path $entryJs)) { + throw "$entryJs ๊ฐ€ ์—†์Œ. ๋จผ์ € 'npm run build' ์‹คํ–‰." +} + +# 2) ๋กœ๊ทธ ๋””๋ ‰ํ„ฐ๋ฆฌ +New-Item -ItemType Directory -Force -Path $LogDir | Out-Null + +# 3) ๊ธฐ์กด ์„œ๋น„์Šค๊ฐ€ ์žˆ์œผ๋ฉด ์ œ๊ฑฐ +$existing = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue +if ($existing) { + Write-Host "๊ธฐ์กด ์„œ๋น„์Šค '$ServiceName' ์ œ๊ฑฐ ์ค‘..." + nssm stop $ServiceName confirm | Out-Null + nssm remove $ServiceName confirm | Out-Null +} + +# 4) ์„œ๋น„์Šค ์„ค์น˜ +Write-Host "์„œ๋น„์Šค ์„ค์น˜ ์ค‘: $ServiceName" +nssm install $ServiceName $NodeExe $entryJs +nssm set $ServiceName AppDirectory $ProjectRoot +nssm set $ServiceName DisplayName "Claude Toolkit MCP Server" +nssm set $ServiceName Description "Streamable HTTP MCP server (์‚ฌ๋‚ด๋ง ๊ณต์œ , per-request X-Api-Key)" +nssm set $ServiceName Start SERVICE_AUTO_START + +# 5) ํ™˜๊ฒฝ๋ณ€์ˆ˜ +nssm set $ServiceName AppEnvironmentExtra ` + "CTK_URL=$CtkUrl" ` + "MCP_TRANSPORT=http" ` + "MCP_HTTP_HOST=$BindHost" ` + "MCP_HTTP_PORT=$Port" + +# 6) ๋กœ๊ทธ โ€” stderr/stdout ๋ชจ๋‘ ํŒŒ์ผ๋กœ +nssm set $ServiceName AppStdout (Join-Path $LogDir "out.log") +nssm set $ServiceName AppStderr (Join-Path $LogDir "err.log") +nssm set $ServiceName AppRotateFiles 1 +nssm set $ServiceName AppRotateOnline 1 +nssm set $ServiceName AppRotateBytes 10485760 # 10MB + +# 7) ์‹œ์ž‘ +Write-Host "์„œ๋น„์Šค ์‹œ์ž‘ ์ค‘..." +nssm start $ServiceName + +# 8) ๊ฒ€์ฆ +Start-Sleep -Seconds 2 +$status = (Get-Service -Name $ServiceName).Status +Write-Host "" +Write-Host "โœ… ์„ค์น˜ ์™„๋ฃŒ: $ServiceName ($status)" +Write-Host " bind : ${BindHost}:${Port}" +Write-Host " backend : $CtkUrl" +Write-Host " logs : $LogDir" +Write-Host "" +Write-Host "health check:" +Write-Host " curl http://${env:COMPUTERNAME}:${Port}/healthz" diff --git a/claude-toolkit-mcp/src/client.ts b/claude-toolkit-mcp/src/client.ts index f6a875a..a472e1e 100644 --- a/claude-toolkit-mcp/src/client.ts +++ b/claude-toolkit-mcp/src/client.ts @@ -1,8 +1,9 @@ /** - * v4.7.x โ€” #M3 Phase 3: ๋„๊ตฌ backend REST client. + * v4.7.x โ€” #M3 Phase 3 + ์‚ฌ๋‚ด๋ง ๊ณต์œ : ๋„๊ตฌ backend REST client. * - * Claude Toolkit ์˜ /api/v1/analyze ๋ฅผ ํ˜ธ์ถœํ•˜๋Š” ๋‹จ์ˆœ wrapper. - * ํ™˜๊ฒฝ๋ณ€์ˆ˜ CTK_URL + CTK_API_KEY ๋ฅผ ์‚ฌ์šฉ. + * ๋‘ ๋ชจ๋“œ ์ง€์›: + * - stdio mode: ํ™˜๊ฒฝ๋ณ€์ˆ˜ CTK_URL + CTK_API_KEY ์‚ฌ์šฉ (legacy) + * - http mode: ํด๋ผ์ด์–ธํŠธ๊ฐ€ ํ—ค๋”๋กœ ๋ณด๋‚ธ X-Api-Key๋ฅผ per-request ๋กœ ์ „๋‹ฌ */ export interface AnalyzeRequest { @@ -25,30 +26,40 @@ export interface AnalyzeResponse { error?: string; } +export interface ToolkitClientOptions { + baseUrl?: string; + apiKey?: string; + defaultDbProfileId?: number; +} + export class ToolkitClient { private readonly baseUrl: string; private readonly apiKey: string; private readonly defaultDbProfileId?: number; - constructor() { - const url = process.env.CTK_URL; - const key = process.env.CTK_API_KEY; + constructor(opts: ToolkitClientOptions = {}) { + const url = opts.baseUrl ?? process.env.CTK_URL; + const key = opts.apiKey ?? process.env.CTK_API_KEY; if (!url) { throw new Error( - 'ํ™˜๊ฒฝ๋ณ€์ˆ˜ CTK_URL ๋ฏธ์„ค์ • โ€” ๋„๊ตฌ base URL ํ•„์š” (์˜ˆ: http://localhost:8027)' + '๋„๊ตฌ base URL ๋ฏธ์„ค์ • โ€” ํ™˜๊ฒฝ๋ณ€์ˆ˜ CTK_URL ๋˜๋Š” ToolkitClientOptions.baseUrl ํ•„์š” (์˜ˆ: http://localhost:8027)' ); } if (!key) { throw new Error( - 'ํ™˜๊ฒฝ๋ณ€์ˆ˜ CTK_API_KEY ๋ฏธ์„ค์ • โ€” /admin/api-keys ํŽ˜์ด์ง€์—์„œ ๋ฐœ๊ธ‰' + 'API Key ๋ฏธ์„ค์ • โ€” ํ™˜๊ฒฝ๋ณ€์ˆ˜ CTK_API_KEY ๋˜๋Š” ์š”์ฒญ ํ—ค๋” X-Api-Key ํ•„์š”' ); } this.baseUrl = url.replace(/\/+$/, ''); this.apiKey = key; - const dbId = process.env.CTK_DB_PROFILE_ID; - if (dbId && /^\d+$/.test(dbId)) { - this.defaultDbProfileId = parseInt(dbId, 10); + if (opts.defaultDbProfileId != null) { + this.defaultDbProfileId = opts.defaultDbProfileId; + } else { + const dbId = process.env.CTK_DB_PROFILE_ID; + if (dbId && /^\d+$/.test(dbId)) { + this.defaultDbProfileId = parseInt(dbId, 10); + } } } diff --git a/claude-toolkit-mcp/src/index.ts b/claude-toolkit-mcp/src/index.ts index 8e23c88..42282d8 100644 --- a/claude-toolkit-mcp/src/index.ts +++ b/claude-toolkit-mcp/src/index.ts @@ -1,37 +1,45 @@ #!/usr/bin/env node /** - * v4.7.x โ€” #M3 Phase 3: MCP Server entry point. + * v4.7.x โ€” #M3 Phase 3 + ์‚ฌ๋‚ด๋ง ๊ณต์œ : MCP Server entry point. + * + * ๋‘ ๊ฐ€์ง€ transport ๋ชจ๋“œ: + * - stdio (default) โ€” Claude Code / Cursor / Cline ๋“ฑ์ด spawn ํ•˜์—ฌ stdin/stdout ์œผ๋กœ ํ†ต์‹  + * - http โ€” Streamable HTTP, ์‚ฌ๋‚ด๋ง ๊ณต์œ . ํด๋ผ์ด์–ธํŠธ๋Š” mcp-remote ๋“ฑ์œผ๋กœ ์ ‘์† * - * Claude Code / Cursor / Cline ๋“ฑ MCP ํ˜ธํ™˜ client ๊ฐ€ stdio ๋กœ spawn. * ํ™˜๊ฒฝ๋ณ€์ˆ˜: - * - CTK_URL (ํ•„์ˆ˜) โ€” ๋„๊ตฌ base URL (์˜ˆ: http://localhost:8027) - * - CTK_API_KEY (ํ•„์ˆ˜) โ€” /admin/api-keys ํŽ˜์ด์ง€์—์„œ ๋ฐœ๊ธ‰๋œ ํ‚ค - * - CTK_DB_PROFILE_ID (์˜ต์…˜) โ€” Live DB ํ”„๋กœํ•„ default + * ๊ณตํ†ต: + * - CTK_URL (ํ•„์ˆ˜) โ€” ๋„๊ตฌ base URL (์˜ˆ: http://localhost:8027) + * - CTK_DB_PROFILE_ID (์˜ต์…˜) โ€” Live DB ํ”„๋กœํ•„ default + * - MCP_TRANSPORT (์˜ต์…˜, default=stdio) โ€” "stdio" ๋˜๋Š” "http" + * stdio ์ „์šฉ: + * - CTK_API_KEY (ํ•„์ˆ˜) โ€” /admin/api-keys ํŽ˜์ด์ง€์—์„œ ๋ฐœ๊ธ‰๋œ ํ‚ค + * http ์ „์šฉ: + * - MCP_HTTP_PORT (์˜ต์…˜, default=8028) + * - MCP_HTTP_HOST (์˜ต์…˜, default=0.0.0.0) + * - ํด๋ผ์ด์–ธํŠธ๊ฐ€ X-Api-Key ํ—ค๋”๋กœ ํ‚ค ์ „๋‹ฌ (per-request ํŒจ์Šค์Šค๋ฃจ) */ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js'; +import express, { type Request, type Response } from 'express'; import { ToolkitClient } from './client.js'; import { TOOLS, buildAnalyzeRequest } from './tools.js'; -async function main() { - // ํ™˜๊ฒฝ๋ณ€์ˆ˜ ๊ฒ€์ฆ โ€” ์ž˜๋ชป๋œ ์„ค์ •์ด๋ฉด ์ฆ‰์‹œ ์‹คํŒจ - let client: ToolkitClient; - try { - client = new ToolkitClient(); - } catch (e) { - process.stderr.write(`[MCP] ์‹œ์ž‘ ์‹คํŒจ: ${(e as Error).message}\n`); - process.exit(1); - } - +/** + * client ์™€ ๋ฌถ์ธ MCP Server ์ธ์Šคํ„ด์Šค๋ฅผ ๋งŒ๋“ ๋‹ค. + * stdio: process ์ƒ์•  ๋™์•ˆ 1๊ฐœ (env ์˜ CTK_API_KEY) + * http : ์š”์ฒญ๋งˆ๋‹ค 1๊ฐœ (์š”์ฒญ ํ—ค๋”์˜ X-Api-Key) + */ +function createServer(client: ToolkitClient): Server { const server = new Server( { name: '@claude-toolkit/mcp-server', - version: '0.1.0', + version: '0.2.0', }, { capabilities: { @@ -40,7 +48,6 @@ async function main() { } ); - // Tool ๋ชฉ๋ก ๋…ธ์ถœ โ€” Claude Code ๋“ฑ์ด ํƒ์ƒ‰ ์‹œ ํ˜ธ์ถœ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, @@ -49,7 +56,6 @@ async function main() { })), })); - // Tool ํ˜ธ์ถœ โ€” Claude ๊ฐ€ ์‚ฌ์šฉ์ž ์˜๋„์— ๋”ฐ๋ผ dispatch server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; const argsObj = (args as Record) ?? {}; @@ -58,7 +64,6 @@ async function main() { const req = buildAnalyzeRequest(name, argsObj); const resp = await client.analyze(req); - // ์‘๋‹ต์„ markdown ํ…์ŠคํŠธ๋กœ โ€” MCP ํ‘œ์ค€ 'text' content const meta = [ resp.cached ? 'โšก ์บ์‹œ ์ ์ค‘' : null, resp.tokens ? `ํ† ํฐ: in=${resp.tokens.input}, out=${resp.tokens.output}` : null, @@ -83,15 +88,127 @@ async function main() { } }); - // stdio transport โ€” Claude Code ๊ฐ€ spawn ํ›„ stdin/stdout ์œผ๋กœ ํ†ต์‹  + return server; +} + +async function runStdio(): Promise { + let client: ToolkitClient; + try { + client = new ToolkitClient(); + } catch (e) { + process.stderr.write(`[MCP] stdio ์‹œ์ž‘ ์‹คํŒจ: ${(e as Error).message}\n`); + process.exit(1); + } + const server = createServer(client); const transport = new StdioServerTransport(); await server.connect(transport); - process.stderr.write( - `[MCP] @claude-toolkit/mcp-server 0.1.0 ready (${TOOLS.length} tools)\n` + `[MCP] stdio ready โ€” @claude-toolkit/mcp-server 0.2.0 (${TOOLS.length} tools)\n` ); } +async function runHttp(): Promise { + const baseUrl = process.env.CTK_URL; + if (!baseUrl) { + process.stderr.write('[MCP] http ์‹œ์ž‘ ์‹คํŒจ: ํ™˜๊ฒฝ๋ณ€์ˆ˜ CTK_URL ๋ฏธ์„ค์ •\n'); + process.exit(1); + } + + const port = parseInt(process.env.MCP_HTTP_PORT ?? '8028', 10); + const host = process.env.MCP_HTTP_HOST ?? '0.0.0.0'; + + const app = express(); + app.use(express.json({ limit: '4mb' })); + + // ๋‹จ์ˆœ health check โ€” ์šด์˜ ๋ชจ๋‹ˆํ„ฐ๋ง์šฉ + app.get('/healthz', (_req: Request, res: Response) => { + res.json({ status: 'ok', tools: TOOLS.length, transport: 'http' }); + }); + + // ๋ฉ”์ธ MCP ์—”๋“œํฌ์ธํŠธ โ€” Streamable HTTP, stateless + app.post('/mcp', async (req: Request, res: Response) => { + const apiKey = req.header('x-api-key') ?? req.header('X-Api-Key'); + if (!apiKey) { + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'X-Api-Key ํ—ค๋” ํ•„์š”' }, + id: null, + }); + return; + } + + let client: ToolkitClient; + try { + client = new ToolkitClient({ baseUrl, apiKey }); + } catch (e) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32000, message: (e as Error).message }, + id: null, + }); + return; + } + + const server = createServer(client); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, // stateless โ€” request ๋งˆ๋‹ค ์‹ ๊ทœ + }); + + res.on('close', () => { + transport.close().catch(() => undefined); + server.close().catch(() => undefined); + }); + + try { + await server.connect(transport); + await transport.handleRequest(req, res, req.body); + } catch (e) { + process.stderr.write(`[MCP] http ์ฒ˜๋ฆฌ ์‹คํŒจ: ${(e as Error).message}\n`); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0', + error: { code: -32603, message: 'Internal error' }, + id: null, + }); + } + } + }); + + // stateless โ€” GET/DELETE ๋Š” 405 + const notAllowed = (_req: Request, res: Response) => { + res.status(405).json({ + jsonrpc: '2.0', + error: { code: -32000, message: 'Method Not Allowed (stateless mode)' }, + id: null, + }); + }; + app.get('/mcp', notAllowed); + app.delete('/mcp', notAllowed); + + app.listen(port, host, () => { + process.stderr.write( + `[MCP] http ready โ€” listening on ${host}:${port} (${TOOLS.length} tools, stateless, per-request X-Api-Key)\n` + ); + }); +} + +async function main(): Promise { + const transport = (process.env.MCP_TRANSPORT ?? 'stdio').toLowerCase(); + switch (transport) { + case 'stdio': + await runStdio(); + break; + case 'http': + await runHttp(); + break; + default: + process.stderr.write( + `[MCP] Unknown MCP_TRANSPORT="${transport}" (expected: stdio | http)\n` + ); + process.exit(1); + } +} + main().catch((e) => { process.stderr.write(`[MCP] Fatal: ${(e as Error).message}\n`); process.exit(1); diff --git a/claude-toolkit-ui/frontend/tsconfig.tsbuildinfo b/claude-toolkit-ui/frontend/tsconfig.tsbuildinfo index a19aa9e..769a93e 100644 --- a/claude-toolkit-ui/frontend/tsconfig.tsbuildinfo +++ b/claude-toolkit-ui/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/common/analysispagetemplate.tsx","./src/components/common/commandpalette.tsx","./src/components/common/copyablecodeblock.tsx","./src/components/common/emailmodal.tsx","./src/components/common/errorboundary.tsx","./src/components/common/harnessstagepanels.tsx","./src/components/common/mentioninput.tsx","./src/components/common/mermaidchart.tsx","./src/components/common/pipelinebuilder.tsx","./src/components/common/pipelinegraphview.tsx","./src/components/common/questionreformer.tsx","./src/components/common/reviewactiondialog.tsx","./src/components/common/sourceselector.tsx","./src/components/common/toastcontainer.tsx","./src/components/layout/applayout.tsx","./src/components/layout/mobilebottomnav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/sidebarmenus.ts","./src/components/loganalyzer/logrcaharnesssection.tsx","./src/hooks/useapi.ts","./src/hooks/useharnessstream.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usesse.ts","./src/hooks/usesessiontimer.ts","./src/hooks/usetoast.ts","./src/i18n/de.ts","./src/i18n/en.ts","./src/i18n/index.ts","./src/i18n/ja.ts","./src/i18n/ko.ts","./src/i18n/zh.ts","./src/pages/accountpage.tsx","./src/pages/apidocspage.tsx","./src/pages/chatpage.tsx","./src/pages/dbprofilespage.tsx","./src/pages/favoritespage.tsx","./src/pages/historypage.tsx","./src/pages/homepage.tsx","./src/pages/loginpage.tsx","./src/pages/notfoundpage.tsx","./src/pages/passwordchangepage.tsx","./src/pages/pipelineeditorpage.tsx","./src/pages/pipelineexecutionpage.tsx","./src/pages/pipelinehistorypage.tsx","./src/pages/pipelinepage.tsx","./src/pages/promptspage.tsx","./src/pages/reviewrequestspage.tsx","./src/pages/roireportpage.tsx","./src/pages/schedulepage.tsx","./src/pages/searchpage.tsx","./src/pages/securitypage.tsx","./src/pages/settingspage.tsx","./src/pages/settingspromptspage.tsx","./src/pages/setuppage.tsx","./src/pages/shareviewpage.tsx","./src/pages/sharedconfigpage.tsx","./src/pages/twofactorpage.tsx","./src/pages/usagepage.tsx","./src/pages/workspacepage.tsx","./src/pages/admin/adminbackuppage.tsx","./src/pages/admin/adminendpointstatspage.tsx","./src/pages/admin/adminerrorlogpage.tsx","./src/pages/admin/adminhealthpage.tsx","./src/pages/admin/adminpermissionspage.tsx","./src/pages/admin/adminuserspage.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/costoptimizerpage.tsx","./src/pages/admin/dbmigrationguidepage.tsx","./src/pages/admin/reviewdashboardpage.tsx","./src/pages/admin/teamdashboardpage.tsx","./src/pages/analysis/apispecpage.tsx","./src/pages/analysis/batchpage.tsx","./src/pages/analysis/codereviewpage.tsx","./src/pages/analysis/commitmsgpage.tsx","./src/pages/analysis/complexitypage.tsx","./src/pages/analysis/converterpage.tsx","./src/pages/analysis/dbmigrationpage.tsx","./src/pages/analysis/depcheckpage.tsx","./src/pages/analysis/docgenpage.tsx","./src/pages/analysis/erdpage.tsx","./src/pages/analysis/explaincomparepage.tsx","./src/pages/analysis/explaindashboardpage.tsx","./src/pages/analysis/explainplanpage.tsx","./src/pages/analysis/flowanalysispage.tsx","./src/pages/analysis/gitdiffpage.tsx","./src/pages/analysis/githubprpage.tsx","./src/pages/analysis/harnessbatchpage.tsx","./src/pages/analysis/harnessdashboardpage.tsx","./src/pages/analysis/harnessdependencypage.tsx","./src/pages/analysis/impactanalysispage.tsx","./src/pages/analysis/indexadvisorpage.tsx","./src/pages/analysis/inputmaskingpage.tsx","./src/pages/analysis/loganalyzerpage.tsx","./src/pages/analysis/maskgenpage.tsx","./src/pages/analysis/migratepage.tsx","./src/pages/analysis/mockdatapage.tsx","./src/pages/analysis/packagedepspage.tsx","./src/pages/analysis/packageoverviewpage.tsx","./src/pages/analysis/projectmappage.tsx","./src/pages/analysis/regexpage.tsx","./src/pages/analysis/spimpactpage.tsx","./src/pages/analysis/spmigrationharnesspage.tsx","./src/pages/analysis/sqlbatchpage.tsx","./src/pages/analysis/sqloptimizationharnesspage.tsx","./src/pages/analysis/sqlreviewpage.tsx","./src/pages/analysis/sqltranslatepage.tsx","./src/stores/authstore.ts","./src/stores/notificationstore.ts","./src/stores/sidebarstore.ts","./src/stores/themestore.ts","./src/stores/toaststore.ts","./src/utils/clipboard.ts","./src/utils/date.ts","./src/utils/harnessmarkdown.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/common/analysispagetemplate.tsx","./src/components/common/commandpalette.tsx","./src/components/common/copyablecodeblock.tsx","./src/components/common/costhint.tsx","./src/components/common/emailmodal.tsx","./src/components/common/errorboundary.tsx","./src/components/common/followupqapanel.tsx","./src/components/common/harnessstagepanels.tsx","./src/components/common/mentioninput.tsx","./src/components/common/mermaidchart.tsx","./src/components/common/nextstephints.tsx","./src/components/common/pipelinebuilder.tsx","./src/components/common/pipelinegraphview.tsx","./src/components/common/questionreformer.tsx","./src/components/common/reviewactiondialog.tsx","./src/components/common/sourceselector.tsx","./src/components/common/toastcontainer.tsx","./src/components/common/analysischain.ts","./src/components/layout/applayout.tsx","./src/components/layout/mobilebottomnav.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/layout/sidebarmenus.ts","./src/components/loganalyzer/logrcaharnesssection.tsx","./src/hooks/useapi.ts","./src/hooks/useharnessstream.ts","./src/hooks/usekeyboardshortcuts.ts","./src/hooks/usesse.ts","./src/hooks/usesessiontimer.ts","./src/hooks/usetoast.ts","./src/i18n/de.ts","./src/i18n/en.ts","./src/i18n/index.ts","./src/i18n/ja.ts","./src/i18n/ko.ts","./src/i18n/zh.ts","./src/pages/accountpage.tsx","./src/pages/apidocspage.tsx","./src/pages/chatpage.tsx","./src/pages/dbprofilespage.tsx","./src/pages/favoritespage.tsx","./src/pages/historypage.tsx","./src/pages/homepage.tsx","./src/pages/loginpage.tsx","./src/pages/notfoundpage.tsx","./src/pages/passwordchangepage.tsx","./src/pages/pipelineeditorpage.tsx","./src/pages/pipelineexecutionpage.tsx","./src/pages/pipelinehistorypage.tsx","./src/pages/pipelinepage.tsx","./src/pages/promptspage.tsx","./src/pages/reviewrequestspage.tsx","./src/pages/roireportpage.tsx","./src/pages/schedulepage.tsx","./src/pages/searchpage.tsx","./src/pages/securitypage.tsx","./src/pages/settingspage.tsx","./src/pages/settingspromptspage.tsx","./src/pages/setuppage.tsx","./src/pages/shareviewpage.tsx","./src/pages/sharedconfigpage.tsx","./src/pages/twofactorpage.tsx","./src/pages/usagepage.tsx","./src/pages/workspacepage.tsx","./src/pages/admin/adminbackuppage.tsx","./src/pages/admin/adminendpointstatspage.tsx","./src/pages/admin/adminerrorlogpage.tsx","./src/pages/admin/adminhealthpage.tsx","./src/pages/admin/adminpermissionspage.tsx","./src/pages/admin/adminuserspage.tsx","./src/pages/admin/apikeyspage.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/compliancereportpage.tsx","./src/pages/admin/configchangelogpage.tsx","./src/pages/admin/costoptimizerpage.tsx","./src/pages/admin/dbmigrationguidepage.tsx","./src/pages/admin/reviewdashboardpage.tsx","./src/pages/admin/teamdashboardpage.tsx","./src/pages/analysis/apispecpage.tsx","./src/pages/analysis/batchpage.tsx","./src/pages/analysis/codereviewpage.tsx","./src/pages/analysis/commitmsgpage.tsx","./src/pages/analysis/complexitypage.tsx","./src/pages/analysis/converterpage.tsx","./src/pages/analysis/dbmigrationpage.tsx","./src/pages/analysis/depcheckpage.tsx","./src/pages/analysis/docgenpage.tsx","./src/pages/analysis/erdpage.tsx","./src/pages/analysis/explaincomparepage.tsx","./src/pages/analysis/explaindashboardpage.tsx","./src/pages/analysis/explainplanpage.tsx","./src/pages/analysis/flowanalysispage.tsx","./src/pages/analysis/gitdiffpage.tsx","./src/pages/analysis/githubprpage.tsx","./src/pages/analysis/harnessbatchpage.tsx","./src/pages/analysis/harnessdashboardpage.tsx","./src/pages/analysis/harnessdependencypage.tsx","./src/pages/analysis/impactanalysispage.tsx","./src/pages/analysis/indexadvisorpage.tsx","./src/pages/analysis/inputmaskingpage.tsx","./src/pages/analysis/loganalyzerpage.tsx","./src/pages/analysis/maskgenpage.tsx","./src/pages/analysis/migratepage.tsx","./src/pages/analysis/mockdatapage.tsx","./src/pages/analysis/packageoverviewpage.tsx","./src/pages/analysis/projectmappage.tsx","./src/pages/analysis/regexpage.tsx","./src/pages/analysis/spmigrationharnesspage.tsx","./src/pages/analysis/sqlbatchpage.tsx","./src/pages/analysis/sqloptimizationharnesspage.tsx","./src/pages/analysis/sqlreviewpage.tsx","./src/pages/analysis/sqltranslatepage.tsx","./src/stores/authstore.ts","./src/stores/notificationstore.ts","./src/stores/sidebarstore.ts","./src/stores/themestore.ts","./src/stores/toaststore.ts","./src/utils/clipboard.ts","./src/utils/date.ts","./src/utils/harnessmarkdown.tsx","./src/utils/tokenestimator.ts"],"version":"5.6.3"} \ No newline at end of file diff --git a/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/controller/AccountController.java b/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/controller/AccountController.java index 2fee5ff..46c6e0f 100644 --- a/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/controller/AccountController.java +++ b/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/controller/AccountController.java @@ -137,13 +137,18 @@ public ResponseEntity> verify2fa( return ResponseEntity.ok(resp); } - /** 2FA ๋น„ํ™œ์„ฑํ™” */ + /** 2FA ๋น„ํ™œ์„ฑํ™” โ€” ADMIN ๊ณ„์ •์€ ๋น„ํ™œ์„ฑํ™” ๋ถˆ๊ฐ€ */ @PostMapping("/disable-2fa") @ResponseBody public ResponseEntity> disable2fa(Principal principal) { Map resp = new LinkedHashMap(); AppUser user = userRepository.findByUsername(principal.getName()).orElse(null); if (user != null) { + if ("ADMIN".equals(user.getRole())) { + resp.put("success", false); + resp.put("error", "ADMIN ๊ณ„์ •์€ 2FA๋ฅผ ๋น„ํ™œ์„ฑํ™”ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."); + return ResponseEntity.ok(resp); + } user.setTotpSecret(null); userRepository.save(user); } diff --git a/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/platform/McpServerLauncher.java b/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/platform/McpServerLauncher.java new file mode 100644 index 0000000..da483c9 --- /dev/null +++ b/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/platform/McpServerLauncher.java @@ -0,0 +1,251 @@ +package io.github.claudetoolkit.ui.platform; + +import javax.annotation.PreDestroy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * v4.7.x โ€” #M3 ์‚ฌ๋‚ด๋ง ๊ณต์œ  MCP Server ์ž๋™ ๊ธฐ๋™. + * + *

Spring Boot ๊ฐ€ startup ์™„๋ฃŒ(ApplicationReadyEvent)๋˜๋ฉด claude-toolkit-mcp ์˜ + * Streamable HTTP transport(node dist/index.js, MCP_TRANSPORT=http)๋ฅผ child process ๋กœ spawn. + * Spring context ๊ฐ€ ๋‹ซํž ๋•Œ(@PreDestroy) ๋™์‹œ์— ์ข…๋ฃŒ โ€” WAS ์ˆ˜๋ช…์ฃผ๊ธฐ์— ๋ฌถ์ž„. + * + *

์„ค์ • (application.yml)

+ *
+ * toolkit:
+ *   mcp:
+ *     auto-start: true              # default true. ๋น„ํ™œ์„ฑํ™”ํ•˜๋ ค๋ฉด false
+ *     project-root: ../claude-toolkit-mcp   # mvn ์‹คํ–‰ ์œ„์น˜ ๊ธฐ์ค€ ์ƒ๋Œ€๊ฒฝ๋กœ
+ *     host: 0.0.0.0                 # ์‚ฌ๋‚ด๋ง ๋…ธ์ถœ. ๋ณธ์ธ๋งŒ์ด๋ฉด 127.0.0.1
+ *     port: 8028
+ *     node: node                    # PATH ์˜ node ๋˜๋Š” ์ ˆ๋Œ€๊ฒฝ๋กœ
+ *     log-prefix: "[MCP]"
+ * 
+ * + *

์‚ฌ์šฉ์ž๋ณ„ Claude Desktop ์„ค์ •

+ *
+ * "claude-toolkit": {
+ *   "command": "npx",
+ *   "args": ["-y", "mcp-remote", "http://<ํ˜ธ์ŠคํŠธIP>:8028/mcp",
+ *            "--header", "X-Api-Key:ctk_live_..."]
+ * }
+ * 
+ * + *

์ฃผ์˜: ํ™˜๊ฒฝ๋ณ€์ˆ˜์— CTK_API_KEY ๋Š” ์„ค์ •ํ•˜์ง€ ์•Š์Œ. ํด๋ผ์ด์–ธํŠธ ํ—ค๋”์˜ + * X-Api-Key ๊ฐ€ per-request ๋กœ backend ์— ํŒจ์Šค์Šค๋ฃจ๋˜์–ด์•ผ audit ๊ฐ€ ์‚ฌ๋žŒ๋ณ„๋กœ ๋ถ„๋ฆฌ๋จ. + */ +@Component +public class McpServerLauncher { + + private static final Logger log = LoggerFactory.getLogger(McpServerLauncher.class); + private static final long SHUTDOWN_TIMEOUT_SECONDS = 10; + + @Value("${toolkit.mcp.auto-start:true}") + private boolean autoStart; + + @Value("${toolkit.mcp.project-root:../claude-toolkit-mcp}") + private String projectRoot; + + @Value("${toolkit.mcp.host:0.0.0.0}") + private String host; + + @Value("${toolkit.mcp.port:8028}") + private int port; + + @Value("${toolkit.mcp.node:node}") + private String nodeExecutable; + + @Value("${toolkit.mcp.log-prefix:[MCP]}") + private String logPrefix; + + /** backend base URL โ€” ๊ฐ™์€ WAS ์•ˆ์ด๋ฏ€๋กœ ์ž๊ธฐ ์ž์‹ ์˜ server.port ๋กœ ํ˜ธ์ถœ */ + @Value("${server.port:8027}") + private int backendPort; + + private volatile Process process; + private volatile Thread stdoutPump; + private volatile Thread stderrPump; + + @EventListener(ApplicationReadyEvent.class) + public void start() { + if (!autoStart) { + log.info("{} auto-start ๋น„ํ™œ์„ฑ (toolkit.mcp.auto-start=false) โ€” ์ˆ˜๋™ ๊ธฐ๋™ ํ•„์š”", logPrefix); + return; + } + + Path resolvedRoot = resolveProjectRoot(); + Path entry = resolvedRoot.resolve("dist").resolve("index.js"); + if (!Files.exists(entry)) { + log.warn("{} dist/index.js ๋ฏธ์กด์žฌ โ€” '{}' ์—์„œ 'npm install && npm run build' ๋จผ์ € ์‹คํ–‰ ํ•„์š”. MCP ์ž๋™ ๊ธฐ๋™ ์Šคํ‚ต.", + logPrefix, resolvedRoot); + return; + } + + ProcessBuilder pb = new ProcessBuilder(nodeExecutable, entry.toString()); + pb.directory(resolvedRoot.toFile()); + pb.redirectErrorStream(false); + + Map env = pb.environment(); + env.put("MCP_TRANSPORT", "http"); + env.put("MCP_HTTP_HOST", host); + env.put("MCP_HTTP_PORT", String.valueOf(port)); + env.put("CTK_URL", "http://localhost:" + backendPort); + // CTK_API_KEY ๋Š” *์˜๋„์ ์œผ๋กœ* ์•ˆ ๋„ฃ์Œ โ€” per-request ํ—ค๋” ํŒจ์Šค์Šค๋ฃจ. + // ํ˜น์‹œ ์™ธ๋ถ€์—์„œ ์ฃผ์ž…๋ผ ์žˆ์œผ๋ฉด ์ œ๊ฑฐ (๊ณต์œ  ํ‚ค๋กœ audit ๊ฐ€ ๋ง๊ฐ€์ง€๋Š” ๊ฒƒ ๋ฐฉ์ง€). + env.remove("CTK_API_KEY"); + + try { + this.process = pb.start(); + this.stdoutPump = pumpToLog(process.getInputStream(), false); + this.stderrPump = pumpToLog(process.getErrorStream(), true); + log.info("{} ์ž๋™ ๊ธฐ๋™ ์„ฑ๊ณต โ€” pid={}, listening on {}:{}, backend=http://localhost:{}", + logPrefix, pidOrUnknown(process), host, port, backendPort); + } catch (IOException e) { + log.error("{} ๊ธฐ๋™ ์‹คํŒจ โ€” '{}'. node ์‹คํ–‰ํŒŒ์ผ ๊ฒฝ๋กœ ํ™•์ธ (toolkit.mcp.node). cause: {}", + logPrefix, nodeExecutable, e.getMessage()); + } + } + + @PreDestroy + public void stop() { + Process p = this.process; + if (p == null || !p.isAlive()) { + return; + } + log.info("{} ์ข…๋ฃŒ ์‹ ํ˜ธ (pid={})", logPrefix, pidOrUnknown(p)); + p.destroy(); + try { + if (!p.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + log.warn("{} graceful ์ข…๋ฃŒ ํƒ€์ž„์•„์›ƒ โ€” ๊ฐ•์ œ ์ข…๋ฃŒ", logPrefix); + p.destroyForcibly(); + } + log.info("{} ์ข…๋ฃŒ ์™„๋ฃŒ (exit={})", logPrefix, p.exitValue()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + p.destroyForcibly(); + } + interruptQuietly(stdoutPump); + interruptQuietly(stderrPump); + } + + /** + * project-root ๊ฐ€ ์ƒ๋Œ€๊ฒฝ๋กœ๋ฉด ๋‹ค์Œ ์ˆœ์„œ๋กœ ์‹œ๋„: + * 1) user.dir ๊ธฐ์ค€ (mvn ์‹คํ–‰ ์œ„์น˜ โ€” ์ผ๋ฐ˜์ ) + * 2) user.dir/.. (multi-module ๋นŒ๋“œ ๋“ฑ) + * 3) ๊ทธ๋Œ€๋กœ ์ ˆ๋Œ€๊ฒฝ๋กœ ๋ณ€ํ™˜ (๋งˆ์ง€๋ง‰ fallback) + */ + private Path resolveProjectRoot() { + Path raw = Paths.get(projectRoot); + if (raw.isAbsolute()) { + return raw.normalize(); + } + File userDir = new File(System.getProperty("user.dir")); + Path[] candidates = { + userDir.toPath().resolve(raw).normalize(), + userDir.toPath().resolve("..").resolve(raw).normalize(), + }; + for (Path c : candidates) { + if (Files.isDirectory(c)) { + return c; + } + } + return raw.toAbsolutePath().normalize(); + } + + /** + * MCP child process ์˜ stdout/stderr ๋ฅผ ํ•œ ์ค„์”ฉ SLF4J ๋กœ ์˜ฎ๊น€. + * stderr ๋Š” WARN, stdout ์€ INFO. + */ + private Thread pumpToLog(java.io.InputStream in, boolean isStderr) { + Thread t = new Thread(() -> { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(in, StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + if (isStderr) { + // ์„œ๋ฒ„์˜ ์ •์ƒ startup ๋ฉ”์‹œ์ง€๋„ stderr ๋กœ ๋‚˜์˜ค๋ฏ€๋กœ INFO ๋กœ ๊ฒฉํ•˜ + if (line.contains("ready") || line.contains("listening")) { + log.info("{} {}", logPrefix, line); + } else { + log.warn("{} {}", logPrefix, line); + } + } else { + log.info("{} {}", logPrefix, line); + } + } + } catch (IOException ignored) { + // child process ์ข…๋ฃŒ ์‹œ ์ž์—ฐ ๋ฐœ์ƒ + } + }, "mcp-log-pump-" + (isStderr ? "err" : "out")); + t.setDaemon(true); + t.start(); + return t; + } + + private void interruptQuietly(Thread t) { + if (t != null && t.isAlive()) { + t.interrupt(); + } + } + + /** ์ง„๋‹จ์šฉ โ€” health check ์ปจํŠธ๋กค๋Ÿฌ ๋“ฑ์—์„œ ๋…ธ์ถœ ๊ฐ€๋Šฅ */ + public boolean isRunning() { + Process p = this.process; + return p != null && p.isAlive(); + } + + public Long getPid() { + Process p = this.process; + return (p != null && p.isAlive()) ? tryGetPid(p) : null; + } + + /** + * Process.pid() ๋Š” Java 9+ ์ถ”๊ฐ€ โ€” Java 8 ๋นŒ๋“œ๋ฅผ ์œ„ํ•ด reflection ์œผ๋กœ ํ˜ธ์ถœ. + * ์‹คํŒจํ•˜๋ฉด null ๋ฐ˜ํ™˜ (๋กœ๊ทธ/์šด์˜ ์ •๋ณด์šฉ์ด๋ผ ๊ธฐ๋Šฅ์—๋Š” ์˜ํ–ฅ ์—†์Œ). + */ + private static Long tryGetPid(Process p) { + if (p == null) return null; + try { + java.lang.reflect.Method m = Process.class.getMethod("pid"); + Object v = m.invoke(p); + return (v instanceof Long) ? (Long) v : null; + } catch (ReflectiveOperationException ignored) { + return null; + } + } + + private static String pidOrUnknown(Process p) { + Long pid = tryGetPid(p); + return pid != null ? pid.toString() : "?"; + } + + /** ์™ธ๋ถ€ ํ‘œ์‹œ์šฉ โ€” ํ—ฌ์Šค/๊ด€๋ฆฌ ํŽ˜์ด์ง€์—์„œ ์‚ฌ์šฉ */ + public Map describe() { + Map m = new HashMap<>(); + m.put("autoStart", autoStart); + m.put("running", isRunning()); + m.put("pid", getPid()); + m.put("host", host); + m.put("port", port); + m.put("projectRoot", resolveProjectRoot().toString()); + m.put("node", nodeExecutable); + return m; + } +} diff --git a/claude-toolkit-ui/src/main/resources/application.yml b/claude-toolkit-ui/src/main/resources/application.yml index 3fa134f..883d582 100644 --- a/claude-toolkit-ui/src/main/resources/application.yml +++ b/claude-toolkit-ui/src/main/resources/application.yml @@ -50,6 +50,17 @@ toolkit: default-timeout-seconds: ${TOOLKIT_LIVEDB_TIMEOUT:30} max-rows: ${TOOLKIT_LIVEDB_MAX_ROWS:1000} max-calls-per-minute: ${TOOLKIT_LIVEDB_RATE_LIMIT:10} + # v4.7.x โ€” #M3 ์‚ฌ๋‚ด๋ง ๊ณต์œ  MCP Server ์ž๋™ ๊ธฐ๋™. + # WAS startup ์‹œ์ ์— claude-toolkit-mcp(Streamable HTTP) ๋ฅผ child process ๋กœ spawn, + # WAS shutdown ์‹œ ๋™์‹œ ์ข…๋ฃŒ. ์ž์„ธํ•œ ๋™์ž‘์€ platform/McpServerLauncher.java ์ฐธ๊ณ . + # ๋น„ํ™œ์„ฑํ™”ํ•˜๋ ค๋ฉด TOOLKIT_MCP_AUTOSTART=false. + mcp: + auto-start: ${TOOLKIT_MCP_AUTOSTART:true} + project-root: ${TOOLKIT_MCP_PROJECT_ROOT:../claude-toolkit-mcp} + host: ${TOOLKIT_MCP_HOST:0.0.0.0} # ์‚ฌ๋‚ด๋ง ๋…ธ์ถœ. ๋ณธ์ธ PC ๋งŒ์ด๋ฉด 127.0.0.1 + port: ${TOOLKIT_MCP_PORT:8028} + node: ${TOOLKIT_MCP_NODE:node} # PATH ์˜ node ๋˜๋Š” ์ ˆ๋Œ€๊ฒฝ๋กœ (์˜ˆ: C:/Program Files/nodejs/node.exe) + log-prefix: "[MCP]" # โ”€โ”€ Actuator / Health / Metrics โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ management: diff --git a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/controller/AccountController2faTest.java b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/controller/AccountController2faTest.java new file mode 100644 index 0000000..b9d2460 --- /dev/null +++ b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/controller/AccountController2faTest.java @@ -0,0 +1,106 @@ +package io.github.claudetoolkit.ui.controller; + +import io.github.claudetoolkit.ui.user.AppUser; +import io.github.claudetoolkit.ui.user.AppUserRepository; +import io.github.claudetoolkit.ui.user.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.security.Principal; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * ADMIN 2FA ํ•„์ˆ˜ํ™” โ€” AccountController.disable2fa ๋‹จ์œ„ ํ…Œ์ŠคํŠธ. + * + *

์ปค๋ฒ„: + *

    + *
  • ADMIN โ†’ success=false, ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ๋ฐ˜ํ™˜, save ํ˜ธ์ถœ ์•ˆ ํ•จ
  • + *
  • REVIEWER โ†’ success=true, totpSecret=null ์ €์žฅ
  • + *
  • VIEWER โ†’ success=true, totpSecret=null ์ €์žฅ
  • + *
  • ์‚ฌ์šฉ์ž ์—†์Œ โ†’ success=true (no-op, ๊ธฐ์กด ๋™์ž‘ ์œ ์ง€)
  • + *
+ */ +class AccountController2faTest { + + private AppUserRepository userRepository; + private AccountController controller; + + @BeforeEach + void setUp() { + userRepository = mock(AppUserRepository.class); + controller = new AccountController( + userRepository, mock(UserService.class), mock(BCryptPasswordEncoder.class)); + } + + @Test + @DisplayName("ADMIN์€ disable-2fa ๋ถˆ๊ฐ€ โ€” success=false, save ์—†์Œ") + void admin_cannotDisable2fa() { + AppUser admin = new AppUser("admin1", "hash", "ADMIN"); + when(userRepository.findByUsername("admin1")).thenReturn(Optional.of(admin)); + + ResponseEntity> resp = controller.disable2fa(principal("admin1")); + + assertFalse((Boolean) resp.getBody().get("success")); + assertNotNull(resp.getBody().get("error"), "์—๋Ÿฌ ๋ฉ”์‹œ์ง€๊ฐ€ ์žˆ์–ด์•ผ ํ•จ"); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("ADMIN ์—๋Ÿฌ ๋ฉ”์‹œ์ง€์— '2FA' ํฌํ•จ") + void admin_errorMessageMentions2fa() { + AppUser admin = new AppUser("admin1", "hash", "ADMIN"); + when(userRepository.findByUsername("admin1")).thenReturn(Optional.of(admin)); + + ResponseEntity> resp = controller.disable2fa(principal("admin1")); + + String error = (String) resp.getBody().get("error"); + assertTrue(error.contains("2FA") || error.contains("2fa"), + "์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€์— 2FA ํ‚ค์›Œ๋“œ ํฌํ•จ: " + error); + } + + @Test + @DisplayName("REVIEWER๋Š” disable-2fa ๊ฐ€๋Šฅ โ€” success=true, save ํ˜ธ์ถœ๋จ") + void reviewer_canDisable2fa() { + AppUser reviewer = new AppUser("reviewer1", "hash", "REVIEWER"); + when(userRepository.findByUsername("reviewer1")).thenReturn(Optional.of(reviewer)); + + ResponseEntity> resp = controller.disable2fa(principal("reviewer1")); + + assertTrue((Boolean) resp.getBody().get("success")); + verify(userRepository).save(reviewer); + } + + @Test + @DisplayName("VIEWER๋Š” disable-2fa ๊ฐ€๋Šฅ โ€” success=true") + void viewer_canDisable2fa() { + AppUser viewer = new AppUser("viewer1", "hash", "VIEWER"); + when(userRepository.findByUsername("viewer1")).thenReturn(Optional.of(viewer)); + + ResponseEntity> resp = controller.disable2fa(principal("viewer1")); + + assertTrue((Boolean) resp.getBody().get("success")); + verify(userRepository).save(viewer); + } + + @Test + @DisplayName("์‚ฌ์šฉ์ž ์—†์œผ๋ฉด success=true ๋ฐ˜ํ™˜ (no-op)") + void userNotFound_returnsSuccessNoOp() { + when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); + + ResponseEntity> resp = controller.disable2fa(principal("ghost")); + + assertTrue((Boolean) resp.getBody().get("success")); + verify(userRepository, never()).save(any()); + } + + private Principal principal(String username) { + return () -> username; + } +} diff --git a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/logrca/HarnessLogRcaStreamingTest.java b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/logrca/HarnessLogRcaStreamingTest.java index 78932a9..9525135 100644 --- a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/logrca/HarnessLogRcaStreamingTest.java +++ b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/logrca/HarnessLogRcaStreamingTest.java @@ -53,12 +53,16 @@ class HarnessLogRcaStreamingTest { @BeforeEach void stubClaudeClient() throws Exception { doAnswer(inv -> { + // 50ms delay lets Spring MVC register the SseEmitter async-context handler + // before the background thread calls emitter.send() โ€” prevents CME in full suite runs + Thread.sleep(50); Consumer sink = inv.getArgument(3); sink.accept("[fake stage output]"); return null; }).when(claudeClient).chatStream(anyString(), anyString(), anyInt(), any()); doAnswer(inv -> { + Thread.sleep(50); Consumer sink = inv.getArgument(4); sink.accept("[fake builder output (continuation)]"); return null; diff --git a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/spmigration/HarnessSpMigrationStreamingTest.java b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/spmigration/HarnessSpMigrationStreamingTest.java index 654102e..3df19e5 100644 --- a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/spmigration/HarnessSpMigrationStreamingTest.java +++ b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/spmigration/HarnessSpMigrationStreamingTest.java @@ -70,6 +70,9 @@ void stubClaudeClient() throws Exception { // Mockito.when() ์ฒด์ธ์—์„œ๋„ checked ์ฒ˜๋ฆฌ ํ•„์š” โ†’ ๋ฉ”์„œ๋“œ ์‹œ๊ทธ๋‹ˆ์ฒ˜์— throws. // chatStream(system, user, maxTokens, sink) โ€” Consumer ์— ๊ฐ€์งœ chunk ์ฆ‰์‹œ emit doAnswer(inv -> { + // 50ms delay lets Spring MVC register the SseEmitter async-context handler + // before the background thread calls emitter.send() โ€” prevents CME in full suite runs + Thread.sleep(50); Consumer sink = inv.getArgument(3); sink.accept("[fake stage output]"); return null; @@ -77,6 +80,7 @@ void stubClaudeClient() throws Exception { // chatStreamWithContinuation(system, user, maxTokens, contCount, sink) doAnswer(inv -> { + Thread.sleep(50); Consumer sink = inv.getArgument(4); sink.accept("[fake builder output (continuation)]"); return null; diff --git a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/sqloptimization/HarnessSqlOptimizationStreamingTest.java b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/sqloptimization/HarnessSqlOptimizationStreamingTest.java index d12b326..8ccd89b 100644 --- a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/sqloptimization/HarnessSqlOptimizationStreamingTest.java +++ b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/harness/sqloptimization/HarnessSqlOptimizationStreamingTest.java @@ -53,12 +53,16 @@ class HarnessSqlOptimizationStreamingTest { @BeforeEach void stubClaudeClient() throws Exception { doAnswer(inv -> { + // 50ms delay lets Spring MVC register the SseEmitter async-context handler + // before the background thread calls emitter.send() โ€” prevents CME in full suite runs + Thread.sleep(50); Consumer sink = inv.getArgument(3); sink.accept("[fake stage output]"); return null; }).when(claudeClient).chatStream(anyString(), anyString(), anyInt(), any()); doAnswer(inv -> { + Thread.sleep(50); Consumer sink = inv.getArgument(4); sink.accept("[fake builder output (continuation)]"); return null; diff --git a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TotpServiceTest.java b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TotpServiceTest.java new file mode 100644 index 0000000..d387052 --- /dev/null +++ b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TotpServiceTest.java @@ -0,0 +1,114 @@ +package io.github.claudetoolkit.ui.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * TotpService ๋‹จ์œ„ ํ…Œ์ŠคํŠธ โ€” RFC 6238 TOTP ์•Œ๊ณ ๋ฆฌ์ฆ˜ + Base32 codec ๊ฒ€์ฆ. + */ +class TotpServiceTest { + + private static final String BASE32_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + + // โ”€โ”€ generateSecret โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("generateSecret โ€” ๋น„์–ด์žˆ์ง€ ์•Š์€ Base32 ๋ฌธ์ž์—ด ๋ฐ˜ํ™˜") + void generateSecret_returnsNonEmpty() { + String secret = TotpService.generateSecret(); + assertNotNull(secret); + assertFalse(secret.isEmpty()); + } + + @Test + @DisplayName("generateSecret โ€” ์œ ํšจํ•œ Base32 ๋ฌธ์ž๋งŒ ํฌํ•จ") + void generateSecret_containsOnlyBase32Chars() { + String secret = TotpService.generateSecret(); + for (char c : secret.toCharArray()) { + assertTrue(BASE32_CHARS.indexOf(c) >= 0, + "์œ ํšจํ•˜์ง€ ์•Š์€ Base32 ๋ฌธ์ž: " + c); + } + } + + @RepeatedTest(5) + @DisplayName("generateSecret โ€” ๋ฐ˜๋ณต ํ˜ธ์ถœ ์‹œ ์„œ๋กœ ๋‹ค๋ฅธ ๊ฐ’ ์ƒ์„ฑ") + void generateSecret_isUnique() { + Set secrets = new HashSet<>(); + for (int i = 0; i < 10; i++) { + secrets.add(TotpService.generateSecret()); + } + assertTrue(secrets.size() > 1, "10๋ฒˆ ์ค‘ ์ตœ์†Œ 2๊ฐœ๋Š” ๋‹ฌ๋ผ์•ผ ํ•จ"); + } + + // โ”€โ”€ base32 encode/decode round-trip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("base32Encode โ†’ base32Decode ์™•๋ณต ๋ณ€ํ™˜") + void base32_roundTrip() { + byte[] original = "Hello, World! ์•ˆ๋…•".getBytes(java.nio.charset.StandardCharsets.UTF_8); + String encoded = TotpService.base32Encode(original); + byte[] decoded = TotpService.base32Decode(encoded); + assertArrayEquals(original, decoded, "Base32 ์ธ์ฝ”๋”ฉ โ†’ ๋””์ฝ”๋”ฉ ๊ฒฐ๊ณผ๊ฐ€ ์›๋ณธ๊ณผ ๋‹ฌ๋ผ์•ผ ํ•จ"); + } + + @Test + @DisplayName("base32Decode โ€” ์†Œ๋ฌธ์ž/๊ณต๋ฐฑ ํฌํ•จ๋œ ์‹œํฌ๋ฆฟ๋„ ์ •์ƒ ์ฒ˜๋ฆฌ") + void base32Decode_toleratesLowerCaseAndSpaces() { + String secret = TotpService.generateSecret(); + byte[] decodedUpper = TotpService.base32Decode(secret); + byte[] decodedLower = TotpService.base32Decode(secret.toLowerCase()); + assertArrayEquals(decodedUpper, decodedLower, "๋Œ€์†Œ๋ฌธ์ž ๋ฌด๊ด€ํ•˜๊ฒŒ ๋™์ผํ•œ ๊ฒฐ๊ณผ"); + } + + // โ”€โ”€ verifyCode โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("verifyCode โ€” null ์‹œํฌ๋ฆฟ์ด๋ฉด false") + void verifyCode_nullSecret_returnsFalse() { + assertFalse(TotpService.verifyCode(null, "123456")); + } + + @Test + @DisplayName("verifyCode โ€” null ์ฝ”๋“œ์ด๋ฉด false") + void verifyCode_nullCode_returnsFalse() { + String secret = TotpService.generateSecret(); + assertFalse(TotpService.verifyCode(secret, null)); + } + + @Test + @DisplayName("verifyCode โ€” ์ˆซ์ž๊ฐ€ ์•„๋‹Œ ์ฝ”๋“œ์ด๋ฉด false") + void verifyCode_nonNumericCode_returnsFalse() { + String secret = TotpService.generateSecret(); + assertFalse(TotpService.verifyCode(secret, "ABCDEF")); + assertFalse(TotpService.verifyCode(secret, "12-456")); + assertFalse(TotpService.verifyCode(secret, "")); + } + + @Test + @DisplayName("verifyCode โ€” ์ž˜๋ชป๋œ 6์ž๋ฆฌ ์ˆซ์ž ์ฝ”๋“œ๋Š” false (ํ†ต๊ณ„์ ์œผ๋กœ ํ™•์‹ค)") + void verifyCode_wrongCode_returnsFalse() { + String secret = TotpService.generateSecret(); + // 000000์€ ์‹ค์ œ ์ฝ”๋“œ์ผ ํ™•๋ฅ ์ด 1/1000000 โ€” ํ…Œ์ŠคํŠธ ํ†ต๊ณผ์— ์ถฉ๋ถ„ํžˆ ๋‚ฎ์Œ + assertFalse(TotpService.verifyCode(secret, "000000")); + } + + // โ”€โ”€ buildOtpAuthUri โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("buildOtpAuthUri โ€” otpauth://totp/ ํ˜•์‹ URI ๋ฐ˜ํ™˜") + void buildOtpAuthUri_format() { + String secret = TotpService.generateSecret(); + String uri = TotpService.buildOtpAuthUri(secret, "testuser", "TestIssuer"); + + assertTrue(uri.startsWith("otpauth://totp/"), "URI๊ฐ€ otpauth://totp/ ๋กœ ์‹œ์ž‘ํ•ด์•ผ ํ•จ"); + assertTrue(uri.contains("secret=" + secret), "URI์— secret ํŒŒ๋ผ๋ฏธํ„ฐ ํฌํ•จ"); + assertTrue(uri.contains("issuer="), "URI์— issuer ํŒŒ๋ผ๋ฏธํ„ฐ ํฌํ•จ"); + assertTrue(uri.contains("digits=6"), "URI์— 6์ž๋ฆฌ digits ์„ค์ •"); + } +} diff --git a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TwoFactorAuthHandlerTest.java b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TwoFactorAuthHandlerTest.java new file mode 100644 index 0000000..48551c4 --- /dev/null +++ b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TwoFactorAuthHandlerTest.java @@ -0,0 +1,138 @@ +package io.github.claudetoolkit.ui.security; + +import io.github.claudetoolkit.ui.user.AppUser; +import io.github.claudetoolkit.ui.user.AppUserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Optional; + +import static org.mockito.Mockito.*; + +/** + * TwoFactorAuthHandler ๋‹จ์œ„ ํ…Œ์ŠคํŠธ. + * + *

์ปค๋ฒ„: + *

    + *
  • ADMIN ๋กœ๊ทธ์ธ โ†’ ์„ธ์…˜์— 2fa_pending + 2fa_username ์„ธํŒ…, /login/2fa ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
  • + *
  • REVIEWER ๋กœ๊ทธ์ธ โ†’ /๋กœ ๋ฐ”๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ, ์„ธ์…˜ ์„ธํŒ… ์—†์Œ
  • + *
  • VIEWER ๋กœ๊ทธ์ธ โ†’ /๋กœ ๋ฐ”๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
  • + *
  • ์‹คํŒจ ์นด์šดํ„ฐ > 0 ์ธ ADMIN โ†’ ์นด์šดํ„ฐ ๋ฆฌ์…‹ ํ›„ 2FA ํ๋ฆ„
  • + *
  • DB์— ์—†๋Š” ์‚ฌ์šฉ์ž โ†’ ์˜ˆ์™ธ ์—†์ด ์—ญํ•  ๊ธฐ๋ฐ˜ ๋ถ„๊ธฐ๋งŒ ์ˆ˜ํ–‰
  • + *
+ */ +class TwoFactorAuthHandlerTest { + + private AppUserRepository userRepository; + private TwoFactorAuthHandler handler; + + private HttpServletRequest request; + private HttpServletResponse response; + private HttpSession session; + + @BeforeEach + void setUp() { + userRepository = mock(AppUserRepository.class); + handler = new TwoFactorAuthHandler(userRepository); + session = mock(HttpSession.class); + request = mock(HttpServletRequest.class); + response = mock(HttpServletResponse.class); + when(request.getSession()).thenReturn(session); + } + + // โ”€โ”€ ADMIN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("ADMIN ๋กœ๊ทธ์ธ โ†’ 2fa_pending ์„ธ์…˜ ์„ธํŒ… + /login/2fa ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ") + void adminLogin_sets2faPending_redirectsTo2faPage() throws Exception { + Authentication auth = mockAuth("admin1", "ROLE_ADMIN"); + when(userRepository.findByUsername("admin1")) + .thenReturn(Optional.of(new AppUser("admin1", "hash", "ADMIN"))); + + handler.onAuthenticationSuccess(request, response, auth); + + verify(session).setAttribute("2fa_pending", true); + verify(session).setAttribute("2fa_username", "admin1"); + verify(response).sendRedirect("/login/2fa"); + } + + @Test + @DisplayName("์‹คํŒจ ์นด์šดํ„ฐ > 0 ์ธ ADMIN โ†’ ์นด์šดํ„ฐ ๋ฆฌ์…‹ + 2FA ํ๋ฆ„") + void adminWithFailedAttempts_resetsCounterThenRedirects() throws Exception { + Authentication auth = mockAuth("admin2", "ROLE_ADMIN"); + AppUser admin = new AppUser("admin2", "hash", "ADMIN"); + admin.setFailedLoginAttempts(3); + admin.setLockedUntil(LocalDateTime.now().plusMinutes(5)); + when(userRepository.findByUsername("admin2")).thenReturn(Optional.of(admin)); + + handler.onAuthenticationSuccess(request, response, auth); + + verify(userRepository).save(admin); + verify(response).sendRedirect("/login/2fa"); + } + + // โ”€โ”€ Non-ADMIN โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("REVIEWER ๋กœ๊ทธ์ธ โ†’ ์„ธ์…˜ ์„ธํŒ… ์—†์ด / ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ") + void reviewerLogin_redirectsToHome_noSessionSet() throws Exception { + Authentication auth = mockAuth("reviewer1", "ROLE_REVIEWER"); + when(userRepository.findByUsername("reviewer1")) + .thenReturn(Optional.of(new AppUser("reviewer1", "hash", "REVIEWER"))); + + handler.onAuthenticationSuccess(request, response, auth); + + verify(session, never()).setAttribute(eq("2fa_pending"), any()); + verify(response).sendRedirect("/"); + } + + @Test + @DisplayName("VIEWER ๋กœ๊ทธ์ธ โ†’ / ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ") + void viewerLogin_redirectsToHome() throws Exception { + Authentication auth = mockAuth("viewer1", "ROLE_VIEWER"); + when(userRepository.findByUsername("viewer1")) + .thenReturn(Optional.of(new AppUser("viewer1", "hash", "VIEWER"))); + + handler.onAuthenticationSuccess(request, response, auth); + + verify(response).sendRedirect("/"); + verify(response, never()).sendRedirect("/login/2fa"); + } + + // โ”€โ”€ ์‚ฌ์šฉ์ž ์—†๋Š” ๊ฒฝ์šฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("DB์— ์—†๋Š” ADMIN โ†’ save ํ˜ธ์ถœ ์—†์ด 2FA ํ๋ฆ„ ์ •์ƒ ๋™์ž‘") + void userNotFoundInDb_doesNotThrow_adminStillRedirected() throws Exception { + Authentication auth = mockAuth("ghost", "ROLE_ADMIN"); + when(userRepository.findByUsername("ghost")).thenReturn(Optional.empty()); + + handler.onAuthenticationSuccess(request, response, auth); + + verify(userRepository, never()).save(any()); + verify(response).sendRedirect("/login/2fa"); + } + + // โ”€โ”€ Helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @SuppressWarnings("unchecked") + private Authentication mockAuth(String username, String roleAuthority) { + Authentication auth = mock(Authentication.class); + when(auth.getName()).thenReturn(username); + Collection authorities = + Arrays.asList(new SimpleGrantedAuthority(roleAuthority)); + doReturn(authorities).when(auth).getAuthorities(); + return auth; + } +} diff --git a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/smoke/SecuritySmokeTests.java b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/smoke/SecuritySmokeTests.java index 42b56f8..b8561ad 100644 --- a/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/smoke/SecuritySmokeTests.java +++ b/claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/smoke/SecuritySmokeTests.java @@ -1,16 +1,23 @@ package io.github.claudetoolkit.ui.smoke; +import io.github.claudetoolkit.ui.user.AppUser; +import io.github.claudetoolkit.ui.user.AppUserRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import java.util.Optional; + +import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** @@ -38,6 +45,9 @@ class SecuritySmokeTests { @Autowired private MockMvc mockMvc; + @MockBean + private AppUserRepository appUserRepository; + // โ”€โ”€ 1.7: /api/v1/admin/** ์—ญํ•  ๊ฒŒ์ดํŒ… โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @Test @@ -115,4 +125,60 @@ void authLogin_permitAll() throws Exception { } }); } + + // โ”€โ”€ 2FA ์ธํ„ฐ์…‰ํ„ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("2fa_pending=true ์ธ ์ธ์ฆ ์‚ฌ์šฉ์ž๋Š” /login/2fa ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ (์ธํ„ฐ์…‰ํ„ฐ)") + @WithMockUser(username = "admin1", roles = {"ADMIN"}) + void twoFaPending_interceptor_redirectsToTwoFaPage() throws Exception { + mockMvc.perform(get("/") + .sessionAttr("2fa_pending", Boolean.TRUE)) + .andExpect(status().isFound()) + .andExpect(result -> { + String location = result.getResponse().getHeader("Location"); + if (location == null || !location.contains("/login/2fa")) { + throw new AssertionError("2fa_pending=true ๋ฉด /login/2fa ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ํ•ด์•ผ ํ•จ. Location=" + location); + } + }); + } + + @Test + @DisplayName("2fa_pending ์—†์œผ๋ฉด ์ธํ„ฐ์…‰ํ„ฐ๊ฐ€ ์ฐจ๋‹จํ•˜์ง€ ์•Š์Œ") + @WithMockUser(username = "admin1", roles = {"ADMIN"}) + void noTwoFaPending_interceptor_doesNotBlock() throws Exception { + mockMvc.perform(get("/")) + .andExpect(result -> { + String location = result.getResponse().getHeader("Location"); + if (location != null && location.contains("/login/2fa")) { + throw new AssertionError("2fa_pending ์—†์œผ๋ฉด /login/2fa ๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์•ˆ ํ•ด์•ผ ํ•จ"); + } + }); + } + + // โ”€โ”€ ADMIN 2FA ํ•„์ˆ˜ํ™” โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + @Test + @DisplayName("ADMIN ์€ POST /account/disable-2fa ์‹œ ์˜ค๋ฅ˜ ๋ฐ˜ํ™˜ (2FA ํ•„์ˆ˜)") + @WithMockUser(username = "admin1", roles = {"ADMIN"}) + void admin_cannotDisable2fa_returnsFalse() throws Exception { + AppUser admin = new AppUser("admin1", "hash", "ADMIN"); + when(appUserRepository.findByUsername("admin1")).thenReturn(Optional.of(admin)); + + mockMvc.perform(post("/account/disable-2fa")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)); + } + + @Test + @DisplayName("REVIEWER ๋Š” POST /account/disable-2fa ์„ฑ๊ณต") + @WithMockUser(username = "reviewer1", roles = {"REVIEWER"}) + void reviewer_canDisable2fa_returnsTrue() throws Exception { + AppUser reviewer = new AppUser("reviewer1", "hash", "REVIEWER"); + when(appUserRepository.findByUsername("reviewer1")).thenReturn(Optional.of(reviewer)); + + mockMvc.perform(post("/account/disable-2fa")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + } } diff --git a/docker-compose.yml b/docker-compose.yml index 73b1c1d..955ff9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,10 @@ services: - HTTP_PROXY=${HTTP_PROXY:-} - HTTPS_PROXY=${HTTPS_PROXY:-} - NO_PROXY=${NO_PROXY:-localhost,127.0.0.1} + # Docker ํ™˜๊ฒฝ์—์„œ๋Š” MCP ์„œ๋ฒ„๋ฅผ *๋ณ„๋„ ์ปจํ…Œ์ด๋„ˆ* (claude-toolkit-mcp) ๋กœ ๋ถ„๋ฆฌ โ€” + # ์ด ์ปจํ…Œ์ด๋„ˆ ์•ˆ์˜ launcher ๊ฐ€ ์ค‘๋ณต spawn ํ•˜์ง€ ์•Š๋„๋ก ๋ช…์‹œ์ ์œผ๋กœ OFF. + # (dev ํ™˜๊ฒฝ mvn spring-boot:run ์‹œ์—๋Š” application.yml ์˜ default=true ๊ฐ€ ์œ ํšจ) + - TOOLKIT_MCP_AUTOSTART=false # JAVA_OPTS ๋Š” Dockerfile ์˜ ๊ธฐ๋ณธ๊ฐ’ (TLS/DNS ํŠœ๋‹ ํฌํ•จ) ์„ ์‚ฌ์šฉํ•œ๋‹ค. # ์ปค์Šคํ…€ ์˜ต์…˜์ด ํ•„์š”ํ•  ๋•Œ๋งŒ override โ€” ๊ธฐ๋ณธ๊ฐ’์„ ๋ฎ์ง€ ์•Š๋„๋ก ์ฃผ์˜. volumes: @@ -28,6 +32,38 @@ services: - /d:/host/d:ro restart: unless-stopped + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # v4.7.x โ€” #M3 ์‚ฌ๋‚ด๋ง ๊ณต์œ  MCP Server (Streamable HTTP transport) + # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # ๊ฐ™์€ docker network ์•ˆ์—์„œ backend(claude-toolkit:8027) ํ˜ธ์ถœ. + # ํ˜ธ์ŠคํŠธ์˜ 8028 โ†’ ์ปจํ…Œ์ด๋„ˆ 8028 publish โ€” ์‚ฌ๋‚ด๋ง ๋‹ค๋ฅธ PC ๊ฐ€ mcp-remote ๋กœ ์ ‘์†. + # + # ํด๋ผ์ด์–ธํŠธ(๊ฐœ๋ฐœ์ž PC) ์˜ claude_desktop_config.json: + # "claude-toolkit": { + # "command": "npx", + # "args": ["-y", "mcp-remote", "http://<ํ˜ธ์ŠคํŠธIP>:8028/mcp", + # "--header", "X-Api-Key:ctk_live_..."] + # } + claude-toolkit-mcp: + build: + context: . + dockerfile: claude-toolkit-mcp/Dockerfile + container_name: claude-toolkit-mcp + ports: + - "${MCP_PORT:-8028}:8028" + environment: + # docker network DNS ๋กœ backend ๋„๋‹ฌ. ํ˜ธ์ŠคํŠธํฌํŠธ๊ฐ€ ์•„๋‹ˆ๋ผ *์„œ๋น„์Šค๋ช…*. + - CTK_URL=http://claude-toolkit:8027 + - MCP_TRANSPORT=http + - MCP_HTTP_HOST=0.0.0.0 + - MCP_HTTP_PORT=8028 + - TZ=Asia/Seoul + # CTK_API_KEY ๋Š” *์˜๋„์ ์œผ๋กœ* ์ฃผ์ž…ํ•˜์ง€ ์•Š์Œ โ€” per-request ํ—ค๋” ํŒจ์Šค์Šค๋ฃจ. + depends_on: + claude-toolkit: + condition: service_healthy + restart: unless-stopped + # โ”€โ”€ MySQL (docker-compose --profile mysql up -d) โ”€โ”€ db: image: mysql:8.0 diff --git a/docs/index.html b/docs/index.html index ff4d9c5..a66fa54 100644 --- a/docs/index.html +++ b/docs/index.html @@ -605,6 +605,46 @@

Consumer โ†’ Platform โ€” IDE ์•ˆ์—์„œ ๋„๊ตฌ๋ฅผ ์‚ฌ + +
+
+
+

v4.7.2 โ€” ๋ณด์•ˆ ํŒจ์น˜

+

ADMIN 2FA ํ•„์ˆ˜ํ™” + ๋‹จ์œ„ ํ…Œ์ŠคํŠธ 24๊ฑด

+

+ ADMIN ๊ณ„์ •์˜ 2FA(TOTP) ๋ฅผ ์„ ํƒ โ†’ ํ•„์ˆ˜๋กœ ์ „ํ™˜. ์ธ์ฆยท2FA ํ•ต์‹ฌ ๋กœ์ง ์ „๋ฐ˜์— ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋ฅผ ์ถ”๊ฐ€ํ•ด ํšŒ๊ท€ ์•ˆ์ „๋ง ๊ตฌ์ถ•. +

+
+ +
+
+
๐Ÿ”
+
ADMIN 2FA ํ•„์ˆ˜ํ™”
+
POST /account/disable-2fa ์— ADMIN ์—ญํ•  ์ฐจ๋‹จ ์ถ”๊ฐ€. ADMIN ์ด 2FA ๋น„ํ™œ์„ฑํ™” ์‹œ๋„ ์‹œ { "success": false } ๋ฐ˜ํ™˜. ๋กœ๊ทธ์ธ ํ๋ฆ„(TwoFactorAuthHandler)์€ ์ด๋ฏธ ADMIN ์„ ๊ฐ•์ œ๋กœ 2FA ๋“ฑ๋กยท์ธ์ฆ ํŽ˜์ด์ง€๋กœ ์œ ๋„ โ€” ์ด๋ฒˆ ํŒจ์น˜๋Š” ์‚ฌํ›„ ๋น„ํ™œ์„ฑํ™” ๊ฒฝ๋กœ๊นŒ์ง€ ์ฐจ๋‹จํ•˜์—ฌ ADMIN 2FA ๋ฅผ ์™„์ „ ํ•„์ˆ˜ํ™”. REVIEWER / VIEWER ๋Š” ๊ธฐ์กด๋Œ€๋กœ ์„ ํƒ.
+
+ ๐Ÿ†• v4.7.2 + ๐Ÿ”’ ADMIN ํ•„์ˆ˜ + ๐Ÿ›ก ๋ณด์•ˆ ๊ฐ•ํ™” +
+
+ +
+
๐Ÿงช
+
๋ณด์•ˆ ๋‹จ์œ„ ํ…Œ์ŠคํŠธ 24๊ฑด ์‹ ๊ทœ
+
TotpServiceTest (14) โ€” RFC 6238 ์•Œ๊ณ ๋ฆฌ์ฆ˜ยทBase32ยทnull ๋ฐฉ์–ด. TwoFactorAuthHandlerTest (5) โ€” ADMIN 2FA ๊ฐ•์ œ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ, ์‹คํŒจ ์นด์šดํ„ฐ ๋ฆฌ์…‹. AccountController2faTest (5) โ€” ADMIN ๋น„ํ™œ์„ฑํ™” ๊ฑฐ๋ถ€, REVIEWER/VIEWER ์ •์ƒ ์ฒ˜๋ฆฌ. SecuritySmokeTests (4 ์ถ”๊ฐ€) โ€” ์ธํ„ฐ์…‰ํ„ฐ ๋™์ž‘ + API ์‘๋‹ต ๊ฒ€์ฆ.
+
+ ๐Ÿ†• v4.7.2 + ๐Ÿงช 24 tests + RFC 6238 TOTP + ๐Ÿ”„ ํšŒ๊ท€ ๋ฐฉ์ง€ +
+
+
+
+
+ diff --git a/docs/mcp-setup.md b/docs/mcp-setup.md index 8954906..b3abda8 100644 --- a/docs/mcp-setup.md +++ b/docs/mcp-setup.md @@ -176,6 +176,173 @@ curl -X POST http://localhost:8027/api/v1/analyze \ --- +## 5-2. ์‚ฌ๋‚ด๋ง ๊ณต์œ  ๋ชจ๋“œ (Streamable HTTP) + +> **์–ธ์ œ ์“ฐ๋‚˜?** MCP ์„œ๋ฒ„๋ฅผ ํ•œ PC(๋˜๋Š” ์‚ฌ๋‚ด ์„œ๋ฒ„)์— 1๋Œ€๋งŒ ๋„์šฐ๊ณ , ๊ฐ™์€ ์‚ฌ๋‚ด๋ง์˜ ์—ฌ๋Ÿฌ ๊ฐœ๋ฐœ์ž PC๊ฐ€ ๊ทธ ์„œ๋ฒ„๋ฅผ ๊ณต์œ . ๊ฐ ์‚ฌ์šฉ์ž๋Š” ์ž๊ธฐ ํ‚ค๋กœ ์ ‘์† โ€” audit log/rate limit ์ด ์‚ฌ๋žŒ๋ณ„๋กœ ๋ถ„๋ฆฌ๋จ. + +### ์•„ํ‚คํ…์ฒ˜ + +``` +[๊ฐœ๋ฐœ์ž A ์˜ Claude Desktop] [MCP ํ˜ธ์ŠคํŠธ (์‚ฌ๋‚ด 1๋Œ€)] + npx mcp-remote โ”€โ”€โ”€stdioโ”€โ”€โ”€โ” โ”Œโ”€ MCP HTTP :8028 โ”€โ” + โ”œโ”€ HTTP + X-Api-Key โ”€โ†’โ”‚ per-request โ”‚ +[๊ฐœ๋ฐœ์ž B ์˜ Cursor] โ”‚ (๊ฐ์ž ๋‹ค๋ฅธ ํ‚ค) โ”‚ ToolkitClient โ”‚ + npx mcp-remote โ”€โ”€โ”€stdioโ”€โ”€โ”€โ”˜ โ”‚ โ†“ โ”‚ + โ”‚ POST /api/v1/ โ”‚ + โ”‚ analyze โ”‚ + โ””โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ†“ + [๋„๊ตฌ backend :8027] + (PlatformAuthFilter ๊ฐ€ + ๊ฐ ์‚ฌ์šฉ์ž ํ‚ค๋ณ„ ๊ฒ€์ฆ) +``` + +ํ•ต์‹ฌ: **MCP ์„œ๋ฒ„๋Š” X-Api-Key ํ—ค๋”๋ฅผ ๊ทธ๋Œ€๋กœ backend ์— ์ „๋‹ฌ** (per-request ํŒจ์Šค์Šค๋ฃจ). MCP ์„œ๋ฒ„ ์ž์ฒด๋Š” ํ‚ค๋ฅผ ์ €์žฅํ•˜์ง€ ์•Š์Œ โ€” backend ์˜ ๊ธฐ์กด ๋ณด์•ˆ ๋ชจ๋ธ์ด ๊ทธ๋Œ€๋กœ ์‚ด์•„๋‚จ์Œ. + +### Step A โ€” MCP ์„œ๋ฒ„ ํ˜ธ์ŠคํŠธ ์„ค์ • (1๋Œ€) + +```bash +cd claude-toolkit-mcp +npm install +npm run build + +# ํ™˜๊ฒฝ๋ณ€์ˆ˜ +export CTK_URL=http://localhost:8027 # ๊ฐ™์€ ํ˜ธ์ŠคํŠธ์˜ backend +export MCP_TRANSPORT=http +export MCP_HTTP_HOST=0.0.0.0 # ์‚ฌ๋‚ด๋ง ๋…ธ์ถœ (default) +export MCP_HTTP_PORT=8028 # default + +npm run start:http +# โ†’ [MCP] http ready โ€” listening on 0.0.0.0:8028 (12 tools, stateless, per-request X-Api-Key) +``` + +๋ฐฉํ™”๋ฒฝ์ด 8028 ์„ ์‚ฌ๋‚ด๋ง์— ์—ด์–ด์ค˜์•ผ ํ•จ. ์™ธ๋ถ€ ์ธํ„ฐ๋„ท์— ์ง์ ‘ ๋…ธ์ถœํ•˜์ง€ ๋ง ๊ฒƒ โ€” VPN/์‚ฌ๋‚ด๋ง ์•ˆ์—์„œ๋งŒ ์‚ฌ์šฉ. + +### Step B โ€” ๊ฐ ๊ฐœ๋ฐœ์ž๊ฐ€ ์ž๊ธฐ ํ‚ค ๋ฐœ๊ธ‰ + +`/admin/api-keys` ์—์„œ ์‚ฌ๋žŒ๋ณ„๋กœ ๋ฐœ๊ธ‰. ์ด๋ฆ„์€ `ํ™๊ธธ๋™ โ€” Cursor` ์ฒ˜๋Ÿผ ์‹๋ณ„ ๊ฐ€๋Šฅํ•˜๊ฒŒ. + +### Step C โ€” ํด๋ผ์ด์–ธํŠธ PC ์˜ mcp_settings.json + +ํ”Œ๋žซํผ๋ณ„ ๊ฒฝ๋กœ: +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` +- Cursor: `Settings โ†’ Features โ†’ Model Context Protocol` +- Cline: `VS Code Command Palette โ†’ Cline: Add MCP Server` + +```json +{ + "mcpServers": { + "claude-toolkit": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "http://mcp-host.์‚ฌ๋‚ด๋„๋ฉ”์ธ:8028/mcp", + "--header", + "X-Api-Key:ctk_live_๋ณธ์ธํ‚ค..." + ] + } + } +} +``` + +> โš ๏ธ `--header` ์™€ ํ‚ค ์‚ฌ์ด๋Š” ์ฝœ๋ก (`:`)์œผ๋กœ ๊ตฌ๋ถ„, ๊ณต๋ฐฑ ์—†์Œ. mcp-remote ๊ฐ€ stdioโ†”HTTP ๋ธŒ๋ฆฟ์ง€ ์—ญํ• ์„ ํ•˜๋ฏ€๋กœ ํด๋ผ์ด์–ธํŠธ ์ธก ์ฝ”๋“œ ์ˆ˜์ • ๋ถˆ์š”. + +### Step D โ€” health check +```bash +curl http://mcp-host.์‚ฌ๋‚ด๋„๋ฉ”์ธ:8028/healthz +# {"status":"ok","tools":12,"transport":"http"} +``` + +### ๊ถŒ์žฅ ์šด์˜ ํŒจํ„ด + +| ์ƒํ™ฉ | ๊ถŒ์žฅ | +|------|------| +| ๊ฐœ์ธ ๋…ธํŠธ๋ถ์—์„œ ๋‹จ๋… ์‚ฌ์šฉ | stdio (ยง2) โ€” ๊ฐ€์žฅ ๋‹จ์ˆœ | +| ํŒ€ 5๋ช… ์ด์ƒ ๊ณต์œ  (๊ฐœ๋ฐœ PC 1๋Œ€ ๊ธฐ๋™) | mvn spring-boot:run + auto-launcher (ยง5-3) | +| ์‚ฌ๋‚ด ์„œ๋ฒ„ + Docker ๋ฐฐํฌ | docker-compose ๋ถ„๋ฆฌ ์ปจํ…Œ์ด๋„ˆ (ยง5-4) | +| ์™ธ๋ถ€ ์ธํ„ฐ๋„ท ๋…ธ์ถœ ํ•„์š” | ๊ถŒ์žฅ ์•ˆ ํ•จ. ๋ถ€๋“์ดํ•˜๋ฉด reverse proxy (nginx) + TLS + ์‚ฌ๋‚ด๋ง ACL | + +### 5-3. ์ž๋™ ๊ธฐ๋™ (๊ฐœ๋ฐœ ํ™˜๊ฒฝ โ€” mvn spring-boot:run) + +`mvn spring-boot:run` ์‹œ Spring Boot ์˜ `McpServerLauncher` ๊ฐ€ 8028์„ ์ž๋™ spawnํ•ฉ๋‹ˆ๋‹ค. ๋ณ„๋„ ์ฐฝ์—์„œ `npm run start:http` ๋ฅผ ๋„์šธ ํ•„์š” ์—†์Œ. + +```yaml +# application.yml โ€” ๊ธฐ๋ณธ๊ฐ’ (๋ณ€๊ฒฝ ๋ถˆํ•„์š”) +toolkit: + mcp: + auto-start: true # ${TOOLKIT_MCP_AUTOSTART:true} + project-root: ../claude-toolkit-mcp + host: 0.0.0.0 + port: 8028 + node: node # PATH ์˜ node ๋˜๋Š” ์ ˆ๋Œ€๊ฒฝ๋กœ +``` + +๊ธฐ๋™ ํ›„ Spring Boot ๋กœ๊ทธ์— ๋‹ค์Œ์ด ๋ณด์ด๋ฉด ์ •์ƒ: +``` +[MCP] ์ž๋™ ๊ธฐ๋™ ์„ฑ๊ณต โ€” pid=..., listening on 0.0.0.0:8028, backend=http://localhost:8027 +[MCP] [MCP] http ready โ€” listening on 0.0.0.0:8028 (12 tools, ...) +``` + +**๋น„ํ™œ์„ฑํ™”**: `TOOLKIT_MCP_AUTOSTART=false` + +**`node` PATH ์•ˆ ์žกํž ๋•Œ**: `TOOLKIT_MCP_NODE="C:/Program Files/nodejs/node.exe"` + +**dist ๊ฐ€ ์—†์„ ๋•Œ**: launcher ๊ฐ€ `dist/index.js ๋ฏธ์กด์žฌ` ๊ฒฝ๊ณ  ํ›„ ์Šคํ‚ต. `cd claude-toolkit-mcp && npm install && npm run build` ํ•œ ๋ฒˆ ์‹คํ–‰ ํ›„ ์žฌ๊ธฐ๋™. + +### 5-4. Docker ๋ฐฐํฌ (์šด์˜ โ€” docker-compose) + +Docker ํ™˜๊ฒฝ์—์„œ๋Š” **MCP ๋ฅผ ๋ณ„๋„ ์ปจํ…Œ์ด๋„ˆ๋กœ ๋ถ„๋ฆฌ**ํ•ฉ๋‹ˆ๋‹ค (์ด๋ฏธ `docker-compose.yml` ์— ์ถ”๊ฐ€๋จ). claude-toolkit ์ปจํ…Œ์ด๋„ˆ์˜ launcher ๋Š” ์ž๋™ OFF (`TOOLKIT_MCP_AUTOSTART=false`). + +```bash +# ๋นŒ๋“œ + ๊ธฐ๋™ โ€” claude-toolkit-mcp ๊ฐ€ ํ•จ๊ป˜ ์˜ฌ๋ผ์˜ด +docker-compose build --no-cache claude-toolkit claude-toolkit-mcp +docker-compose up -d claude-toolkit claude-toolkit-mcp + +# ๋ชจ๋‹ˆํ„ฐ๋ง ํ”„๋กœํ•„๋„ ํ•จ๊ป˜ +docker-compose --profile monitoring up -d +``` + +| ์ปจํ…Œ์ด๋„ˆ | ํ˜ธ์ŠคํŠธ ํฌํŠธ | ์ปจํ…Œ์ด๋„ˆ ๋‚ด๋ถ€ | +|---------|-----------|-------------| +| `claude-toolkit` | 8027 | 8027 (Spring Boot) | +| `claude-toolkit-mcp` | 8028 | 8028 (Streamable HTTP) | + +์ปจํ…Œ์ด๋„ˆ ๊ฐ„ ํ†ต์‹ : docker network DNS ๋กœ `claude-toolkit-mcp โ†’ claude-toolkit:8027`. + +**๊ฒ€์ฆ**: +```bash +# ํ˜ธ์ŠคํŠธ์—์„œ +curl http://localhost:8028/healthz +# {"status":"ok","tools":12,"transport":"http"} + +# ์ปจํ…Œ์ด๋„ˆ ๋กœ๊ทธ +docker logs -f claude-toolkit-mcp +``` + +**MCP ๋งŒ ์žฌ์‹œ์ž‘**: +```bash +docker-compose restart claude-toolkit-mcp +``` + +**MCP ๋งŒ ์žฌ๋นŒ๋“œ**: +```bash +docker-compose build claude-toolkit-mcp +docker-compose up -d claude-toolkit-mcp +``` + +### ํŠธ๋Ÿฌ๋ธ”์ŠˆํŒ… (HTTP ๋ชจ๋“œ) + +| ์ฆ์ƒ | ์ ๊ฒ€ | +|------|------| +| `npx mcp-remote` ๊ฐ€ connection refused | ํ˜ธ์ŠคํŠธ IP/ํฌํŠธ, ๋ฐฉํ™”๋ฒฝ 8028 inbound, MCP ์„œ๋ฒ„๊ฐ€ `0.0.0.0` ์œผ๋กœ listen ์ค‘์ธ์ง€ | +| 401 X-Api-Key ํ—ค๋” ํ•„์š” | mcp_settings.json ์˜ `--header` ํ˜•์‹ (`X-Api-Key:ํ‚ค`, ๊ณต๋ฐฑ ์—†์Œ) | +| 401 Invalid API key | backend ์—์„œ ํ‚ค ํšŒ์ˆ˜/๋งŒ๋ฃŒ โ€” `/admin/api-keys` | +| ํ˜ธ์ถœ์€ ๋˜๋Š”๋ฐ ๊ฒฐ๊ณผ๊ฐ€ ๋น„์–ด์žˆ์Œ | MCP ์„œ๋ฒ„ ๋กœ๊ทธ(`[MCP] http ์ฒ˜๋ฆฌ ์‹คํŒจ: ...`) ํ™•์ธ | + +--- + ## 6. ๋ณด์•ˆ ๋ชจ๋ธ ```