ad
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 단위·통합 테스트 |
|
||||||
|
|
||||||
## 라이선스
|
## 라이선스
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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 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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user