From 88c2f13f78480e72e4ea98e7336e25564a519c24 Mon Sep 17 00:00:00 2001 From: Sangmoo Date: Thu, 7 May 2026 14:22:33 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(mcp):=20#M3=20=EC=82=AC=EB=82=B4?= =?UTF-8?q?=EB=A7=9D=20=EA=B3=B5=EC=9C=A0=20=E2=80=94=20Streamable=20HTTP?= =?UTF-8?q?=20transport=20+=20Spring=20Boot=20launcher=20+=20Docker=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stdio 전용이던 MCP server 에 HTTP transport 추가 — 사내망 1대를 여러 개발자 PC 가 mcp-remote 로 공유하는 경로. per-request X-Api-Key 패스스루로 audit/rate limit 은 사람별로 분리 유지. 핵심 변경: - claude-toolkit-mcp/src: MCP_TRANSPORT=stdio|http 분기, StreamableHTTPServerTransport + Express, X-Api-Key 헤더 → per-request ToolkitClient - ToolkitClient: baseUrl/apiKey 옵션 주입 가능 (env fallback 유지) - platform/McpServerLauncher.java: ApplicationReadyEvent 시 node child process spawn, @PreDestroy 로 동시 종료 (mvn spring-boot:run 시점 자동 기동) - application.yml: toolkit.mcp.* 설정 (auto-start, host, port, node, project-root) - claude-toolkit-mcp/Dockerfile: node:20-alpine 멀티스테이지 + tini + healthcheck - docker-compose.yml: claude-toolkit-mcp 서비스 추가, 컨테이너 안 launcher 는 TOOLKIT_MCP_AUTOSTART=false 로 중복 spawn 방지 - service/: systemd unit + Windows NSSM 스크립트 (별도 호스트 배포용) - docs/mcp-setup.md: §5-2 사내망 공유 / §5-3 dev 자동기동 / §5-4 Docker 배포 Co-Authored-By: Claude Opus 4.7 (1M context) --- claude-toolkit-mcp/Dockerfile | 54 ++ claude-toolkit-mcp/package-lock.json | 872 ++++++++++++++---- claude-toolkit-mcp/package.json | 7 +- claude-toolkit-mcp/service/README.md | 69 ++ .../service/claude-toolkit-mcp.env.example | 23 + .../service/claude-toolkit-mcp.service | 42 + .../service/install-windows-service.ps1 | 89 ++ claude-toolkit-mcp/src/client.ts | 33 +- claude-toolkit-mcp/src/index.ts | 161 +++- .../ui/platform/McpServerLauncher.java | 231 +++++ .../src/main/resources/application.yml | 11 + docker-compose.yml | 36 + docs/mcp-setup.md | 167 ++++ 13 files changed, 1583 insertions(+), 212 deletions(-) create mode 100644 claude-toolkit-mcp/Dockerfile create mode 100644 claude-toolkit-mcp/service/README.md create mode 100644 claude-toolkit-mcp/service/claude-toolkit-mcp.env.example create mode 100644 claude-toolkit-mcp/service/claude-toolkit-mcp.service create mode 100644 claude-toolkit-mcp/service/install-windows-service.ps1 create mode 100644 claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/platform/McpServerLauncher.java 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/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..26c81cb --- /dev/null +++ b/claude-toolkit-ui/src/main/java/io/github/claudetoolkit/ui/platform/McpServerLauncher.java @@ -0,0 +1,231 @@ +package io.github.claudetoolkit.ui.platform; + +import jakarta.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, process.pid(), 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, p.pid()); + 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()) ? p.pid() : null; + } + + /** 외부 표시용 — 헬스/관리 페이지에서 사용 */ + 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/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/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. 보안 모델 ``` From 132c8b77f3fb437502b4ea3482777a98774b92b5 Mon Sep 17 00:00:00 2001 From: Sangmoo Date: Thu, 7 May 2026 14:27:58 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(mcp):=20McpServerLauncher=20Java=208=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=20=E2=80=94=20javax.annotation=20+=20Process?= =?UTF-8?q?.pid()=20reflection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원인: Spring Boot 2.x / JDK 1.8 빌드인데 Spring Boot 3 가정으로 작성되어 docker build 시 컴파일 실패. - jakarta.annotation.PreDestroy → javax.annotation.PreDestroy - Process.pid() (Java 9+) → reflection 으로 wrapping (Java 8 에서는 "?" 표시) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui/platform/McpServerLauncher.java | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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 index 26c81cb..da483c9 100644 --- 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 @@ -1,6 +1,6 @@ package io.github.claudetoolkit.ui.platform; -import jakarta.annotation.PreDestroy; +import javax.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -116,7 +116,7 @@ public void start() { this.stdoutPump = pumpToLog(process.getInputStream(), false); this.stderrPump = pumpToLog(process.getErrorStream(), true); log.info("{} 자동 기동 성공 — pid={}, listening on {}:{}, backend=http://localhost:{}", - logPrefix, process.pid(), host, port, backendPort); + logPrefix, pidOrUnknown(process), host, port, backendPort); } catch (IOException e) { log.error("{} 기동 실패 — '{}'. node 실행파일 경로 확인 (toolkit.mcp.node). cause: {}", logPrefix, nodeExecutable, e.getMessage()); @@ -129,7 +129,7 @@ public void stop() { if (p == null || !p.isAlive()) { return; } - log.info("{} 종료 신호 (pid={})", logPrefix, p.pid()); + log.info("{} 종료 신호 (pid={})", logPrefix, pidOrUnknown(p)); p.destroy(); try { if (!p.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { @@ -213,7 +213,27 @@ public boolean isRunning() { public Long getPid() { Process p = this.process; - return (p != null && p.isAlive()) ? p.pid() : null; + 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() : "?"; } /** 외부 표시용 — 헬스/관리 페이지에서 사용 */ From 185e6c4b905a27cdcc9af4156878965315bb5d6c Mon Sep 17 00:00:00 2001 From: Sangmoo Date: Tue, 19 May 2026 23:31:01 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(security):=20ADMIN=202FA=20=ED=95=84?= =?UTF-8?q?=EC=88=98=ED=99=94=20+=20=EB=B3=B4=EC=95=88=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=2024=EA=B1=B4=20+=20SSE?= =?UTF-8?q?=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20CME=20flaky=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountController.disable-2fa: ADMIN 계정은 2FA 비활성화 차단 (로그인 강제 + 비활성화 차단으로 필수화 완성) - 신규 단위 테스트: TotpServiceTest(14) + TwoFactorAuthHandlerTest(5) + AccountController2faTest(5) - SecuritySmokeTests: 2FA pending 인터셉터/비활성화 권한 검증 4건 추가 - HarnessSqlOptimization/SpMigration/LogRca StreamingTest: mock chatStream 에 50ms 지연 추가 — Spring MVC 가 SseEmitter async-context 리스너를 등록하기 전에 백그라운드 스레드가 emitter.complete() 로 MockAsyncContext.listeners 를 순회하며 발생하던 ConcurrentModificationException (전체 mvn test 시 간헐 실패) 해소 - README.md / docs/index.html v4.7.2 갱신 전체 테스트 통과: Tests run: 319, Failures: 0, Errors: 0 Co-Authored-By: Claude Opus 4.7 --- README.md | 15 ++ .../frontend/tsconfig.tsbuildinfo | 2 +- .../ui/controller/AccountController.java | 7 +- .../controller/AccountController2faTest.java | 106 ++++++++++++++ .../logrca/HarnessLogRcaStreamingTest.java | 4 + .../HarnessSpMigrationStreamingTest.java | 4 + .../HarnessSqlOptimizationStreamingTest.java | 4 + .../ui/security/TotpServiceTest.java | 114 +++++++++++++++ .../ui/security/TwoFactorAuthHandlerTest.java | 138 ++++++++++++++++++ .../ui/smoke/SecuritySmokeTests.java | 66 +++++++++ docs/index.html | 40 +++++ 11 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/controller/AccountController2faTest.java create mode 100644 claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TotpServiceTest.java create mode 100644 claude-toolkit-ui/src/test/java/io/github/claudetoolkit/ui/security/TwoFactorAuthHandlerTest.java 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-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/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/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 + 🔄 회귀 방지 +
+
+
+
+
+