diff --git a/.env.example b/.env.example index 8cfbb7a..c894e89 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,5 @@ MCP_MAX_OUTPUT_BYTES=1048576 MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304 MCP_PROCESS_RETENTION_MS=3600000 MCP_MAX_PROCESSES=128 -MCP_SESSION_TTL_MS=86400000 MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_EDIT_FILE_BYTES=67108864 diff --git a/README.md b/README.md index 4ab7478..6410e75 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ VPS 또는 EC2 인스턴스에서 상시 실행하는 Node.js 원격 개발 MCP - 장기 실행 프로세스의 출력 폴링, 표준입력 전달과 종료 제어 - 절대경로를 포함한 호스트 파일 읽기·쓰기·수정·전송 및 삭제 - 정적 Bearer 인증과 ChatGPT용 OAuth 2.1/DCR/PKCE 내장 -- 프로세스별 출력 보관, MCP 세션 관리와 전송 크기 제한 +- 요청별 stateless JSON 전송, 프로세스별 출력 보관과 전송 크기 제한 - Linux VPS/EC2용 systemd 및 Nginx 배포 예제 ## 제공 도구 @@ -36,6 +36,25 @@ VPS 또는 EC2 인스턴스에서 상시 실행하는 Node.js 원격 개발 MCP 총 20개 도구를 제공합니다. `remove_path`는 휴지통을 사용하지 않고 대상을 영구 삭제하며, `apply_patch`는 호스트의 `git apply --unsafe-paths`를 사용합니다. +### 파일 읽기와 전송 규칙 + +- `read_file`의 `offset`, `bytesRead`, `nextOffset`은 모두 바이트 단위입니다. +- `encoding="utf8"`일 때는 한글·이모지 같은 다중 바이트 문자를 청크 경계에서 자르지 않습니다. 완전한 문자 하나를 담기 위해 `bytesRead`가 요청한 `maxBytes`보다 최대 3바이트 커질 수 있지만, 서버의 `MCP_MAX_FILE_CHUNK_BYTES` 제한은 넘지 않습니다. +- 파일이 올바른 UTF-8이 아니면 텍스트를 임의로 치환하지 않고 오류를 반환합니다. 바이너리 파일은 `encoding="base64"`로 읽으세요. +- `write_file`과 `upload_file`의 base64 입력은 표준 알파벳, 길이와 패딩이 올바른지 엄격하게 검사합니다. 패딩이 생략된 표준 base64도 허용하며, 잘못된 입력은 파일을 변경하기 전에 거부됩니다. +- `write_file.fileMode`는 새 파일뿐 아니라 기존 파일을 덮어쓰거나 이어 쓸 때도 적용됩니다. +- `copy_path`는 대상이 이미 있고 `force=false`이면 파일과 디렉터리 모두 충돌 오류를 반환합니다. + +## 전송 및 상태 모델 + +`/mcp`는 요청마다 독립적으로 처리되는 stateless Streamable HTTP JSON 엔드포인트입니다. + +- 각 `POST /mcp` 요청은 새 MCP transport에서 처리되며 `Mcp-Session-Id`를 발급하거나 요구하지 않습니다. +- 이전 버전 클라이언트가 오래된 `Mcp-Session-Id` 헤더를 보내도 요청 처리에는 사용하지 않습니다. +- `GET /mcp`와 `DELETE /mcp`가 `405 Method Not Allowed`를 반환하는 것은 정상입니다. 서버 푸시용 SSE 세션은 유지하지 않습니다. +- MCP 전송 세션과 명령 프로세스의 `sessionId`는 서로 다릅니다. `exec_command`가 반환한 프로세스 `sessionId`는 후속 HTTP 요청의 `write_stdin`, `read_process`, `terminate_process`에서 계속 사용할 수 있습니다. +- 실행 중이거나 보존 중인 프로세스 상태는 서비스 메모리에 있으므로 서비스를 재시작하면 사라집니다. + ## 요구사항 - Node.js 22 이상과 npm @@ -145,7 +164,7 @@ sudo systemctl status remote-dev-mcp `/usr/bin/node`가 실제 Node.js 경로와 다르면 systemd 파일의 `ExecStart`를 수정합니다. `which node`로 확인할 수 있습니다. -공개 인터넷에서 사용할 때는 HTTPS가 필요합니다. [Nginx 예제](deploy/nginx.remote-dev-mcp.conf)의 도메인과 인증서 경로를 바꾸고 유효한 인증서를 준비한 뒤 활성화합니다. Node 서버는 `127.0.0.1`에 바인딩하고 80/443만 외부에 공개하는 구성을 권장합니다. Streamable HTTP의 SSE 응답을 위해 proxy buffering을 비활성화하고 긴 read timeout을 사용합니다. +공개 인터넷에서 사용할 때는 HTTPS가 필요합니다. [Nginx 예제](deploy/nginx.remote-dev-mcp.conf)의 도메인과 인증서 경로를 바꾸고 유효한 인증서를 준비한 뒤 활성화합니다. Node 서버는 `127.0.0.1`에 바인딩하고 80/443만 외부에 공개하는 구성을 권장합니다. 긴 도구 호출이 프록시에서 먼저 종료되지 않도록 충분한 read timeout을 사용합니다. 운영 환경 파일에서는 최소한 다음 값을 실제 도메인에 맞춰야 합니다. @@ -190,11 +209,39 @@ sudo journalctl -u remote-dev-mcp -f sudo systemctl restart remote-dev-mcp ``` +정상 health 응답 예시는 다음과 같습니다. + +```json +{ + "status": "ok", + "service": "cokacremote", + "version": "0.1.0", + "transportMode": "stateless-json", + "activeMcpSessions": 0, + "activeMcpRequests": 0, + "managedProcesses": 0, + "unrestrictedHostAccess": true, + "oauthEnabled": true +} +``` + +- `activeMcpSessions`는 stateless 모드에서 항상 `0`입니다. 연결이 끊겼다는 뜻이 아닙니다. +- `activeMcpRequests`는 health 요청 시점에 처리 중인 MCP HTTP 요청 수입니다. +- `managedProcesses`는 실행 중인 프로세스뿐 아니라 결과 조회를 위해 잠시 보존된 완료 프로세스도 포함합니다. 실제 실행 여부는 `list_processes`의 `status`로 확인하세요. 완료 기록은 `MCP_PROCESS_RETENTION_MS` 이후 정리됩니다. +- 모든 MCP 응답에는 추적용 `X-Request-Id`가 포함됩니다. 서비스 로그의 `event="mcp_request"` 항목에는 RPC 메서드, 도구 이름, HTTP 상태, 처리 결과와 소요 시간이 기록되며 인증 토큰과 도구 인자는 기록하지 않습니다. + +최근 MCP 요청 로그만 확인하려면 다음 명령을 사용할 수 있습니다. + +```bash +sudo journalctl -u remote-dev-mcp -o cat | grep '"event":"mcp_request"' +``` + - `Error fetching OAuth configuration`: `MCP_OAUTH_ENABLED`, 공개 URL 및 Nginx의 `/.well-known/` 프록시를 확인합니다. - MCP 요청의 `401 Unauthorized`: Bearer 토큰 또는 OAuth access token을 확인합니다. - `403 Host header is not allowed`: 요청 도메인을 `MCP_ALLOWED_HOSTS`에 추가합니다. - 명령이 즉시 끝나지 않고 `sessionId`를 반환: `read_process`로 폴링하거나 `write_stdin`으로 입력을 보냅니다. -- 서비스 재시작: 활성 MCP 세션, 관리 중인 프로세스 정보와 아직 교환되지 않은 authorization code는 유지되지 않습니다. OAuth 등록과 발급된 토큰은 상태 파일에 유지됩니다. +- MCP 요청은 서로 독립적인 stateless POST입니다. `GET /mcp`와 `DELETE /mcp`의 `405 Method Not Allowed`는 정상이며 독립 SSE 스트림을 제공하지 않는다는 뜻입니다. +- 서비스 재시작: 관리 중인 프로세스 정보와 아직 교환되지 않은 authorization code는 유지되지 않습니다. OAuth 등록과 발급된 토큰은 상태 파일에 유지됩니다. ## 검증 @@ -204,7 +251,26 @@ npm test npm run build ``` -테스트에는 실제 Streamable HTTP MCP 클라이언트 연결, bearer 인증, 도구 목록, `run_script`, 파일 읽기·쓰기, 장기 프로세스, 청크 전송 및 unified diff 적용이 포함됩니다. +기본 테스트는 실제 Streamable HTTP MCP 클라이언트를 사용하며 다음 범위를 포함합니다. + +- Bearer 인증, stateless 요청 처리와 요청 추적 헤더 +- 20개 도구 전체의 정상 흐름, 오류 흐름, 입력 경계값 +- 대화형 stdin, 출력 페이징, 타임아웃, 종료, 완료 프로세스 보존 +- UTF-8 문자 경계, 엄격한 base64 검사, 파일 모드, 복사·이동 충돌 +- unified diff의 검사, 적용, 역적용, 3-way 적용 + +### 실행 중인 외부 MCP 전체 E2E 검증 + +개발 의존성이 설치된 별도 소스 복사본에서 다음처럼 실행하면 실제 HTTPS 엔드포인트의 20개 도구를 모두 검증할 수 있습니다. + +```bash +MCP_E2E_URL='https://mcp.example.com/mcp' \ +MCP_E2E_TOKEN='' \ +MCP_E2E_ROOT='/tmp/cokacremote-tools-e2e-manual' \ +npx vitest run test/all-tools.integration.test.ts +``` + +이 검증은 대상 서버에서 실제 명령을 실행하고 테스트 파일을 생성·변경·삭제합니다. 안전을 위해 `MCP_E2E_ROOT`는 반드시 `/tmp/cokacremote-tools-e2e-*` 형식이어야 하며 테스트는 이 격리 디렉터리만 사용한 뒤 정리를 시도합니다. 운영 데이터가 있는 경로를 지정하지 말고, 실패하거나 중단된 뒤에는 지정한 경로가 남았는지 확인하세요. 운영 설치 디렉터리에서 `npm ci`를 실행하면 production-only 의존성 구성이 바뀔 수 있으므로, 테스트는 별도 복사본에서 실행하는 것을 권장합니다. ## 주요 환경 변수 @@ -231,21 +297,22 @@ npm run build | `MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES` | `4194304` | 프로세스별 보관 출력 | | `MCP_PROCESS_RETENTION_MS` | `3600000` | 완료 프로세스 보관 시간 | | `MCP_MAX_PROCESSES` | `128` | 동시에 보관할 프로세스 세션 수 | -| `MCP_SESSION_TTL_MS` | `86400000` | 유휴 MCP 세션 보관 시간 | -| `MCP_MAX_FILE_CHUNK_BYTES` | `1048576` | 파일 전송 청크 크기 | +| `MCP_MAX_FILE_CHUNK_BYTES` | `1048576` | 파일 청크 최대 크기. UTF-8 읽기도 이 상한을 넘지 않음 | | `MCP_MAX_EDIT_FILE_BYTES` | `67108864` | 텍스트 교체 대상 파일의 최대 크기 | ## 프로젝트 구조 | 경로 | 역할 | |---|---| -| `src/http-server.ts` | Streamable HTTP, 세션, OAuth 라우팅과 health endpoint | +| `src/http-server.ts` | Stateless Streamable HTTP, OAuth 라우팅과 health endpoint | | `src/mcp-server.ts` | MCP 서버 정보와 도구 등록 | | `src/exec-tools.ts` | 명령·스크립트·장기 프로세스 도구 | +| `src/file-service.ts` | 파일 읽기·쓰기·전송과 경로 작업 구현 | | `src/file-tools.ts` | 파일 시스템 도구와 입력 스키마 | | `src/oauth.ts` | DCR, PKCE, token 발급·갱신·폐기와 승인 화면 | | `deploy/` | systemd, 환경 파일과 Nginx 예제 | -| `test/` | 설정, 파일, 프로세스, MCP 및 OAuth 통합 테스트 | +| `test/all-tools.integration.test.ts` | 20개 도구 전체와 외부 엔드포인트 E2E 테스트 | +| `test/` | 설정, 파일, 프로세스, MCP 및 OAuth 단위·통합 테스트 | ## 라이선스 diff --git a/deploy/remote-dev-mcp.env.example b/deploy/remote-dev-mcp.env.example index 7ce2663..25242a3 100644 --- a/deploy/remote-dev-mcp.env.example +++ b/deploy/remote-dev-mcp.env.example @@ -19,6 +19,5 @@ MCP_MAX_OUTPUT_BYTES=1048576 MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304 MCP_PROCESS_RETENTION_MS=3600000 MCP_MAX_PROCESSES=128 -MCP_SESSION_TTL_MS=86400000 MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_EDIT_FILE_BYTES=67108864 diff --git a/src/config.ts b/src/config.ts index ac1c6ea..a984a10 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,7 +22,6 @@ export interface AppConfig { maxRetainedProcessOutputBytes: number; processRetentionMs: number; maxProcesses: number; - sessionTtlMs: number; maxFileChunkBytes: number; maxEditFileBytes: number; } @@ -178,12 +177,6 @@ export function loadConfig( "MCP_MAX_PROCESSES", 1, ), - sessionTtlMs: parseInteger( - env.MCP_SESSION_TTL_MS, - 24 * 60 * 60 * 1000, - "MCP_SESSION_TTL_MS", - 60_000, - ), maxFileChunkBytes: parseInteger( env.MCP_MAX_FILE_CHUNK_BYTES, 1024 * 1024, diff --git a/src/file-service.ts b/src/file-service.ts index 693a72e..613641e 100644 --- a/src/file-service.ts +++ b/src/file-service.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from "node:crypto"; -import { createReadStream } from "node:fs"; +import { isUtf8 } from "node:buffer"; +import { constants, createReadStream } from "node:fs"; import { appendFile, chmod, @@ -71,8 +72,89 @@ function encodeContent(data: Buffer, encoding: FileContentEncoding): string { return encoding === "base64" ? data.toString("base64") : data.toString("utf8"); } +function decodeBase64(data: string): Buffer { + if (data.length === 0) { + return Buffer.alloc(0); + } + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data)) { + throw new Error("Invalid base64 content"); + } + const content = data.replace(/=+$/, ""); + const suppliedPadding = data.length - content.length; + if (content.length % 4 === 1) { + throw new Error("Invalid base64 content length"); + } + const requiredPadding = (4 - (content.length % 4)) % 4; + if (suppliedPadding > 0 && suppliedPadding !== requiredPadding) { + throw new Error("Invalid base64 padding"); + } + const canonical = `${content}${"=".repeat(requiredPadding)}`; + const decoded = Buffer.from(canonical, "base64"); + if (decoded.toString("base64").replace(/=+$/, "") !== content) { + throw new Error("Invalid base64 content"); + } + return decoded; +} + function decodeContent(data: string, encoding: FileContentEncoding): Buffer { - return Buffer.from(data, encoding === "base64" ? "base64" : "utf8"); + return encoding === "base64" ? decodeBase64(data) : Buffer.from(data, "utf8"); +} + +function utf8SequenceLength(firstByte: number): number { + if (firstByte <= 0x7f) { + return 1; + } + if (firstByte >= 0xc2 && firstByte <= 0xdf) { + return 2; + } + if (firstByte >= 0xe0 && firstByte <= 0xef) { + return 3; + } + if (firstByte >= 0xf0 && firstByte <= 0xf4) { + return 4; + } + return 0; +} + +function utf8ChunkLength( + buffer: Buffer, + requestedBytes: number, + absoluteOffset: number, + reachesEndOfFile: boolean, +): number { + let cursor = 0; + let lastBoundary = 0; + while (cursor < buffer.length) { + if (cursor >= requestedBytes && lastBoundary > 0) { + return lastBoundary; + } + const sequenceLength = utf8SequenceLength(buffer[cursor]!); + if (sequenceLength === 0) { + throw new Error( + `Invalid UTF-8 at byte offset ${absoluteOffset + cursor}; use encoding=base64`, + ); + } + const nextBoundary = cursor + sequenceLength; + if (nextBoundary > buffer.length) { + if (reachesEndOfFile) { + throw new Error( + `Truncated UTF-8 at byte offset ${absoluteOffset + cursor}; use encoding=base64`, + ); + } + break; + } + if (!isUtf8(buffer.subarray(cursor, nextBoundary))) { + throw new Error( + `Invalid UTF-8 at byte offset ${absoluteOffset + cursor}; use encoding=base64`, + ); + } + if (nextBoundary > requestedBytes) { + return lastBoundary === 0 ? nextBoundary : lastBoundary; + } + lastBoundary = nextBoundary; + cursor = nextBoundary; + } + return lastBoundary; } export class FileService { @@ -180,14 +262,24 @@ export class FileService { throw new Error(`${resolvedPath} is not a regular file`); } const safeOffset = Math.max(0, Math.min(offset, info.size)); - const byteCount = Math.max( - 1, - Math.min(maxBytes, this.#options.maxChunkBytes, info.size - safeOffset), - ); + const availableBytes = info.size - safeOffset; + const requestedBytes = Math.min(maxBytes, this.#options.maxChunkBytes, availableBytes); + const probeBytes = encoding === "utf8" + ? Math.min(this.#options.maxChunkBytes, availableBytes, requestedBytes + 3) + : requestedBytes; const handle = await open(resolvedPath, "r"); try { - const buffer = Buffer.alloc(byteCount); - const { bytesRead } = await handle.read(buffer, 0, byteCount, safeOffset); + const buffer = Buffer.alloc(probeBytes); + const readResult = await handle.read(buffer, 0, probeBytes, safeOffset); + let bytesRead = readResult.bytesRead; + if (encoding === "utf8" && bytesRead > 0) { + bytesRead = utf8ChunkLength( + buffer.subarray(0, bytesRead), + requestedBytes, + safeOffset, + safeOffset + readResult.bytesRead >= info.size, + ); + } const data = buffer.subarray(0, bytesRead); const nextOffset = safeOffset + bytesRead; return { @@ -215,15 +307,18 @@ export class FileService { fileMode?: number, ): Promise> { const resolvedPath = this.resolve(inputPath, cwd); + const data = decodeContent(content, encoding); if (createParents) { await mkdir(path.dirname(resolvedPath), { recursive: true }); } - const data = decodeContent(content, encoding); if (mode === "append") { await appendFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode }); } else { await writeFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode }); } + if (fileMode !== undefined) { + await chmod(resolvedPath, fileMode); + } const info = await stat(resolvedPath); return { path: resolvedPath, @@ -242,7 +337,7 @@ export class FileService { createParents: boolean, ): Promise> { const resolvedPath = this.resolve(inputPath, cwd); - const data = Buffer.from(dataBase64, "base64"); + const data = decodeBase64(dataBase64); if (data.length > this.#options.maxChunkBytes) { throw new Error( `Upload chunk is ${data.length} bytes; maximum is ${this.#options.maxChunkBytes}`, @@ -433,20 +528,22 @@ export class FileService { if (!recursive) { throw new Error("recursive=true is required to copy a directory"); } - await cp(source, destination, { recursive: true, force }); - } else { - if (!force) { - try { - await lstat(destination); - throw new Error(`Destination already exists: ${destination}`); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } + } + if (!force) { + try { + await lstat(destination); + throw new Error(`Destination already exists: ${destination}`); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; } } + } + if (sourceInfo.isDirectory()) { + await cp(source, destination, { recursive: true, force, errorOnExist: !force }); + } else { await mkdir(path.dirname(destination), { recursive: true }); - await copyFile(source, destination); + await copyFile(source, destination, force ? 0 : constants.COPYFILE_EXCL); } return { source, destination, copied: true }; } diff --git a/src/file-tools.ts b/src/file-tools.ts index c9b9d7a..1318579 100644 --- a/src/file-tools.ts +++ b/src/file-tools.ts @@ -92,7 +92,7 @@ export function registerFileTools( { title: "Read file", description: - "Read a bounded chunk of any host file as UTF-8 text or base64. Continue with nextOffset until eof=true.", + "Read a bounded chunk of any host file as UTF-8 text or base64. UTF-8 reads preserve character boundaries and may exceed maxBytes by up to three bytes only when one complete character would otherwise not fit. Continue with nextOffset until eof=true.", inputSchema: { path: pathSchema, cwd: cwdSchema, diff --git a/src/http-server.ts b/src/http-server.ts index 01f8675..024fc65 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -3,7 +3,6 @@ import type { Server as HttpServer } from "node:http"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; -import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import express, { type Request, type Response } from "express"; import { createBearerAuth, createHostValidation } from "./auth.js"; @@ -12,10 +11,8 @@ import { errorMessage } from "./errors.js"; import { createMcpServer, type McpServices } from "./mcp-server.js"; import { OAUTH_SCOPES, RemoteDevOAuthProvider } from "./oauth.js"; -interface ActiveSession { - transport: StreamableHTTPServerTransport; +interface ActiveRequest { server: ReturnType; - lastUsedAt: number; } export interface RunningHttpServer { @@ -31,6 +28,26 @@ function rpcError(response: Response, status: number, message: string): void { }); } +function rpcMethod(body: unknown): string | undefined { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return undefined; + } + const method = (body as { method?: unknown }).method; + return typeof method === "string" ? method : undefined; +} + +function rpcToolName(body: unknown): string | undefined { + if (!body || typeof body !== "object" || Array.isArray(body)) { + return undefined; + } + const params = (body as { params?: unknown }).params; + if (!params || typeof params !== "object" || Array.isArray(params)) { + return undefined; + } + const name = (params as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; +} + export async function startHttpServer( config: AppConfig, services: McpServices, @@ -38,10 +55,46 @@ export async function startHttpServer( const app = express(); app.disable("x-powered-by"); app.set("trust proxy", 1); + app.use((request, response, next) => { + if (request.path !== config.endpoint) { + next(); + return; + } + const requestId = randomUUID(); + const startedAt = performance.now(); + let logged = false; + response.set("X-Request-Id", requestId); + const logCompletion = (outcome: "completed" | "aborted") => { + if (logged) { + return; + } + logged = true; + console.log( + JSON.stringify({ + event: "mcp_request", + requestId, + httpMethod: request.method, + rpcMethod: rpcMethod(request.body), + toolName: rpcToolName(request.body), + status: response.statusCode, + outcome, + durationMs: Math.round((performance.now() - startedAt) * 10) / 10, + }), + ); + }; + response.once("finish", () => logCompletion("completed")); + response.once("close", () => { + if (!response.writableEnded) { + logCompletion("aborted"); + } + }); + next(); + }); app.use(express.json({ limit: config.maxRequestBody })); app.use(createHostValidation(config)); - const sessions = new Map(); + const activeRequests = new Set(); + let activeMcpRequests = 0; const oauthProvider = config.oauthEnabled ? new RemoteDevOAuthProvider(config) : undefined; if (oauthProvider) { app.get("/.well-known/oauth-protected-resource", (_request, response) => { @@ -71,7 +124,9 @@ export async function startHttpServer( status: "ok", service: "cokacremote", version: "0.1.0", - activeMcpSessions: sessions.size, + transportMode: "stateless-json", + activeMcpSessions: 0, + activeMcpRequests, managedProcesses: services.processManager.list().length, unrestrictedHostAccess: true, oauthEnabled: config.oauthEnabled, @@ -79,40 +134,29 @@ export async function startHttpServer( }); const postHandler = async (request: Request, response: Response): Promise => { - const sessionId = request.header("mcp-session-id"); - try { - if (sessionId) { - const session = sessions.get(sessionId); - if (!session) { - rpcError(response, 404, "Unknown or expired MCP session"); - return; - } - session.lastUsedAt = Date.now(); - await session.transport.handleRequest(request, response, request.body); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + const server = createMcpServer(config, services); + const activeRequest = { server }; + activeRequests.add(activeRequest); + activeMcpRequests += 1; + let closed = false; + const closeRequest = async (): Promise => { + if (closed) { return; } - - if (!isInitializeRequest(request.body)) { - rpcError(response, 400, "An initialize request or valid MCP session ID is required"); - return; - } - - let activeSession: ActiveSession; - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (initializedSessionId) => { - activeSession.lastUsedAt = Date.now(); - sessions.set(initializedSessionId, activeSession); - }, + closed = true; + activeRequests.delete(activeRequest); + activeMcpRequests = Math.max(0, activeMcpRequests - 1); + await server.close().catch((error) => { + console.error("Failed to close MCP request:", errorMessage(error)); }); - const server = createMcpServer(config, services); - activeSession = { transport, server, lastUsedAt: Date.now() }; - transport.onclose = () => { - const closedSessionId = transport.sessionId; - if (closedSessionId) { - sessions.delete(closedSessionId); - } - }; + }; + response.once("finish", () => void closeRequest()); + response.once("close", () => void closeRequest()); + try { transport.onerror = (error) => { console.error("MCP transport error:", errorMessage(error)); }; @@ -123,40 +167,20 @@ export async function startHttpServer( if (!response.headersSent) { rpcError(response, 500, "Internal MCP server error"); } + await closeRequest(); } }; - const sessionHandler = async (request: Request, response: Response): Promise => { - const sessionId = request.header("mcp-session-id"); - if (!sessionId) { - rpcError(response, 400, "MCP-Session-Id header is required"); - return; - } - const session = sessions.get(sessionId); - if (!session) { - rpcError(response, 404, "Unknown or expired MCP session"); - return; - } - session.lastUsedAt = Date.now(); - try { - await session.transport.handleRequest(request, response); - } catch (error) { - console.error(`MCP ${request.method} failed:`, errorMessage(error)); - if (!response.headersSent) { - rpcError(response, 500, "Internal MCP server error"); - } - } + const methodNotAllowed = (_request: Request, response: Response): void => { + response.set("Allow", "POST"); + rpcError(response, 405, "Stateless MCP accepts POST requests only"); }; app.post(config.endpoint, authenticate, (request, response) => { void postHandler(request, response); }); - app.get(config.endpoint, authenticate, (request, response) => { - void sessionHandler(request, response); - }); - app.delete(config.endpoint, authenticate, (request, response) => { - void sessionHandler(request, response); - }); + app.get(config.endpoint, authenticate, methodNotAllowed); + app.delete(config.endpoint, authenticate, methodNotAllowed); app.use( ( @@ -172,17 +196,8 @@ export async function startHttpServer( ); const cleanupInterval = setInterval(() => { - const cutoff = Date.now() - config.sessionTtlMs; - for (const [sessionId, session] of sessions) { - if (session.lastUsedAt < cutoff) { - sessions.delete(sessionId); - void session.server.close().catch((error) => { - console.error(`Failed to close expired session ${sessionId}:`, errorMessage(error)); - }); - } - } services.processManager.prune(); - }, Math.min(config.sessionTtlMs, 60_000)); + }, Math.min(config.processRetentionMs, 60_000)); cleanupInterval.unref(); const httpServer = await new Promise((resolve, reject) => { @@ -192,9 +207,10 @@ export async function startHttpServer( const close = async (): Promise => { clearInterval(cleanupInterval); - const activeSessions = [...sessions.values()]; - sessions.clear(); - await Promise.allSettled(activeSessions.map((session) => session.server.close())); + const requests = [...activeRequests]; + activeRequests.clear(); + activeMcpRequests = 0; + await Promise.allSettled(requests.map((request) => request.server.close())); await services.processManager.shutdown(); await new Promise((resolve, reject) => { httpServer.close((error) => { @@ -205,6 +221,7 @@ export async function startHttpServer( } }); }); + await new Promise((resolve) => setImmediate(resolve)); }; return { httpServer, close }; diff --git a/test/all-tools.integration.test.ts b/test/all-tools.integration.test.ts new file mode 100644 index 0000000..92eb6ca --- /dev/null +++ b/test/all-tools.integration.test.ts @@ -0,0 +1,803 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import os from "node:os"; +import path from "node:path"; + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { loadConfig } from "../src/config.js"; +import { startHttpServer, type RunningHttpServer } from "../src/http-server.js"; +import { createServices } from "../src/mcp-server.js"; + +const ALL_TOOLS = [ + "apply_patch", + "chmod_path", + "copy_path", + "download_file", + "exec_command", + "hash_file", + "list_directory", + "list_processes", + "make_directory", + "move_path", + "read_file", + "read_process", + "remove_path", + "replace_in_file", + "run_script", + "stat_path", + "terminate_process", + "upload_file", + "write_file", + "write_stdin", +] as const; + +type ToolName = (typeof ALL_TOOLS)[number]; +type ToolResult = Awaited>; + +function structured(result: ToolResult): Record { + return (result.structuredContent ?? {}) as Record; +} + +function errorText(result: ToolResult): string { + return String(structured(result).error ?? ""); +} + +function externalRoot(): string | undefined { + const value = process.env.MCP_E2E_ROOT?.trim(); + if (!value) { + return undefined; + } + if (!/^\/tmp\/cokacremote-tools-e2e-[A-Za-z0-9._-]+$/.test(value)) { + throw new Error( + "MCP_E2E_ROOT must be an isolated /tmp/cokacremote-tools-e2e-* path", + ); + } + return value; +} + +describe.sequential("all registered MCP tools", () => { + let localDirectory: string | undefined; + let testRoot: string; + let running: RunningHttpServer | undefined; + let client: Client; + let transport: StreamableHTTPClientTransport; + const exercised = new Set(); + + const call = async ( + name: ToolName, + arguments_: Record = {}, + ): Promise => { + exercised.add(name); + return client.callTool({ name, arguments: arguments_ }); + }; + + const callOk = async ( + name: ToolName, + arguments_: Record = {}, + ): Promise> => { + const result = await call(name, arguments_); + expect(result.isError, `${name} unexpectedly returned an error: ${errorText(result)}`).not.toBe( + true, + ); + return structured(result); + }; + + const callError = async ( + name: ToolName, + arguments_: Record = {}, + ): Promise => { + const result = await call(name, arguments_); + expect(result.isError, `${name} unexpectedly succeeded`).toBe(true); + expect(errorText(result)).not.toBe(""); + return errorText(result); + }; + + beforeAll(async () => { + const externalUrl = process.env.MCP_E2E_URL?.trim(); + let endpoint: URL; + let authToken: string; + if (externalUrl) { + authToken = process.env.MCP_E2E_TOKEN?.trim() ?? ""; + if (!authToken) { + throw new Error("MCP_E2E_TOKEN is required with MCP_E2E_URL"); + } + testRoot = externalRoot() ?? `/tmp/cokacremote-tools-e2e-${randomUUID()}`; + endpoint = new URL(externalUrl); + } else { + localDirectory = await mkdtemp(path.join(os.tmpdir(), "cokacremote-all-tools-")); + testRoot = path.join(localDirectory, "tool-root"); + authToken = "all-tools-test-secret"; + const config = loadConfig( + { + MCP_AUTH_TOKEN: authToken, + MCP_HOST: "127.0.0.1", + MCP_DEFAULT_CWD: localDirectory, + MCP_MAX_FILE_CHUNK_BYTES: "65536", + }, + localDirectory, + ); + config.port = 0; + running = await startHttpServer(config, createServices(config)); + const address = running.httpServer.address() as AddressInfo; + endpoint = new URL(`http://127.0.0.1:${address.port}${config.endpoint}`); + } + + client = new Client({ name: "all-tools-e2e", version: "1.0.0" }); + transport = new StreamableHTTPClientTransport(endpoint, { + requestInit: { headers: { authorization: `Bearer ${authToken}` } }, + }); + await client.connect(transport); + await callOk("make_directory", { + path: testRoot, + recursive: true, + mode: "0700", + }); + }, 20_000); + + afterAll(async () => { + if (client && testRoot) { + await client + .callTool({ + name: "remove_path", + arguments: { path: testRoot, recursive: true, force: true }, + }) + .catch(() => undefined); + await client.close().catch(() => undefined); + } + await running?.close(); + if (localDirectory) { + await rm(localDirectory, { recursive: true, force: true }); + } + }); + + it("publishes the exact tool inventory and annotations", async () => { + const listed = await client.listTools(); + expect(listed.tools.map((tool) => tool.name).sort()).toEqual([...ALL_TOOLS]); + for (const tool of listed.tools) { + expect(tool.inputSchema.type).toBe("object"); + expect(tool.annotations).toBeDefined(); + } + }); + + it("executes, polls, writes to, times out, lists, and terminates processes", async () => { + const completed = await callOk("exec_command", { + cmd: "printf '%s\\n' \"$E2E_VALUE\"; pwd; printf 'stderr-ok' >&2; exit 7", + workdir: testRoot, + env: { E2E_VALUE: "env-ok" }, + yieldTimeMs: 3000, + }); + expect(completed).toMatchObject({ completed: true, exitCode: 7, stderr: "stderr-ok" }); + expect(String(completed.stdout)).toContain("env-ok"); + expect(String(completed.stdout)).toContain(testRoot); + expect(await callOk("exec_command", { + cmd: "printf shell-ok", + workdir: testRoot, + shell: "/bin/sh", + login: false, + yieldTimeMs: 3000, + })).toMatchObject({ completed: true, exitCode: 0, stdout: "shell-ok" }); + + const bounded = await callOk("exec_command", { + cmd: "node -e \"process.stdout.write('x'.repeat(20000))\"", + workdir: testRoot, + yieldTimeMs: 3000, + maxOutputBytes: 16384, + }); + expect(bounded).toMatchObject({ completed: true, exitCode: 0, hasMore: true }); + let boundedOutput = String(bounded.stdout); + let boundedCursor = Number(bounded.nextSeq); + for (let page = 0; page < 5 && bounded.hasMore === true; page += 1) { + const next = await callOk("read_process", { + sessionId: bounded.sessionId, + afterSeq: boundedCursor, + maxOutputBytes: 16384, + }); + boundedOutput += String(next.stdout); + boundedCursor = Number(next.nextSeq); + bounded.hasMore = next.hasMore; + } + expect(boundedOutput).toBe("x".repeat(20000)); + + const timedOut = await callOk("exec_command", { + cmd: "sleep 10", + workdir: testRoot, + timeoutMs: 100, + yieldTimeMs: 3000, + }); + expect(timedOut).toMatchObject({ completed: true, timedOut: true }); + expect(String(timedOut.error)).toContain("timeout"); + + const interactive = await callOk("exec_command", { + cmd: "node -e \"process.stdin.once('data', d => { process.stdout.write(d); process.exit(0); })\"", + workdir: testRoot, + yieldTimeMs: 0, + }); + expect(interactive).toMatchObject({ running: true, completed: false }); + const interactiveSession = String(interactive.sessionId); + const written = await callOk("write_stdin", { + sessionId: interactiveSession, + chars: "interactive-ok\\n", + closeStdin: true, + yieldTimeMs: 3000, + }); + expect(String(written.stdout)).toContain("interactive-ok"); + const read = await callOk("read_process", { + sessionId: interactiveSession, + afterSeq: written.nextSeq, + waitMs: 1000, + }); + expect(read).toMatchObject({ running: false, completed: true, exitCode: 0 }); + + const script = await callOk("run_script", { + runtime: "node", + script: + "process.stdin.once('data', d => { console.log(JSON.stringify({ arg: process.argv[2], env: process.env.E2E_SCRIPT, stdin: d.toString() })); process.exit(0); });", + workdir: testRoot, + args: ["argument-ok"], + env: { E2E_SCRIPT: "script-env-ok" }, + stdin: "script-stdin-ok", + yieldTimeMs: 3000, + keepScript: true, + }); + expect(script).toMatchObject({ completed: true, exitCode: 0 }); + expect(JSON.parse(String(script.stdout).trim())).toEqual({ + arg: "argument-ok", + env: "script-env-ok", + stdin: "script-stdin-ok", + }); + expect(String(script.scriptPath)).toMatch(/^\/tmp\/remote-dev-mcp-script-/); + const keptScript = await callOk("stat_path", { path: script.scriptPath }); + expect(keptScript).toMatchObject({ type: "file", mode: "0700" }); + await callOk("remove_path", { + path: path.dirname(String(script.scriptPath)), + recursive: true, + force: true, + }); + for (const request of [ + { script: "printf default-bash-ok", expected: "default-bash-ok" }, + { runtime: "bash", script: "printf bash-ok", expected: "bash-ok" }, + { runtime: "sh", script: "printf sh-ok", expected: "sh-ok" }, + { runtime: "python", script: "print('python-ok')", expected: "python-ok\n" }, + { + runtime: "custom", + interpreter: "/bin/sh", + script: "printf custom-ok", + expected: "custom-ok", + }, + ]) { + const runtimeResult = await callOk("run_script", { + ...request, + workdir: testRoot, + yieldTimeMs: 3000, + }); + expect(runtimeResult).toMatchObject({ + completed: true, + exitCode: 0, + stdout: request.expected, + }); + } + expect(await callError("run_script", { runtime: "custom", script: "exit 0" })).toContain( + "interpreter is required", + ); + + const longRunning = await callOk("exec_command", { + cmd: "node -e \"setInterval(() => {}, 1000)\"", + workdir: testRoot, + yieldTimeMs: 0, + }); + const longSession = String(longRunning.sessionId); + const processes = await callOk("list_processes"); + expect(processes.processes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sessionId: longSession, running: true }), + ]), + ); + await callOk("terminate_process", { + sessionId: longSession, + signal: "SIGTERM", + graceMs: 1000, + }); + const terminated = await callOk("read_process", { + sessionId: longSession, + waitMs: 2000, + }); + expect(terminated).toMatchObject({ running: false, completed: true, signal: "SIGTERM" }); + }, 30_000); + + it("handles text, metadata, listings, permissions, and unified patches", async () => { + await callOk("make_directory", { + path: "text/nested", + cwd: testRoot, + recursive: true, + mode: "0750", + }); + expect(await callError("make_directory", { + path: "missing-parent/child", + cwd: testRoot, + recursive: false, + })).toMatch(/ENOENT|no such file/i); + + const unicodeText = "A😀한글B"; + await callOk("write_file", { + path: "text/nested/unicode.txt", + cwd: testRoot, + content: unicodeText, + fileMode: "0640", + }); + expect(await callOk("stat_path", { + path: "text/nested/unicode.txt", + cwd: testRoot, + })).toMatchObject({ type: "file", mode: "0640" }); + + await callOk("chmod_path", { + path: "text/nested/unicode.txt", + cwd: testRoot, + mode: "0600", + }); + await callOk("write_file", { + path: "text/nested/unicode.txt", + cwd: testRoot, + content: unicodeText, + mode: "overwrite", + fileMode: "0644", + }); + expect(await callOk("stat_path", { + path: "text/nested/unicode.txt", + cwd: testRoot, + })).toMatchObject({ mode: "0644" }); + + let offset = 0; + let reconstructed = ""; + for (let part = 0; part < 20; part += 1) { + const chunk = await callOk("read_file", { + path: "text/nested/unicode.txt", + cwd: testRoot, + offset, + maxBytes: 3, + encoding: "utf8", + }); + reconstructed += String(chunk.content); + const nextOffset = Number(chunk.nextOffset); + expect(nextOffset).toBeGreaterThan(offset); + offset = nextOffset; + if (chunk.eof === true) { + break; + } + } + expect(reconstructed).toBe(unicodeText); + + const encoded = await callOk("read_file", { + path: "text/nested/unicode.txt", + cwd: testRoot, + maxBytes: 65536, + encoding: "base64", + }); + expect(Buffer.from(String(encoded.content), "base64").toString("utf8")).toBe(unicodeText); + expect(await callError("read_file", { + path: "text", + cwd: testRoot, + })).toContain("not a regular file"); + + expect(await callError("write_file", { + path: "text/invalid-base64.bin", + cwd: testRoot, + content: "%%%not-base64%%%", + encoding: "base64", + })).toMatch(/base64/i); + expect(await callError("write_file", { + path: "text/no-parent/value.txt", + cwd: testRoot, + content: "must fail", + createParents: false, + })).toMatch(/ENOENT|no such file/i); + + await callOk("write_file", { + path: "text/append.txt", + cwd: testRoot, + content: "first", + }); + await callOk("write_file", { + path: "text/append.txt", + cwd: testRoot, + content: "-second", + mode: "append", + }); + expect(await callOk("read_file", { + path: "text/append.txt", + cwd: testRoot, + })).toMatchObject({ content: "first-second", eof: true }); + await callOk("write_file", { + path: "text/empty.txt", + cwd: testRoot, + content: "", + }); + expect(await callOk("read_file", { + path: "text/empty.txt", + cwd: testRoot, + maxBytes: 1, + })).toMatchObject({ content: "", bytesRead: 0, eof: true }); + + await callOk("write_file", { + path: "text/replace.txt", + cwd: testRoot, + content: "one two one\n", + }); + expect(await callError("replace_in_file", { + path: "text/replace.txt", + cwd: testRoot, + oldText: "one", + newText: "ONE", + })).toContain("found 2"); + await callOk("replace_in_file", { + path: "text/replace.txt", + cwd: testRoot, + oldText: "one", + newText: "ONE", + replaceAll: true, + expectedOccurrences: 2, + }); + await callOk("replace_in_file", { + path: "text/replace.txt", + cwd: testRoot, + oldText: "two", + newText: "three", + expectedOccurrences: 1, + }); + expect(await callOk("read_file", { + path: "text/replace.txt", + cwd: testRoot, + })).toMatchObject({ content: "ONE three ONE\n", eof: true }); + + await callOk("write_file", { + path: "text/.hidden", + cwd: testRoot, + content: "hidden", + }); + const visible = await callOk("list_directory", { + path: "text", + cwd: testRoot, + recursive: true, + maxDepth: 8, + includeHidden: false, + includeMetadata: true, + }); + expect(visible.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ relativePath: "nested/unicode.txt", type: "file" }), + ]), + ); + expect(visible.entries).not.toEqual( + expect.arrayContaining([expect.objectContaining({ name: ".hidden" })]), + ); + expect(await callOk("list_directory", { + path: "text", + cwd: testRoot, + recursive: true, + maxEntries: 2, + })).toMatchObject({ count: 2, truncated: true }); + + await callOk("exec_command", { + cmd: "ln -s nested/unicode.txt text/unicode-link", + workdir: testRoot, + yieldTimeMs: 3000, + }); + expect(await callOk("stat_path", { + path: "text/unicode-link", + cwd: testRoot, + })).toMatchObject({ type: "symlink", symlinkTarget: "nested/unicode.txt" }); + + await callOk("write_file", { + path: "patch-target.txt", + cwd: testRoot, + content: "old\n", + }); + const patch = [ + "diff --git a/patch-target.txt b/patch-target.txt", + "--- a/patch-target.txt", + "+++ b/patch-target.txt", + "@@ -1 +1 @@", + "-old", + "+new", + "", + ].join("\n"); + expect(await callOk("apply_patch", { + patch, + cwd: testRoot, + checkOnly: true, + })).toMatchObject({ applied: false, checkOnly: true }); + expect(await callOk("read_file", { + path: "patch-target.txt", + cwd: testRoot, + })).toMatchObject({ content: "old\n" }); + expect(await callOk("apply_patch", { patch, cwd: testRoot })).toMatchObject({ + applied: true, + checkOnly: false, + }); + expect(await callOk("apply_patch", { + patch, + cwd: testRoot, + reverse: true, + })).toMatchObject({ applied: true }); + expect(await callOk("read_file", { + path: "patch-target.txt", + cwd: testRoot, + })).toMatchObject({ content: "old\n" }); + expect(await callError("apply_patch", { + patch: "this is not a unified patch", + cwd: testRoot, + })).not.toBe(""); + + await callOk("make_directory", { + path: "three-way", + cwd: testRoot, + recursive: true, + }); + await callOk("write_file", { + path: "three-way/value.txt", + cwd: testRoot, + content: "base\n", + }); + await callOk("exec_command", { + cmd: "git init -q && git config user.email e2e@example.invalid && git config user.name E2E && git add value.txt && git commit -qm base", + workdir: path.join(testRoot, "three-way"), + yieldTimeMs: 3000, + }); + await callOk("write_file", { + path: "value.txt", + cwd: path.join(testRoot, "three-way"), + content: "three-way-result\n", + }); + const generatedPatch = await callOk("exec_command", { + cmd: "git diff --binary -- value.txt", + workdir: path.join(testRoot, "three-way"), + yieldTimeMs: 3000, + }); + await callOk("exec_command", { + cmd: "git checkout -- value.txt", + workdir: path.join(testRoot, "three-way"), + yieldTimeMs: 3000, + }); + expect(await callOk("apply_patch", { + patch: generatedPatch.stdout, + cwd: path.join(testRoot, "three-way"), + threeWay: true, + })).toMatchObject({ applied: true }); + expect(await callOk("read_file", { + path: "value.txt", + cwd: path.join(testRoot, "three-way"), + })).toMatchObject({ content: "three-way-result\n" }); + }, 30_000); + + it("transfers, hashes, copies, moves, and removes isolated paths", async () => { + await callOk("make_directory", { + path: "transfer", + cwd: testRoot, + recursive: true, + }); + expect(await callError("upload_file", { + path: "transfer/invalid.bin", + cwd: testRoot, + dataBase64: "not@base64", + truncate: true, + })).toMatch(/base64/i); + + const binary = Buffer.from(Array.from({ length: 1024 }, (_, index) => index % 256)); + const first = binary.subarray(0, 333); + const second = binary.subarray(333); + await callOk("write_file", { + path: "transfer/artifact.bin", + cwd: testRoot, + content: "stale content that must be truncated", + }); + const firstUpload = await callOk("upload_file", { + path: "transfer/artifact.bin", + cwd: testRoot, + dataBase64: first.toString("base64"), + offset: 0, + truncate: true, + }); + expect(firstUpload).toMatchObject({ + bytesWritten: first.length, + nextOffset: first.length, + chunkSha256: createHash("sha256").update(first).digest("hex"), + }); + await callOk("upload_file", { + path: "transfer/artifact.bin", + cwd: testRoot, + dataBase64: second.toString("base64"), + offset: first.length, + truncate: false, + }); + + let downloaded = Buffer.alloc(0); + let offset = 0; + for (let part = 0; part < 20; part += 1) { + const chunk = await callOk("download_file", { + path: "transfer/artifact.bin", + cwd: testRoot, + offset, + maxBytes: 113, + }); + downloaded = Buffer.concat([ + downloaded, + Buffer.from(String(chunk.dataBase64), "base64"), + ]); + offset = Number(chunk.nextOffset); + if (chunk.eof === true) { + break; + } + } + expect(downloaded).toEqual(binary); + expect(await callOk("download_file", { + path: "transfer/artifact.bin", + cwd: testRoot, + offset: binary.length, + maxBytes: 1, + })).toMatchObject({ bytesRead: 0, eof: true, nextOffset: binary.length }); + for (const algorithm of ["sha256", "sha512", "md5"] as const) { + expect(await callOk("hash_file", { + path: "transfer/artifact.bin", + cwd: testRoot, + algorithm, + })).toMatchObject({ + algorithm, + digest: createHash(algorithm).update(binary).digest("hex"), + }); + } + + await callOk("write_file", { + path: "transfer/base64-copy.bin", + cwd: testRoot, + content: binary.toString("base64"), + encoding: "base64", + }); + expect(await callOk("hash_file", { + path: "transfer/base64-copy.bin", + cwd: testRoot, + })).toMatchObject({ digest: createHash("sha256").update(binary).digest("hex") }); + + await callOk("write_file", { + path: "transfer/existing.bin", + cwd: testRoot, + content: "existing", + }); + expect(await callError("copy_path", { + sourcePath: "transfer/artifact.bin", + destinationPath: "transfer/existing.bin", + cwd: testRoot, + force: false, + })).toContain("already exists"); + expect(await callError("copy_path", { + sourcePath: "transfer/artifact.bin", + destinationPath: "transfer/artifact.bin", + cwd: testRoot, + })).toContain("must be different"); + await callOk("copy_path", { + sourcePath: "transfer/artifact.bin", + destinationPath: "transfer/copied.bin", + cwd: testRoot, + force: true, + }); + expect(await callOk("hash_file", { + path: "transfer/copied.bin", + cwd: testRoot, + })).toMatchObject({ digest: createHash("sha256").update(binary).digest("hex") }); + + await callOk("make_directory", { + path: "transfer/source-dir", + cwd: testRoot, + recursive: true, + }); + await callOk("write_file", { + path: "transfer/source-dir/value.txt", + cwd: testRoot, + content: "source", + }); + await callOk("make_directory", { + path: "transfer/destination-dir", + cwd: testRoot, + recursive: true, + }); + await callOk("write_file", { + path: "transfer/destination-dir/value.txt", + cwd: testRoot, + content: "destination", + }); + expect(await callError("copy_path", { + sourcePath: "transfer/source-dir", + destinationPath: "transfer/destination-dir", + cwd: testRoot, + recursive: false, + })).toContain("recursive=true"); + expect(await callError("copy_path", { + sourcePath: "transfer/source-dir", + destinationPath: "transfer/destination-dir", + cwd: testRoot, + recursive: true, + force: false, + })).toContain("already exists"); + await callOk("copy_path", { + sourcePath: "transfer/source-dir", + destinationPath: "transfer/destination-dir", + cwd: testRoot, + recursive: true, + force: true, + }); + expect(await callOk("read_file", { + path: "transfer/destination-dir/value.txt", + cwd: testRoot, + })).toMatchObject({ content: "source" }); + + await callOk("write_file", { + path: "transfer/move-source.txt", + cwd: testRoot, + content: "move-source", + }); + await callOk("write_file", { + path: "transfer/move-destination.txt", + cwd: testRoot, + content: "move-destination", + }); + expect(await callError("move_path", { + sourcePath: "transfer/move-source.txt", + destinationPath: "transfer/move-destination.txt", + cwd: testRoot, + overwrite: false, + })).toContain("already exists"); + expect(await callOk("move_path", { + sourcePath: "transfer/move-source.txt", + destinationPath: "transfer/move-destination.txt", + cwd: testRoot, + overwrite: true, + })).toMatchObject({ moved: true }); + expect(await callOk("read_file", { + path: "transfer/move-destination.txt", + cwd: testRoot, + })).toMatchObject({ content: "move-source" }); + expect(await callOk("move_path", { + sourcePath: "transfer/move-destination.txt", + destinationPath: "transfer/move-destination.txt", + cwd: testRoot, + })).toMatchObject({ moved: false, samePath: true }); + + await callOk("make_directory", { + path: "transfer/remove-dir/nested", + cwd: testRoot, + recursive: true, + }); + await callOk("write_file", { + path: "transfer/remove-dir/nested/value.txt", + cwd: testRoot, + content: "remove", + }); + expect(await callError("remove_path", { + path: "transfer/remove-dir", + cwd: testRoot, + recursive: false, + force: false, + })).toMatch(/directory|EISDIR|recursive/i); + await callOk("remove_path", { + path: "transfer/remove-dir", + cwd: testRoot, + recursive: true, + force: false, + }); + expect(await callError("stat_path", { + path: "transfer/remove-dir", + cwd: testRoot, + })).toMatch(/ENOENT|no such file/i); + expect(await callOk("remove_path", { + path: "transfer/does-not-exist", + cwd: testRoot, + force: true, + })).toMatchObject({ removed: true }); + }, 30_000); + + it("exercises every published tool through MCP", () => { + expect([...exercised].sort()).toEqual([...ALL_TOOLS]); + }); +}); diff --git a/test/file-service.test.ts b/test/file-service.test.ts index 75ea22d..8612714 100644 --- a/test/file-service.test.ts +++ b/test/file-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -127,4 +127,133 @@ describe("FileService", () => { "new\n", ); }); + + it("preserves UTF-8 characters across every small chunk boundary", async () => { + const text = "ASCII¢한😀끝"; + await files.writeFileContent( + "unicode.txt", + undefined, + text, + "utf8", + "overwrite", + true, + ); + + for (let maxBytes = 1; maxBytes <= 8; maxBytes += 1) { + let offset = 0; + let reconstructed = ""; + for (let part = 0; part < 100; part += 1) { + const chunk = await files.readFileChunk( + "unicode.txt", + undefined, + offset, + maxBytes, + "utf8", + ); + reconstructed += String(chunk.content); + const nextOffset = Number(chunk.nextOffset); + expect(nextOffset).toBeGreaterThan(offset); + expect(Number(chunk.bytesRead)).toBeLessThanOrEqual(maxBytes + 3); + offset = nextOffset; + if (chunk.eof === true) { + break; + } + } + expect(reconstructed).toBe(text); + } + }); + + it("rejects invalid base64 and invalid UTF-8 without silent corruption", async () => { + await expect( + files.writeFileContent( + "invalid-write.bin", + undefined, + "%%%not-base64%%%", + "base64", + "overwrite", + true, + ), + ).rejects.toThrow("base64"); + await expect( + files.uploadChunk( + "invalid-upload.bin", + undefined, + "not@base64", + 0, + true, + true, + ), + ).rejects.toThrow("base64"); + + await files.writeFileContent( + "invalid-utf8.bin", + undefined, + Buffer.from([0xff, 0xfe]).toString("base64"), + "base64", + "overwrite", + true, + ); + await expect( + files.readFileChunk("invalid-utf8.bin", undefined, 0, 8, "utf8"), + ).rejects.toThrow("Invalid UTF-8"); + const binary = await files.readFileChunk( + "invalid-utf8.bin", + undefined, + 0, + 8, + "base64", + ); + expect(binary.content).toBe("//4="); + }); + + it("applies an explicit file mode when overwriting an existing file", async () => { + await files.writeFileContent( + "mode.txt", + undefined, + "first", + "utf8", + "overwrite", + true, + 0o600, + ); + await files.writeFileContent( + "mode.txt", + undefined, + "second", + "utf8", + "overwrite", + true, + 0o644, + ); + expect((await stat(path.join(temporaryDirectory, "mode.txt"))).mode & 0o777).toBe( + 0o644, + ); + }); + + it("rejects a non-forced directory copy when the destination exists", async () => { + await files.makeDirectory("source", undefined, true); + await files.makeDirectory("destination", undefined, true); + await files.writeFileContent( + "source/value.txt", + undefined, + "source", + "utf8", + "overwrite", + true, + ); + await files.writeFileContent( + "destination/value.txt", + undefined, + "destination", + "utf8", + "overwrite", + true, + ); + + await expect( + files.copyPath("source", "destination", undefined, true, false), + ).rejects.toThrow("already exists"); + expect(await readFile(path.join(temporaryDirectory, "destination/value.txt"), "utf8")) + .toBe("destination"); + }); }); diff --git a/test/mcp.integration.test.ts b/test/mcp.integration.test.ts index 63e5738..78fe70e 100644 --- a/test/mcp.integration.test.ts +++ b/test/mcp.integration.test.ts @@ -11,6 +11,13 @@ import { loadConfig, type AppConfig } from "../src/config.js"; import { startHttpServer, type RunningHttpServer } from "../src/http-server.js"; import { createServices, type McpServices } from "../src/mcp-server.js"; +interface JsonRpcResponse { + result?: { + tools?: Array<{ name: string }>; + structuredContent?: Record; + }; +} + describe("remote development MCP server", () => { let temporaryDirectory: string; let config: AppConfig; @@ -69,6 +76,7 @@ describe("remote development MCP server", () => { }); await client.connect(transport); try { + expect(transport.sessionId).toBeUndefined(); expect(client.getServerVersion()).toMatchObject({ name: "cokacremote", version: "0.1.0", @@ -121,4 +129,121 @@ describe("remote development MCP server", () => { await client.close(); } }); + + it("handles every tool call as an independent stateless request", async () => { + const post = async ( + body: unknown, + additionalHeaders: Record = {}, + ): Promise => + fetch(endpoint, { + method: "POST", + headers: { + authorization: "Bearer integration-secret", + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...additionalHeaders, + }, + body: JSON.stringify(body), + }); + + const initializeResponse = await post({ + jsonrpc: "2.0", + id: 10, + method: "initialize", + params: { + protocolVersion: "2025-11-25", + capabilities: {}, + clientInfo: { name: "stateless-test", version: "1" }, + }, + }); + expect(initializeResponse.status).toBe(200); + expect(initializeResponse.headers.get("content-type")).toContain("application/json"); + expect(initializeResponse.headers.get("mcp-session-id")).toBeNull(); + expect(initializeResponse.headers.get("x-request-id")).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + + const listResponse = await post( + { + jsonrpc: "2.0", + id: 11, + method: "tools/list", + params: {}, + }, + { "mcp-session-id": "stale-session-from-the-previous-deployment" }, + ); + expect(listResponse.status).toBe(200); + const listed = (await listResponse.json()) as JsonRpcResponse; + expect(listed.result?.tools?.map((tool) => tool.name)).toContain("exec_command"); + + const startResponse = await post({ + jsonrpc: "2.0", + id: 12, + method: "tools/call", + params: { + name: "exec_command", + arguments: { + cmd: "node -e \"setTimeout(() => console.log('stateless-ok'), 100)\"", + yieldTimeMs: 0, + }, + }, + }); + expect(startResponse.status).toBe(200); + const started = (await startResponse.json()) as JsonRpcResponse; + const sessionId = started.result?.structuredContent?.sessionId; + expect(sessionId).toEqual(expect.any(String)); + expect(started.result?.structuredContent).toMatchObject({ + running: true, + completed: false, + }); + + const readResponse = await post({ + jsonrpc: "2.0", + id: 13, + method: "tools/call", + params: { + name: "read_process", + arguments: { sessionId, waitMs: 3000 }, + }, + }); + expect(readResponse.status).toBe(200); + const read = (await readResponse.json()) as JsonRpcResponse; + expect(read.result?.structuredContent?.stdout).toContain("stateless-ok"); + const nextSeq = read.result?.structuredContent?.nextSeq; + expect(nextSeq).toEqual(expect.any(Number)); + + const completionResponse = await post({ + jsonrpc: "2.0", + id: 14, + method: "tools/call", + params: { + name: "read_process", + arguments: { sessionId, afterSeq: nextSeq, waitMs: 3000 }, + }, + }); + expect(completionResponse.status).toBe(200); + const completion = (await completionResponse.json()) as JsonRpcResponse; + expect(completion.result?.structuredContent).toMatchObject({ + running: false, + completed: true, + exitCode: 0, + }); + + const getResponse = await fetch(endpoint, { + headers: { + authorization: "Bearer integration-secret", + accept: "text/event-stream", + }, + }); + expect(getResponse.status).toBe(405); + expect(getResponse.headers.get("allow")).toBe("POST"); + + const healthResponse = await fetch(new URL("/health", endpoint)); + expect(await healthResponse.json()).toMatchObject({ + status: "ok", + transportMode: "stateless-json", + activeMcpSessions: 0, + activeMcpRequests: 0, + }); + }); });