This commit is contained in:
kst
2026-08-20 00:16:20 +09:00
parent 72a5fdc453
commit efbc533bc1
10 changed files with 1345 additions and 116 deletions
-1
View File
@@ -18,6 +18,5 @@ MCP_MAX_OUTPUT_BYTES=1048576
MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304 MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304
MCP_PROCESS_RETENTION_MS=3600000 MCP_PROCESS_RETENTION_MS=3600000
MCP_MAX_PROCESSES=128 MCP_MAX_PROCESSES=128
MCP_SESSION_TTL_MS=86400000
MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_FILE_CHUNK_BYTES=1048576
MCP_MAX_EDIT_FILE_BYTES=67108864 MCP_MAX_EDIT_FILE_BYTES=67108864
+75 -8
View File
@@ -11,7 +11,7 @@ VPS 또는 EC2 인스턴스에서 상시 실행하는 Node.js 원격 개발 MCP
- 장기 실행 프로세스의 출력 폴링, 표준입력 전달과 종료 제어 - 장기 실행 프로세스의 출력 폴링, 표준입력 전달과 종료 제어
- 절대경로를 포함한 호스트 파일 읽기·쓰기·수정·전송 및 삭제 - 절대경로를 포함한 호스트 파일 읽기·쓰기·수정·전송 및 삭제
- 정적 Bearer 인증과 ChatGPT용 OAuth 2.1/DCR/PKCE 내장 - 정적 Bearer 인증과 ChatGPT용 OAuth 2.1/DCR/PKCE 내장
- 프로세스별 출력 보관, MCP 세션 관리와 전송 크기 제한 - 요청별 stateless JSON 전송, 프로세스별 출력 보관과 전송 크기 제한
- Linux VPS/EC2용 systemd 및 Nginx 배포 예제 - Linux VPS/EC2용 systemd 및 Nginx 배포 예제
## 제공 도구 ## 제공 도구
@@ -36,6 +36,25 @@ VPS 또는 EC2 인스턴스에서 상시 실행하는 Node.js 원격 개발 MCP
총 20개 도구를 제공합니다. `remove_path`는 휴지통을 사용하지 않고 대상을 영구 삭제하며, `apply_patch`는 호스트의 `git apply --unsafe-paths`를 사용합니다. 총 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 - Node.js 22 이상과 npm
@@ -145,7 +164,7 @@ sudo systemctl status remote-dev-mcp
`/usr/bin/node`가 실제 Node.js 경로와 다르면 systemd 파일의 `ExecStart`를 수정합니다. `which node`로 확인할 수 있습니다. `/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 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/` 프록시를 확인합니다. - `Error fetching OAuth configuration`: `MCP_OAUTH_ENABLED`, 공개 URL 및 Nginx의 `/.well-known/` 프록시를 확인합니다.
- MCP 요청의 `401 Unauthorized`: Bearer 토큰 또는 OAuth access token을 확인합니다. - MCP 요청의 `401 Unauthorized`: Bearer 토큰 또는 OAuth access token을 확인합니다.
- `403 Host header is not allowed`: 요청 도메인을 `MCP_ALLOWED_HOSTS`에 추가합니다. - `403 Host header is not allowed`: 요청 도메인을 `MCP_ALLOWED_HOSTS`에 추가합니다.
- 명령이 즉시 끝나지 않고 `sessionId`를 반환: `read_process`로 폴링하거나 `write_stdin`으로 입력을 보냅니다. - 명령이 즉시 끝나지 않고 `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 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='<bearer-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_MAX_RETAINED_PROCESS_OUTPUT_BYTES` | `4194304` | 프로세스별 보관 출력 |
| `MCP_PROCESS_RETENTION_MS` | `3600000` | 완료 프로세스 보관 시간 | | `MCP_PROCESS_RETENTION_MS` | `3600000` | 완료 프로세스 보관 시간 |
| `MCP_MAX_PROCESSES` | `128` | 동시에 보관할 프로세스 세션 수 | | `MCP_MAX_PROCESSES` | `128` | 동시에 보관할 프로세스 세션 수 |
| `MCP_SESSION_TTL_MS` | `86400000` | 유휴 MCP 세션 보관 시간 | | `MCP_MAX_FILE_CHUNK_BYTES` | `1048576` | 파일 청크 최대 크기. UTF-8 읽기도 이 상한을 넘지 않음 |
| `MCP_MAX_FILE_CHUNK_BYTES` | `1048576` | 파일 전송 청크 크기 |
| `MCP_MAX_EDIT_FILE_BYTES` | `67108864` | 텍스트 교체 대상 파일의 최대 크기 | | `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/mcp-server.ts` | MCP 서버 정보와 도구 등록 |
| `src/exec-tools.ts` | 명령·스크립트·장기 프로세스 도구 | | `src/exec-tools.ts` | 명령·스크립트·장기 프로세스 도구 |
| `src/file-service.ts` | 파일 읽기·쓰기·전송과 경로 작업 구현 |
| `src/file-tools.ts` | 파일 시스템 도구와 입력 스키마 | | `src/file-tools.ts` | 파일 시스템 도구와 입력 스키마 |
| `src/oauth.ts` | DCR, PKCE, token 발급·갱신·폐기와 승인 화면 | | `src/oauth.ts` | DCR, PKCE, token 발급·갱신·폐기와 승인 화면 |
| `deploy/` | systemd, 환경 파일과 Nginx 예제 | | `deploy/` | systemd, 환경 파일과 Nginx 예제 |
| `test/` | 설정, 파일, 프로세스, MCP 및 OAuth 통합 테스트 | | `test/all-tools.integration.test.ts` | 20개 도구 전체와 외부 엔드포인트 E2E 테스트 |
| `test/` | 설정, 파일, 프로세스, MCP 및 OAuth 단위·통합 테스트 |
## 라이선스 ## 라이선스
-1
View File
@@ -19,6 +19,5 @@ MCP_MAX_OUTPUT_BYTES=1048576
MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304 MCP_MAX_RETAINED_PROCESS_OUTPUT_BYTES=4194304
MCP_PROCESS_RETENTION_MS=3600000 MCP_PROCESS_RETENTION_MS=3600000
MCP_MAX_PROCESSES=128 MCP_MAX_PROCESSES=128
MCP_SESSION_TTL_MS=86400000
MCP_MAX_FILE_CHUNK_BYTES=1048576 MCP_MAX_FILE_CHUNK_BYTES=1048576
MCP_MAX_EDIT_FILE_BYTES=67108864 MCP_MAX_EDIT_FILE_BYTES=67108864
-7
View File
@@ -22,7 +22,6 @@ export interface AppConfig {
maxRetainedProcessOutputBytes: number; maxRetainedProcessOutputBytes: number;
processRetentionMs: number; processRetentionMs: number;
maxProcesses: number; maxProcesses: number;
sessionTtlMs: number;
maxFileChunkBytes: number; maxFileChunkBytes: number;
maxEditFileBytes: number; maxEditFileBytes: number;
} }
@@ -178,12 +177,6 @@ export function loadConfig(
"MCP_MAX_PROCESSES", "MCP_MAX_PROCESSES",
1, 1,
), ),
sessionTtlMs: parseInteger(
env.MCP_SESSION_TTL_MS,
24 * 60 * 60 * 1000,
"MCP_SESSION_TTL_MS",
60_000,
),
maxFileChunkBytes: parseInteger( maxFileChunkBytes: parseInteger(
env.MCP_MAX_FILE_CHUNK_BYTES, env.MCP_MAX_FILE_CHUNK_BYTES,
1024 * 1024, 1024 * 1024,
+118 -21
View File
@@ -1,5 +1,6 @@
import { createHash, randomUUID } from "node:crypto"; import { createHash, randomUUID } from "node:crypto";
import { createReadStream } from "node:fs"; import { isUtf8 } from "node:buffer";
import { constants, createReadStream } from "node:fs";
import { import {
appendFile, appendFile,
chmod, chmod,
@@ -71,8 +72,89 @@ function encodeContent(data: Buffer, encoding: FileContentEncoding): string {
return encoding === "base64" ? data.toString("base64") : data.toString("utf8"); 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 { 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 { export class FileService {
@@ -180,14 +262,24 @@ export class FileService {
throw new Error(`${resolvedPath} is not a regular file`); throw new Error(`${resolvedPath} is not a regular file`);
} }
const safeOffset = Math.max(0, Math.min(offset, info.size)); const safeOffset = Math.max(0, Math.min(offset, info.size));
const byteCount = Math.max( const availableBytes = info.size - safeOffset;
1, const requestedBytes = Math.min(maxBytes, this.#options.maxChunkBytes, availableBytes);
Math.min(maxBytes, this.#options.maxChunkBytes, info.size - safeOffset), const probeBytes = encoding === "utf8"
); ? Math.min(this.#options.maxChunkBytes, availableBytes, requestedBytes + 3)
: requestedBytes;
const handle = await open(resolvedPath, "r"); const handle = await open(resolvedPath, "r");
try { try {
const buffer = Buffer.alloc(byteCount); const buffer = Buffer.alloc(probeBytes);
const { bytesRead } = await handle.read(buffer, 0, byteCount, safeOffset); 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 data = buffer.subarray(0, bytesRead);
const nextOffset = safeOffset + bytesRead; const nextOffset = safeOffset + bytesRead;
return { return {
@@ -215,15 +307,18 @@ export class FileService {
fileMode?: number, fileMode?: number,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const resolvedPath = this.resolve(inputPath, cwd); const resolvedPath = this.resolve(inputPath, cwd);
const data = decodeContent(content, encoding);
if (createParents) { if (createParents) {
await mkdir(path.dirname(resolvedPath), { recursive: true }); await mkdir(path.dirname(resolvedPath), { recursive: true });
} }
const data = decodeContent(content, encoding);
if (mode === "append") { if (mode === "append") {
await appendFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode }); await appendFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode });
} else { } else {
await writeFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode }); await writeFile(resolvedPath, data, fileMode === undefined ? undefined : { mode: fileMode });
} }
if (fileMode !== undefined) {
await chmod(resolvedPath, fileMode);
}
const info = await stat(resolvedPath); const info = await stat(resolvedPath);
return { return {
path: resolvedPath, path: resolvedPath,
@@ -242,7 +337,7 @@ export class FileService {
createParents: boolean, createParents: boolean,
): Promise<Record<string, unknown>> { ): Promise<Record<string, unknown>> {
const resolvedPath = this.resolve(inputPath, cwd); const resolvedPath = this.resolve(inputPath, cwd);
const data = Buffer.from(dataBase64, "base64"); const data = decodeBase64(dataBase64);
if (data.length > this.#options.maxChunkBytes) { if (data.length > this.#options.maxChunkBytes) {
throw new Error( throw new Error(
`Upload chunk is ${data.length} bytes; maximum is ${this.#options.maxChunkBytes}`, `Upload chunk is ${data.length} bytes; maximum is ${this.#options.maxChunkBytes}`,
@@ -433,20 +528,22 @@ export class FileService {
if (!recursive) { if (!recursive) {
throw new Error("recursive=true is required to copy a directory"); throw new Error("recursive=true is required to copy a directory");
} }
await cp(source, destination, { recursive: true, force }); }
} else { if (!force) {
if (!force) { try {
try { await lstat(destination);
await lstat(destination); throw new Error(`Destination already exists: ${destination}`);
throw new Error(`Destination already exists: ${destination}`); } catch (error) {
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error;
throw error;
}
} }
} }
}
if (sourceInfo.isDirectory()) {
await cp(source, destination, { recursive: true, force, errorOnExist: !force });
} else {
await mkdir(path.dirname(destination), { recursive: true }); 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 }; return { source, destination, copied: true };
} }
+1 -1
View File
@@ -92,7 +92,7 @@ export function registerFileTools(
{ {
title: "Read file", title: "Read file",
description: 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: { inputSchema: {
path: pathSchema, path: pathSchema,
cwd: cwdSchema, cwd: cwdSchema,
+93 -76
View File
@@ -3,7 +3,6 @@ import type { Server as HttpServer } from "node:http";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.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 express, { type Request, type Response } from "express";
import { createBearerAuth, createHostValidation } from "./auth.js"; import { createBearerAuth, createHostValidation } from "./auth.js";
@@ -12,10 +11,8 @@ import { errorMessage } from "./errors.js";
import { createMcpServer, type McpServices } from "./mcp-server.js"; import { createMcpServer, type McpServices } from "./mcp-server.js";
import { OAUTH_SCOPES, RemoteDevOAuthProvider } from "./oauth.js"; import { OAUTH_SCOPES, RemoteDevOAuthProvider } from "./oauth.js";
interface ActiveSession { interface ActiveRequest {
transport: StreamableHTTPServerTransport;
server: ReturnType<typeof createMcpServer>; server: ReturnType<typeof createMcpServer>;
lastUsedAt: number;
} }
export interface RunningHttpServer { 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( export async function startHttpServer(
config: AppConfig, config: AppConfig,
services: McpServices, services: McpServices,
@@ -38,10 +55,46 @@ export async function startHttpServer(
const app = express(); const app = express();
app.disable("x-powered-by"); app.disable("x-powered-by");
app.set("trust proxy", 1); 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(express.json({ limit: config.maxRequestBody }));
app.use(createHostValidation(config)); app.use(createHostValidation(config));
const sessions = new Map<string, ActiveSession>(); const activeRequests = new Set<ActiveRequest>();
let activeMcpRequests = 0;
const oauthProvider = config.oauthEnabled ? new RemoteDevOAuthProvider(config) : undefined; const oauthProvider = config.oauthEnabled ? new RemoteDevOAuthProvider(config) : undefined;
if (oauthProvider) { if (oauthProvider) {
app.get("/.well-known/oauth-protected-resource", (_request, response) => { app.get("/.well-known/oauth-protected-resource", (_request, response) => {
@@ -71,7 +124,9 @@ export async function startHttpServer(
status: "ok", status: "ok",
service: "cokacremote", service: "cokacremote",
version: "0.1.0", version: "0.1.0",
activeMcpSessions: sessions.size, transportMode: "stateless-json",
activeMcpSessions: 0,
activeMcpRequests,
managedProcesses: services.processManager.list().length, managedProcesses: services.processManager.list().length,
unrestrictedHostAccess: true, unrestrictedHostAccess: true,
oauthEnabled: config.oauthEnabled, oauthEnabled: config.oauthEnabled,
@@ -79,40 +134,29 @@ export async function startHttpServer(
}); });
const postHandler = async (request: Request, response: Response): Promise<void> => { const postHandler = async (request: Request, response: Response): Promise<void> => {
const sessionId = request.header("mcp-session-id"); const transport = new StreamableHTTPServerTransport({
try { sessionIdGenerator: undefined,
if (sessionId) { enableJsonResponse: true,
const session = sessions.get(sessionId); });
if (!session) { const server = createMcpServer(config, services);
rpcError(response, 404, "Unknown or expired MCP session"); const activeRequest = { server };
return; activeRequests.add(activeRequest);
} activeMcpRequests += 1;
session.lastUsedAt = Date.now(); let closed = false;
await session.transport.handleRequest(request, response, request.body); const closeRequest = async (): Promise<void> => {
if (closed) {
return; return;
} }
closed = true;
if (!isInitializeRequest(request.body)) { activeRequests.delete(activeRequest);
rpcError(response, 400, "An initialize request or valid MCP session ID is required"); activeMcpRequests = Math.max(0, activeMcpRequests - 1);
return; await server.close().catch((error) => {
} console.error("Failed to close MCP request:", errorMessage(error));
let activeSession: ActiveSession;
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (initializedSessionId) => {
activeSession.lastUsedAt = Date.now();
sessions.set(initializedSessionId, activeSession);
},
}); });
const server = createMcpServer(config, services); };
activeSession = { transport, server, lastUsedAt: Date.now() }; response.once("finish", () => void closeRequest());
transport.onclose = () => { response.once("close", () => void closeRequest());
const closedSessionId = transport.sessionId; try {
if (closedSessionId) {
sessions.delete(closedSessionId);
}
};
transport.onerror = (error) => { transport.onerror = (error) => {
console.error("MCP transport error:", errorMessage(error)); console.error("MCP transport error:", errorMessage(error));
}; };
@@ -123,40 +167,20 @@ export async function startHttpServer(
if (!response.headersSent) { if (!response.headersSent) {
rpcError(response, 500, "Internal MCP server error"); rpcError(response, 500, "Internal MCP server error");
} }
await closeRequest();
} }
}; };
const sessionHandler = async (request: Request, response: Response): Promise<void> => { const methodNotAllowed = (_request: Request, response: Response): void => {
const sessionId = request.header("mcp-session-id"); response.set("Allow", "POST");
if (!sessionId) { rpcError(response, 405, "Stateless MCP accepts POST requests only");
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");
}
}
}; };
app.post(config.endpoint, authenticate, (request, response) => { app.post(config.endpoint, authenticate, (request, response) => {
void postHandler(request, response); void postHandler(request, response);
}); });
app.get(config.endpoint, authenticate, (request, response) => { app.get(config.endpoint, authenticate, methodNotAllowed);
void sessionHandler(request, response); app.delete(config.endpoint, authenticate, methodNotAllowed);
});
app.delete(config.endpoint, authenticate, (request, response) => {
void sessionHandler(request, response);
});
app.use( app.use(
( (
@@ -172,17 +196,8 @@ export async function startHttpServer(
); );
const cleanupInterval = setInterval(() => { 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(); services.processManager.prune();
}, Math.min(config.sessionTtlMs, 60_000)); }, Math.min(config.processRetentionMs, 60_000));
cleanupInterval.unref(); cleanupInterval.unref();
const httpServer = await new Promise<HttpServer>((resolve, reject) => { const httpServer = await new Promise<HttpServer>((resolve, reject) => {
@@ -192,9 +207,10 @@ export async function startHttpServer(
const close = async (): Promise<void> => { const close = async (): Promise<void> => {
clearInterval(cleanupInterval); clearInterval(cleanupInterval);
const activeSessions = [...sessions.values()]; const requests = [...activeRequests];
sessions.clear(); activeRequests.clear();
await Promise.allSettled(activeSessions.map((session) => session.server.close())); activeMcpRequests = 0;
await Promise.allSettled(requests.map((request) => request.server.close()));
await services.processManager.shutdown(); await services.processManager.shutdown();
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
httpServer.close((error) => { httpServer.close((error) => {
@@ -205,6 +221,7 @@ export async function startHttpServer(
} }
}); });
}); });
await new Promise<void>((resolve) => setImmediate(resolve));
}; };
return { httpServer, close }; return { httpServer, close };
+803
View File
@@ -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<ReturnType<Client["callTool"]>>;
function structured(result: ToolResult): Record<string, unknown> {
return (result.structuredContent ?? {}) as Record<string, unknown>;
}
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<ToolName>();
const call = async (
name: ToolName,
arguments_: Record<string, unknown> = {},
): Promise<ToolResult> => {
exercised.add(name);
return client.callTool({ name, arguments: arguments_ });
};
const callOk = async (
name: ToolName,
arguments_: Record<string, unknown> = {},
): Promise<Record<string, unknown>> => {
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<string, unknown> = {},
): Promise<string> => {
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]);
});
});
+130 -1
View File
@@ -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 os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -127,4 +127,133 @@ describe("FileService", () => {
"new\n", "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");
});
}); });
+125
View File
@@ -11,6 +11,13 @@ import { loadConfig, type AppConfig } from "../src/config.js";
import { startHttpServer, type RunningHttpServer } from "../src/http-server.js"; import { startHttpServer, type RunningHttpServer } from "../src/http-server.js";
import { createServices, type McpServices } from "../src/mcp-server.js"; import { createServices, type McpServices } from "../src/mcp-server.js";
interface JsonRpcResponse {
result?: {
tools?: Array<{ name: string }>;
structuredContent?: Record<string, unknown>;
};
}
describe("remote development MCP server", () => { describe("remote development MCP server", () => {
let temporaryDirectory: string; let temporaryDirectory: string;
let config: AppConfig; let config: AppConfig;
@@ -69,6 +76,7 @@ describe("remote development MCP server", () => {
}); });
await client.connect(transport); await client.connect(transport);
try { try {
expect(transport.sessionId).toBeUndefined();
expect(client.getServerVersion()).toMatchObject({ expect(client.getServerVersion()).toMatchObject({
name: "cokacremote", name: "cokacremote",
version: "0.1.0", version: "0.1.0",
@@ -121,4 +129,121 @@ describe("remote development MCP server", () => {
await client.close(); await client.close();
} }
}); });
it("handles every tool call as an independent stateless request", async () => {
const post = async (
body: unknown,
additionalHeaders: Record<string, string> = {},
): Promise<Response> =>
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,
});
});
}); });