ad
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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='<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_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 단위·통합 테스트 |
|
||||
|
||||
## 라이선스
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+118
-21
@@ -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<Record<string, unknown>> {
|
||||
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<Record<string, unknown>> {
|
||||
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 };
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+93
-76
@@ -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<typeof createMcpServer>;
|
||||
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<string, ActiveSession>();
|
||||
const activeRequests = new Set<ActiveRequest>();
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<HttpServer>((resolve, reject) => {
|
||||
@@ -192,9 +207,10 @@ export async function startHttpServer(
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
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<void>((resolve, reject) => {
|
||||
httpServer.close((error) => {
|
||||
@@ -205,6 +221,7 @@ export async function startHttpServer(
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
};
|
||||
|
||||
return { httpServer, close };
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
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<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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user